1
0
forked from baron/baron-sso

RP 정책 설정 UI 수정

This commit is contained in:
2026-04-24 16:32:34 +09:00
parent 5acf248285
commit f97b244a59
5 changed files with 408 additions and 17 deletions

View File

@@ -24,6 +24,7 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { ScrollArea } from "../../components/ui/scroll-area";
import { Switch } from "../../components/ui/switch";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
@@ -31,6 +32,7 @@ import {
createClient,
deleteClient,
fetchClient,
fetchTenants,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
updateClient,
@@ -40,6 +42,7 @@ import type {
ClientStatus,
ClientType,
ClientUpsertRequest,
TenantSummary,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
@@ -50,6 +53,7 @@ interface ScopeItem {
name: string;
description: string;
mandatory: boolean;
locked?: boolean;
}
type SecurityProfile = "private" | "pkce";
@@ -131,6 +135,10 @@ function ClientGeneralPage() {
queryFn: () => fetchClient(clientId as string),
enabled: !isCreate,
});
const { data: tenantData } = useQuery({
queryKey: ["tenants"],
queryFn: () => fetchTenants(1000, 0),
});
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -142,6 +150,9 @@ function ClientGeneralPage() {
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
const [tenantSearch, setTenantSearch] = useState("");
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
@@ -168,6 +179,15 @@ function ClientGeneralPage() {
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
mandatory: false,
},
{
id: "4",
name: "tenant",
description: t(
"msg.dev.clients.scopes.tenant",
"소속 테넌트 정보 접근",
),
mandatory: false,
},
]);
useEffect(() => {
@@ -185,6 +205,16 @@ function ClientGeneralPage() {
const headlessEnabled = !!metadata.headless_login_enabled;
setHeadlessLoginEnabled(headlessEnabled);
const restrictedTenants = Array.isArray(metadata.allowed_tenants)
? metadata.allowed_tenants
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
setTenantAccessRestricted(
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
);
setAllowedTenantIds(restrictedTenants);
const savedAuthMethod =
client.tokenEndpointAuthMethod ||
@@ -230,15 +260,25 @@ function ClientGeneralPage() {
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
if (savedScopes && Array.isArray(savedScopes)) {
setScopes(savedScopes);
setScopes(
normalizeScopesForTenantAccess(
savedScopes,
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
),
);
} else {
setScopes(
client.scopes.map((s, idx) => ({
id: String(idx + 1),
name: s,
description: "",
mandatory: s === "openid",
})),
normalizeScopesForTenantAccess(
client.scopes.map((s, idx) => ({
id: String(idx + 1),
name: s,
description: "",
mandatory: s === "openid",
})),
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
),
);
}
}, [data]);
@@ -279,6 +319,55 @@ function ClientGeneralPage() {
}
};
const tenantScopeDescription = t(
"msg.dev.clients.scopes.tenant",
"소속 테넌트 정보 접근",
);
const buildTenantScope = (id: string): ScopeItem => ({
id,
name: "tenant",
description: tenantScopeDescription,
mandatory: true,
locked: true,
});
function normalizeScopesForTenantAccess(
nextScopes: ScopeItem[],
restricted: boolean,
): ScopeItem[] {
const normalized = nextScopes.map((scope) => {
if (scope.name.trim() !== "tenant") {
return scope;
}
return {
...scope,
description: scope.description || tenantScopeDescription,
mandatory: restricted ? true : false,
locked: restricted,
};
});
if (restricted && !normalized.some((scope) => scope.name.trim() === "tenant")) {
normalized.push(buildTenantScope(`tenant-${Date.now()}`));
}
return normalized;
}
const handleTenantAccessToggle = (enabled: boolean) => {
setTenantAccessRestricted(enabled);
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
};
const toggleAllowedTenant = (tenantId: string) => {
setAllowedTenantIds((current) =>
current.includes(tenantId)
? current.filter((id) => id !== tenantId)
: [...current, tenantId],
);
};
const addScope = () => {
const newId = String(Date.now());
setScopes([
@@ -292,15 +381,23 @@ function ClientGeneralPage() {
field: K,
value: ScopeItem[K],
) => {
setScopes(
scopes.map((scope) =>
scope.id === id ? { ...scope, [field]: value } : scope,
),
setScopes((current) =>
current.map((scope) => {
if (scope.id !== id) {
return scope;
}
if (scope.locked) {
return scope;
}
return { ...scope, [field]: value };
}),
);
};
const removeScope = (id: string) => {
setScopes(scopes.filter((s) => s.id !== id));
setScopes((current) =>
current.filter((scope) => scope.id !== id || scope.locked === true),
);
};
const handleStatusChange = (nextStatus: ClientStatus) => {
@@ -391,7 +488,25 @@ function ClientGeneralPage() {
}
}
if (tenantAccessRestricted && allowedTenantIds.length === 0) {
validationErrors.push(
t(
"ui.dev.clients.general.tenant_access.validation_required",
"테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.",
),
);
}
const hasValidationErrors = validationErrors.length > 0;
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
const filteredTenants = tenantOptions.filter((tenant) => {
if (!normalizedTenantSearch) {
return true;
}
const searchable = `${tenant.name} ${tenant.slug} ${tenant.description} ${tenant.type}`.toLowerCase();
return searchable.includes(normalizedTenantSearch);
});
const refreshHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
@@ -467,7 +582,16 @@ function ClientGeneralPage() {
);
}
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
const normalizedScopes = normalizeScopesForTenantAccess(
scopes,
tenantAccessRestricted,
);
const normalizedAllowedTenantIds = Array.from(
new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)),
);
const scopeNames = normalizedScopes
.map((scope) => scope.name.trim())
.filter(Boolean);
const effectiveTokenEndpointAuthMethod =
clientType === "pkce" && headlessLoginEnabled
@@ -487,7 +611,7 @@ function ClientGeneralPage() {
metadata: {
description,
logo_url: trimmedLogoUrl,
structured_scopes: scopes,
structured_scopes: normalizedScopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method:
@@ -498,6 +622,10 @@ function ClientGeneralPage() {
clientType === "pkce" && headlessLoginEnabled
? trimmedJwksUri
: undefined,
tenant_access_restricted: tenantAccessRestricted,
allowed_tenants: tenantAccessRestricted
? normalizedAllowedTenantIds
: [],
},
};
@@ -972,7 +1100,10 @@ function ClientGeneralPage() {
{scopes.map((s) => (
<tr
key={s.id}
className="hover:bg-muted/30 transition-colors"
className={cn(
"transition-colors",
s.locked ? "bg-primary/5" : "hover:bg-muted/30",
)}
>
<td className="px-4 py-3">
<Input
@@ -985,6 +1116,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.name_placeholder",
"e.g. profile",
)}
disabled={s.locked}
/>
</td>
<td className="px-4 py-3">
@@ -998,6 +1130,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.description_placeholder",
"권한에 대한 설명",
)}
disabled={s.locked}
/>
</td>
<td className="px-4 py-3 text-center">
@@ -1007,6 +1140,7 @@ function ClientGeneralPage() {
onCheckedChange={(checked) =>
updateScope(s.id, "mandatory", checked)
}
disabled={s.locked}
/>
</div>
</td>
@@ -1016,6 +1150,7 @@ function ClientGeneralPage() {
size="icon"
onClick={() => removeScope(s.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={s.locked}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -1041,6 +1176,187 @@ function ClientGeneralPage() {
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-bold">
{t(
"ui.dev.clients.general.tenant_access.title",
"테넌트 접근 제한",
)}
</CardTitle>
<CardDescription>
{t(
"ui.dev.clients.general.tenant_access.subtitle",
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-0.5 text-right">
<p className="text-sm font-semibold">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.enabled",
"제한 있음",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.title",
"테넌트 접근 제한",
)}
</p>
</div>
<Switch
checked={tenantAccessRestricted}
onCheckedChange={handleTenantAccessToggle}
id="tenant-access-toggle"
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.hint",
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
)}
</p>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-3">
<Label htmlFor="tenant-search" className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
</Label>
<Input
id="tenant-search"
value={tenantSearch}
onChange={(e) => setTenantSearch(e.target.value)}
placeholder={t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
disabled={!tenantAccessRestricted}
/>
<ScrollArea className="h-72 rounded-xl border border-border bg-card">
<div className="divide-y divide-border">
{tenantAccessRestricted ? (
filteredTenants.length > 0 ? (
filteredTenants.map((tenant) => {
const checked = allowedTenantIds.includes(tenant.id);
return (
<label
key={tenant.id}
className="flex cursor-pointer items-center justify-between gap-4 px-4 py-3 transition hover:bg-muted/30"
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{tenant.name}
</span>
<Badge variant="outline" className="text-[11px]">
{tenant.slug}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{tenant.description || tenant.type}
</p>
</div>
<input
type="checkbox"
checked={checked}
onChange={() => toggleAllowedTenant(tenant.id)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
</label>
);
})
) : (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.empty",
"검색 결과가 없습니다.",
)}
</div>
)
) : (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
)}
</div>
</ScrollArea>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.selected_title",
"허용 테넌트",
)}
</Label>
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{allowedTenantIds.map((tenantId) => {
const tenant = tenantData?.items.find(
(item) => item.id === tenantId,
);
return (
<Badge
key={tenantId}
variant="secondary"
className="gap-2 px-3 py-1.5"
>
<span className="max-w-44 truncate">
{tenant?.name || tenantId}
</span>
<button
type="button"
aria-label={t(
"ui.common.delete",
"삭제",
)}
onClick={() => toggleAllowedTenant(tenantId)}
className="text-xs font-semibold text-muted-foreground hover:text-destructive"
>
×
</button>
</Badge>
);
})}
</div>
) : (
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.selected_empty",
"아직 선택된 테넌트가 없습니다.",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 3. Security Settings */}
<Card className="glass-panel">
<CardHeader className="pb-3">