1
0
forked from baron/baron-sso

개발자 권한 신청 승인/취소 및 RP 생성 흐름 개선

This commit is contained in:
2026-04-22 14:39:06 +09:00
parent 2216d9c4e4
commit 685923a03e
12 changed files with 382 additions and 44 deletions

View File

@@ -727,6 +727,11 @@ func main() {
// [New] Developer Registration Flow // [New] Developer Registration Flow
dev.Post("/developer-request", devHandler.RequestDeveloperAccess) dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus) dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus)
dev.Get("/developer-request/status", devHandler.GetDeveloperRequestStatus)
dev.Get("/developer-request/list", devHandler.ListDeveloperRequests)
dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest)
dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest)
dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval)
// Webhook for Kratos courier (HTTP delivery) // Webhook for Kratos courier (HTTP delivery)
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)

View File

@@ -8,6 +8,7 @@ const (
DeveloperRequestStatusPending = "pending" DeveloperRequestStatusPending = "pending"
DeveloperRequestStatusApproved = "approved" DeveloperRequestStatusApproved = "approved"
DeveloperRequestStatusRejected = "rejected" DeveloperRequestStatusRejected = "rejected"
DeveloperRequestStatusCancelled = "cancelled"
) )
// DeveloperRequest represents a user's application to become a developer. // DeveloperRequest represents a user's application to become a developer.
@@ -18,7 +19,7 @@ type DeveloperRequest struct {
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Organization string `json:"organization"` Organization string `json:"organization"`
Reason string `json:"reason"` Reason string `json:"reason"`
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
AdminNotes string `json:"adminNotes"` AdminNotes string `json:"adminNotes"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`

View File

@@ -349,21 +349,47 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
if profile == nil { if profile == nil {
return false return false
} }
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { role := normalizeUserRole(profile.Role)
if role == domain.RoleSuperAdmin {
return true
}
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
return true return true
} }
clientTenantID := resolveClientTenantID(summary) clientTenantID := resolveClientTenantID(summary)
if clientTenantID != "" { if role != domain.RoleUser && clientTenantID != "" {
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed { if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
return true return true
} }
} }
if role == domain.RoleUser {
return false
}
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view") allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
return err == nil && allowed return err == nil && allowed
} }
func (h *DevHandler) hasDirectRelyingPartyOperatorRelation(c *fiber.Ctx, profile *domain.UserProfileResponse, clientID string) bool {
if h.Keto == nil || profile == nil {
return false
}
subject := ketoSubjectFromProfile(profile)
if subject == "" || strings.TrimSpace(clientID) == "" {
return false
}
for relation := range allowedRelyingPartyOperatorRelations {
tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, subject)
if err == nil && len(tuples) > 0 {
return true
}
}
return false
}
func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool { func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
if strings.TrimSpace(tenantID) == "" { if strings.TrimSpace(tenantID) == "" {
return false return false
@@ -1428,7 +1454,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if tenantID == "" && profile.TenantID != nil { if tenantID == "" && profile.TenantID != nil {
tenantID = *profile.TenantID tenantID = *profile.TenantID
} }
if role == domain.RoleRPAdmin && !h.canManageTenantClientsByPermit(c, profile, tenantID) { if (role == domain.RoleRPAdmin || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required") return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required")
} }
@@ -1473,7 +1499,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error") return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
} }
if !isAppManager { if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
} }
} }
@@ -1557,11 +1583,12 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
// [New] Automatically grant admin permission to the creator in Keto // [New] Automatically grant admin permission to the creator in Keto
if h.KetoOutbox != nil && profile != nil { if h.KetoOutbox != nil && profile != nil {
subject := "User:" + profile.ID
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "RelyingParty", Namespace: "RelyingParty",
Object: created.ClientID, Object: created.ClientID,
Relation: "admins", Relation: "admins",
Subject: "User:" + profile.ID, Subject: subject,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
if err != nil { if err != nil {
@@ -1569,6 +1596,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
} else { } else {
slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID) slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID)
} }
if h.Keto != nil {
if err := h.Keto.CreateRelation(c.Context(), "RelyingParty", created.ClientID, "admins", subject); err != nil {
slog.Warn("failed to grant immediate admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
}
}
} }
// Store secret in metadata for later retrieval // Store secret in metadata for later retrieval
@@ -2863,10 +2895,66 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
if status == nil { if status == nil {
return c.JSON(fiber.Map{"status": "none"}) return c.JSON(fiber.Map{"status": "none"})
} }
if status.Status == domain.DeveloperRequestStatusApproved {
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID)
}
return c.JSON(status) return c.JSON(status)
} }
func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) {
if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" {
return
}
subject := "User:" + strings.TrimSpace(userID)
for _, relation := range []string{"view_dev_console", "grant_dev_permissions"} {
if !h.hasDirectTenantRelation(c, tenantID, relation, subject) {
continue
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionDelete,
})
}
if h.hasDirectTenantRelation(c, tenantID, "developer_console_grant_manager", subject) {
return
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "developer_console_grant_manager",
Subject: subject,
Action: domain.KetoOutboxActionCreate,
})
}
func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) {
if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" {
return
}
subject := "User:" + strings.TrimSpace(userID)
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionDelete,
})
}
}
func (h *DevHandler) hasDirectTenantRelation(c *fiber.Ctx, tenantID, relation, subject string) bool {
if h.Keto == nil {
return false
}
tuples, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, relation, subject)
return err == nil && len(tuples) > 0
}
func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error { func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
profile := h.getCurrentProfile(c) profile := h.getCurrentProfile(c)
if profile == nil { if profile == nil {
@@ -2923,18 +3011,7 @@ func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error {
// Grant Keto Permissions // Grant Keto Permissions
if h.KetoOutbox != nil { if h.KetoOutbox != nil {
subject := "User:" + devReq.UserID h.ensureDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
permissions := []string{"view_dev_console", "grant_dev_permissions"}
for _, relation := range permissions {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: devReq.TenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionCreate,
})
}
} }
return c.JSON(fiber.Map{"status": "ok"}) return c.JSON(fiber.Map{"status": "ok"})
@@ -2968,3 +3045,42 @@ func (h *DevHandler) RejectDeveloperRequest(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"}) return c.JSON(fiber.Map{"status": "ok"})
} }
func (h *DevHandler) CancelDeveloperRequestApproval(c *fiber.Ctx) error {
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
}
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request id")
}
var reqBody struct {
AdminNotes string `json:"adminNotes"`
}
if err := c.BodyParser(&reqBody); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id))
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch request details")
}
if devReq.Status != domain.DeveloperRequestStatusApproved {
return errorJSON(c, fiber.StatusBadRequest, "only approved requests can be cancelled")
}
if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
return c.JSON(fiber.Map{"status": "ok"})
}

