1
0
forked from baron/baron-sso

11 Commits

51 changed files with 2548 additions and 1455 deletions

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,19 +396,14 @@ 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",
@@ -395,7 +411,7 @@ test.describe("User Management", () => {
});
await page
.getByRole("button", { name: /전역 Claim 저장|Save Global Claim/i })
.getByRole("button", { name: /사용자 Claim 저장/i })
.click();
await expect
@@ -403,15 +419,15 @@ test.describe("User Management", () => {
.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")

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

@@ -127,7 +127,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>[],
@@ -109,10 +123,10 @@ test.describe("DevFront security and isolation", () => {
).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>[],

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,31 @@ 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 +277,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 +382,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

@@ -1,412 +0,0 @@
# [요청과업1] Baron-SSO 개발환경 구축 (WSL2 + Docker Engine)
## 1. 작업 개요
| 항목 | 내용 |
| ------ | ----------------------------- |
| 작업명 | 요청과업1 - Docker 설치 및 WSL 환경 구축 |
| 작업일 | 2026-06-11 |
| 시작 | 08:00 |
| 종료 | 15:00 |
| 휴게시간 | 12:00 ~ 13:00 |
| 총 작업시간 | 6시간 |
| 작업자 | ERP기획팀 |
---
# 2. 작업 배경
기존 Windows 환경에서 Baron-SSO 프로젝트를 실행하기 위해 Docker Desktop 기반으로 개발환경 구축을 진행하였다.
그러나 설치 과정에서 반복적인 오류가 발생하였고, 09:00 ~ 09:30 팀 회의 중 팀장님 의견에 따라 Docker Desktop 사용을 중단하였다.
회의 종료 후 개발환경 전략을 다음과 같이 변경하였다.
```text
기존
Windows + Docker Desktop
변경
Windows + WSL2(Ubuntu) + Docker Engine
```
---
# 3. 작업 목표
Baron-SSO 프로젝트 실행을 위한 개발환경 구축
## 목표 범위
* WSL2 설치
* Ubuntu 설치
* Docker Engine 설치
* Docker Compose 설치
* Baron Infra 실행
* Ory Stack 실행
* Baron App 실행
* UserFront / Backend 정상 기동 확인
---
# 4. 최종 결과
## 완료 항목
* WSL2 구축 완료
* Ubuntu 환경 구축 완료
* Docker Engine 설치 완료
* Docker Compose 설치 완료
* PostgreSQL 정상
* Redis 정상
* ClickHouse 정상
* Ory Kratos 정상
* Ory Hydra 정상
* Ory Keto 정상
* Ory Oathkeeper 정상
* Gateway 정상
* UserFront 정상
* Backend 정상
---
# 5. 실행 순서
```mermaid
flowchart TD
A[WSL2 설치]
--> B[Ubuntu 설치]
B
--> C[Docker Engine 설치]
C
--> D[Docker Compose 설치]
D
--> E[Docker Network 생성]
E
--> F[Baron Infra 실행]
F
--> G[Auth Config 생성]
G
--> H[Ory Config 생성]
H
--> I[Ory Stack 실행]
I
--> J[Baron App 실행]
J
--> K[Gateway 확인]
K
--> L[UserFront 확인]
L
--> M[Backend 확인]
M
--> N[전체 Healthy 상태 확인]
```
---
# 6. 시스템 구성도
```mermaid
flowchart LR
USER[Developer]
USER
--> GATEWAY[Gateway]
GATEWAY
--> USERFRONT[UserFront]
GATEWAY
--> BACKEND[Backend]
BACKEND
--> PG[(PostgreSQL)]
BACKEND
--> REDIS[(Redis)]
BACKEND
--> CLICKHOUSE[(ClickHouse)]
BACKEND
--> KRATOS[Kratos]
BACKEND
--> HYDRA[Hydra]
BACKEND
--> KETO[Keto]
BACKEND
--> OATHKEEPER[Oathkeeper]
```
---
# 7. 주요 문제 발생 내역
## 문제 1
### Windows Git Bash 환경
증상
```text
make 명령 실행 불가
```
원인
```text
Linux 기반 개발환경 전제 프로젝트
```
조치
```text
WSL2 + Ubuntu 환경으로 전환
```
---
## 문제 2
### Docker Desktop 설치 오류
증상
```text
설치 중 반복 오류 발생
```
조치
```text
Docker Desktop 사용 중단
Docker Engine 직접 설치 방식 적용
```
---
## 문제 3
### CRLF(Line Ending) 문제
가장 많은 시간을 소모한 원인
증상
```text
set: pipefail: invalid option
$'\\r': command not found
cannot execute: required file not found
set: Illegal option -
': No such file or directory
```
원인
```text
Windows CRLF 줄바꿈
Linux는 LF 필요
```
---
# 8. 수정 파일 목록
```text
.env
scripts/auth_config.sh
scripts/render_ory_config.sh
docker/ory/init-db/01_create_dbs.sh
userfront/scripts/dev-server.sh
scripts/sync_userfront_locales.sh
config/.generated/ory/oathkeeper/entrypoint.sh
```
---
# 9. 추가 문제 및 해결
## Ory DB 생성 실패
필요 DB
```text
ory_hydra
ory_keto
ory_kratos
```
조치
```sql
CREATE DATABASE ory_hydra;
CREATE DATABASE ory_keto;
CREATE DATABASE ory_kratos;
```
---
## Kratos 설정 오류
문제 값
```env
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
```
수정
```env
KRATOS_ALLOWED_RETURN_URLS_EXTRA=
```
---
## Gateway Unhealthy
원인
```text
baron_userfront 컨테이너 종료
```
조치
```bash
dos2unix userfront/scripts/dev-server.sh
dos2unix scripts/sync_userfront_locales.sh
docker restart baron_userfront
```
---
## Backend Unhealthy
원인
```text
Oathkeeper 기동 실패
```
조치
```bash
dos2unix config/.generated/ory/oathkeeper/entrypoint.sh
docker restart ory_oathkeeper
docker restart baron_backend
```
---
# 10. 작업 시간 초과 원인 분석
## 계획
```text
Docker 설치
→ Baron 실행
→ 기능 테스트
```
예상 2~3시간
````
## 실제
```text
Docker Desktop 설치 실패
→ WSL 환경 전환
→ Linux 환경 구성
→ 다수의 CRLF 문제 발견
→ Ory DB 재구성
→ Oathkeeper 복구
→ Backend Health 복구
````
결과
```text
총 6시간 소요
```
---
# 11. 재발 방지 방안
## Git 정책 검토
```gitattributes
*.sh text eol=lf
*.env text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
```
검토 필요
---
## 개발환경 표준화
권장
```text
Windows
WSL2 Ubuntu
Docker Engine
Baron-SSO
```
팀 공통 환경으로 통일 시
환경 이슈 감소 예상
---
# 12. 작업 결과 요약
금일 WSL2 + Ubuntu 기반 Docker Engine 개발환경 구축을 완료하였다.
Docker Desktop 기반 접근은 설치 오류로 인해 중단하였으며, 팀장님 조언에 따라 WSL2 + Docker Engine 방식으로 전환하였다.
구축 과정에서 다수의 CRLF(Line Ending) 문제가 확인되었으며, 관련 Shell Script 및 환경설정 파일을 수정하여 해결하였다.
최종적으로 Baron-SSO의 Infra, Ory Stack, Gateway, UserFront, Backend가 모두 정상 기동되었으며 Healthy 상태를 확인하였다.
다음 단계는 기능 검증 및 인증 흐름 테스트이다.

View File

@@ -1,639 +0,0 @@
# WSL · Ubuntu · Docker 이해하기 (초보자용 가이드)
## 문서 정보
| 항목 | 내용 |
| ---- | -------------------------- |
| 문서명 | WSL · Ubuntu · Docker 이해하기 |
| 대상 | 개발 입문자, 신규 팀원 |
| 작성목적 | Baron SSO 개발환경 이해 |
| 작성일 | 2026-06-12 |
---
# 1. 개요
현재 Baron SSO 개발환경은 다음과 같은 구조로 구성되어 있습니다.
```text
Windows
WSL
Ubuntu
Docker
Baron 서비스들
```
처음 접하는 사람은 용어가 많아 어렵게 느껴질 수 있지만,
사실은 다음과 같이 이해하면 쉽습니다.
| 구성요소 | 비유 |
| -------------- | ---------- |
| Windows | 집 |
| WSL | 집 안의 리눅스 방 |
| Ubuntu | 방에 사는 사람 |
| Docker | 작업실 |
| Container | 개별 작업실 |
| Baron Backend | 백엔드 작업실 |
| Baron Frontend | 프론트엔드 작업실 |
| ClickHouse | 데이터 저장 작업실 |
---
# 2. 전체 구조 이해하기
## 실제 개발환경 구조
```text
┌─────────────────────────┐
│ Windows 11 │
│ │
│ VS Code │
│ Chrome │
│ Git │
└──────────┬──────────────┘
┌─────────────────────────┐
│ WSL │
│ (Windows Linux Layer) │
└──────────┬──────────────┘
┌─────────────────────────┐
│ Ubuntu │
│ (Linux) │
└──────────┬──────────────┘
┌─────────────────────────┐
│ Docker │
└──────────┬──────────────┘
┌─────────┼─────────┬─────────┐
▼ ▼ ▼ ▼
Backend UserFront AdminFront ClickHouse
```
---
# 3. Windows란?
현재 우리가 사용하는 기본 운영체제(OS)입니다.
예)
```text
C:\Users
D:\Project
E:\Workspace
```
Windows 환경에서 사용하는 경로입니다.
주요 프로그램
* VS Code
* Chrome
* Edge
* Explorer
* Git GUI
등이 Windows 위에서 실행됩니다.
---
# 4. WSL이란?
## WSL
Windows Subsystem for Linux
즉,
"Windows 안에서 Linux를 실행할 수 있게 해주는 기능"
입니다.
---
## WSL이 없던 시절
Linux를 사용하려면
```text
PC
├─ Windows
└─ Virtual Machine
└─ Ubuntu
```
구조를 사용했습니다.
문제점
* 느림
* 무거움
* 메모리 많이 사용
---
## WSL 사용
```text
PC
├─ Windows
└─ WSL
└─ Ubuntu
```
장점
* 빠름
* 가벼움
* 실제 Linux와 거의 동일
---
# 5. Ubuntu란?
Ubuntu는 Linux 운영체제입니다.
Windows와 같은 OS입니다.
비교하면
| Windows | Linux |
| ---------- | -------- |
| Windows 11 | Ubuntu |
| 탐색기 | Shell |
| CMD | Terminal |
| PowerShell | Bash |
---
Ubuntu 접속 예시
```bash
wsl
```
또는
```bash
ubuntu
```
실행 후
```bash
ubuntu@DESKTOP-XXXX:~$
```
가 보이면 Ubuntu 안에 들어온 상태입니다.
---
# 6. Docker란?
Docker는
"프로그램을 독립적으로 실행하는 기술"
입니다.
---
예를 들어
Baron 프로젝트에 필요한 것
```text
NodeJS
Database
Backend
Frontend
ClickHouse
```
를 직접 설치하면
PC가 복잡해집니다.
---
Docker 사용 시
```text
┌────────────┐
│ Backend │
└────────────┘
┌────────────┐
│ Frontend │
└────────────┘
┌────────────┐
│ Database │
└────────────┘
┌────────────┐
│ ClickHouse │
└────────────┘
```
각각 독립적으로 실행됩니다.
---
# 7. Container란?
Docker 안에서 실행되는 작은 서버입니다.
쉽게 말하면
"미니 컴퓨터"
라고 생각하면 됩니다.
---
현재 Baron 환경 예시
```text
Container 1
baron_backend
Container 2
baron_userfront
Container 3
baron_adminfront
Container 4
baron_clickhouse
```
각 컨테이너는
* 독립 실행
* 독립 설정
* 독립 포트
를 가집니다.
---
# 8. Image와 Container 차이
많이 헷갈리는 부분입니다.
## Image
설치 파일
예)
```text
Windows 설치 ISO
```
와 비슷
---
## Container
실행 중인 프로그램
예)
```text
설치 완료 후 실행된 Windows
```
와 비슷
---
관계
```text
Docker Image
실행
Container
```
---
# 9. 실제 Baron 프로젝트 구조
현재 개발환경
```text
Windows
├─ VS Code
├─ Git
└─ E:\h_workspace\baron-sso
```
---
WSL에서 보면
```text
/mnt/e/h_workspace/baron-sso
```
가 됩니다.
---
같은 폴더를
Windows와 Ubuntu가
다르게 표현하는 것입니다.
| Windows | Ubuntu |
| ------------------------ | ---------------------------- |
| E:\h_workspace\baron-sso | /mnt/e/h_workspace/baron-sso |
---
# 10. VS Code는 어디서 실행되는가?
VS Code 자체는 Windows에서 실행됩니다.
```text
Windows
└─ VS Code
```
하지만
Remote WSL 기능을 사용하면
```text
VS Code
WSL
Ubuntu
```
에 연결됩니다.
---
좌측 하단에
```text
WSL: Ubuntu
```
가 보이면
Ubuntu 내부를 편집 중인 상태입니다.
---
# 11. Docker Compose란?
여러 컨테이너를 한 번에 실행하는 기능입니다.
예)
docker-compose.yml
```yaml
services:
backend:
...
userfront:
...
adminfront:
...
clickhouse:
...
```
---
실행
```bash
docker compose up -d
```
---
Docker가 자동으로
```text
Backend 시작
UserFront 시작
AdminFront 시작
ClickHouse 시작
```
을 수행합니다.
---
# 12. Baron SSO 실행 흐름
```text
개발자
VS Code 수정
Git 저장
Docker Compose 실행
Backend Container
UserFront Container
AdminFront Container
ClickHouse Container
웹 브라우저 접속
```
---
# 13. 자주 사용하는 명령어
## 현재 컨테이너 보기
```bash
docker ps
```
---
## 전체 컨테이너 보기
```bash
docker ps -a
```
---
## 로그 보기
```bash
docker logs 컨테이너명
```
예)
```bash
docker logs baron_backend
```
---
## 컨테이너 접속
```bash
docker exec -it 컨테이너명 bash
```
예)
```bash
docker exec -it baron_backend bash
```
---
## 컨테이너 중지
```bash
docker stop 컨테이너명
```
---
## 컨테이너 재시작
```bash
docker restart 컨테이너명
```
---
# 14. 최종 정리
## 한 문장 설명
### Windows
사용자가 사용하는 실제 운영체제
---
### WSL
Windows 안에서 Linux를 사용할 수 있게 해주는 기능
---
### Ubuntu
WSL 안에서 실행되는 Linux 운영체제
---
### Docker
프로그램을 독립된 환경에서 실행하는 기술
---
### Container
Docker 안에서 실행되는 개별 서비스
---
## Baron SSO 전체 구조
```text
사용자
Windows
VS Code
WSL
Ubuntu
Docker
├─ baron_backend
├─ baron_userfront
├─ baron_adminfront
└─ baron_clickhouse
브라우저 접속
```
---
## 기억해야 할 핵심
"Windows 위에서 WSL이 동작하고,
WSL 안에서 Ubuntu가 실행되며,
Ubuntu 안에서 Docker가 실행되고,
Docker 안에서 Baron 서비스들이 실행된다."

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