1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

@@ -4,11 +4,14 @@ import {
ChevronLeft,
ChevronRight,
Download,
Edit3,
Filter,
Save,
Search,
ShieldHalf,
X,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import {
@@ -34,11 +37,208 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
import {
fetchClient,
fetchConsents,
fetchRPUserMetadata,
revokeConsent,
updateRPUserMetadata,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs";
type RPClaimValueType =
| "text"
| "number"
| "boolean"
| "array"
| "object"
| "date"
| "datetime";
type CustomClaimPermission = "admin_only" | "user_and_admin";
type RPClaimSchema = {
key: string;
value: string;
valueType: RPClaimValueType;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
};
type MetadataDraftRow = {
id: string;
key: string;
value: string;
valueType: RPClaimValueType;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
schemaBacked: boolean;
};
function isCustomClaimPermission(
value: unknown,
): value is CustomClaimPermission {
return value === "admin_only" || value === "user_and_admin";
}
function readPermissionMetadata(
metadata: Record<string, unknown> | undefined,
key: string,
fallback: CustomClaimPermission,
field: "readPermission" | "writePermission",
) {
const raw = metadata?.[`${key}_permissions`];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return fallback;
}
const value = (raw as Record<string, unknown>)[field];
return isCustomClaimPermission(value) ? value : fallback;
}
function metadataToDraftRows(
metadata: Record<string, unknown> | undefined,
schemas: RPClaimSchema[],
): MetadataDraftRow[] {
if (schemas.length > 0) {
return schemas.map((schema) => ({
id: `rp-claim-${schema.key}`,
key: schema.key,
value: metadataValueToInputString(
metadata?.[schema.key],
schema.value,
schema.valueType,
),
valueType: schema.valueType,
readPermission: readPermissionMetadata(
metadata,
schema.key,
schema.readPermission,
"readPermission",
),
writePermission: readPermissionMetadata(
metadata,
schema.key,
schema.writePermission,
"writePermission",
),
schemaBacked: true,
}));
}
return Object.entries(metadata ?? {})
.filter(([key]) => !key.endsWith("_permissions"))
.map(([key, value], index) => ({
id: `${key}-${index}`,
key,
value: metadataValueToString(value, ""),
valueType: "text",
readPermission: readPermissionMetadata(
metadata,
key,
"admin_only",
"readPermission",
),
writePermission: readPermissionMetadata(
metadata,
key,
"admin_only",
"writePermission",
),
schemaBacked: false,
}));
}
function draftRowsToMetadata(rows: MetadataDraftRow[]) {
const metadata: Record<string, unknown> = {};
for (const row of rows) {
const key = row.key.trim();
if (!key) continue;
metadata[key] = row.value.trim();
metadata[`${key}_permissions`] = {
readPermission: row.readPermission,
writePermission: row.writePermission,
};
}
return metadata;
}
function isRPClaimValueType(value: string): value is RPClaimValueType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "array" ||
value === "object" ||
value === "date" ||
value === "datetime"
);
}
function metadataValueToString(value: unknown, fallback: string) {
if (typeof value === "string") return value;
if (value == null) return fallback;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
function metadataValueToInputString(
value: unknown,
fallback: string,
valueType: RPClaimValueType,
) {
const text = metadataValueToString(value, fallback);
if (valueType === "date") {
return text.slice(0, 10);
}
if (valueType === "datetime") {
return text.slice(0, 16);
}
return text;
}
function readRPClaimSchemas(
metadata: Record<string, unknown> | undefined,
): RPClaimSchema[] {
const rawClaims = metadata?.id_token_claims;
if (!Array.isArray(rawClaims)) return [];
return rawClaims
.map((item) => {
if (!item || typeof item !== "object") return null;
const record = item as Record<string, unknown>;
if (record.namespace !== "rp_claims") return null;
const key = typeof record.key === "string" ? record.key.trim() : "";
if (!key) return null;
const rawValueType =
typeof record.valueType === "string" ? record.valueType : "text";
const valueType = isRPClaimValueType(rawValueType)
? rawValueType
: "text";
return {
key,
value: metadataValueToString(record.value, ""),
valueType,
readPermission: isCustomClaimPermission(record.readPermission)
? record.readPermission
: "admin_only",
writePermission: isCustomClaimPermission(record.writePermission)
? record.writePermission
: "admin_only",
};
})
.filter((item): item is RPClaimSchema => item !== null);
}
function rpClaimInputType(valueType: RPClaimValueType) {
if (valueType === "date") return "date";
if (valueType === "datetime") return "datetime-local";
if (valueType === "number") return "number";
return "text";
}
function ClientConsentsPage() {
const params = useParams();
const clientId = params.id ?? "";
@@ -47,12 +247,20 @@ function ClientConsentsPage() {
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [scopeFilter, setScopeFilter] = useState<string[]>([]);
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [editingSubject, setEditingSubject] = useState("");
const [metadataDraftRows, setMetadataDraftRows] = useState<
MetadataDraftRow[]
>([]);
const { data: clientData } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
const rpClaimSchemas = useMemo(
() => readRPClaimSchemas(clientData?.client.metadata),
[clientData?.client.metadata],
);
const {
data: consentsData,
isLoading,
@@ -63,6 +271,7 @@ function ClientConsentsPage() {
queryFn: () => fetchConsents(subject, clientId, "all"),
enabled: clientId.length > 0,
});
const rows = consentsData?.items ?? [];
const revokeMutation = useMutation({
mutationFn: (payload: { subject: string }) =>
revokeConsent(payload.subject, clientId),
@@ -70,6 +279,34 @@ function ClientConsentsPage() {
refetch();
},
});
const selectedRow =
rows.find((row) => row.subject === editingSubject) ?? undefined;
const metadataQuery = useQuery({
queryKey: ["rp-user-metadata", clientId, editingSubject],
queryFn: () => fetchRPUserMetadata(clientId, editingSubject),
enabled: clientId.length > 0 && editingSubject.length > 0,
});
const metadataMutation = useMutation({
mutationFn: () =>
updateRPUserMetadata(
clientId,
editingSubject,
draftRowsToMetadata(metadataDraftRows),
),
onSuccess: () => {
setEditingSubject("");
setMetadataDraftRows([]);
refetch();
},
});
useEffect(() => {
if (metadataQuery.data) {
setMetadataDraftRows(
metadataToDraftRows(metadataQuery.data.metadata, rpClaimSchemas),
);
}
}, [metadataQuery.data, rpClaimSchemas]);
const handleRevoke = (sub: string) => {
if (
@@ -84,7 +321,6 @@ function ClientConsentsPage() {
}
};
const rows = consentsData?.items ?? [];
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
const filteredRows = rows.filter((row) => {
const matchStatus =
@@ -169,6 +405,39 @@ function ClientConsentsPage() {
}
};
const openMetadataEditor = (row: (typeof rows)[number]) => {
setEditingSubject(row.subject);
setMetadataDraftRows(metadataToDraftRows(row.rpMetadata, rpClaimSchemas));
};
const updateMetadataDraftRow = (
id: string,
patch: Partial<MetadataDraftRow>,
) => {
setMetadataDraftRows((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
};
const addMetadataDraftRow = () => {
setMetadataDraftRows((current) => [
...current,
{
id: `rp-metadata-${Date.now()}`,
key: "",
value: "",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
schemaBacked: false,
},
]);
};
const removeMetadataDraftRow = (id: string) => {
setMetadataDraftRows((current) => current.filter((row) => row.id !== id));
};
if (error) {
const axiosError = error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
@@ -450,6 +719,9 @@ function ClientConsentsPage() {
"Last Authenticated / Revoked",
)}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.rp_claims", "RP Claims")}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.consents.table.action", "Action")}
</TableHead>
@@ -459,7 +731,7 @@ function ClientConsentsPage() {
{filteredRows.length === 0 && !isLoading && !error ? (
<TableRow>
<TableCell
colSpan={7}
colSpan={8}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
@@ -552,17 +824,57 @@ function ClientConsentsPage() {
"-"
)}
</TableCell>
<TableCell>
{row.rpMetadata &&
Object.keys(row.rpMetadata).some(
(key) => !key.endsWith("_permissions"),
) ? (
<div className="flex flex-wrap gap-1">
{Object.entries(row.rpMetadata)
.filter(([key]) => !key.endsWith("_permissions"))
.map(([key, value]) => (
<Badge
key={key}
variant="muted"
className="border bg-muted/40 font-mono text-[11px] text-foreground"
>
{key}:{" "}
{typeof value === "string"
? value
: JSON.stringify(value)}
</Badge>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">
{t("ui.common.na", "N/A")}
</span>
)}
</TableCell>
<TableCell className="text-right">
{row.status === "active" && (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(row.subject)}
disabled={revokeMutation.isPending}
className="gap-2"
onClick={() => openMetadataEditor(row)}
>
{t("ui.dev.clients.consents.revoke", "Revoke")}
<Edit3 className="h-4 w-4" />
{t(
"ui.dev.clients.consents.rp_claims.edit",
"사용자 Claim 설정",
)}
</Button>
)}
{row.status === "active" && (
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(row.subject)}
disabled={revokeMutation.isPending}
>
{t("ui.dev.clients.consents.revoke", "Revoke")}
</Button>
)}
</div>
</TableCell>
</TableRow>
))
@@ -597,6 +909,182 @@ function ClientConsentsPage() {
</CardContent>
</Card>
{editingSubject && (
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-lg">
{t(
"ui.dev.clients.consents.rp_claims.title",
"RP Custom Claims",
)}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{selectedRow?.userName || editingSubject}
</p>
</div>
<div className="flex gap-2">
{rpClaimSchemas.length === 0 && (
<Button
variant="outline"
className="gap-2"
onClick={addMetadataDraftRow}
>
<Edit3 className="h-4 w-4" />
{t("ui.common.add", "추가")}
</Button>
)}
<Button
variant="ghost"
className="gap-2"
onClick={() => {
setEditingSubject("");
setMetadataDraftRows([]);
}}
>
<X className="h-4 w-4" />
{t("ui.common.cancel", "취소")}
</Button>
<Button
className="gap-2"
disabled={metadataMutation.isPending}
onClick={() => metadataMutation.mutate()}
>
<Save className="h-4 w-4" />
{t("ui.dev.clients.consents.rp_claims.save", "Claim 저장")}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{metadataQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
{t("msg.common.loading", "불러오는 중...")}
</p>
) : metadataDraftRows.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.consents.rp_claims.empty",
"등록된 RP custom claim 값이 없습니다.",
)}
</div>
) : (
metadataDraftRows.map((row) => (
<div
key={row.id}
className="grid gap-3 md:grid-cols-[minmax(180px,0.8fr)_minmax(220px,1fr)_150px_150px_auto]"
>
{row.schemaBacked ? (
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
{row.key}
</div>
) : (
<Input
value={row.key}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
key: event.target.value,
})
}
className="font-mono text-xs"
placeholder={t(
"ui.dev.clients.consents.rp_claims.key_placeholder",
"claim_key",
)}
/>
)}
<Input
type={rpClaimInputType(row.valueType)}
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
value: event.target.value,
})
}
className="font-mono text-xs"
placeholder={t(
"ui.dev.clients.consents.rp_claims.value_placeholder",
"claim value",
)}
/>
<select
value={row.readPermission}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
readPermission: event.target
.value as CustomClaimPermission,
})
}
className="h-10 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t(
"ui.dev.clients.consents.rp_claims.read_permission",
"읽기 권한",
)}
>
<option value="admin_only">
{t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</option>
<option value="user_and_admin">
{t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)}
</option>
</select>
<select
value={row.writePermission}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
writePermission: event.target
.value as CustomClaimPermission,
})
}
className="h-10 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t(
"ui.dev.clients.consents.rp_claims.write_permission",
"쓰기 권한",
)}
>
<option value="admin_only">
{t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</option>
<option value="user_and_admin">
{t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)}
</option>
</select>
{row.schemaBacked ? (
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
>
{row.valueType}
</Badge>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => removeMetadataDraftRow(row.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))
)}
</CardContent>
</Card>
)}
<div className="grid gap-6 md:grid-cols-3">
<Card className="glass-panel">
<CardHeader className="pb-2">