View File

@@ -1023,6 +1023,68 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
mockKeto.AssertExpectations(t) mockKeto.AssertExpectations(t)
} }
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
body["client_secret"] = "generated-secret"
return httpJSONAny(r, http.StatusCreated, body), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil)
mockOutbox := new(devMockKetoOutboxRepository)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" &&
entry.Object == "client-1" &&
entry.Relation == "admins" &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
Redis: &devMockRedisRepo{data: make(map[string]string)},
Keto: mockKeto,
KetoOutbox: mockOutbox,
}
app := fiber.New()
tenantID := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"id": "client-1",
"name": "App One",
"type": "private",
"redirectUris": []string{"http://localhost/cb"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestGetStats_Success(t *testing.T) { func TestGetStats_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {

View File

@@ -21,7 +21,10 @@ func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.Develop
var existing domain.DeveloperRequest var existing domain.DeveloperRequest
err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).First(&existing).Error 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 { if err == nil {
return errors.New("already has a pending request") return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
} }
return s.db.WithContext(ctx).Create(&req).Error return s.db.WithContext(ctx).Create(&req).Error
@@ -74,3 +77,10 @@ func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNote
"admin_notes": adminNotes, "admin_notes": adminNotes,
}).Error }).Error
} }
func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": domain.DeveloperRequestStatusCancelled,
"admin_notes": adminNotes,
}).Error
}

View File

