1
0
forked from baron/baron-sso

개발자 권한을 페이지별로 선택/부여 가능하도록 개선

This commit is contained in:
2026-06-09 16:47:20 +09:00
parent 3ed9e912e6
commit 437a3ad98d
18 changed files with 782 additions and 91 deletions

View File

@@ -2,6 +2,8 @@ package domain
import ( import (
"time" "time"
"github.com/lib/pq"
) )
const ( const (
@@ -11,19 +13,39 @@ const (
DeveloperRequestStatusCancelled = "cancelled" 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. // DeveloperRequest represents a user's application to become a developer.
type DeveloperRequest struct { type DeveloperRequest struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
TenantID string `gorm:"index;not null" json:"tenantId"` TenantID string `gorm:"index;not null" json:"tenantId"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Organization string `json:"organization"` Organization string `json:"organization"`
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"` Role string `json:"role"`
Reason string `json:"reason"` Reason string `json:"reason"`
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled AccessPages pq.StringArray `gorm:"type:text[]" json:"accessPages,omitempty"`
AdminNotes string `json:"adminNotes"` Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
CreatedAt time.Time `json:"createdAt"` AdminNotes string `json:"adminNotes"`
UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DeveloperAccessStatus struct {
Status string `json:"status"`
ApprovedPages pq.StringArray `json:"approvedPages,omitempty"`
PendingPages pq.StringArray `json:"pendingPages,omitempty"`
} }

View File

@@ -274,6 +274,56 @@ func isDevConsoleViewerRole(role string) bool {
return r == domain.RoleSuperAdmin || r == domain.RoleUser 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) { func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
if profile == nil { if profile == nil {
return return
@@ -3871,10 +3921,11 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
} }
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
Organization string `json:"organization"` Organization string `json:"organization"`
Reason string `json:"reason"` Reason string `json:"reason"`
TenantID string `json:"tenantId"` TenantID string `json:"tenantId"`
AccessPages []string `json:"accessPages"`
} }
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body") return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -3907,6 +3958,7 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
Phone: profile.Phone, Phone: profile.Phone,
Role: normalizeUserRole(profile.Role), Role: normalizeUserRole(profile.Role),
Reason: req.Reason, Reason: req.Reason,
AccessPages: req.AccessPages,
Status: domain.DeveloperRequestStatusPending, Status: domain.DeveloperRequestStatusPending,
} }
@@ -3934,10 +3986,10 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
} }
if status == nil { if status == nil {
return c.JSON(fiber.Map{"status": "none"}) return c.JSON(domain.DeveloperAccessStatus{Status: "none"})
} }
if status.Status == domain.DeveloperRequestStatusApproved { if status.Status == domain.DeveloperRequestStatusApproved {
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID) h.ensureDeveloperGrantRelation(c, profile.ID, tenantID)
} }
return c.JSON(status) return c.JSON(status)
@@ -4082,10 +4134,11 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
} }
var reqBody struct { var reqBody struct {
UserID string `json:"userId"` UserID string `json:"userId"`
TenantID string `json:"tenantId"` TenantID string `json:"tenantId"`
Reason string `json:"reason"` Reason string `json:"reason"`
AdminNotes string `json:"adminNotes"` AdminNotes string `json:"adminNotes"`
AccessPages []string `json:"accessPages"`
} }
if err := c.BodyParser(&reqBody); err != nil { if err := c.BodyParser(&reqBody); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body") return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
@@ -4132,11 +4185,15 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
reason = "직접 부여" reason = "직접 부여"
} }
existing, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID) existingRequests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, "", tenantID)
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if existing != nil { for _, existing := range existingRequests {
if !developerAccessPagesEqual(existing.AccessPages, reqBody.AccessPages) {
continue
}
switch existing.Status { switch existing.Status {
case domain.DeveloperRequestStatusApproved: case domain.DeveloperRequestStatusApproved:
h.ensureDeveloperGrantRelation(c, userID, tenantID) h.ensureDeveloperGrantRelation(c, userID, tenantID)
@@ -4161,6 +4218,7 @@ func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error {
Phone: phone, Phone: phone,
Role: role, Role: role,
Reason: reason, Reason: reason,
AccessPages: reqBody.AccessPages,
Status: domain.DeveloperRequestStatusApproved, Status: domain.DeveloperRequestStatusApproved,
AdminNotes: strings.TrimSpace(reqBody.AdminNotes), AdminNotes: strings.TrimSpace(reqBody.AdminNotes),
} }

View File

@@ -3,7 +3,8 @@ package service
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"context" "context"
"errors" "sort"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -16,34 +17,179 @@ func NewDeveloperService(db *gorm.DB) *DeveloperService {
return &DeveloperService{db: db} return &DeveloperService{db: db}
} }
func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error { func normalizeDeveloperAccessPages(pages []string) []string {
// Check if there is already a pending request seen := make(map[string]struct{})
var existing domain.DeveloperRequest normalized := make([]string, 0, len(pages))
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 { 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 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 return err
} }
for _, current := range existing {
if accessPagesOverlap(current.AccessPages, req.AccessPages) {
return nil
}
}
return s.db.WithContext(ctx).Create(&req).Error return s.db.WithContext(ctx).Create(&req).Error
} }
func (s *DeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) 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 return s.db.WithContext(ctx).Create(&req).Error
} }
func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) { func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) {
var req domain.DeveloperRequest var requests []domain.DeveloperRequest
err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Order("created_at DESC").First(&req).Error err := s.db.WithContext(ctx).
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
Order("created_at DESC").
Find(&requests).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err 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) { func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) {

View File

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

View File

@@ -30,6 +30,7 @@ import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import { Switch } from "../../components/ui/switch"; import { Switch } from "../../components/ui/switch";
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
import { toast } from "../../components/ui/use-toast"; import { toast } from "../../components/ui/use-toast";
import type { import type {
ClientStatus, ClientStatus,
@@ -54,6 +55,7 @@ import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { fetchMe, type UserProfile } from "../auth/authApi"; import { fetchMe, type UserProfile } from "../auth/authApi";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import { ClientDetailTabs } from "./ClientDetailTabs"; import { ClientDetailTabs } from "./ClientDetailTabs";
import { AllowedTenantBadge } from "./components/AllowedTenantBadge"; import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
@@ -358,16 +360,27 @@ function ClientGeneralPage() {
const hasAccessToken = Boolean(auth.user?.access_token); const hasAccessToken = Boolean(auth.user?.access_token);
const clientId = params.id; const clientId = params.id;
const isCreate = !clientId; const isCreate = !clientId;
const systemRole = resolveProfileRole( const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
auth.user?.profile as Record<string, unknown> | undefined, const systemRole = resolveProfileRole(userProfile);
); const { data: me, isLoading: isLoadingMe } = useQuery<UserProfile>({
const { data: me } = useQuery<UserProfile>({
queryKey: ["userMe"], queryKey: ["userMe"],
queryFn: fetchMe, queryFn: fetchMe,
enabled: hasAccessToken, enabled: hasAccessToken,
}); });
const currentUserId = me?.id ?? auth.user?.profile.sub; const currentUserId = me?.id ?? auth.user?.profile.sub;
const effectiveSystemRole = me?.role?.trim() || systemRole; 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({ const { data, isLoading, error } = useQuery({
queryKey: ["client", clientId], queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId as string), queryFn: () => fetchClient(clientId as string),
@@ -1161,10 +1174,44 @@ function ClientGeneralPage() {
} }
}; };
if (!isCreate && isLoading) { if ((isCreate && isLoadingDeveloperAccessGate) || (!isCreate && isLoading)) {
return ( return (
<div className="p-8 text-center"> <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> </div>
); );
} }

View File

@@ -17,6 +17,7 @@ import {
toggleSort, toggleSort,
} from "../../../../common/core/utils"; } from "../../../../common/core/utils";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
import { import {
commonTableShellClass, commonTableShellClass,
commonTableViewportClass, commonTableViewportClass,
@@ -53,6 +54,7 @@ import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi"; import { fetchMe } from "../auth/authApi";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import { resolveClientCreateAccess } from "./clientCreateAccess"; import { resolveClientCreateAccess } from "./clientCreateAccess";
import { ClientLogo } from "./components/ClientLogo"; import { ClientLogo } from "./components/ClientLogo";
@@ -101,13 +103,26 @@ function ClientsPage() {
enabled: hasAccessToken, enabled: hasAccessToken,
}); });
const {
hasDeveloperAccess: hasClientsPageAccess,
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
requiredPages: ["client_create"],
isLoadingIdentity: isLoadingMe,
});
const createAccessState = resolveClientCreateAccess({ const createAccessState = resolveClientCreateAccess({
role: profileRole, role: profileRole,
requestStatus: requestStatus?.status, accessStatus: requestStatus,
}); });
const canCreateClient = createAccessState === "can_create"; const canCreateClient = createAccessState === "can_create";
const isDeveloperRequestPending = createAccessState === "pending"; const isClientCreatePending = createAccessState === "pending";
const canRequestDeveloperAccess = const canRequestClientCreateAccess =
createAccessState === "request_required" && !isLoadingRequest; createAccessState === "request_required" && !isLoadingRequest;
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -189,6 +204,7 @@ function ClientsPage() {
const isLoading = const isLoading =
isLoadingClients || isLoadingClients ||
isLoadingRequest || isLoadingRequest ||
isLoadingDeveloperAccessGate ||
(hasAccessToken && !profileRole && isLoadingMe); (hasAccessToken && !profileRole && isLoadingMe);
const requestSort = (key: ClientSortKey) => { const requestSort = (key: ClientSortKey) => {
@@ -203,6 +219,38 @@ function ClientsPage() {
); );
} }
if (!hasClientsPageAccess) {
return (
<div className="p-8">
<div className="mx-auto max-w-2xl">
<DeveloperAccessRequestCard
title={t("ui.dev.clients.registry.subtitle", "연동 앱")}
isPending={isDeveloperRequestPending}
canRequest={canRequestDeveloperAccess}
pendingMessage={t(
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.clients.access_denied",
"연동 앱 페이지에 접근할 권한이 없습니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.clients.access_denied_detail",
"개발자 권한 신청에서 개요 또는 연동 앱 추가 권한을 선택한 뒤 승인받아주세요.",
)}
actionLabel={t("ui.dev.welcome.btn_request", "개발자 등록 신청하기")}
onAction={() => navigate("/developer-requests")}
/>
</div>
</div>
);
}
if (clientError) { if (clientError) {
const axiosError = clientError as AxiosError<{ error?: string }>; const axiosError = clientError as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) { if (axiosError.response?.status === 403) {
@@ -255,7 +303,7 @@ function ClientsPage() {
{t("ui.dev.nav.developer_request", "개발자 권한 신청")} {t("ui.dev.nav.developer_request", "개발자 권한 신청")}
</Button> </Button>
</div> </div>
) : canRequestDeveloperAccess ? ( ) : canRequestClientCreateAccess ? (
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<p className="max-w-xs whitespace-pre-line text-right text-sm text-muted-foreground"> <p className="max-w-xs whitespace-pre-line text-right text-sm text-muted-foreground">
{t( {t(
@@ -458,11 +506,11 @@ function ClientsPage() {
"msg.dev.clients.empty_can_create", "msg.dev.clients.empty_can_create",
"아직 등록된 연동 앱이 없습니다.", "아직 등록된 연동 앱이 없습니다.",
) )
: isDeveloperRequestPending : isClientCreatePending
? t( ? t(
"msg.dev.clients.empty_pending", "msg.dev.clients.empty_pending",
"개발자 권한 신청을 검토 중입니다.", "개발자 권한 신청을 검토 중입니다.",
) )
: t( : t(
"msg.dev.clients.empty", "msg.dev.clients.empty",
"조회 가능한 RP가 없습니다.", "조회 가능한 RP가 없습니다.",
@@ -480,7 +528,7 @@ function ClientsPage() {
"msg.dev.clients.empty_can_create_detail", "msg.dev.clients.empty_can_create_detail",
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.", "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
) )
: isDeveloperRequestPending : isClientCreatePending
? t( ? t(
"msg.dev.clients.empty_pending_detail", "msg.dev.clients.empty_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.", "super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
@@ -499,7 +547,7 @@ function ClientsPage() {
{t("ui.dev.clients.new", "연동 앱 추가")} {t("ui.dev.clients.new", "연동 앱 추가")}
</button> </button>
)} )}
{!isFilteredOut && canRequestDeveloperAccess && ( {!isFilteredOut && canRequestClientCreateAccess && (
<button <button
type="button" type="button"
className="text-primary font-bold hover:underline" className="text-primary font-bold hover:underline"
@@ -693,6 +741,7 @@ function RequestAccessModal({
organization, organization,
reason, reason,
tenantId, tenantId,
accessPages: ["all"],
}); });
}; };

View File

@@ -14,7 +14,7 @@ describe("client create access", () => {
expect( expect(
resolveClientCreateAccess({ resolveClientCreateAccess({
role: "user", role: "user",
requestStatus: "none", accessStatus: { status: "none" },
}), }),
).toBe("request_required"); ).toBe("request_required");
}); });
@@ -23,7 +23,7 @@ describe("client create access", () => {
expect( expect(
resolveClientCreateAccess({ resolveClientCreateAccess({
role: "", role: "",
requestStatus: undefined, accessStatus: undefined,
}), }),
).toBe("request_required"); ).toBe("request_required");
}); });
@@ -32,7 +32,7 @@ describe("client create access", () => {
expect( expect(
resolveClientCreateAccess({ resolveClientCreateAccess({
role: "user", role: "user",
requestStatus: "pending", accessStatus: { status: "pending", pendingPages: ["client_create"] },
}), }),
).toBe("pending"); ).toBe("pending");
}); });
@@ -41,7 +41,10 @@ describe("client create access", () => {
expect( expect(
resolveClientCreateAccess({ resolveClientCreateAccess({
role: "user", role: "user",
requestStatus: "approved", accessStatus: {
status: "approved",
approvedPages: ["client_create"],
},
}), }),
).toBe("can_create"); ).toBe("can_create");
}); });
@@ -50,14 +53,14 @@ describe("client create access", () => {
expect( expect(
resolveClientCreateAccess({ resolveClientCreateAccess({
role: "user", role: "user",
requestStatus: "cancelled", accessStatus: { status: "cancelled" },
}), }),
).toBe("request_required"); ).toBe("request_required");
expect( expect(
resolveClientCreateAccess({ resolveClientCreateAccess({
role: "user", role: "user",
requestStatus: "rejected", accessStatus: { status: "rejected" },
}), }),
).toBe("request_required"); ).toBe("request_required");
}); });

