1
0
forked from baron/baron-sso

Merge pull request 'feature/df-permission' (#1073) from feature/df-permission into dev

Reviewed-on: baron/baron-sso#1073
This commit is contained in:
2026-06-10 16:42:46 +09:00
50 changed files with 2591 additions and 410 deletions

View File

@@ -5,8 +5,8 @@ import {
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchMe,
fetchOrySSOTSystemStatus,
fetchOrphanUserLoginIDs,
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";

View File

@@ -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<string, unknown> | 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",
},
},
},

View File

@@ -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/),

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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: <ClientRelationsPage />,
},
{ path: "developer-requests", element: <DeveloperRequestPage /> },
{ path: "developer-grants", element: <DeveloperGrantsPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> },
];

View File

@@ -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");
});
});

View File

@@ -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 =

View File

@@ -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<string, unknown> | 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;

View File

@@ -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")

View File

@@ -101,6 +101,7 @@ function AuditLogsPage() {
hasAccessToken,
profileRole,
tenantId,
requiredPages: ["audit"],
isLoadingIdentity: isLoadingMe,
});

View File

@@ -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<string, unknown> | undefined,
);
const { data: me } = useQuery<UserProfile>({
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const systemRole = resolveProfileRole(userProfile);
const { data: me, isLoading: isLoadingMe } = useQuery<UserProfile>({
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 (
<div className="p-8 text-center">
{t("msg.dev.clients.general.loading", "Loading client...")}
{t(
"msg.dev.clients.general.loading",
isCreate ? "Loading client creation..." : "Loading client...",
)}
</div>
);
}
if (isCreate && !hasClientCreateAccess) {
return (
<div className="p-8">
<div className="mx-auto max-w-2xl">
<DeveloperAccessRequestCard
title={t("ui.dev.clients.general.title_create", "Create Client")}
isPending={isDeveloperRequestPending}
canRequest={canRequestDeveloperAccess}
pendingMessage={t(
"msg.dev.clients.general.create_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.clients.general.create_forbidden",
"이 RP를 생성할 권한이 없습니다.",
)}
pendingDetailMessage={t(
"msg.dev.clients.general.create_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.clients.general.create_forbidden_detail",
"개발자 권한 신청에서 연동 앱 추가 권한을 선택한 뒤 승인받아주세요.",
)}
actionLabel={t(
"ui.dev.welcome.btn_request",
"개발자 등록 신청하기",
)}
onAction={() => navigate("/developer-requests")}
/>
</div>
</div>
);
}

View File

@@ -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");
});
});

View File

@@ -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() {
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
) : isDeveloperRequestPending ? (
) : isClientCreatePending ? (
<div className="flex items-center justify-end gap-3">
<p className="max-w-xs text-right text-sm text-muted-foreground">
{t(
@@ -257,7 +255,7 @@ function ClientsPage() {
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
</Button>
</div>
) : canRequestDeveloperAccess ? (
) : canRequestClientCreateAccess ? (
<div className="flex items-center justify-end gap-3">
<p className="max-w-xs whitespace-pre-line text-right text-sm text-muted-foreground">
{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", "연동 앱 추가")}
</button>
)}
{!isFilteredOut && canRequestDeveloperAccess && (
{!isFilteredOut && canRequestClientCreateAccess && (
<button
type="button"
className="text-primary font-bold hover:underline"
@@ -695,6 +693,7 @@ function RequestAccessModal({
organization,
reason,
tenantId,
accessPages: ["all"],
});
};

View File

@@ -5,7 +5,7 @@ describe("client create access", () => {
it("allows privileged roles to create clients without developer request approval", () => {
expect(
resolveClientCreateAccess({
role: "rp_admin",
role: "super_admin",
}),
).toBe("can_create");
});
@@ -14,7 +14,7 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "none",
accessStatus: { status: "none" },
}),
).toBe("request_required");
});
@@ -23,7 +23,7 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "",
requestStatus: undefined,
accessStatus: undefined,
}),
).toBe("request_required");
});
@@ -31,8 +31,8 @@ describe("client create access", () => {
it("shows pending state while a developer request is under review", () => {
expect(
resolveClientCreateAccess({
role: "tenant_member",
requestStatus: "pending",
role: "user",
accessStatus: { status: "pending", pendingPages: ["client_create"] },
}),
).toBe("pending");
});
@@ -41,7 +41,10 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "approved",
accessStatus: {
status: "approved",
approvedPages: ["client_create"],
},
}),
).toBe("can_create");
});
@@ -50,14 +53,14 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "cancelled",
accessStatus: { status: "cancelled" },
}),
).toBe("request_required");
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "rejected",
accessStatus: { status: "rejected" },
}),
).toBe("request_required");
});

View File