@@ -529,12 +529,16 @@ function ClientGeneralPage() {
onError: (err) => { onError: (err) => {
const axiosError = err as AxiosError<{ error?: string }>; const axiosError = err as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) { if (axiosError.response?.status === 403) {
toast( alert(
t( isCreate
? t(
"msg.dev.clients.general.create_forbidden",
"이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.",
)
: t(
"msg.dev.clients.general.save_forbidden", "msg.dev.clients.general.save_forbidden",
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
), ),
"error",
); );
return; return;
} }

View File

@@ -57,8 +57,6 @@ function ClientsPage() {
const role = resolveProfileRole(userProfile); const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined; const tenantId = userProfile?.tenant_id as string | undefined;
const canCreateClient = role !== "user" && role !== "tenant_member";
const { const {
data, data,
isLoading: isLoadingClients, isLoading: isLoadingClients,
@@ -76,6 +74,7 @@ function ClientsPage() {
}); });
const { const {
data: requestStatus,
isLoading: isLoadingRequest, isLoading: isLoadingRequest,
refetch: refetchRequest, refetch: refetchRequest,
} = useQuery({ } = useQuery({
@@ -84,6 +83,16 @@ function ClientsPage() {
enabled: hasAccessToken && role === "user", enabled: hasAccessToken && role === "user",
}); });
const canCreateClient =
(role !== "user" && role !== "tenant_member") ||
requestStatus?.status === "approved";
const isDeveloperRequestPending = requestStatus?.status === "pending";
const canRequestDeveloperAccess =
role === "user" &&
!isLoadingRequest &&
!canCreateClient &&
!isDeveloperRequestPending;
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
@@ -106,6 +115,8 @@ function ClientsPage() {
const totalClients = statsData?.total_clients ?? clients.length; const totalClients = statsData?.total_clients ?? clients.length;
const activeSessions = statsData?.active_sessions ?? 0; const activeSessions = statsData?.active_sessions ?? 0;
const authFailures = statsData?.auth_failures_24h ?? 0; const authFailures = statsData?.auth_failures_24h ?? 0;
const hasFilterResult = filteredClients.length > 0;
const isFilteredOut = clients.length > 0 && !hasFilterResult;
type StatTone = "up" | "down" | "stable"; type StatTone = "up" | "down" | "stable";
type StatItem = { type StatItem = {
@@ -378,7 +389,7 @@ function ClientsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredClients.length === 0 && ( {!hasFilterResult && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={6}
@@ -386,19 +397,58 @@ function ClientsPage() {
> >
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-foreground"> <p className="font-medium text-foreground">
{t( {isFilteredOut
? t(
"msg.dev.clients.empty_filtered",
"조건에 맞는 연동 앱이 없습니다.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create",
"아직 등록된 연동 앱이 없습니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending",
"개발자 권한 신청을 검토 중입니다.",
)
: t(
"msg.dev.clients.empty", "msg.dev.clients.empty",
"조회 가능한 RP가 없습니다.", "조회 가능한 RP가 없습니다.",
)} )}
</p> </p>
<div className="text-sm space-y-2"> <div className="text-sm space-y-2">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t( {isFilteredOut
? t(
"msg.dev.clients.empty_filtered_detail",
"검색어나 필터 조건을 변경해 보세요.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create_detail",
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
)
: t(
"msg.dev.clients.empty_detail", "msg.dev.clients.empty_detail",
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
)} )}
</p> </p>
{role === "user" && ( {!isFilteredOut && canCreateClient && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/clients/new")}
>
{t("ui.dev.clients.new", "연동 앱 추가")}
</button>
)}
{!isFilteredOut && canRequestDeveloperAccess && (
<button <button
type="button" type="button"
className="text-primary font-bold hover:underline" className="text-primary font-bold hover:underline"

View File

@@ -30,6 +30,7 @@ import {
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
import { import {
approveDeveloperRequest, approveDeveloperRequest,
cancelDeveloperRequestApproval,
fetchDeveloperRequests, fetchDeveloperRequests,
rejectDeveloperRequest, rejectDeveloperRequest,
requestDeveloperAccess, requestDeveloperAccess,
@@ -72,6 +73,16 @@ export default function DeveloperRequestPage() {
}, },
}); });
const cancelApprovalMutation = useMutation({
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
cancelDeveloperRequestApproval(id, adminNotes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
queryClient.invalidateQueries({ queryKey: ["developer-request"] });
alert(t("msg.dev.request.cancelled", "승인이 취소되었습니다."));
},
});
const handleApprove = (id: number) => { const handleApprove = (id: number) => {
approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" }); approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" });
}; };
@@ -84,6 +95,14 @@ export default function DeveloperRequestPage() {
rejectMutation.mutate({ id, adminNotes: adminNotes[id] }); rejectMutation.mutate({ id, adminNotes: adminNotes[id] });
}; };
const handleCancelApproval = (id: number) => {
if (!adminNotes[id]) {
alert(t("msg.dev.request.need_cancel_notes", "승인 취소 사유를 입력해주세요."));
return;
}
cancelApprovalMutation.mutate({ id, adminNotes: adminNotes[id] });
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-8 text-center"> <div className="p-8 text-center">
@@ -95,6 +114,10 @@ export default function DeveloperRequestPage() {
const hasActiveRequest = requests?.some( const hasActiveRequest = requests?.some(
(r) => r.status === "pending" || r.status === "approved", (r) => r.status === "pending" || r.status === "approved",
); );
const isActionPending =
approveMutation.isPending ||
rejectMutation.isPending ||
cancelApprovalMutation.isPending;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -211,7 +234,7 @@ export default function DeveloperRequestPage() {
variant="outline" variant="outline"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10"
onClick={() => handleReject(req.id)} onClick={() => handleReject(req.id)}
disabled={rejectMutation.isPending} disabled={isActionPending}
> >
<XCircle className="mr-1 h-3 w-3" /> <XCircle className="mr-1 h-3 w-3" />
{t("ui.common.reject", "반려")} {t("ui.common.reject", "반려")}
@@ -220,17 +243,44 @@ export default function DeveloperRequestPage() {
size="sm" size="sm"
className="bg-emerald-600 hover:bg-emerald-700" className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => handleApprove(req.id)} onClick={() => handleApprove(req.id)}
disabled={approveMutation.isPending} disabled={isActionPending}
> >
<CheckCircle2 className="mr-1 h-3 w-3" /> <CheckCircle2 className="mr-1 h-3 w-3" />
{t("ui.common.approve", "승인")} {t("ui.common.approve", "승인")}
</Button> </Button>
</div> </div>
</div> </div>
) : req.status === "approved" ? (
<div className="flex flex-col gap-2 min-w-[200px] items-end ml-auto">
<Input
placeholder={t(
"ui.dev.request.cancel_notes_placeholder",
"승인 취소 사유 입력...",
)}
className="h-8 text-xs"
value={adminNotes[req.id] || ""}
onChange={(e) =>
setAdminNotes({
...adminNotes,
[req.id]: e.target.value,
})
}
/>
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleCancelApproval(req.id)}
disabled={isActionPending}
>
<XCircle className="mr-1 h-3 w-3" />
{t("ui.dev.request.cancel_approval", "승인 취소")}
</Button>
</div>
) : ( ) : (
<span className="text-muted-foreground text-xs italic"> <span className="text-muted-foreground text-xs italic">
{req.status === "approved" {req.status === "cancelled"
? t("ui.common.completed", "처리 완료") ? t("ui.dev.request.status.cancelled", "승인 취소됨")
: t("ui.common.rejected", "반려됨")} : t("ui.common.rejected", "반려됨")}
</span> </span>
)} )}
@@ -282,6 +332,13 @@ function StatusBadge({ status }: { status: string }) {
{t("ui.dev.request.status.rejected", "반려됨")} {t("ui.dev.request.status.rejected", "반려됨")}
</Badge> </Badge>
); );
case "cancelled":
return (
<Badge variant="muted" className="gap-1">
<XCircle className="h-3 w-3" />
{t("ui.dev.request.status.cancelled", "승인 취소됨")}
</Badge>
);
default: default:
return <Badge variant="muted">{status}</Badge>; return <Badge variant="muted">{status}</Badge>;
} }

View File

@@ -392,6 +392,7 @@ export type DeveloperRequestStatus =
| "pending" | "pending"
| "approved" | "approved"
| "rejected" | "rejected"
| "cancelled"
| "none"; | "none";
export type DeveloperRequest = { export type DeveloperRequest = {
@@ -461,3 +462,14 @@ export async function rejectDeveloperRequest(
); );
return data; return data;
} }
export async function cancelDeveloperRequestApproval(
id: number,
adminNotes: string,
) {
const { data } = await apiClient.post<{ status: string }>(
`/dev/developer-request/${id}/cancel-approval`,
{ adminNotes },
);
return data;
}

