1
0
forked from baron/baron-sso

테넌트 접근 제한/커스텀 클레임 관계 설정

This commit is contained in:
2026-04-30 10:01:00 +09:00
parent 613d198690
commit 52936b2b88
7 changed files with 214 additions and 12 deletions

View File

@@ -15,6 +15,7 @@ import {
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -31,9 +32,11 @@ import { Switch } from "../../components/ui/switch";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import {
type ClientRelation,
createClient,
deleteClient,
fetchClient,
fetchClientRelations,
fetchMyTenants,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
@@ -48,6 +51,7 @@ import type {
TenantSummary,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs";
@@ -275,16 +279,27 @@ function formatDateTime(value?: string) {
}
function ClientGeneralPage() {
const auth = useAuth();
const params = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const clientId = params.id;
const isCreate = !clientId;
const currentUserId = auth.user?.profile.sub;
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const { data, isLoading, error } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId as string),
enabled: !isCreate,
});
const { data: relationData } = useQuery({
queryKey: ["client-relations", clientId],
queryFn: () => fetchClientRelations(clientId as string),
enabled: !isCreate,
retry: false,
});
const { data: tenantData } = useQuery({
queryKey: ["my-tenants"],
queryFn: fetchMyTenants,
@@ -440,6 +455,17 @@ function ClientGeneralPage() {
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const canEditExistingClientGeneralSettings =
systemRole === "super_admin" ||
relationData?.items?.some(
(item: ClientRelation) =>
item.subject === `User:${currentUserId}` &&
(item.relation === "admins" || item.relation === "config_editor"),
) === true;
const isGeneralSettingsReadOnly =
!isCreate &&
relationData != null &&
!canEditExistingClientGeneralSettings;
const trimmedLogoUrl = logoUrl.trim();
const trimmedAutoLoginUrl = autoLoginUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
@@ -869,6 +895,14 @@ function ClientGeneralPage() {
),
);
}
if (isGeneralSettingsReadOnly) {
throw new Error(
t(
"msg.dev.clients.general.read_only_forbidden",
"이 RP의 일반 설정을 수정할 권한이 없습니다.",
),
);
}
if (autoLoginSupported && !hasValidAutoLoginUrl) {
throw new Error(
t(
@@ -1114,6 +1148,14 @@ function ClientGeneralPage() {
{!isCreate && (
<ClientDetailTabs activeTab="settings" clientId={clientId} />
)}
{isGeneralSettingsReadOnly && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{t(
"msg.dev.clients.general.read_only_hint",
"이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.",
)}
</div>
)}
</header>
{/* 1. Application Identity */}
@@ -1148,6 +1190,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.identity.name_placeholder",
"My Awesome Application",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
<div className="space-y-2">
@@ -1165,6 +1208,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.identity.description_placeholder",
"앱에 대한 간단한 설명을 입력하세요.",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</div>
@@ -1184,6 +1228,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.identity.logo_placeholder",
"https://example.com/logo.png",
)}
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -1302,6 +1347,7 @@ function ClientGeneralPage() {
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => handleStatusChange("active")}
disabled={isGeneralSettingsReadOnly}
>
{t("ui.common.status.active", "활성")}
</Button>
@@ -1310,6 +1356,7 @@ function ClientGeneralPage() {
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => handleStatusChange("inactive")}
disabled={isGeneralSettingsReadOnly}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
@@ -1416,6 +1463,7 @@ function ClientGeneralPage() {
size="sm"
onClick={addScope}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
@@ -1436,6 +1484,7 @@ function ClientGeneralPage() {
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
)}
className="font-mono text-sm"
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -1493,7 +1542,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.name_placeholder",
"e.g. profile",
)}
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3">
@@ -1507,7 +1556,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.description_placeholder",
"권한에 대한 설명",
)}
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 text-center">
@@ -1517,7 +1566,7 @@ function ClientGeneralPage() {
onCheckedChange={(checked) =>
updateScope(s.id, "mandatory", checked)
}
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</div>
</td>
@@ -1527,7 +1576,7 @@ function ClientGeneralPage() {
size="icon"
onClick={() => removeScope(s.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -1594,6 +1643,7 @@ function ClientGeneralPage() {
checked={tenantAccessRestricted}
onCheckedChange={handleTenantAccessToggle}
id="tenant-access-toggle"
disabled={isGeneralSettingsReadOnly}
/>
</div>
</div>
@@ -1638,7 +1688,9 @@ function ClientGeneralPage() {
"테넌트 이름 또는 슬러그로 검색",
)}
className="pl-10"
disabled={!tenantAccessRestricted}
disabled={
isGeneralSettingsReadOnly || !tenantAccessRestricted
}
/>
{tenantAccessRestricted && isTenantSearchOpen && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
@@ -1652,6 +1704,7 @@ function ClientGeneralPage() {
event.preventDefault();
handleSelectAllowedTenant(tenant.id);
}}
disabled={isGeneralSettingsReadOnly}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
@@ -1719,6 +1772,7 @@ function ClientGeneralPage() {
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenant.id)}
className="text-muted-foreground transition hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<X className="h-3.5 w-3.5" />
</button>
@@ -1744,6 +1798,7 @@ function ClientGeneralPage() {
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenantId)}
className="text-muted-foreground transition hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<X className="h-3.5 w-3.5" />
</button>
@@ -1786,7 +1841,11 @@ function ClientGeneralPage() {
)}
</CardDescription>
</div>
<Button onClick={addIdTokenClaim} className="gap-2">
<Button
onClick={addIdTokenClaim}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
</Button>
@@ -1849,6 +1908,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 align-top">
@@ -1866,6 +1926,7 @@ function ClientGeneralPage() {
"Claim namespace",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="top_level">
{t(
@@ -1896,6 +1957,7 @@ function ClientGeneralPage() {
"Claim value type",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="text">
{t(
@@ -1944,6 +2006,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the claim value",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 text-right align-top">
@@ -1952,6 +2015,7 @@ function ClientGeneralPage() {
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -2116,6 +2180,7 @@ function ClientGeneralPage() {
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
disabled={isGeneralSettingsReadOnly}
/>
</div>
)}
@@ -2589,6 +2654,7 @@ function ClientGeneralPage() {
<Button
onClick={() => mutation.mutate()}
disabled={
isGeneralSettingsReadOnly ||
mutation.isPending ||
isLoading ||
name.trim() === "" ||

View File

@@ -47,7 +47,6 @@ const relationOptions = [
"consent_revoker",
"relationship_viewer",
"audit_viewer",
"status_operator",
] as const;
type RelationOption = (typeof relationOptions)[number];

View File

@@ -389,6 +389,8 @@ note = "Keep endpoints read-only, and tie secret rotation/copy actions to audit
[msg.dev.clients.general]
load_error = "Error loading client: {{error}}"
loading = "Loading client..."
read_only_forbidden = "You do not have permission to edit this RP's general settings."
read_only_hint = "Only users with the `RP Admin` or `RP General Settings` relationship can edit this RP's general settings."
saved = "Saved"
save_error = "Failed to 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."

View File

@@ -389,6 +389,8 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
[msg.dev.clients.general]
load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}"
loading = "클라이언트 정보를 불러오는 중..."
read_only_forbidden = "이 RP의 일반 설정을 수정할 권한이 없습니다."
read_only_hint = "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다."
save_error = "저장 실패: {{error}}"
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
saved = "설정이 저장되었습니다."

View File

@@ -414,6 +414,8 @@ note = ""
[msg.dev.clients.general]
load_error = ""
loading = ""
read_only_forbidden = ""
read_only_hint = ""
saved = ""
save_error = ""
save_forbidden = ""