@@ -1,4 +1,8 @@
import type { DeveloperRequestStatus } from "../../lib/devApi";
import type { DeveloperAccessStatus } from "../../lib/devApi";
import {
hasDeveloperAccessForPages,
isDeveloperRequestPendingForPages,
} from "../developer-access/developerAccessPages";
export type ClientCreateAccessState =
| "can_create"
@@ -8,16 +12,16 @@ export type ClientCreateAccessState =
type ResolveClientCreateAccessParams = {
role: string;
requestStatus?: DeveloperRequestStatus;
accessStatus?: DeveloperAccessStatus;
};
function canSelfRequestDeveloperAccess(role: string) {
return role === "user" || role === "tenant_member";
return role === "user";
}
export function resolveClientCreateAccess({
role,
requestStatus,
accessStatus,
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
if (!role.trim()) {
return "request_required";
@@ -27,22 +31,19 @@ export function resolveClientCreateAccess({
return "can_create";
}
if (requestStatus === "approved") {
if (
hasDeveloperAccessForPages(accessStatus?.approvedPages, ["client_create"])
) {
return "can_create";
}
if (requestStatus === "pending") {
if (
isDeveloperRequestPendingForPages(accessStatus?.pendingPages, [
"client_create",
])
) {
return "pending";
}
if (
requestStatus === "none" ||
requestStatus === "rejected" ||
requestStatus === "cancelled" ||
typeof requestStatus === "undefined"
) {
return "request_required";
}
return "forbidden";
return "request_required";
}

View File

@@ -11,12 +11,16 @@ import ClientRelationsPage from "../clients/ClientRelationsPage";
import ClientsPage from "../clients/ClientsPage";
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
import DeveloperGrantsPage from "../developer-grants/DeveloperGrantsPage";
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
import ProfilePage from "../profile/ProfilePage";
import {
approveDeveloperRequest,
cancelDeveloperRequestApproval,
createDeveloperGrant,
fetchDeveloperGrants,
rejectDeveloperRequest,
revokeDeveloperGrant,
} from "../../lib/devApi";
const authProfile = {
@@ -195,6 +199,29 @@ vi.mock("../../lib/devApi", () => ({
},
],
})),
fetchDevUser: vi.fn(async () => ({
id: "user-2",
email: "editor@example.com",
name: "Editor User",
phone: "010-1111-2222",
role: "user",
status: "active",
tenant: {
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
status: "active",
description: "",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
tenantSlug: "hanmac",
companyCode: "HANMAC",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
})),
addClientRelation: vi.fn(async () => ({
relation: "admins",
subject: "User:user-2",
@@ -290,6 +317,24 @@ vi.mock("../../lib/devApi", () => ({
updatedAt: "2026-05-01T00:00:00Z",
},
]),
fetchTenants: vi.fn(async () => ({
items: [
{
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
status: "active",
description: "",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
],
limit: 1000,
offset: 0,
total: 1,
})),
fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
fetchDeveloperRequests: vi.fn(async () => [
@@ -319,9 +364,26 @@ vi.mock("../../lib/devApi", () => ({
updatedAt: "2026-05-02T00:00:00Z",
},
]),
fetchDeveloperGrants: vi.fn(async () => [
{
id: 3,
userId: "user-5",
tenantId: "tenant-1",
name: "Granted User",
organization: "Hanmac",
email: "granted@example.com",
reason: "Direct grant",
status: "approved",
adminNotes: "Manual grant",
createdAt: "2026-05-03T00:00:00Z",
updatedAt: "2026-05-03T00:00:00Z",
},
]),
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
createDeveloperGrant: vi.fn(async () => ({ status: "approved" })),
revokeDeveloperGrant: vi.fn(async () => ({ status: "ok" })),
}));
vi.mock("../auth/authApi", () => ({
@@ -408,6 +470,9 @@ describe("devfront coverage smoke pages", () => {
const requests = await renderPage(<DeveloperRequestPage />);
expect(requests.textContent).toContain("Requester");
const grants = await renderPage(<DeveloperGrantsPage />);
expect(grants.textContent).toContain("개발자 권한 부여");
const profile = await renderPage(<ProfilePage />);
expect(profile.textContent).toContain("Dev Admin");
});

View File

@@ -8,30 +8,63 @@ import {
describe("developer access gate", () => {
it("fetches request status only for user roles", () => {
expect(shouldFetchDeveloperRequestStatus("user")).toBe(true);
expect(shouldFetchDeveloperRequestStatus("tenant_admin")).toBe(false);
expect(shouldFetchDeveloperRequestStatus("rp_admin")).toBe(false);
expect(shouldFetchDeveloperRequestStatus("super_admin")).toBe(false);
});
it("resolves access and request states from the request status", () => {
expect(resolveDeveloperAccessGate("super_admin", "pending")).toEqual({
expect(
resolveDeveloperAccessGate("super_admin", {
status: "pending",
pendingPages: ["overview"],
}),
).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "approved")).toEqual({
expect(
resolveDeveloperAccessGate("user", {
status: "approved",
approvedPages: ["overview"],
}),
).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "pending")).toEqual({
expect(
resolveDeveloperAccessGate(
"user",
{
status: "pending",
pendingPages: ["audit"],
},
["audit"],
),
).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "none")).toEqual({
expect(
resolveDeveloperAccessGate(
"user",
{
status: "approved",
approvedPages: ["overview"],
},
["audit"],
),
).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: true,
});
expect(resolveDeveloperAccessGate("user", { status: "none" })).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: true,
@@ -41,7 +74,7 @@ describe("developer access gate", () => {
it("shows the loading gate only for user requests", () => {
expect(shouldShowDeveloperAccessLoading("user", true, false)).toBe(true);
expect(shouldShowDeveloperAccessLoading("user", false, true)).toBe(true);
expect(shouldShowDeveloperAccessLoading("tenant_admin", true, true)).toBe(
expect(shouldShowDeveloperAccessLoading("super_admin", true, true)).toBe(
false,
);
});

View File

@@ -1,31 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import {
type DeveloperRequestStatus,
type DeveloperAccessStatus,
fetchDeveloperRequestStatus,
} from "../../lib/devApi";
import {
type DeveloperAccessPage,
hasDeveloperAccessForPages,
isDeveloperRequestPendingForPages,
} from "./developerAccessPages";
export type DeveloperAccessGateState = {
hasDeveloperAccess: boolean;
isDeveloperRequestPending: boolean;
canRequestDeveloperAccess: boolean;
isLoadingDeveloperAccessGate: boolean;
isTenantContextMissing: boolean;
};
function isPrivilegedDeveloperRole(profileRole: string) {
return (
profileRole === "super_admin" ||
profileRole === "rp_admin" ||
profileRole === "tenant_admin"
);
}
export function resolveDeveloperAccessGate(
profileRole: string,
requestStatus?: DeveloperRequestStatus,
): Omit<DeveloperAccessGateState, "isLoadingDeveloperAccessGate"> {
accessStatus?: DeveloperAccessStatus,
requiredPages: DeveloperAccessPage[] = ["overview"],
): Omit<
DeveloperAccessGateState,
"isLoadingDeveloperAccessGate" | "isTenantContextMissing"
> {
const hasDeveloperAccess =
isPrivilegedDeveloperRole(profileRole) || requestStatus === "approved";
const isDeveloperRequestPending = requestStatus === "pending";
profileRole === "super_admin" ||
hasDeveloperAccessForPages(accessStatus?.approvedPages, requiredPages);
const isDeveloperRequestPending = isDeveloperRequestPendingForPages(
accessStatus?.pendingPages,
requiredPages,
);
const canRequestDeveloperAccess =
profileRole === "user" && !hasDeveloperAccess && !isDeveloperRequestPending;
@@ -54,15 +60,18 @@ export function useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
requiredPages = ["overview"],
isLoadingIdentity = false,
}: {
hasAccessToken: boolean;
profileRole: string;
tenantId?: string;
requiredPages?: DeveloperAccessPage[];
isLoadingIdentity?: boolean;
}) {
const shouldFetchRequestStatus =
shouldFetchDeveloperRequestStatus(profileRole);
const isTenantContextMissing = !tenantId?.trim();
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
@@ -71,11 +80,14 @@ export function useDeveloperAccessGate({
const resolvedGate = resolveDeveloperAccessGate(
profileRole,
requestStatus?.status,
requestStatus,
requiredPages,
);
return {
...resolvedGate,
isTenantContextMissing,
canRequestDeveloperAccess: resolvedGate.canRequestDeveloperAccess,
isLoadingDeveloperAccessGate: shouldShowDeveloperAccessLoading(
profileRole,
isLoadingIdentity,

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { normalizeDeveloperAccessPageSelection } from "./developerAccessPages";
describe("developer access pages", () => {
it("collapses all non-all pages into all", () => {
expect(
normalizeDeveloperAccessPageSelection([
"overview",
"client_create",
"audit",
]),
).toEqual(["all"]);
});
it("keeps partial selections as-is", () => {
expect(
normalizeDeveloperAccessPageSelection(["overview", "audit"]),
).toEqual(["overview", "audit"]);
});
it("keeps explicit all selection", () => {
expect(normalizeDeveloperAccessPageSelection(["all"])).toEqual(["all"]);
});
});

View File

@@ -0,0 +1,102 @@
export type DeveloperAccessPage =
| "all"
| "overview"
| "client_create"
| "audit";
export const developerAccessPageOrder: DeveloperAccessPage[] = [
"overview",
"client_create",
"audit",
];
export const developerAccessPageOptions: Array<{
value: DeveloperAccessPage;
label: string;
}> = [
{ value: "all", label: "전체" },
{ value: "overview", label: "개요" },
{ value: "client_create", label: "연동 앱 추가" },
{ value: "audit", label: "감사로그" },
];
export function normalizeDeveloperAccessPages(
pages: Array<string | undefined | null>,
): DeveloperAccessPage[] {
const normalized = new Set<DeveloperAccessPage>();
for (const raw of pages) {
const page = String(raw ?? "")
.trim()
.toLowerCase();
if (!page) {
continue;
}
if (page === "all") {
return ["all"];
}
if (page === "overview" || page === "client_create" || page === "audit") {
normalized.add(page);
}
}
return [...developerAccessPageOrder.filter((page) => normalized.has(page))];
}
export function normalizeDeveloperAccessPageSelection(
pages: DeveloperAccessPage[],
): DeveloperAccessPage[] {
if (pages.includes("all")) {
return ["all"];
}
const normalized = normalizeDeveloperAccessPages(pages);
if (normalized.length === 0) {
return ["all"];
}
if (normalized.length === developerAccessPageOrder.length) {
return ["all"];
}
return normalized;
}
export function developerAccessPagesToLabel(pages?: Array<string | null>) {
const normalized = normalizeDeveloperAccessPages(pages ?? []);
if (normalized.length === 0 || normalized.includes("all")) {
return "전체";
}
return normalized
.map((page) => {
switch (page) {
case "overview":
return "개요";
case "client_create":
return "연동 앱 추가";
case "audit":
return "감사로그";
default:
return page;
}
})
.join(", ");
}
export function hasDeveloperAccessForPages(
grantedPages: Array<string | null> | undefined,
requiredPages: DeveloperAccessPage[],
) {
const normalized = normalizeDeveloperAccessPages(grantedPages ?? []);
if (normalized.includes("all")) {
return true;
}
return requiredPages.some((page) => normalized.includes(page));
}
export function isDeveloperRequestPendingForPages(
pendingPages: Array<string | null> | undefined,
requiredPages: DeveloperAccessPage[],
) {
const normalized = normalizeDeveloperAccessPages(pendingPages ?? []);
if (normalized.includes("all")) {
return true;
}
return requiredPages.some((page) => normalized.includes(page));
}

View File

@@ -0,0 +1,681 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { KeyRound, Plus, Search, ShieldCheck, X } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { PageHeader } from "../../../../common/core/components/page";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import {
createDeveloperGrant,
type DevAssignableUser,
fetchDeveloperGrants,
fetchDevUser,
fetchDevUsers,
revokeDeveloperGrant,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import {
type DeveloperAccessPage,
developerAccessPageOptions,
normalizeDeveloperAccessPageSelection,
normalizeDeveloperAccessPages,
} from "../developer-access/developerAccessPages";
function formatUserLabel(user: DevAssignableUser) {
const primary = user.name.trim() || user.email.trim();
return `${primary} (${user.email.trim()})`;
}
export default function DeveloperGrantsPage() {
const auth = useAuth();
const queryClient = useQueryClient();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const profileRole = me?.role?.trim() || role;
const isSuperAdmin = profileRole === "super_admin";
const [userSearch, setUserSearch] = useState("");
const deferredUserSearch = useDeferredValue(userSearch.trim());
const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>(
null,
);
const [selectedAccessPages, setSelectedAccessPages] = useState<
DeveloperAccessPage[]
>(["all"]);
const [grantNotes, setGrantNotes] = useState("");
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
const { data: userSearchData, isFetching: isUserSearchLoading } = useQuery({
queryKey: ["developer-grant-users", deferredUserSearch],
queryFn: () => fetchDevUsers(deferredUserSearch, 10),
enabled:
hasAccessToken &&
isSuperAdmin &&
deferredUserSearch.length > 0 &&
selectedUser == null,
});
const { data: selectedUserDetail, isFetching: isSelectedUserDetailLoading } =
useQuery({
queryKey: ["developer-grant-user", selectedUser?.id],
queryFn: () => fetchDevUser(selectedUser?.id || ""),
enabled: hasAccessToken && isSuperAdmin && selectedUser != null,
});
const {
data: grants,
isLoading: isLoadingGrants,
error: grantsError,
} = useQuery({
queryKey: ["developer-grants"],
queryFn: () => fetchDeveloperGrants(),
enabled: hasAccessToken && isSuperAdmin,
});
const grantList = grants ?? [];
const filteredGrantedUsers = useMemo(() => {
return [...grantList].sort((a, b) => {
const tenantCompare = a.organization.localeCompare(b.organization);
if (tenantCompare !== 0) {
return tenantCompare;
}
return a.name.localeCompare(b.name);
});
}, [grantList]);
const createGrantMutation = useMutation({
mutationFn: createDeveloperGrant,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["developer-grants"] });
toast(
t(
"msg.dev.grants.create_success",
"개발자 권한이 직접 부여되었습니다.",
),
"success",
);
setSelectedUser(null);
setUserSearch("");
setSelectedAccessPages(["all"]);
setGrantNotes("");
},
onError: (err: AxiosError<{ error?: string }> | Error) => {
toast(
(err as AxiosError<{ error?: string }>).response?.data?.error ||
(err as Error).message ||
t("msg.common.error", "오류가 발생했습니다."),
"error",
);
},
});
const revokeGrantMutation = useMutation({
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
revokeDeveloperGrant(id, adminNotes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["developer-grants"] });
toast(
t("msg.dev.grants.revoke_success", "개발자 권한이 회수되었습니다."),
"success",
);
},
onError: (err: AxiosError<{ error?: string }> | Error) => {
toast(
(err as AxiosError<{ error?: string }>).response?.data?.error ||
(err as Error).message ||
t("msg.common.error", "오류가 발생했습니다."),
"error",
);
},
});
if (isLoadingMe) {
return (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
if (!isSuperAdmin) {
return (
<div className="space-y-6">
<PageHeader
icon={<ShieldCheck size={20} />}
title={t("ui.dev.nav.developer_grants", "개발자 권한 부여")}
description={t(
"msg.dev.grants.forbidden_desc",
"이 화면은 super admin만 사용할 수 있습니다.",
)}
/>
<Card className="border-amber-500/30 bg-amber-500/10">
<CardContent className="p-4 text-sm text-foreground">
{t(
"msg.dev.grants.forbidden",
"개발자 권한 직접 부여는 super admin만 사용할 수 있습니다.",
)}
</CardContent>
</Card>
</div>
);
}
const handleGrant = () => {
if (!selectedUser) {
toast(
t("msg.dev.grants.user_required", "부여할 사용자를 선택해주세요."),
"error",
);
return;
}
const tenantId =
selectedUserDetail?.tenant?.id?.trim() ||
selectedUserDetail?.tenantSlug?.trim() ||
selectedUserDetail?.companyCode?.trim() ||
"";
createGrantMutation.mutate({
userId: selectedUser.id,
tenantId,
reason: grantNotes.trim() || "직접 부여",
adminNotes: grantNotes.trim(),
accessPages: normalizeDeveloperAccessPageSelection(selectedAccessPages),
});
};
const handleSelectUser = (user: DevAssignableUser) => {
setSelectedUser(user);
setUserSearch(formatUserLabel(user));
setSelectedAccessPages(["all"]);
};
const handleAccessPageToggle = (page: DeveloperAccessPage) => {
setSelectedAccessPages((current) => {
if (page === "all") {
return ["all"];
}
const withoutAll = current.filter((item) => item !== "all");
if (withoutAll.includes(page)) {
const next = withoutAll.filter((item) => item !== page);
return next.length > 0 ? next : ["all"];
}
return normalizeDeveloperAccessPageSelection([...withoutAll, page]);
});
};
return (
<div className="space-y-8">
<PageHeader
icon={<KeyRound size={20} />}
title={t("ui.dev.nav.developer_grants", "개발자 권한 부여")}
description={t(
"msg.dev.grants.description",
"사용자에게 개발자 권한을 직접 부여하고, 부여된 권한을 회수할 수 있습니다.",
)}
actions={
<Badge variant="muted">
{t("msg.dev.grants.count", "총 {{count}}건", {
count: filteredGrantedUsers.length,
})}
</Badge>
}
/>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-xl">
{t("ui.dev.grants.form.title", "직접 부여")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.grants.form.description",
"사용자를 선택하면 현재 소속 정보가 표시되고, 그 사용자에게 개발자 권한을 즉시 부여합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<Card className="border-primary/10 bg-primary/5 shadow-sm">
<CardHeader className="space-y-1 pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">
{t("ui.dev.grants.user_section", "사용자 선택")}
</CardTitle>
<Badge variant="outline">
{t("ui.dev.grants.input_section", "입력")}
</Badge>
</div>
<CardDescription>
{t(
"msg.dev.grants.user_section_description",
"검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-0">
<div className="space-y-2">
<Label htmlFor="user-search">
{t("ui.dev.grants.user", "사용자")}{" "}
<span className="text-destructive">*</span>
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="user-search"
className="pl-10"
placeholder={t(
"ui.dev.grants.user_search_placeholder",
"이름 또는 이메일 검색...",
)}
value={userSearch}
onChange={(event) => {
setSelectedUser(null);
setUserSearch(event.target.value);
}}
/>
</div>
{selectedUser && (
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.grants.selected_user",
"선택된 사용자: {{user}}",
{ user: formatUserLabel(selectedUser) },
)}
</p>
)}
</div>
{userSearch.trim() !== "" && selectedUser == null && (
<div className="mt-2 max-h-64 overflow-y-auto rounded-lg border border-border/70 bg-muted/20 shadow-sm">
{isUserSearchLoading ? (
<div className="px-3 py-3 text-sm text-muted-foreground">
{t(
"msg.dev.grants.search_loading",
"사용자를 찾는 중입니다...",
)}
</div>
) : (userSearchData?.items ?? []).length > 0 ? (
(userSearchData?.items ?? []).map((user) => (
<button
key={user.id}
type="button"
className="flex w-full flex-col gap-1 border-b border-border/40 px-3 py-2 text-left last:border-b-0 hover:bg-primary/5"
onMouseDown={(event) => {
event.preventDefault();
handleSelectUser(user);
}}
>
<span className="text-sm font-semibold">
{user.name || user.email}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
{user.loginId ? ` · ${user.loginId}` : ""}
</span>
</button>
))
) : (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{t(
"msg.dev.grants.search_empty",
"검색 결과가 없습니다.",
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
<Card className="border-dashed bg-muted/20 shadow-none">
<CardHeader className="space-y-1 pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">
{t("ui.dev.grants.selected_info", "선택된 사용자 정보")}
</CardTitle>
<Badge variant="secondary">
{t("ui.dev.grants.read_only", "읽기 전용")}
</Badge>
</div>
<CardDescription>
{t(
"msg.dev.grants.selected_info_description",
"선택된 사용자의 소속, 이메일, 전화번호를 확인합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<div className="space-y-2">
<Label htmlFor="tenant-readonly">
{t("ui.dev.grants.tenant", "소속")}
</Label>
<Input
id="tenant-readonly"
className="border-dashed bg-background/70 text-foreground/90 shadow-none focus-visible:border-input focus-visible:ring-0 focus-visible:ring-offset-0"
value={
selectedUserDetail?.tenant?.name ||
selectedUserDetail?.tenantSlug ||
selectedUserDetail?.companyCode ||
(selectedUser && !isSelectedUserDetailLoading
? t("ui.common.na", "없음")
: "")
}
readOnly
placeholder={
selectedUser
? isSelectedUserDetailLoading
? t("msg.common.loading", "Loading...")
: t(
"msg.dev.grants.tenant_missing",
"선택한 사용자의 테넌트 정보가 없습니다.",
)
: t(
"msg.dev.grants.user_required",
"부여할 사용자를 선택해주세요.",
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email-readonly">
{t("ui.dev.grants.email", "이메일")}
</Label>
<Input
id="email-readonly"
className="border-dashed bg-background/70 text-foreground/90 shadow-none focus-visible:border-input focus-visible:ring-0 focus-visible:ring-offset-0"
value={
selectedUserDetail?.email || selectedUser?.email || ""
}
readOnly
placeholder={t(
"msg.dev.grants.user_required",
"부여할 사용자를 선택해주세요.",
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone-readonly">
{t("ui.dev.grants.phone", "전화번호")}
</Label>
<Input
id="phone-readonly"
className="border-dashed bg-background/70 text-foreground/90 shadow-none focus-visible:border-input focus-visible:ring-0 focus-visible:ring-offset-0"
value={selectedUserDetail?.phone || ""}
readOnly
placeholder={t(
"msg.dev.grants.phone_missing",
"등록된 전화번호가 없습니다.",
)}
/>
</div>
<div className="space-y-2">
<Label>
{t("ui.dev.grants.pages", "권한 페이지")}{" "}
<span className="text-destructive">*</span>
</Label>
<div className="grid gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
{developerAccessPageOptions.map((option) => {
const checked =
option.value === "all"
? selectedAccessPages.includes("all")
: selectedAccessPages.includes(option.value);
return (
<label
key={option.value}
className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-background/60"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
handleAccessPageToggle(option.value)
}
/>
<span className="font-medium">{option.label}</span>
</label>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.grants.pages_hint",
"전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
)}
</p>
</div>
</CardContent>
</Card>
</div>
<Card className="border-border/70 bg-background/80 shadow-sm">
<CardHeader className="space-y-1 pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">
{t("ui.dev.grants.admin_notes", "부여 사유")}
</CardTitle>
</div>
<CardDescription>
{t(
"msg.dev.grants.admin_notes_description",
"직접 부여의 근거를 간단히 남겨 두면 추후 회수와 검토에 도움이 됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<Textarea
id="admin-notes"
value={grantNotes}
onChange={(event) => setGrantNotes(event.target.value)}
className="min-h-[132px] bg-background"
placeholder={t(
"msg.dev.grants.admin_notes_placeholder",
"예: 테스트 환경 확인 후 권한 부여",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.grants.admin_notes_hint",
"회수는 목록의 회수 버튼으로 처리합니다.",
)}
</p>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={handleGrant}
disabled={createGrantMutation.isPending}
className="gap-2"
>
<Plus className="h-4 w-4" />
{createGrantMutation.isPending
? t("ui.common.submitting", "제출 중...")
: t("ui.dev.grants.grant", "직접 부여")}
</Button>
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-xl">
{t("ui.dev.grants.list.title", "부여된 권한")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.grants.list.description",
"현재 부여된 개발자 권한 목록입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingGrants ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("msg.common.loading", "Loading...")}
</div>
) : grantsError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{t(
"msg.dev.grants.load_error",
"개발자 권한 목록을 불러오지 못했습니다.",
)}
</div>
) : filteredGrantedUsers.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("msg.dev.grants.empty", "부여된 권한이 없습니다.")}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("ui.dev.grants.user", "사용자")}</TableHead>
<TableHead>{t("ui.dev.grants.tenant", "테넌트")}</TableHead>
<TableHead>
{t("ui.dev.grants.reason", "부여 사유")}
</TableHead>
<TableHead>
{t("ui.dev.grants.pages", "권한 페이지")}
</TableHead>
<TableHead>{t("ui.dev.grants.status", "상태")}</TableHead>
<TableHead>{t("ui.dev.grants.date", "부여일")}</TableHead>
<TableHead className="text-right">
{t("ui.dev.grants.actions", "관리")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGrantedUsers.map((grant) => (
<TableRow key={grant.id}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{grant.name || grant.email || grant.userId}
</div>
<div className="text-xs text-muted-foreground">
{grant.email || grant.userId}
</div>
<div className="font-mono text-xs text-muted-foreground">
{grant.userId}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{grant.organization ||
grant.tenantId ||
t("ui.common.na", "없음")}
</div>
<div className="font-mono text-xs text-muted-foreground">
{grant.tenantId || t("ui.common.na", "없음")}
</div>
</div>
</TableCell>
<TableCell className="max-w-md">
<div className="truncate" title={grant.reason}>
{grant.reason}
</div>
{grant.adminNotes && (
<div className="mt-1 text-xs text-muted-foreground">
{grant.adminNotes}
</div>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(grant.accessPages?.length
? normalizeDeveloperAccessPages(grant.accessPages)
: ["all"]
).map((page) => (
<Badge key={page} variant="outline">
{developerAccessPageOptions.find(
(option) => option.value === page,
)?.label ?? page}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant="success">
{t("ui.dev.grants.approved", "승인됨")}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(grant.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="ml-auto flex min-w-[220px] flex-col items-end gap-2">
<Input
placeholder={t(
"ui.dev.grants.revoke_notes_placeholder",
"회수 메모 (선택)...",
)}
className="h-8 text-xs"
value={adminNotes[grant.id] || ""}
onChange={(event) =>
setAdminNotes({
...adminNotes,
[grant.id]: event.target.value,
})
}
/>
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10"
disabled={revokeGrantMutation.isPending}
onClick={() => {
revokeGrantMutation.mutate({
id: grant.id,
adminNotes: adminNotes[grant.id] || "",
});
}}
>
<X className="mr-1 h-3 w-3" />
{t("ui.dev.grants.revoke", "회수")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -141,6 +141,34 @@ async function renderPage() {
}
describe("DeveloperRequestPage", () => {
it("shows selected access pages in the request list", async () => {
fetchDeveloperRequestsMock.mockResolvedValueOnce([
{
id: 1,
userId: "user-1",
tenantId: "tenant-1",
name: "Requester",
organization: "Hanmac",
email: "requester@example.com",
phone: "010-1234-5678",
role: "user",
reason: "Need RP access",
accessPages: ["overview", "audit"],
status: "pending",
createdAt: "2026-06-09T00:00:00Z",
updatedAt: "2026-06-09T00:00:00Z",
},
]);
const container = await renderPage();
const pageCell = container.querySelector(
"table tbody tr td:nth-child(3)",
) as HTMLTableCellElement | null;
expect(pageCell?.textContent).toContain("개요");
expect(pageCell?.textContent).toContain("감사로그");
expect(pageCell?.textContent).not.toContain("전체");
});
it("opens the request modal and submits a request", async () => {
const container = await renderPage();
expect(container.textContent).toContain("신규 신청하기");
@@ -183,6 +211,115 @@ describe("DeveloperRequestPage", () => {
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-1",
accessPages: ["all"],
});
});
it("allows requesting developer access even when tenant context is missing", async () => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
companyCode: "HANMAC",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
fetchMeMock.mockResolvedValue({
id: "user-1",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
role: "user",
});
fetchMyTenantsMock.mockResolvedValue([]);
const container = await renderPage();
expect(container.textContent).toContain("신규 신청하기");
expect(container.textContent).not.toContain(
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
);
const actionButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("신규 신청하기"),
);
expect(actionButton).toBeTruthy();
await act(async () => {
actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("개발자 등록 신청");
const reasonField = container.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
if (!reasonField) {
throw new Error("Expected reason textarea to be rendered");
}
await act(async () => {
await setTextAreaValue(reasonField, "Need RP access");
});
const submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "신청하기",
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(requestDeveloperAccessMock.mock.calls[0]?.[0]).toEqual({
name: "Requester",
organization: "HANMAC",
reason: "Need RP access",
tenantId: "",
accessPages: ["all"],
});
});
it("shows '없음' when organization is unavailable", async () => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
fetchMeMock.mockResolvedValue({
id: "user-1",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
role: "user",
});
fetchMyTenantsMock.mockResolvedValue([]);
const container = await renderPage();
const actionButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("신규 신청하기"),
);
expect(actionButton).toBeTruthy();
await act(async () => {
actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const orgField = container.querySelector("#org") as HTMLInputElement | null;
if (!orgField) {
throw new Error("Expected organization input to be rendered");
}
expect(orgField.value).toBe("없음");
});
});

View File

@@ -47,6 +47,12 @@ import {
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import {
type DeveloperAccessPage,
developerAccessPageOptions,
normalizeDeveloperAccessPageSelection,
normalizeDeveloperAccessPages,
} from "../developer-access/developerAccessPages";
export default function DeveloperRequestPage() {
const auth = useAuth();
@@ -152,9 +158,7 @@ export default function DeveloperRequestPage() {
);
}
const hasActiveRequest = requests?.some(
(r) => r.status === "pending" || r.status === "approved",
);
const hasActiveRequest = requests?.some((r) => r.status === "pending");
const approvedRequestCount =
requests?.filter((request) => request.status === "approved").length ?? 0;
const isActionPending =
@@ -218,6 +222,9 @@ export default function DeveloperRequestPage() {
<TableHead>
{t("ui.dev.request.table.reason", "신청 사유")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.pages", "권한 페이지")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.status", "상태")}
</TableHead>
@@ -235,7 +242,7 @@ export default function DeveloperRequestPage() {
{!requests || requests.length === 0 ? (
<TableRow>
<TableCell
colSpan={isSuperAdmin ? 6 : 4}
colSpan={isSuperAdmin ? 7 : 5}
className="h-32 text-center text-muted-foreground"
>
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
@@ -259,7 +266,10 @@ export default function DeveloperRequestPage() {
)}
</TableCell>
)}
<TableCell>{req.organization}</TableCell>
<TableCell>
{req.organization?.trim() ||
t("ui.common.na", "없음")}
</TableCell>
<TableCell className="max-w-md">
<div className="truncate" title={req.reason}>
{req.reason}
@@ -270,6 +280,25 @@ export default function DeveloperRequestPage() {
</div>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{req.accessPages?.length ? (
normalizeDeveloperAccessPages(
req.accessPages,
).map((page) => (
<Badge key={page} variant="outline">
{developerAccessPageOptions.find(
(option) => option.value === page,
)?.label ?? page}
</Badge>
))
) : (
<Badge variant="secondary">
{t("ui.common.na", "없음")}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<StatusBadge status={req.status} />
</TableCell>
@@ -447,11 +476,16 @@ function RequestAccessModal({
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([
"all",
]);
const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => {
if (!isOpen) return;
setName(initialName);
setOrganization(initialOrg);
setAccessPages(["all"]);
}, [initialName, initialOrg, isOpen]);
const mutation = useMutation({
@@ -468,6 +502,21 @@ function RequestAccessModal({
organization,
reason,
tenantId,
accessPages: normalizeDeveloperAccessPageSelection(accessPages),
});
};
const handleAccessPageToggle = (page: DeveloperAccessPage) => {
setAccessPages((current) => {
if (page === "all") {
return ["all"];
}
const withoutAll = current.filter((item) => item !== "all");
if (withoutAll.includes(page)) {
const next = withoutAll.filter((item) => item !== page);
return next.length > 0 ? next : ["all"];
}
return normalizeDeveloperAccessPageSelection([...withoutAll, page]);
});
};
@@ -518,7 +567,7 @@ function RequestAccessModal({
</Label>
<Input
id="org"
value={organization}
value={organizationDisplay}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required
@@ -559,6 +608,39 @@ function RequestAccessModal({
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-3">
<Label>
{t("ui.dev.request.modal.pages", "권한 페이지")}{" "}
<span className="text-destructive">*</span>
</Label>
<div className="grid gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
{developerAccessPageOptions.map((option) => {
const checked =
option.value === "all"
? accessPages.includes("all")
: accessPages.includes(option.value);
return (
<label
key={option.value}
className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-background/60"
>
<input
type="checkbox"
checked={checked}
onChange={() => handleAccessPageToggle(option.value)}
/>
<span className="font-medium">{option.label}</span>
</label>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.request.modal.pages_hint",
"전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
)}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}

View File

@@ -972,6 +972,7 @@ function GlobalOverviewPage() {
hasAccessToken,
profileRole,
tenantId,
requiredPages: ["overview"],
isLoadingIdentity: isLoadingMe,
});
const distribution = useMemo(
@@ -1275,14 +1276,14 @@ function GlobalOverviewPage() {
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedMessage={t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",

View File

@@ -173,6 +173,7 @@ describe("devApi", () => {
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-a",
accessPages: ["all"],
});
await approveDeveloperRequest(1, "approved");
await rejectDeveloperRequest(2, "rejected");
@@ -238,6 +239,7 @@ describe("devApi", () => {
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-a",
accessPages: ["all"],
});
expect(apiClient.post).toHaveBeenCalledWith(
"/dev/developer-request/1/approve",

View File

@@ -530,14 +530,23 @@ export type DeveloperRequest = {
phone?: string;
role?: string;
reason: string;
accessPages?: string[];
status: DeveloperRequestStatus;
adminNotes?: string;
createdAt: string;
updatedAt: string;
};
export type DeveloperGrant = DeveloperRequest;
export type DeveloperAccessStatus = {
status: DeveloperRequestStatus | "none";
approvedPages?: string[];
pendingPages?: string[];
};
export async function fetchDeveloperRequestStatus(tenantId?: string) {
const { data } = await apiClient.get<DeveloperRequest | { status: "none" }>(
const { data } = await apiClient.get<DeveloperAccessStatus>(
"/dev/developer-request/status",
{
params: { tenantId },
@@ -551,6 +560,7 @@ export async function requestDeveloperAccess(payload: {
organization: string;
reason: string;
tenantId: string;
accessPages: string[];
}) {
const { data } = await apiClient.post<{ status: string }>(
"/dev/developer-request",
@@ -595,3 +605,35 @@ export async function cancelDeveloperRequestApproval(
);
return data;
}
export async function fetchDeveloperGrants(tenantId?: string) {
const { data } = await apiClient.get<DeveloperGrant[]>(
"/dev/developer-grants",
{
params: { tenantId },
},
);
return data;
}
export async function createDeveloperGrant(payload: {
userId: string;
tenantId: string;
reason?: string;
adminNotes?: string;
accessPages: string[];
}) {
const { data } = await apiClient.post<DeveloperGrant>(
"/dev/developer-grants",
payload,
);
return data;
}
export async function revokeDeveloperGrant(id: number, adminNotes: string) {
const { data } = await apiClient.post<{ status: string }>(
`/dev/developer-grants/${id}/revoke`,
{ adminNotes },
);
return data;
}

View File

@@ -3,11 +3,8 @@ import { normalizeRole, resolveProfileRole } from "./role";
describe("normalizeRole", () => {
it("normalizes known role aliases", () => {
expect(normalizeRole("tenant_member")).toBe("user");
expect(normalizeRole("admin")).toBe("user");
expect(normalizeRole("superadmin")).toBe("super_admin");
expect(normalizeRole("tenantadmin")).toBe("tenant_admin");
expect(normalizeRole("rpadmin")).toBe("rp_admin");
});
it("returns 'user' for unknown string values and empty string for non-strings", () => {
@@ -21,7 +18,7 @@ describe("resolveProfileRole", () => {
expect(
resolveProfileRole({
role: " ",
grade: "tenant_member",
grade: " ",
"custom:role": "admin",
}),
).toBe("user");

View File

@@ -7,14 +7,6 @@ export function normalizeRole(rawRole: unknown): string {
case "superadmin":
case "super-admin":
return "super_admin";
case "rp_admin":
case "rpadmin":
case "rp-admin":
return "rp_admin";
case "tenant_admin":
case "tenantadmin":
case "tenant-admin":
return "tenant_admin";
default:
return "user";
}

View File

@@ -329,6 +329,9 @@ user_desc = "Review your request history and submit a new access request."
[msg.dev.request.modal]
desc = "Please enter the reason for your request. It will be approved after administrator review."
tenant_required = "Please submit a developer access request."
tenant_required_detail = "Enter a request reason and submit it for administrator review."
pages_hint = "If you select All, Overview, Add linked app, and Audit Logs are all included."
[msg.dev.clients]
load_error = "Error loading clients: {{error}}"
@@ -342,12 +345,16 @@ empty_detail = "RPs will appear here when a relationship is assigned to your acc
empty_can_create = "No linked apps have been registered yet."
empty_can_create_detail = "Create a new RP with the Add linked app button, and it will appear here."
create_requires_request = "You do not have permission to create applications.\nSubmit a developer access request and wait for approval."
create_requires_tenant = "Please submit a developer access request."
create_requires_tenant_detail = "Enter a request reason and submit it for administrator review."
create_pending_detail = "Your developer access request is under review. You will be able to add applications after approval."
create_forbidden_detail = "You do not have permission to create applications. Ask an administrator to grant developer access or the appropriate RP permissions."
empty_filtered = "No linked apps match the current filters."
empty_filtered_detail = "Try changing the search text or filters."
empty_pending = "Your developer access request is under review."
empty_pending_detail = "You can add linked apps after a super admin approves it."
empty_tenant_missing = "Please submit a developer access request."
empty_tenant_missing_detail = "Enter a request reason and submit it for administrator review."
[msg.dev.clients.consents]
empty = "No consents found."
@@ -537,6 +544,7 @@ unavailable_with_reason = "RP usage statistics API is unavailable. {{reason}}"
audit = "Review RP configuration changes and operational history."
clients = "Browse registered RPs and manage their status and type."
description = "Jump directly to key operational screens."
developer_grants = "Directly grant or revoke developer access for users."
developer_request = "Review developer access requests or submit a new one."
new_client = "Configure redirect URIs, grant types, and authentication methods."
@@ -548,6 +556,33 @@ none = "No connected applications to display."
description = "Review trends for changed or deleted applications on the dashboard."
empty = "There are no recent change logs yet."
[msg.dev.grants]
approved = "Approved"
count = "Total {{count}}"
create_success = "Developer access has been granted directly."
description = "Directly grant developer access to users and revoke granted access."
admin_notes_hint = "Revocations are handled from the list below."
admin_notes_description = "Leaving a short note for the direct grant helps later reviews and revocations."
admin_notes_placeholder = "e.g. Grant access after verifying the test environment"
empty = "There are no granted permissions."
forbidden = "Only super admin can directly grant developer access."
forbidden_desc = "This screen is available only to super admin."
form.description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately."
selected_info_description = "Review the selected user's tenant, email, and phone."
user_section_description = "Enter a search term to select a user. The next-step information stays empty until a user is chosen."
list.description = "Current developer access grants."
load_error = "Failed to load developer access grants."
reason = "Grant reason"
revoke = "Revoke"
revoke_success = "Developer access has been revoked."
search_empty = "No users found."
search_loading = "Searching users..."
selected_user = "Selected user: {{user}}"
tenant_required = "The selected user's tenant information is unavailable."
tenant_missing = "No tenant information is available for the selected user."
user_required = "Select a user before granting access."
phone_missing = "No phone number is registered."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
dev_scope = "Dev Scope"
@@ -1001,11 +1036,8 @@ total_tenants = "Total Tenants"
manageable_tenants = "Manageable Tenants"
[ui.admin.role]
rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)"
tenant_admin = "Tenant Administrator (Tenant Admin)"
tenant_member = "General User (Tenant Member)"
user = "General User (Tenant Member)"
user = "General User"
[ui.admin.tenants]
add = "Add Tenant"
@@ -1291,6 +1323,7 @@ scope_badge = "Scoped to /dev"
audit_logs = "Audit Logs"
clients = "Connected Application"
developer_request = "Developer Access Request"
developer_grants = "Developer Access Grants"
logout = "Logout"
overview = "Overview"
@@ -1306,10 +1339,35 @@ cancel_notes_placeholder = "Enter reason for approval cancellation..."
[ui.dev.request.list]
title = "Request History"
[ui.dev.grants]
actions = "Actions"
admin_notes = "Grant Reason"
all_tenants = "All Tenants"
approved = "Approved"
date = "Granted At"
form.title = "Direct Grant"
grant = "Grant Directly"
input_section = "Input"
list.title = "Granted Access"
pages = "Access Pages"
read_only = "Read Only"
reason = "Grant Reason"
reason_placeholder = "e.g. Developer console access is required for operational support."
required = "Required"
selected_info = "Selected User Info"
revoke = "Revoke"
revoke_notes_placeholder = "Revoke note (optional)..."
status = "Status"
tenant = "Affiliation"
user_section = "User Selection"
user = "User"
user_search_placeholder = "Search by name or email..."
[ui.dev.request.modal]
email = "Email"
name = "Name"
org = "Organization"
pages = "Access Pages"
phone = "Phone Number"
reason = "Reason"
reason_placeholder = "e.g. I need to create an OIDC client for internal service integration and testing."
@@ -1327,6 +1385,7 @@ actions = "Actions"
date = "Requested At"
org = "Organization"
reason = "Reason"
pages = "Access Pages"
status = "Status"
user = "User"
@@ -1366,10 +1425,8 @@ collapse = "Collapse sidebar"
expand = "Expand sidebar"
[ui.shell.role]
rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)"
tenant_admin = "Tenant Administrator (Tenant Admin)"
user = "General User (Tenant Member)"
user = "General User"
[ui.dev.clients]
new = "Add Connected Application"
@@ -2043,10 +2100,7 @@ title = "System Role"
description = "The permission level granted to this account."
current = "Current Role"
desc_super_admin = "Can manage all tenants and applications system-wide without restriction."
desc_tenant_admin = "Can manage all applications within their assigned tenant."
desc_rp_admin = "Can view and manage only assigned/linked applications."
desc_user = "Standard application access. DevFront access is denied."
desc_tenant_member = "Standard application access. DevFront access is denied."
[ui.admin.nav]
api_keys = "API Keys"
@@ -2068,9 +2122,10 @@ single_notice = "You belong to a single tenant and do not need to switch."
[msg.dev.forbidden]
default = "You do not have permission to access this resource. Please contact an administrator."
rp_admin = "RP administrators can only view resources for their assigned apps."
tenant_admin = "Tenant administrator permissions are not configured correctly or have expired."
user.clients = "General user accounts can only use this feature if they have been granted operational or management relationships for the relevant RP (App). If you need access, please request it from an administrator."
user.consents = "Viewing consent history for this App (RP) is only available when granted 'RP Admin', 'Consent View', or 'Consent Revoke' relationships. If you need access, please request it from an administrator."
user.audit = "Viewing audit logs for this App (RP) is only available when granted 'RP Admin' or 'Audit View' relationships. If you need access, please request it from an administrator."
user.consents = "Viewing consent history for this App (RP) is only available when granted operational, consent view, or consent revoke relationships. If you need access, please request it from an administrator."
user.audit = "Viewing audit logs for this App (RP) is only available when granted operational or audit view relationships. If you need access, please request it from an administrator."
title = "Access Denied: {{resource}}"
[ui.common]
na = "N/A"

View File

@@ -329,6 +329,9 @@ user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수
[msg.dev.request.modal]
desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다."
tenant_required = "개발자 권한 신청을 진행해 주세요."
tenant_required_detail = "신청 사유를 입력해 제출하면 관리자 검토 후 승인됩니다."
pages_hint = "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다."
[msg.dev.clients]
deleted = "앱이 삭제되었습니다."
@@ -339,12 +342,16 @@ empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩
empty_can_create = "아직 등록된 연동 앱이 없습니다."
empty_can_create_detail = "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다."
create_requires_request = "연동 앱을 생성할 권한이 없습니다.\n개발자 권한 신청을 요청한 뒤 승인 받아주세요."
create_requires_tenant = "개발자 권한 신청을 진행해 주세요."
create_requires_tenant_detail = "신청 사유를 입력해 제출하면 관리자 검토 후 승인됩니다."
create_pending_detail = "개발자 권한 신청을 검토 중입니다. 승인되면 연동 앱을 추가할 수 있습니다."
create_forbidden_detail = "연동 앱을 생성할 권한이 없습니다. 관리자에게 개발자 권한 또는 적절한 RP 권한 부여를 요청해 주세요."
empty_filtered = "조건에 맞는 연동 앱이 없습니다."
empty_filtered_detail = "검색어나 필터 조건을 변경해 보세요."
empty_pending = "개발자 권한 신청을 검토 중입니다."
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
empty_tenant_missing = "개발자 권한 신청을 진행해 주세요."
empty_tenant_missing_detail = "신청 사유를 입력해 제출하면 관리자 검토 후 승인됩니다."
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
loading = "앱 정보를 불러오는 중..."
showing = "총 {{shown}}개의 애플리케이션이 등록되어 있습니다."
@@ -537,6 +544,7 @@ unavailable_with_reason = "RP 이용 통계 API 응답을 확인할 수 없습
audit = "RP 설정 변경과 운영 이력을 확인합니다."
clients = "등록된 RP를 조회하고 상태와 유형을 관리합니다."
description = "주요 운영 화면으로 바로 이동합니다."
developer_grants = "사용자에게 개발자 권한을 직접 부여하거나 회수합니다."
developer_request = "개발자 권한 신청 내역을 확인하거나 새 요청을 등록합니다."
new_client = "redirect URI, grant type, 인증 방식을 설정합니다."
@@ -548,6 +556,33 @@ none = "표시할 연동 앱이 없습니다."
description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다."
empty = "최근 변경 로그가 아직 없습니다."
[msg.dev.grants]
approved = "승인됨"
count = "총 {{count}}건"
create_success = "개발자 권한을 직접 부여했습니다."
description = "사용자에게 개발자 권한을 직접 부여하고, 부여된 권한을 회수합니다."
admin_notes_hint = "회수는 목록의 회수 버튼으로 처리합니다."
admin_notes_description = "직접 부여의 근거를 간단히 남겨 두면 추후 회수와 검토에 도움이 됩니다."
admin_notes_placeholder = "예: 테스트 환경 확인 후 권한 부여"
empty = "부여된 권한이 없습니다."
forbidden = "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다."
forbidden_desc = "이 화면은 super admin만 사용할 수 있습니다."
form.description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다."
selected_info_description = "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다."
user_section_description = "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다."
list.description = "현재 부여된 개발자 권한 목록입니다."
load_error = "개발자 권한 목록을 불러오지 못했습니다."
reason = "부여 사유"
revoke = "회수"
revoke_success = "개발자 권한을 회수했습니다."
search_empty = "검색 결과가 없습니다."
search_loading = "사용자를 찾는 중입니다..."
selected_user = "선택된 사용자: {{user}}"
tenant_required = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다."
tenant_missing = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다."
user_required = "부여할 사용자를 선택해주세요."
phone_missing = "등록된 전화번호가 없습니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
dev_scope = "RP 정책은 dev scope에서만 적용"
@@ -1001,11 +1036,8 @@ total_tenants = "전체 테넌트 수"
manageable_tenants = "관리 가능한 테넌트"
[ui.admin.role]
rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "시스템 관리자 (Super Admin)"
tenant_admin = "테넌트 관리자 (Tenant Admin)"
tenant_member = "일반 사용자 (Tenant Member)"
user = "일반 사용자 (Tenant Member)"
user = "일반 사용자"
[ui.admin.tenants]
add = "테넌트 추가"
@@ -1291,6 +1323,7 @@ scope_badge = "Scoped to /dev"
audit_logs = "감사 로그"
clients = "연동 앱"
developer_request = "개발자 권한 신청"
developer_grants = "개발자 권한 부여"
logout = "로그아웃"
overview = "개요"
@@ -1306,10 +1339,35 @@ cancel_notes_placeholder = "승인 취소 사유 입력..."
[ui.dev.request.list]
title = "신청 내역"
[ui.dev.grants]
actions = "관리"
admin_notes = "부여 사유"
all_tenants = "전체 테넌트"
approved = "승인됨"
date = "부여일"
form.title = "직접 부여"
grant = "직접 부여"
input_section = "입력"
list.title = "부여된 권한"
pages = "권한 페이지"
read_only = "읽기 전용"
reason = "부여 사유"
reason_placeholder = "예: 운영 지원을 위해 개발 콘솔 접근이 필요합니다."
required = "필수"
selected_info = "선택된 사용자 정보"
revoke = "회수"
revoke_notes_placeholder = "회수 메모 (선택)..."
status = "상태"
tenant = "소속"
user_section = "사용자 선택"
user = "사용자"
user_search_placeholder = "이름 또는 이메일 검색..."
[ui.dev.request.modal]
email = "이메일"
name = "성함"
org = "소속"
pages = "권한 페이지"
phone = "전화번호"
reason = "신청 사유"
reason_placeholder = "예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다."
@@ -1327,6 +1385,7 @@ actions = "관리"
date = "신청일"
org = "소속"
reason = "신청 사유"
pages = "권한 페이지"
status = "상태"
user = "사용자"
@@ -1366,10 +1425,8 @@ collapse = "사이드바 접기"
expand = "사이드바 펼치기"
[ui.shell.role]
rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "시스템 관리자 (Super Admin)"
tenant_admin = "테넌트 관리자 (Tenant Admin)"
user = "일반 사용자 (Tenant Member)"
user = "일반 사용자"
[ui.dev.clients]
new = "연동 앱 추가"
@@ -2051,10 +2108,7 @@ title = "시스템 역할"
description = "현재 계정에 부여된 권한 등급입니다."
current = "현재 역할"
desc_super_admin = "전체 시스템의 모든 테넌트와 모든 앱을 제한 없이 관리할 수 있습니다."
desc_tenant_admin = "본인이 속한 테넌트(조직/회사) 하위의 모든 앱을 관리할 수 있습니다."
desc_rp_admin = "본인에게 할당된 연동 앱(Client)만 확인 및 관리할 수 있습니다."
desc_user = "기본 앱 이용 권한을 가지며, DevFront 접근은 차단됩니다."
desc_tenant_member = "기본 앱 이용 권한을 가지며, DevFront 접근은 차단됩니다."
[ui.dev.tenant]
workspace = "작업 테넌트 (컨텍스트)"
@@ -2064,9 +2118,10 @@ single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니
[msg.dev.forbidden]
default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요."
rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다."
tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다."
user.clients = "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
user.consents = "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
user.audit = "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
user.consents = "해당 앱(RP)에 대한 동의 내역 조회는 운영, 동의 조회, 동의 회수 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
user.audit = "해당 앱(RP)에 대한 감사 로그 조회는 운영 또는 감사 조회 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
title = "{{resource}} 접근 권한 없음"
[ui.common]
na = "없음"

View File

@@ -343,6 +343,8 @@ user_desc = ""
[msg.dev.request.modal]
desc = ""
tenant_required = ""
tenant_required_detail = ""
[msg.dev.request.status]
approved = ""
@@ -379,6 +381,8 @@ empty = ""
empty_detail = ""
empty_can_create = ""
empty_can_create_detail = ""
create_requires_tenant = ""
create_requires_tenant_detail = ""
create_requires_request = ""
create_pending_detail = ""
create_forbidden_detail = ""
@@ -386,6 +390,8 @@ empty_filtered = ""
empty_filtered_detail = ""
empty_pending = ""
empty_pending_detail = ""
empty_tenant_missing = ""
empty_tenant_missing_detail = ""
[msg.dev.clients.consents]
empty = ""
@@ -575,6 +581,7 @@ unavailable_with_reason = ""
audit = ""
clients = ""
description = ""
developer_grants = ""
developer_request = ""
new_client = ""
@@ -586,6 +593,34 @@ none = ""
description = ""
empty = ""
[msg.dev.grants]
approved = ""
count = ""
create_success = ""
description = ""
admin_notes_hint = ""
admin_notes_description = ""
admin_notes_placeholder = ""
empty = ""
forbidden = ""
forbidden_desc = ""
form.description = ""
selected_info_description = ""
user_section_description = ""
list.description = ""
load_error = ""
reason = ""
revoke = ""
revoke_success = ""
search_empty = ""
search_loading = ""
selected_user = ""
tenant_required = ""
tenant_missing = ""
user_required = ""
phone_missing = ""
required = ""
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -1040,10 +1075,7 @@ total_tenants = ""
manageable_tenants = ""
[ui.admin.role]
rp_admin = ""
super_admin = ""
tenant_admin = ""
tenant_member = ""
user = ""
[ui.admin.tenants]
@@ -1344,6 +1376,7 @@ scope_badge = ""
audit_logs = ""
clients = ""
developer_request = ""
developer_grants = ""
logout = ""
overview = ""
@@ -1358,6 +1391,29 @@ cancel_notes_placeholder = ""
[ui.dev.request.list]
title = ""
[ui.dev.grants]
actions = ""
admin_notes = ""
all_tenants = ""
approved = ""
date = ""
form.title = ""
grant = ""
input_section = ""
list.title = ""
read_only = ""
reason = ""
reason_placeholder = ""
required = ""
selected_info = ""
revoke = ""
revoke_notes_placeholder = ""
status = ""
tenant = ""
user_section = ""
user = ""
user_search_placeholder = ""
[ui.dev.request.modal]
email = ""
name = ""
@@ -1422,9 +1478,7 @@ collapse = ""
expand = ""
[ui.shell.role]
rp_admin = ""
super_admin = ""
tenant_admin = ""
user = ""
[ui.dev.clients]
@@ -2093,10 +2147,7 @@ title = ""
description = ""
current = ""
desc_super_admin = ""
desc_tenant_admin = ""
desc_rp_admin = ""
desc_user = ""
desc_tenant_member = ""
[ui.dev.tenant]
workspace = "Workspace Tenant (Context)"

View File

@@ -53,7 +53,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
test.describe("DevFront client detail tabs", () => {
test.beforeEach(async ({ page }) => {
await seedAuth(page, "rp_admin");
await seedAuth(page, "super_admin");
});
test(

View File

@@ -49,7 +49,12 @@ test.describe("DevFront developer request and management", () => {
await page.locator("#reason").fill("Need to test OIDC integration");
// Submit
await page.getByRole("button", { name: "신청하기", exact: true }).click();
await page
.locator("form")
.filter({ has: page.locator("#reason") })
.evaluate((form) => {
(form as HTMLFormElement).requestSubmit();
});
// Verify Status - Look for "Pending" or "대기" anywhere
await expect(page.locator("body")).toContainText(/대기|Pending/);
@@ -127,7 +132,7 @@ test.describe("DevFront developer request and management", () => {
developerRequests: [request],
};
await seedAuth(page, "rp_admin");
await seedAuth(page, "user");
await installDevApiMock(page, state);
await page.goto("/clients");

View File

@@ -19,7 +19,7 @@ test.describe("DevFront relationships", () => {
page.on("dialog", async (dialog) => {
await dialog.accept();
});
await seedAuth(page, "rp_admin");
await seedAuth(page, "super_admin");
});
test("list add and remove direct RP relationships", async ({ page }) => {

View File

@@ -100,13 +100,29 @@ test.describe("DevFront role report", () => {
await captureEvidence(page, testInfo, "role-user-overview-approved");
});
test("rp_admin sees only assigned Gitea app and its logs", async ({
test("user sees only assigned Gitea app and its logs", async ({
page,
}, testInfo) => {
await seedAuth(page, "rp_admin");
await seedAuth(page, "user");
const state = {
clients: [makeClient("gitea-client", { name: "Gitea" })],
consents: [] as Consent[],
developerRequests: [
{
id: "req-audit-approved",
userId: "playwright-user",
userName: "Playwright User",
name: "Playwright User",
userEmail: "playwright@example.com",
organization: "Tenant A",
reason: "Need access",
status: "approved",
accessPages: ["audit"],
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:10:00.000Z",
approvedAt: "2026-05-29T00:10:00.000Z",
},
],
auditLogs: [
{
event_id: "evt-rp-1",
@@ -133,18 +149,18 @@ test.describe("DevFront role report", () => {
await expect(
page.getByRole("cell", { name: "gitea-client" }),
).toBeVisible();
await captureEvidence(page, testInfo, "role-rp-admin-clients");
await captureEvidence(page, testInfo, "role-user-clients");
await page.goto("/audit-logs");
await expect(page.getByText("UPDATE_CLIENT")).toBeVisible();
await expect(page.getByText("gitea-client")).toBeVisible();
await captureEvidence(page, testInfo, "role-rp-admin-audit");
await captureEvidence(page, testInfo, "role-user-audit");
});
test("tenant_admin can manage tenant apps and see tenant logs", async ({
test("super_admin can manage tenant apps and see tenant logs", async ({
page,
}, testInfo) => {
await seedAuth(page, "tenant_admin");
await seedAuth(page, "super_admin");
const state = {
clients: [
makeClient("tenant-a-app-1", { name: "Tenant A CRM" }),
@@ -159,7 +175,7 @@ test.describe("DevFront role report", () => {
await page.goto("/clients");
await expect(page.getByText("Tenant A CRM")).toBeVisible();
await expect(page.getByText("Tenant A ERP")).toBeVisible();
await captureEvidence(page, testInfo, "role-tenant-admin-clients");
await captureEvidence(page, testInfo, "role-super-admin-clients");
await page.goto("/clients/tenant-a-app-1/settings");
await page
@@ -179,7 +195,7 @@ test.describe("DevFront role report", () => {
timeout: 30000,
});
await expect(page.getByText("tenant-a-app-1")).toBeVisible();
await captureEvidence(page, testInfo, "role-tenant-admin-audit");
await captureEvidence(page, testInfo, "role-super-admin-audit");
});
test("super_admin sees all and can generate log entries", async ({

View File

@@ -59,14 +59,28 @@ test.describe("DevFront security and isolation", () => {
await expect(page.getByText("Server side App")).not.toBeVisible();
});
test("tenant_member user can enter DevFront and sees empty RP list", async ({
page,
}) => {
await seedAuth(page, "tenant_member");
test("user can enter DevFront and sees empty RP list", async ({ page }) => {
await seedAuth(page, "user");
const state = {
clients: [] as ReturnType<typeof makeClient>[],
consents: [] as Consent[],
auditLogsByCursor: undefined,
developerRequests: [
{
id: "req-audit-approved",
userId: "playwright-user",
userName: "Playwright User",
name: "Playwright User",
userEmail: "playwright@example.com",
organization: "Tenant A",
reason: "Need access",
status: "approved",
accessPages: ["audit"],
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:10:00.000Z",
approvedAt: "2026-05-29T00:10:00.000Z",
},
],
};
await installDevApiMock(page, state);
@@ -80,10 +94,10 @@ test.describe("DevFront security and isolation", () => {
).not.toBeVisible();
});
test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({
test("user receives 403 on clients list and sees ForbiddenMessage", async ({
page,
}) => {
await seedAuth(page, "rp_admin");
await seedAuth(page, "user");
const state = {
clients: [] as ReturnType<typeof makeClient>[],
@@ -105,19 +119,42 @@ test.describe("DevFront security and isolation", () => {
await page.goto("/clients");
await expect(
page.getByText(/RP 관리자는|RP administrators can only access/i),
page.getByText(
/연동 앱 접근 권한 없음|Access denied: Connected Applications/i,
),
).toBeVisible();
await expect(
page.getByText(
/일반 사용자 계정은 담당 RP\(앱\)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다|Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target application/i,
),
).toBeVisible();
});
test("tenant_admin receives 403 on audit logs and sees ForbiddenMessage", async ({
test("user receives 403 on audit logs and sees ForbiddenMessage", async ({
page,
}) => {
await seedAuth(page, "tenant_admin");
await seedAuth(page, "user");
const state = {
clients: [] as ReturnType<typeof makeClient>[],
consents: [] as Consent[],
auditLogsByCursor: undefined,
developerRequests: [
{
id: "req-audit-approved",
userId: "playwright-user",
userName: "Playwright User",
name: "Playwright User",
userEmail: "playwright@example.com",
organization: "Tenant A",
reason: "Need audit access",
status: "approved",
accessPages: ["audit"],
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:10:00.000Z",
approvedAt: "2026-05-29T00:10:00.000Z",
},
],
};
await installDevApiMock(page, state);
@@ -134,7 +171,12 @@ test.describe("DevFront security and isolation", () => {
await page.goto("/audit-logs");
await expect(
page.getByText(/테넌트 관리자 권한|Tenant administrator permissions/i),
page.getByText(/감사 로그 접근 권한 없음|Access denied: Audit Logs/i),
).toBeVisible();
await expect(
page.getByText(
/해당 앱\(RP\)에 대한 감사 로그 조회는 운영 또는 감사 조회 관계가 부여된 경우에만 사용할 수 있습니다|Viewing audit logs for this application requires an audit read relationship/i,
),
).toBeVisible();
});

View File

@@ -29,7 +29,7 @@ test.describe("DevFront tenant switch", () => {
id: "playwright-user",
email: "playwright@example.com",
name: "Playwright User",
role: "tenant_admin",
role: "user",
tenantId: "tenant-a",
}),
});
@@ -40,8 +40,8 @@ test.describe("DevFront tenant switch", () => {
});
test("multiple tenants: user can switch tenant context", async ({ page }) => {
// Seed an admin user
await seedAuth(page, "tenant_admin");
// Seed a standard user
await seedAuth(page, "user");
await installDevApiMock(page, MOCK_STATE);
@@ -87,7 +87,7 @@ test.describe("DevFront tenant switch", () => {
test("single tenant: switcher is disabled with a notice", async ({
page,
}) => {
await seedAuth(page, "tenant_admin");
await seedAuth(page, "user");
// Mock API to return only ONE tenant
await page.route("**/api/v1/dev/my-tenants", async (route) => {

View File

@@ -150,7 +150,7 @@ export function makeClient(
export async function seedAuth(page: Page, role?: string) {
const nowInSeconds = Math.floor(Date.now() / 1000);
seededRoles.set(page, role || "rp_admin");
seededRoles.set(page, role || "super_admin");
await page.addInitScript(
({ issuedAt, injectedRole }) => {
@@ -190,7 +190,7 @@ export async function seedAuth(page: Page, role?: string) {
window.sessionStorage.setItem(key, JSON.stringify(mockOidcUser));
}
window.localStorage.setItem("dev_role", injectedRole || "rp_admin");
window.localStorage.setItem("dev_role", injectedRole || "super_admin");
window.localStorage.setItem("dev_tenant_id", "tenant-a");
},
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
@@ -240,7 +240,33 @@ function parseClientId(pathname: string): string {
export async function installDevApiMock(page: Page, state: DevApiMockState) {
const readMockRole = () =>
(state.mockRole ?? seededRoles.get(page) ?? "rp_admin").trim();
(state.mockRole ?? seededRoles.get(page) ?? "super_admin").trim();
const buildDeveloperAccessStatus = () => {
const requests = state.developerRequests ?? [];
const myRequests = requests.filter(
(request) => request.userId === "playwright-user",
);
const approvedPages = myRequests
.filter((request) => request.status === "approved")
.flatMap((request) => request.accessPages ?? ["all"]);
const pendingPages = myRequests
.filter((request) => request.status === "pending")
.flatMap((request) => request.accessPages ?? ["all"]);
const latestRequest = myRequests[myRequests.length - 1];
if (!latestRequest) {
return {
status: "none" as const,
};
}
return {
status: latestRequest.status,
approvedPages,
pendingPages,
};
};
const buildSelfConfigEditorRelation = (): ClientRelation => ({
relation: "config_editor",
@@ -253,7 +279,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
const shouldGrantDefaultEditRelation = (role: string) =>
role === "rp_admin" || role === "tenant_admin" || role === "super_admin";
role === "super_admin";
const resolveClientRelations = async (clientId: string) => {
const explicitRelations = state.relations?.[clientId];
@@ -358,10 +384,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
pathname === "/api/v1/dev/developer-request/status") &&
method === "GET"
) {
const myRequest = (state.developerRequests ?? []).find(
(r) => r.userId === "playwright-user",
);
return json(route, myRequest || null);
return json(route, buildDeveloperAccessStatus());
}
if (

View File

@@ -420,8 +420,37 @@ phone = "Phone"
reason = "Request Reason"
reason_placeholder = "Explain why you need developer access."
role = "Role"
pages_hint = "If you select All, Overview, Add linked app, and Audit Logs are all included."
title = "Developer Registration Request"
[msg.dev.grants]
admin_notes_description = "Leaving a short note for the direct grant helps later reviews and revocations."
admin_notes_hint = "Revocations are handled from the list below."
admin_notes_placeholder = "e.g. Grant access after verifying the test environment"
approved = "Approved"
count = "Total {{count}}"
create_success = "Developer access has been granted directly."
description = "Directly grant developer access to users and revoke granted access."
empty = "There are no granted permissions."
forbidden = "Only super admin can directly grant developer access."
forbidden_desc = "This screen is available only to super admin."
form.description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately."
list.description = "Current developer access grants."
load_error = "Failed to load developer access grants."
pages_hint = "If you select All, Overview, Add linked app, and Audit Logs are all included."
phone_missing = "No phone number is registered."
reason = "Grant reason"
revoke = "Revoke"
revoke_success = "Developer access has been revoked."
search_empty = "No users found."
search_loading = "Searching users..."
selected_info_description = "Review the selected user's tenant, email, and phone."
selected_user = "Selected user: {{user}}"
tenant_missing = "No tenant information is available for the selected user."
tenant_required = "The selected user's tenant information is unavailable."
user_required = "Select a user before granting access."
user_section_description = "Enter a search term to select a user. The next-step information stays empty until a user is chosen."
[msg.dev.request.status]
approved = "Approved"
cancelled = "Approval Cancelled"
@@ -2377,6 +2406,7 @@ overview = "Overview"
clients = "Connected Application"
logout = "Logout"
developer_request = "Developer Access Request"
developer_grants = "Developer Access Grants"
[ui.dev.welcome]
btn_request = "New Request"
@@ -2394,6 +2424,7 @@ desc = "Review the information below and enter a request reason to apply for dev
email = "Email"
name = "Name"
org = "Organization"
pages = "Access Pages"
phone = "Phone"
reason = "Request Reason"
reason_placeholder = "Explain why you need developer access."
@@ -2411,9 +2442,33 @@ actions = "Actions"
date = "Requested At"
org = "Organization"
reason = "Request Reason"
pages = "Access Pages"
status = "Status"
user = "User"
[ui.dev.grants]
actions = "Actions"
admin_notes = "Grant Reason"
approved = "Approved"
date = "Granted At"
email = "Email"
form.title = "Direct Grant"
grant = "Grant Directly"
input_section = "Input"
list.title = "Granted Access"
pages = "Access Pages"
read_only = "Read Only"
reason = "Grant Reason"
revoke = "Revoke"
revoke_notes_placeholder = "Revoke note (optional)..."
selected_info = "Selected User Info"
status = "Status"
phone = "Phone Number"
tenant = "Affiliation"
user = "User"
user_search_placeholder = "Search by name or email..."
user_section = "User Selection"
[ui.dev.profile]
error = "Failed to load profile."
loading = "Loading profile..."

View File

@@ -421,6 +421,7 @@ overview = "개요"
clients = "연동 앱"
logout = "로그아웃"
developer_request = "개발자 권한 신청"
developer_grants = "개발자 권한 부여"
[ui.dev.welcome]
btn_request = "신규 신청하기"
@@ -438,6 +439,7 @@ desc = "개발자 권한을 신청하려면 아래 정보를 확인한 뒤 신
email = "이메일"
name = "성함"
org = "소속"
pages = "권한 페이지"
phone = "전화번호"
reason = "신청 사유"
reason_placeholder = "개발자 권한이 필요한 이유를 작성해주세요."
@@ -455,9 +457,54 @@ actions = "관리"
date = "신청일"
org = "소속"
reason = "신청 사유"
pages = "권한 페이지"
status = "상태"
user = "사용자"
[ui.dev.grants]
actions = "관리"
admin_notes = "부여 사유"
approved = "승인됨"
date = "부여일"
email = "이메일"
form.title = "직접 부여"
grant = "직접 부여"
input_section = "입력"
list.title = "부여된 권한"
pages = "권한 페이지"
read_only = "읽기 전용"
reason = "부여 사유"
revoke = "회수"
revoke_notes_placeholder = "회수 메모 (선택)..."
selected_info = "선택된 사용자 정보"
status = "상태"
phone = "전화번호"
tenant = "소속"
user = "사용자"
user_search_placeholder = "이름 또는 이메일 검색..."
user_section = "사용자 선택"
[ui.dev.grants]
actions = "관리"
admin_notes = "부여 사유"
approved = "승인됨"
date = "부여일"
form.title = "직접 부여"
grant = "직접 부여"
input_section = "입력"
list.title = "부여된 권한"
pages = "권한 페이지"
read_only = "읽기 전용"
reason = "부여 사유"
revoke = "회수"
revoke_notes_placeholder = "회수 메모 (선택)..."
selected_info = "선택된 사용자 정보"
status = "상태"
tenant = "소속"
user = "사용자"
user_search_placeholder = "이름 또는 이메일 검색..."
user_section = "사용자 선택"
[ui.dev.tenant]
single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다."
switch_success = "테넌트 전환 완료"
@@ -918,8 +965,37 @@ phone = "전화번호"
reason = "신청 사유"
reason_placeholder = "개발자 권한이 필요한 이유를 작성해주세요."
role = "역할"
pages_hint = "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다."
title = "개발자 등록 신청"
[msg.dev.grants]
admin_notes_description = "직접 부여의 근거를 간단히 남겨 두면 추후 회수와 검토에 도움이 됩니다."
admin_notes_hint = "회수는 목록의 회수 버튼으로 처리합니다."
admin_notes_placeholder = "예: 테스트 환경 확인 후 권한 부여"
approved = "승인됨"
count = "총 {{count}}건"
create_success = "개발자 권한을 직접 부여했습니다."
description = "사용자에게 개발자 권한을 직접 부여하고, 부여된 권한을 회수합니다."
empty = "부여된 권한이 없습니다."
forbidden = "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다."
forbidden_desc = "이 화면은 super admin만 사용할 수 있습니다."
form.description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다."
list.description = "현재 부여된 개발자 권한 목록입니다."
load_error = "개발자 권한 목록을 불러오지 못했습니다."
pages_hint = "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다."
phone_missing = "등록된 전화번호가 없습니다."
reason = "부여 사유"
revoke = "회수"
revoke_success = "개발자 권한을 회수했습니다."
search_empty = "검색 결과가 없습니다."
search_loading = "사용자를 찾는 중입니다..."
selected_info_description = "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다."
selected_user = "선택된 사용자: {{user}}"
tenant_missing = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다."
tenant_required = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다."
user_required = "부여할 사용자를 선택해주세요."
user_section_description = "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다."
[msg.dev.request.status]
approved = "승인됨"
cancelled = "승인 취소됨"

View File

@@ -278,6 +278,7 @@ success = ""
clients = ""
logout = ""
developer_request = ""
developer_grants = ""
[ui.dev.welcome]
btn_request = ""
@@ -299,6 +300,7 @@ phone = ""
reason = ""
reason_placeholder = ""
role = ""
pages = ""
title = ""
[ui.dev.request.status]
@@ -312,9 +314,33 @@ actions = ""
date = ""
org = ""
reason = ""
pages = ""
status = ""
user = ""
[ui.dev.grants]
actions = ""
admin_notes = ""
approved = ""
date = ""
email = ""
form.title = ""
grant = ""
input_section = ""
list.title = ""
pages = ""
read_only = ""
reason = ""
revoke = ""
revoke_notes_placeholder = ""
selected_info = ""
status = ""
phone = ""
tenant = ""
user = ""
user_search_placeholder = ""
user_section = ""
[ui.dev.tenant]
single_notice = ""
switch_success = ""
@@ -748,6 +774,34 @@ unknown_error = ""
[msg.dev]
logout_confirm = ""
[msg.dev.grants]
admin_notes_description = ""
admin_notes_hint = ""
admin_notes_placeholder = ""
approved = ""
count = ""
create_success = ""
description = ""
empty = ""
forbidden = ""
forbidden_desc = ""
form.description = ""
list.description = ""
load_error = ""
pages_hint = ""
phone_missing = ""
reason = ""
revoke = ""
revoke_success = ""
search_empty = ""
search_loading = ""
selected_info_description = ""
selected_user = ""
tenant_missing = ""
tenant_required = ""
user_required = ""
user_section_description = ""
[msg.dev.audit]
access_denied = ""
access_denied_detail = ""
@@ -778,6 +832,7 @@ phone = ""
reason = ""
reason_placeholder = ""
role = ""
pages_hint = ""
title = ""
[msg.dev.request.status]

View File

@@ -1682,11 +1682,7 @@ export function TenantOrgChartPage() {
const errorMessage = shareToken
? "조직도를 불러올 수 없거나 만료된 링크입니다."
: "조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.";
return (
<div className="p-8 text-center text-red-500">
{errorMessage}
</div>
);
return <div className="p-8 text-center text-red-500">{errorMessage}</div>;
}
const updateLayoutMode = (value: ChildLayoutMode) => {

View File

@@ -39,8 +39,6 @@ describe("buildTenantFullTree", () => {
expect(result.subTree).toHaveLength(1);
expect(result.subTree[0]?.id).toBe("hanmac-family-id");
expect(result.subTree[0]?.children[0]?.id).toBe("saman-id");
expect(result.subTree[0]?.children[0]?.children[0]?.id).toBe(
"platform-id",
);
expect(result.subTree[0]?.children[0]?.children[0]?.id).toBe("platform-id");
});
});