View File

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

View File

@@ -12,25 +12,51 @@ describe("developer access gate", () => {
}); });
it("resolves access and request states from the request status", () => { 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, hasDeveloperAccess: true,
isDeveloperRequestPending: true, isDeveloperRequestPending: true,
canRequestDeveloperAccess: false, canRequestDeveloperAccess: false,
}); });
expect(resolveDeveloperAccessGate("user", "approved")).toEqual({ expect(
resolveDeveloperAccessGate("user", {
status: "approved",
approvedPages: ["overview"],
}),
).toEqual({
hasDeveloperAccess: true, hasDeveloperAccess: true,
isDeveloperRequestPending: false, isDeveloperRequestPending: false,
canRequestDeveloperAccess: false, canRequestDeveloperAccess: false,
}); });
expect(resolveDeveloperAccessGate("user", "pending")).toEqual({ expect(
resolveDeveloperAccessGate("user", {
status: "pending",
pendingPages: ["audit"],
}, ["audit"]),
).toEqual({
hasDeveloperAccess: false, hasDeveloperAccess: false,
isDeveloperRequestPending: true, isDeveloperRequestPending: true,
canRequestDeveloperAccess: false, 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, hasDeveloperAccess: false,
isDeveloperRequestPending: false, isDeveloperRequestPending: false,
canRequestDeveloperAccess: true, canRequestDeveloperAccess: true,

View File

@@ -1,8 +1,13 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
type DeveloperRequestStatus, type DeveloperAccessStatus,
fetchDeveloperRequestStatus, fetchDeveloperRequestStatus,
} from "../../lib/devApi"; } from "../../lib/devApi";
import {
hasDeveloperAccessForPages,
isDeveloperRequestPendingForPages,
type DeveloperAccessPage,
} from "./developerAccessPages";
export type DeveloperAccessGateState = { export type DeveloperAccessGateState = {
hasDeveloperAccess: boolean; hasDeveloperAccess: boolean;
@@ -14,16 +19,23 @@ export type DeveloperAccessGateState = {
export function resolveDeveloperAccessGate( export function resolveDeveloperAccessGate(
profileRole: string, profileRole: string,
requestStatus?: DeveloperRequestStatus, accessStatus?: DeveloperAccessStatus,
requiredPages: DeveloperAccessPage[] = ["overview"],
): Omit< ): Omit<
DeveloperAccessGateState, DeveloperAccessGateState,
"isLoadingDeveloperAccessGate" | "isTenantContextMissing" "isLoadingDeveloperAccessGate" | "isTenantContextMissing"
> { > {
const hasDeveloperAccess = const hasDeveloperAccess =
profileRole === "super_admin" || requestStatus === "approved"; profileRole === "super_admin" ||
const isDeveloperRequestPending = requestStatus === "pending"; hasDeveloperAccessForPages(accessStatus?.approvedPages, requiredPages);
const isDeveloperRequestPending = isDeveloperRequestPendingForPages(
accessStatus?.pendingPages,
requiredPages,
);
const canRequestDeveloperAccess = const canRequestDeveloperAccess =
profileRole === "user" && !hasDeveloperAccess && !isDeveloperRequestPending; profileRole === "user" &&
!hasDeveloperAccess &&
!isDeveloperRequestPending;
return { return {
hasDeveloperAccess, hasDeveloperAccess,
@@ -50,11 +62,13 @@ export function useDeveloperAccessGate({
hasAccessToken, hasAccessToken,
profileRole, profileRole,
tenantId, tenantId,
requiredPages = ["overview"],
isLoadingIdentity = false, isLoadingIdentity = false,
}: { }: {
hasAccessToken: boolean; hasAccessToken: boolean;
profileRole: string; profileRole: string;
tenantId?: string; tenantId?: string;
requiredPages?: DeveloperAccessPage[];
isLoadingIdentity?: boolean; isLoadingIdentity?: boolean;
}) { }) {
const shouldFetchRequestStatus = const shouldFetchRequestStatus =
@@ -68,7 +82,8 @@ export function useDeveloperAccessGate({
const resolvedGate = resolveDeveloperAccessGate( const resolvedGate = resolveDeveloperAccessGate(
profileRole, profileRole,
requestStatus?.status, requestStatus,
requiredPages,
); );
return { return {

View File

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

View File

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

View File

@@ -36,6 +36,12 @@ import {
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi"; import { fetchMe } from "../auth/authApi";
import {
developerAccessPageOptions,
normalizeDeveloperAccessPages,
normalizeDeveloperAccessPageSelection,
type DeveloperAccessPage,
} from "../developer-access/developerAccessPages";
function formatUserLabel(user: DevAssignableUser) { function formatUserLabel(user: DevAssignableUser) {
const primary = user.name.trim() || user.email.trim(); const primary = user.name.trim() || user.email.trim();
@@ -62,6 +68,9 @@ export default function DeveloperGrantsPage() {
const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>( const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>(
null, null,
); );
const [selectedAccessPages, setSelectedAccessPages] = useState<
DeveloperAccessPage[]
>(["all"]);
const [grantNotes, setGrantNotes] = useState(""); const [grantNotes, setGrantNotes] = useState("");
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({}); const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
@@ -122,6 +131,7 @@ export default function DeveloperGrantsPage() {
); );
setSelectedUser(null); setSelectedUser(null);
setUserSearch(""); setUserSearch("");
setSelectedAccessPages(["all"]);
setGrantNotes(""); setGrantNotes("");
}, },
onError: (err: AxiosError<{ error?: string }> | Error) => { onError: (err: AxiosError<{ error?: string }> | Error) => {
@@ -212,12 +222,28 @@ export default function DeveloperGrantsPage() {
tenantId, tenantId,
reason: grantNotes.trim() || "직접 부여", reason: grantNotes.trim() || "직접 부여",
adminNotes: grantNotes.trim(), adminNotes: grantNotes.trim(),
accessPages: normalizeDeveloperAccessPageSelection(selectedAccessPages),
}); });
}; };
const handleSelectUser = (user: DevAssignableUser) => { const handleSelectUser = (user: DevAssignableUser) => {
setSelectedUser(user); setSelectedUser(user);
setUserSearch(formatUserLabel(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 ( return (
@@ -430,6 +456,40 @@ export default function DeveloperGrantsPage() {
)} )}
/> />
</div> </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> </CardContent>
</Card> </Card>
</div> </div>
@@ -518,6 +578,7 @@ export default function DeveloperGrantsPage() {
<TableHead>{t("ui.dev.grants.user", "사용자")}</TableHead> <TableHead>{t("ui.dev.grants.user", "사용자")}</TableHead>
<TableHead>{t("ui.dev.grants.tenant", "테넌트")}</TableHead> <TableHead>{t("ui.dev.grants.tenant", "테넌트")}</TableHead>
<TableHead>{t("ui.dev.grants.reason", "부여 사유")}</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.status", "상태")}</TableHead>
<TableHead>{t("ui.dev.grants.date", "부여일")}</TableHead> <TableHead>{t("ui.dev.grants.date", "부여일")}</TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
@@ -536,7 +597,7 @@ export default function DeveloperGrantsPage() {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{grant.email || grant.userId} {grant.email || grant.userId}
</div> </div>
<div className="font-mono text-xs text-muted-foreground"> <div className="font-mono text-xs text-muted-foreground">
{grant.userId} {grant.userId}
</div> </div>
</div> </div>
@@ -561,6 +622,20 @@ export default function DeveloperGrantsPage() {
</div> </div>
)} )}
</TableCell> </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> <TableCell>
<Badge variant="success"> <Badge variant="success">
{t("ui.dev.grants.approved", "승인됨")} {t("ui.dev.grants.approved", "승인됨")}

View File

@@ -141,6 +141,34 @@ async function renderPage() {
} }
describe("DeveloperRequestPage", () => { 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 () => { it("opens the request modal and submits a request", async () => {
const container = await renderPage(); const container = await renderPage();
expect(container.textContent).toContain("신규 신청하기"); expect(container.textContent).toContain("신규 신청하기");
@@ -183,6 +211,7 @@ describe("DeveloperRequestPage", () => {
organization: "Hanmac", organization: "Hanmac",
reason: "Need RP access", reason: "Need RP access",
tenantId: "tenant-1", tenantId: "tenant-1",
accessPages: ["all"],
}); });
}); });
@@ -251,6 +280,7 @@ describe("DeveloperRequestPage", () => {
organization: "HANMAC", organization: "HANMAC",
reason: "Need RP access", reason: "Need RP access",
tenantId: "", tenantId: "",
accessPages: ["all"],
}); });
}); });

View File

@@ -47,6 +47,12 @@ import {
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi"; import { fetchMe } from "../auth/authApi";
import {
developerAccessPageOptions,
normalizeDeveloperAccessPages,
normalizeDeveloperAccessPageSelection,
type DeveloperAccessPage,
} from "../developer-access/developerAccessPages";
export default function DeveloperRequestPage() { export default function DeveloperRequestPage() {
const auth = useAuth(); const auth = useAuth();
@@ -153,7 +159,7 @@ export default function DeveloperRequestPage() {
} }
const hasActiveRequest = requests?.some( const hasActiveRequest = requests?.some(
(r) => r.status === "pending" || r.status === "approved", (r) => r.status === "pending",
); );
const approvedRequestCount = const approvedRequestCount =
requests?.filter((request) => request.status === "approved").length ?? 0; requests?.filter((request) => request.status === "approved").length ?? 0;
@@ -218,6 +224,9 @@ export default function DeveloperRequestPage() {
<TableHead> <TableHead>
{t("ui.dev.request.table.reason", "신청 사유")} {t("ui.dev.request.table.reason", "신청 사유")}
</TableHead> </TableHead>
<TableHead>
{t("ui.dev.request.table.pages", "권한 페이지")}
</TableHead>
<TableHead> <TableHead>
{t("ui.dev.request.table.status", "상태")} {t("ui.dev.request.table.status", "상태")}
</TableHead> </TableHead>
@@ -235,7 +244,7 @@ export default function DeveloperRequestPage() {
{!requests || requests.length === 0 ? ( {!requests || requests.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={isSuperAdmin ? 6 : 4} colSpan={isSuperAdmin ? 7 : 5}
className="h-32 text-center text-muted-foreground" className="h-32 text-center text-muted-foreground"
> >
{t("msg.dev.request.empty", "신청 내역이 없습니다.")} {t("msg.dev.request.empty", "신청 내역이 없습니다.")}
@@ -272,6 +281,25 @@ export default function DeveloperRequestPage() {
</div> </div>
)} )}
</TableCell> </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> <TableCell>
<StatusBadge status={req.status} /> <StatusBadge status={req.status} />
</TableCell> </TableCell>
@@ -449,12 +477,16 @@ function RequestAccessModal({
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg); const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([
"all",
]);
const organizationDisplay = organization.trim() || t("ui.common.na", "없음"); const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
setName(initialName); setName(initialName);
setOrganization(initialOrg); setOrganization(initialOrg);
setAccessPages(["all"]);
}, [initialName, initialOrg, isOpen]); }, [initialName, initialOrg, isOpen]);
const mutation = useMutation({ const mutation = useMutation({
@@ -471,6 +503,21 @@ function RequestAccessModal({
organization, organization,
reason, reason,
tenantId, 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" className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/> />
</div> </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"> <div className="grid gap-2">
<Label htmlFor="reason"> <Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")}{" "} {t("ui.dev.request.modal.reason", "신청 사유")}{" "}

View File

@@ -972,6 +972,7 @@ function GlobalOverviewPage() {
hasAccessToken, hasAccessToken,
profileRole, profileRole,
tenantId, tenantId,
requiredPages: ["overview"],
isLoadingIdentity: isLoadingMe, isLoadingIdentity: isLoadingMe,
}); });
const distribution = useMemo( const distribution = useMemo(

View File

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

View File

@@ -530,6 +530,7 @@ export type DeveloperRequest = {
phone?: string; phone?: string;
role?: string; role?: string;
reason: string; reason: string;
accessPages?: string[];
status: DeveloperRequestStatus; status: DeveloperRequestStatus;
adminNotes?: string; adminNotes?: string;
createdAt: string; createdAt: string;
@@ -538,8 +539,14 @@ export type DeveloperRequest = {
export type DeveloperGrant = DeveloperRequest; export type DeveloperGrant = DeveloperRequest;
export type DeveloperAccessStatus = {
status: DeveloperRequestStatus | "none";
approvedPages?: string[];
pendingPages?: string[];
};
export async function fetchDeveloperRequestStatus(tenantId?: 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", "/dev/developer-request/status",
{ {
params: { tenantId }, params: { tenantId },
@@ -553,6 +560,7 @@ export async function requestDeveloperAccess(payload: {
organization: string; organization: string;
reason: string; reason: string;
tenantId: string; tenantId: string;
accessPages: string[];
}) { }) {
const { data } = await apiClient.post<{ status: string }>( const { data } = await apiClient.post<{ status: string }>(
"/dev/developer-request", "/dev/developer-request",
@@ -610,6 +618,7 @@ export async function createDeveloperGrant(payload: {
tenantId: string; tenantId: string;
reason?: string; reason?: string;
adminNotes?: string; adminNotes?: string;
accessPages: string[];
}) { }) {
const { data } = await apiClient.post<DeveloperGrant>( const { data } = await apiClient.post<DeveloperGrant>(
"/dev/developer-grants", "/dev/developer-grants",