forked from baron/baron-sso
Compare commits
11 Commits
4089455985
...
feature/df
| Author | SHA1 | Date | |
|---|---|---|---|
| 52046e4a66 | |||
| e9af231fb0 | |||
| 85c2eb1690 | |||
| 4c9d219fd4 | |||
| 2234986abd | |||
| b919f600e1 | |||
| 437a3ad98d | |||
| 3ed9e912e6 | |||
| 0f11173739 | |||
| 41e755b1c7 | |||
| 894feb20f1 |
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
91
backend/internal/handler/test_server_helper_test.go
Normal file
91
backend/internal/handler/test_server_helper_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -101,6 +101,7 @@ function AuditLogsPage() {
|
||||
hasAccessToken,
|
||||
profileRole,
|
||||
tenantId,
|
||||
requiredPages: ["audit"],
|
||||
isLoadingIdentity: isLoadingMe,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
102
devfront/src/features/developer-access/developerAccessPages.ts
Normal file
102
devfront/src/features/developer-access/developerAccessPages.ts
Normal 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));
|
||||
}
|
||||
681
devfront/src/features/developer-grants/DeveloperGrantsPage.tsx
Normal file
681
devfront/src/features/developer-grants/DeveloperGrantsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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("없음");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", "신청 사유")}{" "}
|
||||
|
||||
@@ -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",
|
||||
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "없음"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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>[],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 상태를 확인하였다.
|
||||
|
||||
다음 단계는 기능 검증 및 인증 흐름 테스트이다.
|
||||
@@ -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 서비스들이 실행된다."
|
||||
@@ -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..."
|
||||
|
||||
@@ -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 = "승인 취소됨"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user