View File

@@ -334,6 +334,12 @@ delete_error = "Failed to delete: {{error}}"
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone." delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
empty = "No RPs are available." empty = "No RPs are available."
empty_detail = "RPs will appear here when a relationship is assigned to your account." empty_detail = "RPs will appear here when a relationship is assigned to your account."
empty_can_create = "No linked apps have been registered yet."
empty_can_create_detail = "Create a new RP with the Add linked app button, and it will appear here."
empty_filtered = "No linked apps match the current filters."
empty_filtered_detail = "Try changing the search text or filters."
empty_pending = "Your developer access request is under review."
empty_pending_detail = "You can add linked apps after a super admin approves it."
[msg.dev.clients.consents] [msg.dev.clients.consents]
empty = "No consents found." empty = "No consents found."
@@ -353,6 +359,7 @@ missing_id = "Client ID is required."
redirect_saved = "Redirect URIs saved." redirect_saved = "Redirect URIs saved."
rotate_confirm = "Rotate Confirm" rotate_confirm = "Rotate Confirm"
rotate_error = "Rotate Error" rotate_error = "Rotate Error"
create_forbidden = "You do not have permission to create this RP. Ask an administrator to grant developer access."
save_error = "Save Error" save_error = "Save Error"
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship." save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
secret_rotated = "Secret Rotated" secret_rotated = "Secret Rotated"

