diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index e7f1e56c..3983f37d 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -5,8 +5,8 @@ import { deleteOrphanUserLoginIDs, fetchDataIntegrityReport, fetchMe, - fetchOrySSOTSystemStatus, fetchOrphanUserLoginIDs, + fetchOrySSOTSystemStatus, flushIdentityCache, } from "../../lib/adminApi"; import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics"; diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index f116fb5e..177d221f 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -315,11 +315,32 @@ test.describe("User Management", () => { await expect(page.getByText(/저장/i).first()).toBeVisible(); }); - test("should manage global custom claim permissions in user detail", async ({ + test("should manage global custom claim values in user detail", async ({ page, }) => { let updatePayload: Record | undefined; + await page.route(/\/admin\/global-custom-claims$/, async (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + + return route.fulfill({ + json: { + items: [ + { + key: "contract_date", + label: "계약일", + valueType: "date", + readPermission: "admin_only", + writePermission: "admin_only", + description: "", + }, + ], + }, + }); + }); + await page.route(/\/admin\/users\/u-1$/, async (route) => { const method = route.request().method(); @@ -375,43 +396,36 @@ test.describe("User Management", () => { .getByRole("tab", { name: /전역 Custom Claims|Custom Claims/i }) .click(); - await expect( - page.getByTestId("global-custom-claim-key-contract_date"), - ).toHaveValue("contract_date"); - await expect( - page.getByTestId("global-custom-claim-read-permission-contract_date"), - ).toHaveValue("user_and_admin"); - await expect( - page.getByTestId("global-custom-claim-write-permission-contract_date"), - ).toHaveValue("admin_only"); + await expect(page.getByText("contract_date")).toBeVisible(); + const valueInput = page.getByTestId( + "global-custom-claim-value-contract_date", + ); + await expect(valueInput).toHaveValue("2026-06-09"); + await expect(valueInput).toHaveAttribute("type", "date"); - await page - .getByTestId("global-custom-claim-write-permission-contract_date") - .selectOption("user_and_admin"); + await valueInput.fill("2026-07-01"); await page.screenshot({ path: "test-results/adminfront-global-custom-claim-permissions.png", fullPage: true, }); - await page - .getByRole("button", { name: /전역 Claim 저장|Save Global Claim/i }) - .click(); + await page.getByRole("button", { name: /사용자 Claim 값 저장/i }).click(); await expect .poll(() => updatePayload) .toMatchObject({ metadata: { global_custom_claims: { - contract_date: "2026-06-09", + contract_date: "2026-07-01", }, global_custom_claim_types: { contract_date: "date", }, global_custom_claim_permissions: { contract_date: { - readPermission: "user_and_admin", - writePermission: "user_and_admin", + readPermission: "admin_only", + writePermission: "admin_only", }, }, }, diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts index b25bada6..89d5ea00 100644 --- a/adminfront/tests/worksmobile.spec.ts +++ b/adminfront/tests/worksmobile.spec.ts @@ -605,6 +605,10 @@ test.describe("Worksmobile tenant management", () => { }, ]); + const updateRowCheckbox = userComparisonSection + .getByRole("row", { name: /이업데이트/ }) + .getByRole("checkbox"); + await expect(updateRowCheckbox).not.toBeChecked(); await page .getByRole("row", { name: /이업데이트/ }) .getByRole("checkbox") @@ -734,6 +738,10 @@ test.describe("Worksmobile tenant management", () => { .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) .click(); + await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); + await page.getByLabel("초기 비밀번호").fill("InitPass123!"); + await page.getByRole("button", { name: "생성 작업 등록" }).click(); + await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible(); await expect( page.getByText(/WORKS API rejected user creation/), diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 63a1f033..df9fafac 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -861,6 +861,9 @@ func main() { dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest) dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest) dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval) + dev.Get("/developer-grants", devHandler.ListDeveloperGrants) + dev.Post("/developer-grants", devHandler.CreateDeveloperGrant) + dev.Post("/developer-grants/:id/revoke", devHandler.RevokeDeveloperGrant) // Webhook for Kratos courier (HTTP delivery) auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) diff --git a/backend/internal/domain/developer_request.go b/backend/internal/domain/developer_request.go index 58bfbc64..61a319c3 100644 --- a/backend/internal/domain/developer_request.go +++ b/backend/internal/domain/developer_request.go @@ -2,6 +2,8 @@ package domain import ( "time" + + "github.com/lib/pq" ) const ( @@ -11,19 +13,39 @@ const ( DeveloperRequestStatusCancelled = "cancelled" ) +const ( + DeveloperAccessPageAll = "all" + DeveloperAccessPageOverview = "overview" + DeveloperAccessPageClientCreate = "client_create" + DeveloperAccessPageAudit = "audit" +) + +var DeveloperAccessPageOrder = []string{ + DeveloperAccessPageOverview, + DeveloperAccessPageClientCreate, + DeveloperAccessPageAudit, +} + // DeveloperRequest represents a user's application to become a developer. type DeveloperRequest struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID - TenantID string `gorm:"index;not null" json:"tenantId"` - Name string `gorm:"not null" json:"name"` - Organization string `json:"organization"` - Email string `json:"email"` - Phone string `json:"phone"` - Role string `json:"role"` - Reason string `json:"reason"` - Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled - AdminNotes string `json:"adminNotes"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID + TenantID string `gorm:"index;not null" json:"tenantId"` + Name string `gorm:"not null" json:"name"` + Organization string `json:"organization"` + Email string `json:"email"` + Phone string `json:"phone"` + Role string `json:"role"` + Reason string `json:"reason"` + AccessPages pq.StringArray `gorm:"type:text[]" json:"accessPages,omitempty"` + Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled + AdminNotes string `json:"adminNotes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type DeveloperAccessStatus struct { + Status string `json:"status"` + ApprovedPages pq.StringArray `json:"approvedPages,omitempty"` + PendingPages pq.StringArray `json:"pendingPages,omitempty"` } diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 2518c5a0..18e8410c 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -3,7 +3,6 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" - "baron-sso-backend/internal/testsupport" "bytes" "encoding/json" "io" @@ -50,35 +49,37 @@ func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App { return app } -func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server { +func newKratosWhoamiTestServer(t *testing.T, identityID string) string { t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/sessions/whoami" { - http.NotFound(w, r) - return - } - if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" { - http.Error(w, "missing session", http.StatusUnauthorized) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": "session-123", - "authenticated_at": "2026-05-21T00:00:00Z", - "identity": map[string]any{ - "id": identityID, - "traits": map[string]any{ - "email": "user@example.com", - }, - }, - }) - })) origDefaultClient := http.DefaultClient - http.DefaultClient = server.Client() + http.DefaultClient = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/sessions/whoami" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" { + return httpResponse(r, http.StatusUnauthorized, "missing session"), nil + } + body, err := json.Marshal(map[string]any{ + "id": "session-123", + "authenticated_at": "2026-05-21T00:00:00Z", + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "user@example.com", + }, + }, + }) + if err != nil { + return nil, err + } + return httpResponse(r, http.StatusOK, string(body)), nil + }), + } t.Cleanup(func() { http.DefaultClient = origDefaultClient }) - t.Cleanup(server.Close) - return server + return "http://kratos.test" } func TestEnchantedLinkFlow_Email_Success(t *testing.T) { @@ -215,8 +216,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi redis := &mockRedisRepo{data: map[string]string{ prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1")) h := &AuthHandler{ RedisService: redis, @@ -248,8 +248,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t * redis := &mockRedisRepo{data: map[string]string{ prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) h := &AuthHandler{ RedisService: redis, @@ -302,8 +301,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t * prefixLoginCodePending + "user@example.com": "pending-123", prefixLoginCodeValue + "pending-123": "569765", }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) h := &AuthHandler{ RedisService: redis, @@ -393,8 +391,7 @@ func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) { redis := &mockRedisRepo{data: map[string]string{ prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1")) h := &AuthHandler{ RedisService: redis, @@ -425,8 +422,7 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T) redis := &mockRedisRepo{data: map[string]string{ prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`, }} - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) h := &AuthHandler{ RedisService: redis, @@ -456,18 +452,11 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T) func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() idp := &mockIdpProvider{ userExists: true, @@ -485,7 +474,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -497,6 +486,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { h := &AuthHandler{ RedisService: redis, IdpProvider: idp, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), SmsService: &mockSmsService{}, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", @@ -529,10 +519,6 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) @@ -659,10 +645,6 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) @@ -748,8 +730,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) { } assert.NotEmpty(t, token) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a")) verifyBody, _ := json.Marshal(map[string]any{ "token": token, @@ -785,10 +766,6 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) { func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") - } - redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) @@ -880,8 +857,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T resp, _ = app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-userfront-a")) pollBody, _ := json.Marshal(map[string]string{ "client_id": "headless-login-client", diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 19c9fc6c..c4565403 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -9,7 +9,6 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/service" - "baron-sso-backend/internal/testsupport" "bytes" "context" "crypto/ecdsa" @@ -446,10 +445,6 @@ func runHeadlessPasswordLoginWithAssertionRequest( headers map[string]string, ) *http.Response { t.Helper() - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -463,11 +458,8 @@ func runHeadlessPasswordLoginWithAssertionRequest( if err != nil { t.Fatalf("failed to marshal jwks body: %v", err) } - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - t.Cleanup(jwksServer.Close) + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -481,7 +473,7 @@ func runHeadlessPasswordLoginWithAssertionRequest( "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -496,6 +488,7 @@ func runHeadlessPasswordLoginWithAssertionRequest( h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -551,10 +544,6 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( logger *slog.Logger, ) *http.Response { t.Helper() - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -568,11 +557,8 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( if err != nil { t.Fatalf("failed to marshal jwks body: %v", err) } - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - t.Cleanup(jwksServer.Close) + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -586,7 +572,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -601,6 +587,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -879,10 +866,6 @@ func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -891,11 +874,8 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -909,7 +889,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -926,6 +906,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -979,10 +960,6 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee002", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -991,11 +968,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() acceptCalled := false hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1012,7 +986,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1030,6 +1004,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -1065,10 +1040,6 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1077,11 +1048,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1097,7 +1065,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1114,6 +1082,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -1271,10 +1240,6 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) { func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1283,11 +1248,8 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1301,7 +1263,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, "headless_jwks": map[string]any{ "keys": []map[string]any{}, }, @@ -1321,6 +1283,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -1360,10 +1323,6 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1383,12 +1342,11 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te } fetchCount := 0 - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwksClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { fetchCount++ - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(freshRaw) - })) - defer jwksServer.Close() + return httpResponse(r, http.StatusOK, string(freshRaw)), nil + })} + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1402,7 +1360,7 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1417,12 +1375,12 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil) redisRepo := &testRedisRepo{values: map[string]string{}} - cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksServer.Client()) + cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksClient) now := time.Now() expiresAt := now.Add(30 * time.Minute) if err := cacheService.SaveState("headless-login-client", domain.HeadlessJWKSCacheState{ ClientID: "headless-login-client", - JWKSURI: jwksServer.URL + "/.well-known/jwks.json", + JWKSURI: jwksURI, RawJWKS: string(staleRaw), CachedKids: []string{"test-kid"}, CachedAt: &now, @@ -1546,10 +1504,6 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) { } func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { - if !testsupport.PortBindingAvailable() { - t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") - } - mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -1562,11 +1516,8 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { invalidKey, _ := mustHeadlessRSAJWK(t) _ = validKey jwksBody, _ := json.Marshal(jwks) - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - defer jwksServer.Close() + jwksClient := newJWKSHTTPClient(t, jwksBody) + jwksURI := jwksURL() hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1580,7 +1531,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", - "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": jwksURI, }, }, }) @@ -1595,6 +1546,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, @@ -2198,8 +2150,7 @@ func TestPasswordLogin_SharedBrowserSameSubjectAllowed(t *testing.T) { Subject: "kratos-user-1", }, nil) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-user-1")) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) @@ -2237,8 +2188,7 @@ func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) { Subject: "kratos-user-1", }, nil) - kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") - t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + t.Setenv("KRATOS_PUBLIC_URL", newKratosWhoamiTestServer(t, "kratos-other-user")) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 1f12384e..5cd69713 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -49,9 +49,10 @@ type DevHandler struct { type developerRequestService interface { RequestAccess(ctx context.Context, req domain.DeveloperRequest) error - GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) + GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) - ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) + ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) + CreateGrant(ctx context.Context, req domain.DeveloperRequest) error ApproveRequest(ctx context.Context, id uint, adminNotes string) error RejectRequest(ctx context.Context, id uint, adminNotes string) error CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error @@ -274,6 +275,56 @@ func isDevConsoleViewerRole(role string) bool { return r == domain.RoleSuperAdmin || r == domain.RoleUser } +func normalizeDeveloperAccessPagesForHandler(pages []string) []string { + seen := make(map[string]struct{}) + normalized := make([]string, 0, len(pages)) + add := func(page string) { + page = strings.ToLower(strings.TrimSpace(page)) + if page == "" { + return + } + if page == domain.DeveloperAccessPageAll { + normalized = []string{domain.DeveloperAccessPageAll} + seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}} + return + } + for _, allowed := range domain.DeveloperAccessPageOrder { + if page == allowed { + if _, exists := seen[page]; exists { + return + } + seen[page] = struct{}{} + normalized = append(normalized, page) + return + } + } + } + for _, page := range pages { + add(page) + if len(normalized) == 1 && normalized[0] == domain.DeveloperAccessPageAll { + return normalized + } + } + if len(normalized) == 0 { + return []string{domain.DeveloperAccessPageAll} + } + return normalized +} + +func developerAccessPagesEqual(left, right []string) bool { + leftNormalized := normalizeDeveloperAccessPagesForHandler(left) + rightNormalized := normalizeDeveloperAccessPagesForHandler(right) + if len(leftNormalized) != len(rightNormalized) { + return false + } + for i := range leftNormalized { + if leftNormalized[i] != rightNormalized[i] { + return false + } + } + return true +} + func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) { if profile == nil { return @@ -455,9 +506,7 @@ func (h *DevHandler) hasApprovedDeveloperRequest(c *fiber.Ctx, profile *domain.U if err != nil || status == nil { return false } - return status.Status == domain.DeveloperRequestStatusApproved && - strings.TrimSpace(status.UserID) == userID && - strings.TrimSpace(status.TenantID) == tenantID + return status.Status == domain.DeveloperRequestStatusApproved } func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool { @@ -3871,10 +3920,11 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error { } var req struct { - Name string `json:"name"` - Organization string `json:"organization"` - Reason string `json:"reason"` - TenantID string `json:"tenantId"` + Name string `json:"name"` + Organization string `json:"organization"` + Reason string `json:"reason"` + TenantID string `json:"tenantId"` + AccessPages []string `json:"accessPages"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") @@ -3883,16 +3933,16 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error { if req.TenantID == "" && profile.TenantID != nil { req.TenantID = *profile.TenantID } - if req.TenantID == "" { - return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") - } name := strings.TrimSpace(profile.Name) if name == "" { name = strings.TrimSpace(req.Name) } organization := strings.TrimSpace(req.Organization) - if h.TenantSvc != nil { + if organization == "" { + organization = strings.TrimSpace(profile.CompanyCode) + } + if req.TenantID != "" && h.TenantSvc != nil { if tenant, err := h.TenantSvc.GetTenant(c.Context(), req.TenantID); err == nil && tenant != nil && strings.TrimSpace(tenant.Name) != "" { organization = strings.TrimSpace(tenant.Name) } @@ -3907,6 +3957,7 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error { Phone: profile.Phone, Role: normalizeUserRole(profile.Role), Reason: req.Reason, + AccessPages: req.AccessPages, Status: domain.DeveloperRequestStatusPending, } @@ -3927,9 +3978,6 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error { if tenantID == "" && profile.TenantID != nil { tenantID = *profile.TenantID } - if tenantID == "" { - return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") - } status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), profile.ID, tenantID) if err != nil { @@ -3937,10 +3985,10 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error { } if status == nil { - return c.JSON(fiber.Map{"status": "none"}) + return c.JSON(domain.DeveloperAccessStatus{Status: "none"}) } if status.Status == domain.DeveloperRequestStatusApproved { - h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID) + h.ensureDeveloperGrantRelation(c, profile.ID, tenantID) } return c.JSON(status) @@ -4049,7 +4097,7 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error { userID = "" } - requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status) + requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -4057,6 +4105,169 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error { return c.JSON(requests) } +func (h *DevHandler) ListDeveloperGrants(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { + return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") + } + + tenantID := strings.TrimSpace(c.Query("tenantId")) + grants, err := h.DeveloperSvc.ListRequests(c.Context(), "", domain.DeveloperRequestStatusApproved, tenantID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(grants) +} + +func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { + return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") + } + + var reqBody struct { + UserID string `json:"userId"` + TenantID string `json:"tenantId"` + Reason string `json:"reason"` + AdminNotes string `json:"adminNotes"` + AccessPages []string `json:"accessPages"` + } + if err := c.BodyParser(&reqBody); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + userID := strings.TrimSpace(reqBody.UserID) + tenantID := strings.TrimSpace(reqBody.TenantID) + if userID == "" { + return errorJSON(c, fiber.StatusBadRequest, "userId is required") + } + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "required services are unavailable") + } + + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err != nil || identity == nil { + return errorJSON(c, fiber.StatusNotFound, "user not found") + } + + name := strings.TrimSpace(extractTraitString(identity.Traits, "name")) + if name == "" { + name = userID + } + organization := strings.TrimSpace(extractTraitString(identity.Traits, "companyCode")) + if tenantID != "" && h.TenantSvc != nil { + tenant, err := h.TenantSvc.GetTenant(c.Context(), tenantID) + if err != nil || tenant == nil { + return errorJSON(c, fiber.StatusNotFound, "tenant not found") + } + if strings.TrimSpace(tenant.Name) != "" { + organization = strings.TrimSpace(tenant.Name) + } else if organization == "" { + organization = tenantID + } + } + email := strings.TrimSpace(extractTraitString(identity.Traits, "email")) + phone := strings.TrimSpace(extractTraitString(identity.Traits, "phone")) + role := normalizeUserRole(extractTraitString(identity.Traits, "role")) + if role == "" { + role = domain.RoleUser + } + reason := strings.TrimSpace(reqBody.Reason) + if reason == "" { + reason = "직접 부여" + } + + existingRequests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, "", tenantID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + for _, existing := range existingRequests { + if !developerAccessPagesEqual(existing.AccessPages, reqBody.AccessPages) { + continue + } + + switch existing.Status { + case domain.DeveloperRequestStatusApproved: + h.ensureDeveloperGrantRelation(c, userID, tenantID) + return c.JSON(existing) + case domain.DeveloperRequestStatusPending: + if err := h.DeveloperSvc.ApproveRequest(c.Context(), existing.ID, reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + h.ensureDeveloperGrantRelation(c, userID, tenantID) + existing.Status = domain.DeveloperRequestStatusApproved + existing.AdminNotes = reqBody.AdminNotes + return c.JSON(existing) + } + } + + grant := domain.DeveloperRequest{ + UserID: userID, + TenantID: tenantID, + Name: name, + Organization: organization, + Email: email, + Phone: phone, + Role: role, + Reason: reason, + AccessPages: reqBody.AccessPages, + Status: domain.DeveloperRequestStatusApproved, + AdminNotes: strings.TrimSpace(reqBody.AdminNotes), + } + if err := h.DeveloperSvc.CreateGrant(c.Context(), grant); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + h.ensureDeveloperGrantRelation(c, userID, tenantID) + + return c.Status(fiber.StatusCreated).JSON(grant) +} + +func (h *DevHandler) RevokeDeveloperGrant(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { + return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") + } + + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid grant id") + } + + var reqBody struct { + AdminNotes string `json:"adminNotes"` + } + if err := c.BodyParser(&reqBody); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id)) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch grant details") + } + if devReq.Status != domain.DeveloperRequestStatusApproved { + return errorJSON(c, fiber.StatusBadRequest, "only approved grants can be revoked") + } + + if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID) + + return c.JSON(fiber.Map{"status": "ok"}) +} + func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index bdfb70e8..c299a01c 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -71,10 +71,10 @@ func (m *devMockDeveloperService) RequestAccess(ctx context.Context, req domain. return args.Error(0) } -func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) { +func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) { args := m.Called(ctx, userID, tenantID) - if req, ok := args.Get(0).(*domain.DeveloperRequest); ok { - return req, args.Error(1) + if status, ok := args.Get(0).(*domain.DeveloperAccessStatus); ok { + return status, args.Error(1) } return nil, args.Error(1) } @@ -87,14 +87,19 @@ func (m *devMockDeveloperService) GetRequestByID(ctx context.Context, id uint) ( return nil, args.Error(1) } -func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) { - args := m.Called(ctx, userID, status) +func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) { + args := m.Called(ctx, userID, status, tenantID) if requests, ok := args.Get(0).([]domain.DeveloperRequest); ok { return requests, args.Error(1) } return nil, args.Error(1) } +func (m *devMockDeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + func (m *devMockDeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error { args := m.Called(ctx, id, adminNotes) return args.Error(0) @@ -1585,10 +1590,8 @@ func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisi mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "System", "global", "manage_all").Return(false, nil).Maybe() developerSvc := new(devMockDeveloperService) - developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperRequest{ - UserID: "user-1", - TenantID: "tenant-a", - Status: domain.DeveloperRequestStatusApproved, + developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperAccessStatus{ + Status: domain.DeveloperRequestStatusApproved, }, nil).Maybe() h := &DevHandler{ diff --git a/backend/internal/handler/test_server_helper_test.go b/backend/internal/handler/test_server_helper_test.go new file mode 100644 index 00000000..c7f08798 --- /dev/null +++ b/backend/internal/handler/test_server_helper_test.go @@ -0,0 +1,91 @@ +package handler + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + ln, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to bind test server listener: %v", err) + } + + server := httptest.NewUnstartedServer(handler) + server.Listener = ln + server.Start() + t.Cleanup(server.Close) + + return server +} + +func newJWKSHTTPClient(t *testing.T, jwksBody []byte) *http.Client { + t.Helper() + + return &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/.well-known/jwks.json" { + return httpResponse(r, http.StatusOK, string(jwksBody)), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }), + } +} + +func installKratosWhoamiClient(t *testing.T, identityID string) string { + t.Helper() + + origDefaultClient := http.DefaultClient + http.DefaultClient = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/sessions/whoami" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" { + return httpResponse(r, http.StatusUnauthorized, "missing session"), nil + } + body, err := json.Marshal(map[string]any{ + "id": "session-123", + "authenticated_at": "2026-05-21T00:00:00Z", + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "user@example.com", + }, + }, + }) + if err != nil { + return nil, err + } + resp := httpResponse(r, http.StatusOK, string(body)) + resp.Header.Set("Content-Type", "application/json") + return resp, nil + }), + } + t.Cleanup(func() { + http.DefaultClient = origDefaultClient + }) + + return "http://kratos.test" +} + +func jwksURL() string { + u := &url.URL{Scheme: "http", Host: "jwks.test", Path: "/.well-known/jwks.json"} + return u.String() +} + +func mustJSONBody(t *testing.T, value any) []byte { + t.Helper() + + body, err := json.Marshal(value) + if err != nil { + t.Fatalf("failed to marshal test body: %v", err) + } + return body +} diff --git a/backend/internal/service/developer_service.go b/backend/internal/service/developer_service.go index 7dc57f70..799a1e2e 100644 --- a/backend/internal/service/developer_service.go +++ b/backend/internal/service/developer_service.go @@ -3,7 +3,8 @@ package service import ( "baron-sso-backend/internal/domain" "context" - "errors" + "sort" + "strings" "gorm.io/gorm" ) @@ -16,30 +17,179 @@ func NewDeveloperService(db *gorm.DB) *DeveloperService { return &DeveloperService{db: db} } -func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error { - // Check if there is already a pending request - var existing domain.DeveloperRequest - err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).First(&existing).Error - if err == nil { +func normalizeDeveloperAccessPages(pages []string) []string { + seen := make(map[string]struct{}) + normalized := make([]string, 0, len(pages)) + + add := func(page string) { + page = strings.ToLower(strings.TrimSpace(page)) + if page == "" { + return + } + if page == domain.DeveloperAccessPageAll { + normalized = []string{domain.DeveloperAccessPageAll} + seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}} + return + } + if page != domain.DeveloperAccessPageOverview && + page != domain.DeveloperAccessPageClientCreate && + page != domain.DeveloperAccessPageAudit { + return + } + if _, exists := seen[page]; exists { + return + } + seen[page] = struct{}{} + normalized = append(normalized, page) + } + + for _, page := range pages { + add(page) + if len(normalized) == 1 && normalized[0] == domain.DeveloperAccessPageAll { + return normalized + } + } + + if len(normalized) == 0 { + return []string{domain.DeveloperAccessPageAll} + } + + sort.SliceStable(normalized, func(i, j int) bool { + return accessPageSortIndex(normalized[i]) < accessPageSortIndex(normalized[j]) + }) + + return normalized +} + +func accessPageSortIndex(page string) int { + switch page { + case domain.DeveloperAccessPageOverview: + return 0 + case domain.DeveloperAccessPageClientCreate: + return 1 + case domain.DeveloperAccessPageAudit: + return 2 + default: + return 99 + } +} + +func accessPagesOverlap(left, right []string) bool { + if len(left) == 0 || len(right) == 0 { + return false + } + + leftSet := make(map[string]struct{}, len(left)) + for _, page := range normalizeDeveloperAccessPages(left) { + if page == domain.DeveloperAccessPageAll { + return true + } + leftSet[page] = struct{}{} + } + + for _, page := range normalizeDeveloperAccessPages(right) { + if page == domain.DeveloperAccessPageAll { + return true + } + if _, ok := leftSet[page]; ok { + return true + } + } + + return false +} + +func unionDeveloperAccessPages(requests []domain.DeveloperRequest, statuses ...string) []string { + statusSet := make(map[string]struct{}, len(statuses)) + for _, status := range statuses { + if trimmed := strings.TrimSpace(status); trimmed != "" { + statusSet[trimmed] = struct{}{} + } + } + + acc := make(map[string]struct{}) + for _, req := range requests { + if len(statusSet) > 0 { + if _, ok := statusSet[strings.TrimSpace(req.Status)]; !ok { + continue + } + } + pages := normalizeDeveloperAccessPages(req.AccessPages) + for _, page := range pages { + acc[page] = struct{}{} + } + } + + if len(acc) == 0 { return nil } - if !errors.Is(err, gorm.ErrRecordNotFound) { + + result := make([]string, 0, len(acc)) + if _, ok := acc[domain.DeveloperAccessPageAll]; ok { + return []string{domain.DeveloperAccessPageAll} + } + for _, page := range domain.DeveloperAccessPageOrder { + if _, ok := acc[page]; ok { + result = append(result, page) + } + } + return result +} + +func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error { + req.AccessPages = normalizeDeveloperAccessPages(req.AccessPages) + // Check if there is already a pending request + var existing []domain.DeveloperRequest + err := s.db.WithContext(ctx). + Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending). + Order("created_at DESC"). + Find(&existing).Error + if err != nil { return err } + for _, current := range existing { + if accessPagesOverlap(current.AccessPages, req.AccessPages) { + return nil + } + } return s.db.WithContext(ctx).Create(&req).Error } -func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) { - var req domain.DeveloperRequest - err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Order("created_at DESC").First(&req).Error +func (s *DeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error { + req.AccessPages = normalizeDeveloperAccessPages(req.AccessPages) + return s.db.WithContext(ctx).Create(&req).Error +} + +func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) { + var requests []domain.DeveloperRequest + err := s.db.WithContext(ctx). + Where("user_id = ? AND tenant_id = ?", userID, tenantID). + Order("created_at DESC"). + Find(&requests).Error if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } return nil, err } - return &req, nil + if len(requests) == 0 { + return &domain.DeveloperAccessStatus{Status: "none"}, nil + } + + approvedPages := unionDeveloperAccessPages(requests, domain.DeveloperRequestStatusApproved) + pendingPages := unionDeveloperAccessPages(requests, domain.DeveloperRequestStatusPending) + + status := "none" + switch { + case len(approvedPages) > 0: + status = domain.DeveloperRequestStatusApproved + case len(pendingPages) > 0: + status = domain.DeveloperRequestStatusPending + } + + return &domain.DeveloperAccessStatus{ + Status: status, + ApprovedPages: approvedPages, + PendingPages: pendingPages, + }, nil } func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) { @@ -51,7 +201,7 @@ func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain return &req, nil } -func (s *DeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) { +func (s *DeveloperService) ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) { var requests []domain.DeveloperRequest query := s.db.WithContext(ctx) if userID != "" { @@ -60,6 +210,9 @@ func (s *DeveloperService) ListRequests(ctx context.Context, userID, status stri if status != "" { query = query.Where("status = ?", status) } + if tenantID != "" { + query = query.Where("tenant_id = ?", tenantID) + } err := query.Order("created_at DESC").Find(&requests).Error return requests, err } diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index b42bc4da..eba8a863 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -9,6 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage"; import ClientGeneralPage from "../features/clients/ClientGeneralPage"; import ClientRelationsPage from "../features/clients/ClientRelationsPage"; import ClientsPage from "../features/clients/ClientsPage"; +import DeveloperGrantsPage from "../features/developer-grants/DeveloperGrantsPage"; import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import ProfilePage from "../features/profile/ProfilePage"; @@ -26,6 +27,7 @@ const devFrontAppChildren: RouteObject[] = [ element: , }, { path: "developer-requests", element: }, + { path: "developer-grants", element: }, { path: "audit-logs", element: }, { path: "profile", element: }, ]; diff --git a/devfront/src/components/common/ForbiddenMessage.test.tsx b/devfront/src/components/common/ForbiddenMessage.test.tsx index bbad6610..286be653 100644 --- a/devfront/src/components/common/ForbiddenMessage.test.tsx +++ b/devfront/src/components/common/ForbiddenMessage.test.tsx @@ -65,17 +65,9 @@ describe("ForbiddenMessage", () => { expect(clients.textContent).toContain("target application"); }); - it("renders specific guidance for privileged admin roles", async () => { - authState.user.profile.role = "rp_admin"; - const rpAdmin = await renderMessage("clients"); - expect(rpAdmin.textContent).toContain( - "RP administrators can only access resources for their assigned applications.", - ); - - authState.user.profile.role = "tenant_admin"; - const tenantAdmin = await renderMessage("clients"); - expect(tenantAdmin.textContent).toContain( - "Tenant administrator permissions are not configured correctly or have expired.", - ); + it("falls back to the default message for non-user roles", async () => { + authState.user.profile.role = "super_admin"; + const admin = await renderMessage("clients"); + expect(admin.textContent).toContain("You do not have permission"); }); }); diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx index ac4b197d..3bf488cd 100644 --- a/devfront/src/components/common/ForbiddenMessage.tsx +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -34,16 +34,6 @@ export function ForbiddenMessage({ resourceToken }: Props) { "Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target application. Request access from an administrator if needed.", ); } - } else if (role === "rp_admin") { - explanation = t( - "msg.dev.forbidden.rp_admin", - "RP administrators can only access resources for their assigned applications.", - ); - } else if (role === "tenant_admin") { - explanation = t( - "msg.dev.forbidden.tenant_admin", - "Tenant administrator permissions are not configured correctly or have expired.", - ); } const resourceLabel = diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 3572cc18..21002e45 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronDown, ClipboardCheck, + KeyRound, LayoutDashboard, LogOut, Moon, @@ -39,7 +40,7 @@ import { Toaster } from "../ui/toaster"; const LOCALE_CHANGED_EVENT = "baron_locale_changed"; -const navItems: ShellSidebarNavItem[] = [ +const baseNavItems: ShellSidebarNavItem[] = [ { labelKey: "ui.dev.nav.overview", labelFallback: "Overview", @@ -350,6 +351,18 @@ function AppLayout() { auth.user?.profile as Record | undefined, ); const displayRoleKey = profile?.role || currentRole; + const navItems = + displayRoleKey === "super_admin" + ? [ + ...baseNavItems, + { + labelKey: "ui.dev.nav.developer_grants", + labelFallback: "Developer Access Grants", + to: "/developer-grants", + icon: KeyRound, + }, + ] + : baseNavItems; const handleSessionExpiryToggle = () => { setIsSessionExpiryEnabled((prev) => { const next = !prev; diff --git a/devfront/src/features/audit/AuditLogsPage.test.tsx b/devfront/src/features/audit/AuditLogsPage.test.tsx index a8b9f9db..cb23e050 100644 --- a/devfront/src/features/audit/AuditLogsPage.test.tsx +++ b/devfront/src/features/audit/AuditLogsPage.test.tsx @@ -174,6 +174,22 @@ describe("AuditLogsPage", () => { expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); }); + it("renders the generic access request card when tenant context is missing", async () => { + gateState = { + hasDeveloperAccess: false, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: true, + isLoadingDeveloperAccessGate: false, + isTenantContextMissing: true, + }; + + const container = await renderPage(); + expect(container.textContent).toContain( + "감사 로그는 개발자 권한이 있어야 볼 수 있습니다.", + ); + expect(container.textContent).toContain("개발자 권한 신청"); + }); + it("exports the fetched logs as CSV", async () => { const createObjectURL = vi .spyOn(URL, "createObjectURL") diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index f7d939bb..af240be8 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -101,6 +101,7 @@ function AuditLogsPage() { hasAccessToken, profileRole, tenantId, + requiredPages: ["audit"], isLoadingIdentity: isLoadingMe, }); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 569dcc96..ec409582 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -17,6 +17,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate, useParams } from "react-router-dom"; import { PageHeader } from "../../../../common/core/components/page"; +import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { @@ -54,6 +55,7 @@ import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; import { fetchMe, type UserProfile } from "../auth/authApi"; +import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { ClientDetailTabs } from "./ClientDetailTabs"; import { AllowedTenantBadge } from "./components/AllowedTenantBadge"; @@ -358,16 +360,27 @@ function ClientGeneralPage() { const hasAccessToken = Boolean(auth.user?.access_token); const clientId = params.id; const isCreate = !clientId; - const systemRole = resolveProfileRole( - auth.user?.profile as Record | undefined, - ); - const { data: me } = useQuery({ + const userProfile = auth.user?.profile as Record | undefined; + const systemRole = resolveProfileRole(userProfile); + const { data: me, isLoading: isLoadingMe } = useQuery({ queryKey: ["userMe"], queryFn: fetchMe, enabled: hasAccessToken, }); const currentUserId = me?.id ?? auth.user?.profile.sub; const effectiveSystemRole = me?.role?.trim() || systemRole; + const { + hasDeveloperAccess: hasClientCreateAccess, + isDeveloperRequestPending, + canRequestDeveloperAccess, + isLoadingDeveloperAccessGate, + } = useDeveloperAccessGate({ + hasAccessToken, + profileRole: effectiveSystemRole, + tenantId: userProfile?.tenant_id as string | undefined, + requiredPages: ["client_create"], + isLoadingIdentity: isLoadingMe, + }); const { data, isLoading, error } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId as string), @@ -1161,10 +1174,47 @@ function ClientGeneralPage() { } }; - if (!isCreate && isLoading) { + if ((isCreate && isLoadingDeveloperAccessGate) || (!isCreate && isLoading)) { return (
- {t("msg.dev.clients.general.loading", "Loading client...")} + {t( + "msg.dev.clients.general.loading", + isCreate ? "Loading client creation..." : "Loading client...", + )} +
+ ); + } + if (isCreate && !hasClientCreateAccess) { + return ( +
+
+ navigate("/developer-requests")} + /> +
); } diff --git a/devfront/src/features/clients/ClientsPage.test.tsx b/devfront/src/features/clients/ClientsPage.test.tsx index 2f92ecba..ff792cd6 100644 --- a/devfront/src/features/clients/ClientsPage.test.tsx +++ b/devfront/src/features/clients/ClientsPage.test.tsx @@ -167,6 +167,20 @@ async function renderPage() { return container; } +async function waitForTextContent(container: HTMLElement, text: string) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (container.textContent?.includes(text)) { + return; + } + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + throw new Error(`Expected container text to include: ${text}`); +} + describe("ClientsPage", () => { it("expands the list and applies search filters", async () => { fetchClientsMock.mockResolvedValue({ @@ -277,4 +291,76 @@ describe("ClientsPage", () => { expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); }); + + it("allows a user without tenant context to request developer access", async () => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "user", + companyCode: "HANMAC", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }, + }, + }; + fetchMeMock.mockResolvedValue({ + role: "user", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }); + fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); + + const container = await renderPage(); + await waitForTextContent(container, "개발자 등록 신청하기"); + + const requestButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "개발자 등록 신청하기", + ); + expect(requestButton).toBeTruthy(); + + await act(async () => { + requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); + expect(fetchDeveloperRequestStatusMock).toHaveBeenCalled(); + }); + + it("shows the create app button for a super admin without tenant context", async () => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "super_admin", + companyCode: "HANMAC", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }, + }, + }; + fetchMeMock.mockResolvedValue({ + role: "super_admin", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }); + + const container = await renderPage(); + expect(container.textContent).toContain("연동 앱 추가"); + + const createButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "연동 앱 추가", + ); + expect(createButton).toBeTruthy(); + + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigateMock).toHaveBeenCalledWith("/clients/new"); + }); }); diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index c82680f6..e183bded 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -93,9 +93,7 @@ function ClientsPage() { } = useQuery({ queryKey: ["developer-request", tenantId], queryFn: () => fetchDeveloperRequestStatus(tenantId), - enabled: - hasAccessToken && - (profileRole === "user" || profileRole === "tenant_member"), + enabled: hasAccessToken && profileRole === "user", }); const { data: tenants } = useQuery({ queryKey: ["myTenants"], @@ -105,11 +103,11 @@ function ClientsPage() { const createAccessState = resolveClientCreateAccess({ role: profileRole, - requestStatus: requestStatus?.status, + accessStatus: requestStatus, }); const canCreateClient = createAccessState === "can_create"; - const isDeveloperRequestPending = createAccessState === "pending"; - const canRequestDeveloperAccess = + const isClientCreatePending = createAccessState === "pending"; + const canRequestClientCreateAccess = createAccessState === "request_required" && !isLoadingRequest; const [searchQuery, setSearchQuery] = useState(""); @@ -240,7 +238,7 @@ function ClientsPage() { {t("ui.dev.clients.new", "새 클라이언트")} - ) : isDeveloperRequestPending ? ( + ) : isClientCreatePending ? (

{t( @@ -257,7 +255,7 @@ function ClientsPage() { {t("ui.dev.nav.developer_request", "개발자 권한 신청")}

- ) : canRequestDeveloperAccess ? ( + ) : canRequestClientCreateAccess ? (

{t( @@ -460,7 +458,7 @@ function ClientsPage() { "msg.dev.clients.empty_can_create", "아직 등록된 연동 앱이 없습니다.", ) - : isDeveloperRequestPending + : isClientCreatePending ? t( "msg.dev.clients.empty_pending", "개발자 권한 신청을 검토 중입니다.", @@ -482,7 +480,7 @@ function ClientsPage() { "msg.dev.clients.empty_can_create_detail", "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.", ) - : isDeveloperRequestPending + : isClientCreatePending ? t( "msg.dev.clients.empty_pending_detail", "super admin이 승인하면 연동 앱을 추가할 수 있습니다.", @@ -501,7 +499,7 @@ function ClientsPage() { {t("ui.dev.clients.new", "연동 앱 추가")} )} - {!isFilteredOut && canRequestDeveloperAccess && ( + {!isFilteredOut && canRequestClientCreateAccess && ( + )) + ) : ( +

+ {t( + "msg.dev.grants.search_empty", + "검색 결과가 없습니다.", + )} +
+ )} +
+ )} + + + + + +
+ + {t("ui.dev.grants.selected_info", "선택된 사용자 정보")} + + + {t("ui.dev.grants.read_only", "읽기 전용")} + +
+ + {t( + "msg.dev.grants.selected_info_description", + "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다.", + )} + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {developerAccessPageOptions.map((option) => { + const checked = + option.value === "all" + ? selectedAccessPages.includes("all") + : selectedAccessPages.includes(option.value); + return ( + + ); + })} +
+

+ {t( + "msg.dev.grants.pages_hint", + "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.", + )} +

+
+
+
+ + + + +
+ + {t("ui.dev.grants.admin_notes", "부여 사유")} + +
+ + {t( + "msg.dev.grants.admin_notes_description", + "직접 부여의 근거를 간단히 남겨 두면 추후 회수와 검토에 도움이 됩니다.", + )} + +
+ +