View File

@@ -0,0 +1,29 @@
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { ClientDetailTabs } from "./ClientDetailTabs";
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string) =>
({
"ui.dev.clients.details.tab.connection": "연동 설정",
"ui.dev.clients.details.tab.user_claims": "사용자 Claim",
"ui.dev.clients.details.tab.settings": "설정",
"ui.dev.clients.details.tab.relationships": "관계",
})[key] ??
fallback ??
key,
}));
describe("ClientDetailTabs", () => {
it("exposes the RP user custom claim screen as a first-class tab", () => {
const html = renderToString(
<MemoryRouter>
<ClientDetailTabs activeTab="connection" clientId="client-a" />
</MemoryRouter>,
);
expect(html).toContain("사용자 Claim");
expect(html).toContain('href="/clients/client-a/consents"');
});
});

View File

@@ -12,9 +12,14 @@ interface ClientDetailTabsProps {
const tabOrder: Array<{
key: ClientDetailTab;
href: (clientId: string) => string;
labelKey?: string;
}> = [
{ key: "connection", href: (clientId) => `/clients/${clientId}` },
{ key: "consents", href: (clientId) => `/clients/${clientId}/consents` },
{
key: "consents",
href: (clientId) => `/clients/${clientId}/consents`,
labelKey: "ui.dev.clients.details.tab.user_claims",
},
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
{
key: "relationships",
@@ -30,12 +35,13 @@ export function ClientDetailTabs({
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
{tabOrder.map((tab) => {
const isActive = tab.key === activeTab;
const labelKey = tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
return isActive ? (
<span
key={tab.key}
className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary"
>
{t(`ui.dev.clients.details.tab.${tab.key}`)}
{t(labelKey)}
</span>
) : (
<Link
@@ -45,7 +51,7 @@ export function ClientDetailTabs({
"whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t(`ui.dev.clients.details.tab.${tab.key}`)}
{t(labelKey)}
</Link>
);
})}

View File

@@ -1,7 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Check,
ExternalLink,
Info,
Plus,
@@ -56,6 +55,7 @@ import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
import { fetchMe, type UserProfile } from "../auth/authApi";
import { ClientDetailTabs } from "./ClientDetailTabs";
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
interface ScopeItem {
id: string;
@@ -65,8 +65,16 @@ interface ScopeItem {
locked?: boolean;
}
type ClaimNamespace = "top_level" | "rp_claims";
type ClaimValueType = "text" | "number" | "boolean" | "array" | "object";
type ClaimNamespace = "rp_claims";
type ClaimValueType =
| "text"
| "number"
| "boolean"
| "array"
| "object"
| "date"
| "datetime";
type CustomClaimPermission = "admin_only" | "user_and_admin";
interface IdTokenClaimItem {
id: string;
@@ -74,6 +82,8 @@ interface IdTokenClaimItem {
key: string;
value: string;
valueType: ClaimValueType;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
}
type SecurityProfile = "private" | "pkce";
@@ -129,7 +139,7 @@ function readMetadataString(
}
function isClaimNamespace(value: string): value is ClaimNamespace {
return value === "top_level" || value === "rp_claims";
return value === "rp_claims";
}
function isClaimValueType(value: string): value is ClaimValueType {
@@ -138,17 +148,27 @@ function isClaimValueType(value: string): value is ClaimValueType {
value === "number" ||
value === "boolean" ||
value === "array" ||
value === "object"
value === "object" ||
value === "date" ||
value === "datetime"
);
}
function isCustomClaimPermission(
value: unknown,
): value is CustomClaimPermission {
return value === "admin_only" || value === "user_and_admin";
}
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
return {
id,
namespace: "top_level",
namespace: "rp_claims",
key: "",
value: "",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
};
}
@@ -171,7 +191,10 @@ function readIdTokenClaimsMetadata(
typeof record.namespace === "string" &&
isClaimNamespace(record.namespace)
? record.namespace
: "top_level";
: null;
if (namespaceValue === null) {
return null;
}
const keyValue = typeof record.key === "string" ? record.key : "";
const rawValue = record.value;
const valueValue =
@@ -192,6 +215,12 @@ function readIdTokenClaimsMetadata(
key: keyValue,
value: valueValue,
valueType: valueTypeValue,
readPermission: isCustomClaimPermission(record.readPermission)
? record.readPermission
: "admin_only",
writePermission: isCustomClaimPermission(record.writePermission)
? record.writePermission
: "admin_only",
};
})
.filter((item): item is IdTokenClaimItem => item !== null);
@@ -253,8 +282,7 @@ function buildIdTokenClaimsPreview(
continue;
}
const target = item.namespace === "rp_claims" ? rpClaims : preview;
target[key] = normalizeClaimPreviewValue(item.value, item.valueType);
rpClaims[key] = normalizeClaimPreviewValue(item.value, item.valueType);
}
if (Object.keys(rpClaims).length > 0) {
@@ -841,16 +869,6 @@ function ClientGeneralPage() {
continue;
}
if (claim.key === "rp_claims" && claim.namespace === "top_level") {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.reserved_key",
"`rp_claims`는 예약된 namespace 키입니다.",
),
);
continue;
}
const keySignature = `${claim.namespace}:${claim.key}`;
if (seenClaimKeys.has(keySignature)) {
claimValidationErrors.push(
@@ -858,16 +876,10 @@ function ClientGeneralPage() {
"msg.dev.clients.general.id_token_claims.duplicate_key",
"중복된 claim key가 있습니다: {{namespace}}.{{key}}",
{
namespace:
claim.namespace === "rp_claims"
? t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)
: t(
"ui.dev.clients.general.id_token_claims.namespace_top_level",
"top-level",
),
namespace: t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
),
key: claim.key,
},
),
@@ -1951,26 +1963,12 @@ function ClientGeneralPage() {
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedAllowedTenants.map((tenant) => (
<Badge
<AllowedTenantBadge
key={tenant.id}
variant="secondary"
className="gap-2 px-3 py-1.5"
>
<Check className="h-3.5 w-3.5" />
<span className="max-w-44 truncate">{tenant.name}</span>
<span className="text-[11px] text-muted-foreground">
{tenant.slug}
</span>
<button
type="button"
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>
</Badge>
tenant={tenant}
onRemove={() => toggleAllowedTenant(tenant.id)}
disabled={isGeneralSettingsReadOnly}
/>
))}
{allowedTenantIds
.filter(
@@ -1980,23 +1978,12 @@ function ClientGeneralPage() {
),
)
.map((tenantId) => (
<Badge
<AllowedTenantBadge
key={tenantId}
variant="secondary"
className="gap-2 px-3 py-1.5"
>
<Check className="h-3.5 w-3.5" />
<span className="max-w-44 truncate">{tenantId}</span>
<button
type="button"
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>
</Badge>
tenant={{ id: tenantId, name: tenantId }}
onRemove={() => toggleAllowedTenant(tenantId)}
disabled={isGeneralSettingsReadOnly}
/>
))}
</div>
) : (
@@ -2070,6 +2057,18 @@ function ClientGeneralPage() {
"Value Type",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.read_permission",
"Read",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.write_permission",
"Write",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value",
@@ -2106,35 +2105,15 @@ function ClientGeneralPage() {
/>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.namespace}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"namespace",
e.target.value as ClaimNamespace,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.namespace_label",
"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}
<Badge
variant="muted"
className="h-9 rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs"
>
<option value="top_level">
{t(
"ui.dev.clients.general.id_token_claims.namespace_top_level",
"top-level",
)}
</option>
<option value="rp_claims">
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</option>
</select>
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</Badge>
</td>
<td className="px-4 py-3 align-top">
<select
@@ -2183,6 +2162,80 @@ function ClientGeneralPage() {
"Object",
)}
</option>
<option value="date">
{t(
"ui.dev.clients.general.id_token_claims.value_type_date",
"Date",
)}
</option>
<option value="datetime">
{t(
"ui.dev.clients.general.id_token_claims.value_type_datetime",
"Datetime",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.readPermission}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"readPermission",
e.target.value as CustomClaimPermission,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.read_permission_label",
"읽기 권한",
)}
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="admin_only">
{t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</option>
<option value="user_and_admin">
{t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.writePermission}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"writePermission",
e.target.value as CustomClaimPermission,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.write_permission_label",
"쓰기 권한",
)}
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="admin_only">
{t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</option>
<option value="user_and_admin">
{t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
@@ -2219,7 +2272,7 @@ function ClientGeneralPage() {
{idTokenClaims.length === 0 && (
<tr>
<td
colSpan={5}
colSpan={7}
className="px-4 py-8 text-center text-muted-foreground"
>
{t(
@@ -2235,7 +2288,7 @@ function ClientGeneralPage() {
<p className="text-xs leading-6 text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.hint",
"top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
"RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
)}
</p>
</div>

View File

@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AllowedTenantBadge } from "./AllowedTenantBadge";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
document.body.innerHTML = "";
});
function renderAllowedTenantBadge() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(
<AllowedTenantBadge
tenant={{
id: "11111111-2222-3333-4444-555555555555",
name: "한맥기술",
slug: "hanmac",
}}
onRemove={vi.fn()}
/>,
);
});
return container;
}
describe("AllowedTenantBadge", () => {
it("renders tenant name, slug, full UUID, and copies the UUID", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: true,
configurable: true,
});
const container = renderAllowedTenantBadge();
const badge = container.querySelector(
'[data-testid="allowed-tenant-11111111-2222-3333-4444-555555555555"]',
);
expect(container.textContent).toContain("한맥기술");
expect(container.textContent).toContain("hanmac");
expect(container.textContent).toContain(
"11111111-2222-3333-4444-555555555555",
);
expect(badge).not.toBeNull();
const copyButton = container.querySelector(
'[data-testid="allowed-tenant-copy-11111111-2222-3333-4444-555555555555"]',
);
expect(copyButton).not.toBeNull();
await act(async () => {
copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith(
"11111111-2222-3333-4444-555555555555",
);
});
});

View File

@@ -0,0 +1,59 @@
import { Check, X } from "lucide-react";
import { Badge } from "../../../components/ui/badge";
import { CopyButton } from "../../../components/ui/copy-button";
import type { MyTenantSummary, TenantSummary } from "../../../lib/devApi";
import { t } from "../../../lib/i18n";
type AllowedTenant = Pick<TenantSummary | MyTenantSummary, "id" | "name"> & {
slug?: string;
};
type AllowedTenantBadgeProps = {
disabled?: boolean;
onRemove: () => void;
tenant: AllowedTenant;
};
export function AllowedTenantBadge({
disabled = false,
onRemove,
tenant,
}: AllowedTenantBadgeProps) {
return (
<Badge
variant="secondary"
className="flex max-w-full items-center gap-2 px-3 py-1.5"
data-testid={`allowed-tenant-${tenant.id}`}
data-tenant-id={tenant.id}
>
<Check className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-44 truncate">{tenant.name}</span>
{tenant.slug ? (
<span className="shrink-0 text-[11px] text-muted-foreground">
{tenant.slug}
</span>
) : null}
<span className="max-w-64 truncate font-mono text-[11px] text-muted-foreground">
{tenant.id}
</span>
<CopyButton
aria-label="테넌트 UUID 복사"
className="h-6 w-6 shrink-0"
data-testid={`allowed-tenant-copy-${tenant.id}`}
size="icon"
value={tenant.id}
variant="ghost"
/>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={onRemove}
className="shrink-0 text-muted-foreground transition hover:text-destructive"
data-testid={`allowed-tenant-remove-${tenant.id}`}
disabled={disabled}
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
);
}

View File

@@ -84,6 +84,8 @@ const clientSummary = {
key: "employee_id",
value: "E001",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
@@ -171,6 +173,18 @@ vi.mock("../../lib/devApi", () => ({
},
],
})),
fetchRPUserMetadata: vi.fn(async () => ({
clientId: "client-a",
userId: "user-1",
metadata: {
approvalLevel: "A",
},
})),
updateRPUserMetadata: vi.fn(async (_clientId: string, userId: string, metadata: Record<string, unknown>) => ({
clientId: "client-a",
userId,
metadata,
})),
fetchDevUsers: vi.fn(async () => ({
items: [
{
@@ -208,6 +222,10 @@ vi.mock("../../lib/devApi", () => ({
status: "active",
tenantId: "tenant-1",
tenantName: "Hanmac",
rpMetadata: {
approvalLevel: "A",
reviewedAt: "2026-06-09T09:30:00+09:00",
},
},
],
})),
@@ -406,12 +424,18 @@ describe("devfront coverage smoke pages", () => {
entry: "/clients/client-a/settings",
});
expect(settings.textContent).toContain("Console App");
expect(settings.textContent).not.toContain("top-level");
expect(settings.textContent).toContain("Date");
expect(settings.textContent).toContain("Datetime");
expect(settings.textContent).toContain("관리자만 가능");
const consents = await renderPage(<ClientConsentsPage />, {
path: "/clients/:id/consents",
entry: "/clients/client-a/consents",
});
expect(consents.textContent).toContain("Consent User");
expect(consents.textContent).toContain("approvalLevel");
expect(consents.textContent).toContain("A");
const federation = await renderPage(<ClientFederationPage />, {
path: "/clients/:id/federation",