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

View File

@@ -29,6 +29,7 @@ describe("devApi", () => {
fetchTenants,
fetchClient,
fetchClientRelations,
fetchRPUserMetadata,
fetchDevUsers,
fetchConsents,
fetchDevAuditLogs,
@@ -45,6 +46,7 @@ describe("devApi", () => {
await fetchTenants(25, 50, "tenant-parent");
await fetchClient("client-a");
await fetchClientRelations("client-a");
await fetchRPUserMetadata("client-a", "user-a");
await fetchDevUsers("admin", 5, "client-a");
await fetchConsents("user-a", "client-a", "active");
await fetchDevAuditLogs(10, "cursor-a", {
@@ -70,6 +72,9 @@ describe("devApi", () => {
expect(apiClient.get).toHaveBeenCalledWith(
"/dev/clients/client-a/relations",
);
expect(apiClient.get).toHaveBeenCalledWith(
"/dev/clients/client-a/users/user-a/metadata",
);
expect(apiClient.get).toHaveBeenCalledWith("/dev/users", {
params: { search: "admin", limit: 5, clientId: "client-a" },
});
@@ -119,6 +124,7 @@ describe("devApi", () => {
const {
addClientRelation,
removeClientRelation,
updateRPUserMetadata,
updateClientStatus,
createClient,
updateClient,
@@ -145,6 +151,7 @@ describe("devApi", () => {
userId: "user-a",
});
await removeClientRelation("client-a", "admins", "User:user-a");
await updateRPUserMetadata("client-a", "user-a", { approvalLevel: "A" });
await updateClientStatus("client-a", "inactive");
await createClient({ id: "client-a", name: "Console App" });
await updateClient("client-a", { name: "Console App Updated" });
@@ -181,6 +188,10 @@ describe("devApi", () => {
params: { relation: "admins", subject: "User:user-a" },
},
);
expect(apiClient.put).toHaveBeenCalledWith(
"/dev/clients/client-a/users/user-a/metadata",
{ metadata: { approvalLevel: "A" } },
);
expect(apiClient.patch).toHaveBeenCalledWith(
"/dev/clients/client-a/status",
{

View File

@@ -210,12 +210,19 @@ export type ConsentSummary = {
status: "active" | "revoked";
tenantId?: string;
tenantName?: string;
rpMetadata?: Record<string, unknown>;
};
export type ConsentListResponse = {
items: ConsentSummary[];
};
export type RPUserMetadataResponse = {
clientId: string;
userId: string;
metadata: Record<string, unknown>;
};
// --- Federation / IdP Config Types ---
export type ProviderType = "oidc" | "saml";
@@ -297,6 +304,25 @@ export async function fetchClientRelations(clientId: string) {
return data;
}
export async function fetchRPUserMetadata(clientId: string, userId: string) {
const { data } = await apiClient.get<RPUserMetadataResponse>(
`/dev/clients/${clientId}/users/${userId}/metadata`,
);
return data;
}
export async function updateRPUserMetadata(
clientId: string,
userId: string,
metadata: Record<string, unknown>,
) {
const { data } = await apiClient.put<RPUserMetadataResponse>(
`/dev/clients/${clientId}/users/${userId}/metadata`,
{ metadata },
);
return data;
}
export async function fetchDevUsers(
search: string,
limit = 10,

View File

@@ -449,7 +449,7 @@ tenant = "Tenant access claim"
[msg.dev.clients.general.id_token_claims]
subtitle = "Separate shared claims from RP-specific extension claims."
empty = "No ID Token claims have been added yet."
hint = "Use top-level for shared claims and rp_claims for RP-specific extension claims. Arrays accept JSON or comma-separated values, and objects accept JSON."
hint = "Manage RP-specific extension claims only. Arrays accept JSON or comma-separated values, and objects accept JSON."
preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
key_required = "Enter a claim key."
reserved_key = "`rp_claims` is a reserved namespace key."
@@ -1427,6 +1427,9 @@ status_revoked = "Revoked"
subject = "Subject"
title = "User Consent Grants"
[ui.dev.clients.consents.rp_claims]
edit = "User Claim Settings"
[ui.dev.clients.consents.breadcrumb]
clients = "Clients"
current = "User Consent Grants"
@@ -1480,6 +1483,7 @@ connection = "Federation"
consents = "Consent & Users"
settings = "Settings"
relationships = "Relationships"
user_claims = "User Claims"
[ui.dev.clients.general]
create = "Create Application"

View File

@@ -449,7 +449,7 @@ tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.id_token_claims]
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다."
hint = "top-level은 공통 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
hint = "RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
@@ -1426,6 +1426,9 @@ status_revoked = "철회됨"
subject = "사용자"
title = "사용자 동의 내역"
[ui.dev.clients.consents.rp_claims]
edit = "사용자 Claim 설정"
[ui.dev.clients.consents.breadcrumb]
clients = "연동 앱"
current = "동의 및 사용자"
@@ -1479,6 +1482,7 @@ connection = "연동 설정"
consents = "동의 및 사용자"
settings = "설정"
relationships = "관계"
user_claims = "사용자 Claim"
[ui.dev.clients.general]
create = "앱 생성"