From 437a3ad98da8f2701bb1ce352a3ea567aa181f0a Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 9 Jun 2026 16:47:20 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=EC=9D=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=B3=84?= =?UTF-8?q?=EB=A1=9C=20=EC=84=A0=ED=83=9D/=EB=B6=80=EC=97=AC=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/developer_request.go | 48 +++-- backend/internal/handler/dev_handler.go | 82 +++++++-- backend/internal/service/developer_service.go | 174 ++++++++++++++++-- devfront/src/features/audit/AuditLogsPage.tsx | 1 + .../features/clients/ClientGeneralPage.tsx | 59 +++++- devfront/src/features/clients/ClientsPage.tsx | 71 +++++-- .../clients/clientCreateAccess.test.ts | 15 +- .../features/clients/clientCreateAccess.ts | 29 ++- .../developerAccessGate.test.ts | 34 +++- .../developer-access/developerAccessGate.ts | 27 ++- .../developerAccessPages.test.ts | 24 +++ .../developer-access/developerAccessPages.ts | 104 +++++++++++ .../developer-grants/DeveloperGrantsPage.tsx | 77 +++++++- .../DeveloperRequestPage.test.tsx | 30 +++ .../DeveloperRequestPage.tsx | 84 ++++++++- .../features/overview/GlobalOverviewPage.tsx | 1 + devfront/src/lib/devApi.test.ts | 2 + devfront/src/lib/devApi.ts | 11 +- 18 files changed, 782 insertions(+), 91 deletions(-) create mode 100644 devfront/src/features/developer-access/developerAccessPages.test.ts create mode 100644 devfront/src/features/developer-access/developerAccessPages.ts diff --git a/backend/internal/domain/developer_request.go b/backend/internal/domain/developer_request.go index 58bfbc64..61a319c3 100644 --- a/backend/internal/domain/developer_request.go +++ b/backend/internal/domain/developer_request.go @@ -2,6 +2,8 @@ package domain import ( "time" + + "github.com/lib/pq" ) const ( @@ -11,19 +13,39 @@ const ( DeveloperRequestStatusCancelled = "cancelled" ) +const ( + DeveloperAccessPageAll = "all" + DeveloperAccessPageOverview = "overview" + DeveloperAccessPageClientCreate = "client_create" + DeveloperAccessPageAudit = "audit" +) + +var DeveloperAccessPageOrder = []string{ + DeveloperAccessPageOverview, + DeveloperAccessPageClientCreate, + DeveloperAccessPageAudit, +} + // DeveloperRequest represents a user's application to become a developer. type DeveloperRequest struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID - TenantID string `gorm:"index;not null" json:"tenantId"` - Name string `gorm:"not null" json:"name"` - Organization string `json:"organization"` - Email string `json:"email"` - Phone string `json:"phone"` - Role string `json:"role"` - Reason string `json:"reason"` - Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled - AdminNotes string `json:"adminNotes"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID + TenantID string `gorm:"index;not null" json:"tenantId"` + Name string `gorm:"not null" json:"name"` + Organization string `json:"organization"` + Email string `json:"email"` + Phone string `json:"phone"` + Role string `json:"role"` + Reason string `json:"reason"` + AccessPages pq.StringArray `gorm:"type:text[]" json:"accessPages,omitempty"` + Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled + AdminNotes string `json:"adminNotes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type DeveloperAccessStatus struct { + Status string `json:"status"` + ApprovedPages pq.StringArray `json:"approvedPages,omitempty"` + PendingPages pq.StringArray `json:"pendingPages,omitempty"` } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index f2a29f46..e0562817 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -274,6 +274,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 @@ -3871,10 +3921,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") @@ -3907,6 +3958,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, } @@ -3934,10 +3986,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) @@ -4082,10 +4134,11 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error { } var reqBody struct { - UserID string `json:"userId"` - TenantID string `json:"tenantId"` - Reason string `json:"reason"` - AdminNotes string `json:"adminNotes"` + 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") @@ -4132,11 +4185,15 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error { reason = "직접 부여" } - existing, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID) + existingRequests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, "", tenantID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - if existing != nil { + for _, existing := range existingRequests { + if !developerAccessPagesEqual(existing.AccessPages, reqBody.AccessPages) { + continue + } + switch existing.Status { case domain.DeveloperRequestStatusApproved: h.ensureDeveloperGrantRelation(c, userID, tenantID) @@ -4161,6 +4218,7 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error { Phone: phone, Role: role, Reason: reason, + AccessPages: reqBody.AccessPages, Status: domain.DeveloperRequestStatusApproved, AdminNotes: strings.TrimSpace(reqBody.AdminNotes), } diff --git a/backend/internal/service/developer_service.go b/backend/internal/service/developer_service.go index cf6d0d49..799a1e2e 100644 --- a/backend/internal/service/developer_service.go +++ b/backend/internal/service/developer_service.go @@ -3,7 +3,8 @@ package service import ( "baron-sso-backend/internal/domain" "context" - "errors" + "sort" + "strings" "gorm.io/gorm" ) @@ -16,34 +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) 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.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) 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) { diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index f7d939bb..af240be8 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -101,6 +101,7 @@ function AuditLogsPage() { hasAccessToken, profileRole, tenantId, + requiredPages: ["audit"], isLoadingIdentity: isLoadingMe, }); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 569dcc96..69eb77c0 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -30,6 +30,7 @@ import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Switch } from "../../components/ui/switch"; import { Textarea } from "../../components/ui/textarea"; +import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; import { toast } from "../../components/ui/use-toast"; import type { ClientStatus, @@ -54,6 +55,7 @@ import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; import { fetchMe, type UserProfile } from "../auth/authApi"; +import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { ClientDetailTabs } from "./ClientDetailTabs"; import { AllowedTenantBadge } from "./components/AllowedTenantBadge"; @@ -358,16 +360,27 @@ function ClientGeneralPage() { const hasAccessToken = Boolean(auth.user?.access_token); const clientId = params.id; const isCreate = !clientId; - const systemRole = resolveProfileRole( - auth.user?.profile as Record | undefined, - ); - const { data: me } = useQuery({ + const userProfile = auth.user?.profile as Record | undefined; + const systemRole = resolveProfileRole(userProfile); + const { data: me, isLoading: isLoadingMe } = useQuery({ queryKey: ["userMe"], queryFn: fetchMe, enabled: hasAccessToken, }); const currentUserId = me?.id ?? auth.user?.profile.sub; const effectiveSystemRole = me?.role?.trim() || systemRole; + const { + hasDeveloperAccess: hasClientCreateAccess, + isDeveloperRequestPending, + canRequestDeveloperAccess, + isLoadingDeveloperAccessGate, + } = useDeveloperAccessGate({ + hasAccessToken, + profileRole: effectiveSystemRole, + tenantId: userProfile?.tenant_id as string | undefined, + requiredPages: ["client_create"], + isLoadingIdentity: isLoadingMe, + }); const { data, isLoading, error } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId as string), @@ -1161,10 +1174,44 @@ function ClientGeneralPage() { } }; - if (!isCreate && isLoading) { + if ((isCreate && isLoadingDeveloperAccessGate) || (!isCreate && isLoading)) { return (
- {t("msg.dev.clients.general.loading", "Loading client...")} + {t( + "msg.dev.clients.general.loading", + isCreate ? "Loading client creation..." : "Loading client...", + )} +
+ ); + } + if (isCreate && !hasClientCreateAccess) { + return ( +
+
+ navigate("/developer-requests")} + /> +
); } diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 6c22bc88..a771ab34 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -17,6 +17,7 @@ import { toggleSort, } from "../../../../common/core/utils"; import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; +import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; import { commonTableShellClass, commonTableViewportClass, @@ -53,6 +54,7 @@ import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; +import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { resolveClientCreateAccess } from "./clientCreateAccess"; import { ClientLogo } from "./components/ClientLogo"; @@ -101,13 +103,26 @@ function ClientsPage() { enabled: hasAccessToken, }); + const { + hasDeveloperAccess: hasClientsPageAccess, + isDeveloperRequestPending, + canRequestDeveloperAccess, + isLoadingDeveloperAccessGate, + } = useDeveloperAccessGate({ + hasAccessToken, + profileRole, + tenantId, + requiredPages: ["client_create"], + isLoadingIdentity: isLoadingMe, + }); + 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(""); @@ -189,6 +204,7 @@ function ClientsPage() { const isLoading = isLoadingClients || isLoadingRequest || + isLoadingDeveloperAccessGate || (hasAccessToken && !profileRole && isLoadingMe); const requestSort = (key: ClientSortKey) => { @@ -203,6 +219,38 @@ function ClientsPage() { ); } + if (!hasClientsPageAccess) { + return ( +
+
+ navigate("/developer-requests")} + /> +
+
+ ); + } + if (clientError) { const axiosError = clientError as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { @@ -255,7 +303,7 @@ function ClientsPage() { {t("ui.dev.nav.developer_request", "개발자 권한 신청")} - ) : canRequestDeveloperAccess ? ( + ) : canRequestClientCreateAccess ? (

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

+ +
+ +
+ {developerAccessPageOptions.map((option) => { + const checked = + option.value === "all" + ? selectedAccessPages.includes("all") + : selectedAccessPages.includes(option.value); + return ( + + ); + })} +
+

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

+
@@ -518,6 +578,7 @@ export default function DeveloperGrantsPage() { {t("ui.dev.grants.user", "사용자")} {t("ui.dev.grants.tenant", "테넌트")} {t("ui.dev.grants.reason", "부여 사유")} + {t("ui.dev.grants.pages", "권한 페이지")} {t("ui.dev.grants.status", "상태")} {t("ui.dev.grants.date", "부여일")} @@ -536,7 +597,7 @@ export default function DeveloperGrantsPage() {
{grant.email || grant.userId}
-
+
{grant.userId}
@@ -561,6 +622,20 @@ export default function DeveloperGrantsPage() { )} + +
+ {(grant.accessPages?.length + ? normalizeDeveloperAccessPages(grant.accessPages) + : ["all"] + ).map((page) => ( + + {developerAccessPageOptions.find( + (option) => option.value === page, + )?.label ?? page} + + ))} +
+
{t("ui.dev.grants.approved", "승인됨")} diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx index ad941a59..cf65298f 100644 --- a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx +++ b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx @@ -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,7 @@ describe("DeveloperRequestPage", () => { organization: "Hanmac", reason: "Need RP access", tenantId: "tenant-1", + accessPages: ["all"], }); }); @@ -251,6 +280,7 @@ describe("DeveloperRequestPage", () => { organization: "HANMAC", reason: "Need RP access", tenantId: "", + accessPages: ["all"], }); }); diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.tsx index ba812110..a16cb7e2 100644 --- a/devfront/src/features/developer-request/DeveloperRequestPage.tsx +++ b/devfront/src/features/developer-request/DeveloperRequestPage.tsx @@ -47,6 +47,12 @@ import { import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; +import { + developerAccessPageOptions, + normalizeDeveloperAccessPages, + normalizeDeveloperAccessPageSelection, + type DeveloperAccessPage, +} from "../developer-access/developerAccessPages"; export default function DeveloperRequestPage() { const auth = useAuth(); @@ -153,7 +159,7 @@ export default function DeveloperRequestPage() { } const hasActiveRequest = requests?.some( - (r) => r.status === "pending" || r.status === "approved", + (r) => r.status === "pending", ); const approvedRequestCount = requests?.filter((request) => request.status === "approved").length ?? 0; @@ -218,6 +224,9 @@ export default function DeveloperRequestPage() { {t("ui.dev.request.table.reason", "신청 사유")} + + {t("ui.dev.request.table.pages", "권한 페이지")} + {t("ui.dev.request.table.status", "상태")} @@ -235,7 +244,7 @@ export default function DeveloperRequestPage() { {!requests || requests.length === 0 ? ( {t("msg.dev.request.empty", "신청 내역이 없습니다.")} @@ -272,6 +281,25 @@ export default function DeveloperRequestPage() { )} + +
+ {req.accessPages?.length ? ( + normalizeDeveloperAccessPages(req.accessPages).map( + (page) => ( + + {developerAccessPageOptions.find( + (option) => option.value === page, + )?.label ?? page} + + ), + ) + ) : ( + + {t("ui.common.na", "없음")} + + )} +
+
@@ -449,12 +477,16 @@ function RequestAccessModal({ const [name, setName] = useState(initialName); const [organization, setOrganization] = useState(initialOrg); const [reason, setReason] = useState(""); + const [accessPages, setAccessPages] = useState([ + "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({ @@ -471,6 +503,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]); }); }; @@ -562,6 +609,39 @@ function RequestAccessModal({ className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input" /> +
+ +
+ {developerAccessPageOptions.map((option) => { + const checked = + option.value === "all" + ? accessPages.includes("all") + : accessPages.includes(option.value); + return ( + + ); + })} +
+

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

+