View File

@@ -331,6 +331,12 @@ delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은
delete_error = "삭제 실패: {{error}}" delete_error = "삭제 실패: {{error}}"
empty = "조회 가능한 RP가 없습니다." empty = "조회 가능한 RP가 없습니다."
empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다." empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다."
empty_can_create = "아직 등록된 연동 앱이 없습니다."
empty_can_create_detail = "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다."
empty_filtered = "조건에 맞는 연동 앱이 없습니다."
empty_filtered_detail = "검색어나 필터 조건을 변경해 보세요."
empty_pending = "개발자 권한 신청을 검토 중입니다."
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
load_error = "앱 정보를 불러오지 못했습니다: {{error}}" load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
loading = "앱 정보를 불러오는 중..." loading = "앱 정보를 불러오는 중..."
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
@@ -353,6 +359,7 @@ missing_id = "Client ID가 필요합니다."
redirect_saved = "Redirect URIs가 저장되었습니다." redirect_saved = "Redirect URIs가 저장되었습니다."
rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?" rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?"
rotate_error = "재발급 실패: {{error}}" rotate_error = "재발급 실패: {{error}}"
create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요."
save_error = "저장 실패: {{error}}" save_error = "저장 실패: {{error}}"
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요." save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
secret_rotated = "Client Secret이 재발급되었습니다." secret_rotated = "Client Secret이 재발급되었습니다."

View File

@@ -334,6 +334,12 @@ delete_error = ""
delete_confirm = "" delete_confirm = ""
empty = "" empty = ""
empty_detail = "" empty_detail = ""
empty_can_create = ""
empty_can_create_detail = ""
empty_filtered = ""
empty_filtered_detail = ""
empty_pending = ""
empty_pending_detail = ""
[msg.dev.clients.consents] [msg.dev.clients.consents]
empty = "" empty = ""
@@ -353,6 +359,7 @@ missing_id = ""
redirect_saved = "" redirect_saved = ""
rotate_confirm = "" rotate_confirm = ""
rotate_error = "" rotate_error = ""
create_forbidden = ""
save_error = "" save_error = ""
save_forbidden = "" save_forbidden = ""
secret_rotated = "" secret_rotated = ""