forked from baron/baron-sso
개발자 권한을 페이지별로 선택/부여 가능하도록 개선
This commit is contained in:
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function AuditLogsPage() {
|
|||||||
hasAccessToken,
|
hasAccessToken,
|
||||||
profileRole,
|
profileRole,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
requiredPages: ["audit"],
|
||||||
isLoadingIdentity: isLoadingMe,
|
isLoadingIdentity: isLoadingMe,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
devfront/src/features/developer-access/developerAccessPages.ts
Normal file
104
devfront/src/features/developer-access/developerAccessPages.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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", "승인됨")}
|
||||||
|
|||||||
@@ -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"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", "신청 사유")}{" "}
|
||||||
|
|||||||
@@ -972,6 +972,7 @@ function GlobalOverviewPage() {
|
|||||||
hasAccessToken,
|
hasAccessToken,
|
||||||
profileRole,
|
profileRole,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
requiredPages: ["overview"],
|
||||||
isLoadingIdentity: isLoadingMe,
|
isLoadingIdentity: isLoadingMe,
|
||||||
});
|
});
|
||||||
const distribution = useMemo(
|
const distribution = useMemo(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user