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
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)

View File

@@ -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"`

View File

@@ -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"})
}

View File

@@ -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" {

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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() {
</TableRow>
</TableHeader>
<TableBody>
{filteredClients.length === 0 && (
{!hasFilterResult && (
<TableRow>
<TableCell
colSpan={6}
@@ -386,19 +397,58 @@ function ClientsPage() {
>
<div className="space-y-1">
<p className="font-medium text-foreground">
{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가 없습니다.",
)}
</p>
<div className="text-sm space-y-2">
<p className="text-muted-foreground">
{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가 표시됩니다.",
)}
</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
type="button"
className="text-primary font-bold hover:underline"

View File

@@ -30,6 +30,7 @@ import {
import { Textarea } from "../../components/ui/textarea";
import {
approveDeveloperRequest,
cancelDeveloperRequestApproval,
fetchDeveloperRequests,
rejectDeveloperRequest,
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) => {
approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" });
};
@@ -84,6 +95,14 @@ export default function DeveloperRequestPage() {
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) {
return (
<div className="p-8 text-center">
@@ -95,6 +114,10 @@ export default function DeveloperRequestPage() {
const hasActiveRequest = requests?.some(
(r) => r.status === "pending" || r.status === "approved",
);
const isActionPending =
approveMutation.isPending ||
rejectMutation.isPending ||
cancelApprovalMutation.isPending;
return (
<div className="space-y-6">
@@ -211,7 +234,7 @@ export default function DeveloperRequestPage() {
variant="outline"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleReject(req.id)}
disabled={rejectMutation.isPending}
disabled={isActionPending}
>
<XCircle className="mr-1 h-3 w-3" />
{t("ui.common.reject", "반려")}
@@ -220,17 +243,44 @@ export default function DeveloperRequestPage() {
size="sm"
className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => handleApprove(req.id)}
disabled={approveMutation.isPending}
disabled={isActionPending}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
{t("ui.common.approve", "승인")}
</Button>
</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">
{req.status === "approved"
? t("ui.common.completed", "처리 완료")
{req.status === "cancelled"
? t("ui.dev.request.status.cancelled", "승인 취소됨")
: t("ui.common.rejected", "반려됨")}
</span>
)}
@@ -282,6 +332,13 @@ function StatusBadge({ status }: { status: string }) {
{t("ui.dev.request.status.rejected", "반려됨")}
</Badge>
);
case "cancelled":
return (
<Badge variant="muted" className="gap-1">
<XCircle className="h-3 w-3" />
{t("ui.dev.request.status.cancelled", "승인 취소됨")}
</Badge>
);
default:
return <Badge variant="muted">{status}</Badge>;
}

View File

@@ -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;
}

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."
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"

View File

@@ -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이 재발급되었습니다."

View File

@@ -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 = ""