From 685923a03e150df0f6ad4418b8f1dacd1205df71 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 22 Apr 2026 14:39:06 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=8B=A0=EC=B2=AD=20=EC=8A=B9=EC=9D=B8/=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EB=B0=8F=20RP=20=EC=83=9D=EC=84=B1=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 5 + backend/internal/domain/developer_request.go | 9 +- backend/internal/handler/dev_handler.go | 150 ++++++++++++++++-- backend/internal/handler/dev_handler_test.go | 62 ++++++++ backend/internal/service/developer_service.go | 12 +- .../features/clients/ClientGeneralPage.tsx | 16 +- devfront/src/features/clients/ClientsPage.tsx | 74 +++++++-- .../DeveloperRequestPage.tsx | 65 +++++++- devfront/src/lib/devApi.ts | 12 ++ devfront/src/locales/en.toml | 7 + devfront/src/locales/ko.toml | 7 + devfront/src/locales/template.toml | 7 + 12 files changed, 382 insertions(+), 44 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1984c339..5c628958 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -727,6 +727,11 @@ func main() { // [New] Developer Registration Flow dev.Post("/developer-request", devHandler.RequestDeveloperAccess) 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) auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) diff --git a/backend/internal/domain/developer_request.go b/backend/internal/domain/developer_request.go index 73c8b5c5..22645924 100644 --- a/backend/internal/domain/developer_request.go +++ b/backend/internal/domain/developer_request.go @@ -5,9 +5,10 @@ import ( ) const ( - DeveloperRequestStatusPending = "pending" - DeveloperRequestStatusApproved = "approved" - DeveloperRequestStatusRejected = "rejected" + DeveloperRequestStatusPending = "pending" + DeveloperRequestStatusApproved = "approved" + DeveloperRequestStatusRejected = "rejected" + DeveloperRequestStatusCancelled = "cancelled" ) // DeveloperRequest represents a user's application to become a developer. @@ -18,7 +19,7 @@ type DeveloperRequest struct { Name string `gorm:"not null" json:"name"` Organization string `json:"organization"` 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"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 4a3ce284..1af7534e 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -349,21 +349,47 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro if profile == nil { 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 } 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 { return true } } + if role == domain.RoleUser { + return false + } + allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view") 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 { if strings.TrimSpace(tenantID) == "" { return false @@ -1428,7 +1454,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if tenantID == "" && profile.TenantID != nil { 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") } @@ -1473,7 +1499,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if err != nil { 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") } } @@ -1557,11 +1583,12 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { // [New] Automatically grant admin permission to the creator in Keto if h.KetoOutbox != nil && profile != nil { + subject := "User:" + profile.ID err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "RelyingParty", Object: created.ClientID, Relation: "admins", - Subject: "User:" + profile.ID, + Subject: subject, Action: domain.KetoOutboxActionCreate, }) if err != nil { @@ -1569,6 +1596,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { } else { 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 @@ -2863,10 +2895,66 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error { if status == nil { return c.JSON(fiber.Map{"status": "none"}) } + if status.Status == domain.DeveloperRequestStatusApproved { + h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID) + } 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 { profile := h.getCurrentProfile(c) if profile == nil { @@ -2923,18 +3011,7 @@ func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error { // Grant Keto Permissions if h.KetoOutbox != nil { - subject := "User:" + devReq.UserID - 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, - }) - } + h.ensureDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID) } 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"}) } + +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"}) +} diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 52db7050..7c5ef5c0 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1023,6 +1023,68 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.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) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { diff --git a/backend/internal/service/developer_service.go b/backend/internal/service/developer_service.go index 6040e83a..ff0081dd 100644 --- a/backend/internal/service/developer_service.go +++ b/backend/internal/service/developer_service.go @@ -21,7 +21,10 @@ func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.Develop var existing domain.DeveloperRequest err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).First(&existing).Error if err == nil { - 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 @@ -74,3 +77,10 @@ func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNote "admin_notes": adminNotes, }).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 +} diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 91590fba..152480e2 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -529,12 +529,16 @@ function ClientGeneralPage() { onError: (err) => { const axiosError = err as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { - toast( - t( - "msg.dev.clients.general.save_forbidden", - "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", - ), - "error", + alert( + isCreate + ? t( + "msg.dev.clients.general.create_forbidden", + "이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.", + ) + : t( + "msg.dev.clients.general.save_forbidden", + "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", + ), ); return; } diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index e3a10a75..a00979a8 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -57,8 +57,6 @@ function ClientsPage() { const role = resolveProfileRole(userProfile); const tenantId = userProfile?.tenant_id as string | undefined; - const canCreateClient = role !== "user" && role !== "tenant_member"; - const { data, isLoading: isLoadingClients, @@ -76,6 +74,7 @@ function ClientsPage() { }); const { + data: requestStatus, isLoading: isLoadingRequest, refetch: refetchRequest, } = useQuery({ @@ -84,6 +83,16 @@ function ClientsPage() { 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 [typeFilter, setTypeFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); @@ -106,6 +115,8 @@ function ClientsPage() { const totalClients = statsData?.total_clients ?? clients.length; const activeSessions = statsData?.active_sessions ?? 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 StatItem = { @@ -378,7 +389,7 @@ function ClientsPage() { - {filteredClients.length === 0 && ( + {!hasFilterResult && (

- {t( - "msg.dev.clients.empty", - "조회 가능한 RP가 없습니다.", - )} + {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", + "조회 가능한 RP가 없습니다.", + )}

- {t( - "msg.dev.clients.empty_detail", - "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", - )} + {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", + "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", + )}

- {role === "user" && ( + {!isFilteredOut && canCreateClient && ( + + )} + {!isFilteredOut && canRequestDeveloperAccess && (
+ ) : req.status === "approved" ? ( +
+ + setAdminNotes({ + ...adminNotes, + [req.id]: e.target.value, + }) + } + /> + +
) : ( - {req.status === "approved" - ? t("ui.common.completed", "처리 완료") + {req.status === "cancelled" + ? t("ui.dev.request.status.cancelled", "승인 취소됨") : t("ui.common.rejected", "반려됨")} )} @@ -282,6 +332,13 @@ function StatusBadge({ status }: { status: string }) { {t("ui.dev.request.status.rejected", "반려됨")} ); + case "cancelled": + return ( + + + {t("ui.dev.request.status.cancelled", "승인 취소됨")} + + ); default: return {status}; } diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 27b97b0c..d1c91313 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -392,6 +392,7 @@ export type DeveloperRequestStatus = | "pending" | "approved" | "rejected" + | "cancelled" | "none"; export type DeveloperRequest = { @@ -461,3 +462,14 @@ export async function rejectDeveloperRequest( ); 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; +} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 7a50ffb0..5a3280c0 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -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." empty = "No RPs are available." 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] empty = "No consents found." @@ -353,6 +359,7 @@ missing_id = "Client ID is required." redirect_saved = "Redirect URIs saved." rotate_confirm = "Rotate Confirm" 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_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" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 788d08a7..0cc7f832 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -331,6 +331,12 @@ delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 delete_error = "삭제 실패: {{error}}" empty = "조회 가능한 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}}" loading = "앱 정보를 불러오는 중..." showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." @@ -353,6 +359,7 @@ missing_id = "Client ID가 필요합니다." redirect_saved = "Redirect URIs가 저장되었습니다." rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?" rotate_error = "재발급 실패: {{error}}" +create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요." save_error = "저장 실패: {{error}}" save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요." secret_rotated = "Client Secret이 재발급되었습니다." diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 9e43f416..2dffcfd3 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -334,6 +334,12 @@ delete_error = "" delete_confirm = "" empty = "" empty_detail = "" +empty_can_create = "" +empty_can_create_detail = "" +empty_filtered = "" +empty_filtered_detail = "" +empty_pending = "" +empty_pending_detail = "" [msg.dev.clients.consents] empty = "" @@ -353,6 +359,7 @@ missing_id = "" redirect_saved = "" rotate_confirm = "" rotate_error = "" +create_forbidden = "" save_error = "" save_forbidden = "" secret_rotated = ""