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

@@ -51,14 +51,17 @@ ensure_frontend_dependencies() {
if [ -n "$WORKSPACE_ROOT" ]; then
WORKSPACE_DIR="$WORKSPACE_ROOT"
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
elif [ -f "pnpm-lock.yaml" ]; then
WORKSPACE_DIR="."
LOCK_FILE="pnpm-lock.yaml"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="npm ci"
fi
@@ -100,9 +103,9 @@ ensure_frontend_dependencies() {
}
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi
deps_stamp="node_modules/.baron-deps-hash"
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
@@ -111,9 +114,9 @@ ensure_frontend_dependencies() {
echo "Installing frontend dependencies..."
acquire_install_lock
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" = "$deps_hash" ]; then

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 = "앱 생성"

View File

@@ -0,0 +1,124 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Page, type TestInfo, test } from "@playwright/test";
import {
type Consent,
installDevApiMock,
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
import { captureEvidence } from "./helpers/evidence";
const existingTenantId = "11111111-1111-4111-8111-111111111111";
const addedTenantId = "22222222-2222-4222-8222-222222222222";
async function captureTenantAccessEvidence(
page: Page,
testInfo: TestInfo,
name: string,
) {
await captureEvidence(page, testInfo, name);
const evidenceDir = path.join(process.cwd(), "e2e-evidence");
await mkdir(evidenceDir, { recursive: true });
await page.screenshot({
path: path.join(evidenceDir, `${name}.png`),
fullPage: true,
});
}
test.describe("DevFront client tenant access settings", () => {
test.beforeEach(async ({ page }) => {
page.on("dialog", async (dialog) => {
await dialog.accept();
});
await seedAuth(page);
});
test("adds and removes allowed tenants with UUID copy evidence", async ({
context,
page,
}, testInfo) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const state = {
clients: [
makeClient("client-tenant-access", {
name: "Tenant Access App",
scopes: ["openid", "profile", "email"],
metadata: {
tenant_access_restricted: true,
allowed_tenants: [existingTenantId],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
tenants: [
{
id: existingTenantId,
name: "Alpha Tenant",
slug: "alpha",
description: "Existing allowed tenant",
type: "organization",
},
{
id: addedTenantId,
name: "Beta Tenant",
slug: "beta",
description: "Tenant added during E2E",
type: "organization",
},
],
};
await installDevApiMock(page, state);
await page.goto("/clients/client-tenant-access/settings");
await expect(
page.getByRole("heading", { name: /테넌트 접근 제한|Tenant access/i }),
).toBeVisible();
await expect(
page.getByTestId(`allowed-tenant-${existingTenantId}`),
).toContainText(existingTenantId);
await page.getByTestId(`allowed-tenant-copy-${existingTenantId}`).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(existingTenantId);
await page
.getByPlaceholder(/테넌트 이름 또는 슬러그로 검색|tenant name or slug/i)
.fill("beta");
await page.getByRole("button", { name: /Beta Tenant/i }).click();
await expect(
page.getByTestId(`allowed-tenant-${addedTenantId}`),
).toContainText(addedTenantId);
await captureTenantAccessEvidence(
page,
testInfo,
"tenant-access-allowed-tenant-added",
);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(() => state.clients[0]?.metadata?.allowed_tenants)
.toEqual([existingTenantId, addedTenantId]);
await page.getByTestId(`allowed-tenant-remove-${addedTenantId}`).click();
await expect(
page.getByTestId(`allowed-tenant-${addedTenantId}`),
).toHaveCount(0);
await expect(
page.getByTestId(`allowed-tenant-${existingTenantId}`),
).toContainText(existingTenantId);
await captureTenantAccessEvidence(
page,
testInfo,
"tenant-access-allowed-tenant-deleted",
);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(() => state.clients[0]?.metadata?.allowed_tenants)
.toEqual([existingTenantId]);
});
});

View File

@@ -144,29 +144,31 @@ test.describe("DevFront clients lifecycle", () => {
await page.goto("/clients/client-claims/settings");
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
await page.getByPlaceholder(/e\.g\. locale|예: locale/i).fill("locale");
await expect(page.getByText("rp_claims").first()).toBeVisible();
await expect(
page.getByLabel(/Claim namespace|Claim 네임스페이스/i),
).toHaveCount(0);
await page
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
.first()
.selectOption("top_level");
.getByPlaceholder(/e\.g\. locale|예: locale/i)
.fill("contract_date");
await page
.getByLabel(/Claim value type|Claim 값 타입/i)
.first()
.selectOption("text");
.selectOption("date");
await page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.first()
.fill("ko-KR");
.fill("2026-06-09");
await page
.getByLabel(/읽기 권한|Read permission/i)
.first()
.selectOption("user_and_admin");
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
await page
.getByPlaceholder(/e\.g\. locale|예: locale/i)
.nth(1)
.fill("tier");
await page
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
.nth(1)
.selectOption("rp_claims");
await page
.getByLabel(/Claim value type|Claim 값 타입/i)
.nth(1)
@@ -210,7 +212,7 @@ test.describe("DevFront clients lifecycle", () => {
| undefined
)?.[0]?.namespace,
)
.toBe("top_level");
.toBe("rp_claims");
await expect
.poll(
() =>
@@ -225,7 +227,50 @@ test.describe("DevFront clients lifecycle", () => {
| undefined
)?.[0]?.key,
)
.toBe("locale");
.toBe("contract_date");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
namespace?: string;
key?: string;
value?: string;
valueType?: string;
readPermission?: string;
writePermission?: string;
}>
| undefined
)?.[0]?.valueType,
)
.toBe("date");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
readPermission?: string;
writePermission?: string;
}>
| undefined
)?.[0]?.readPermission,
)
.toBe("user_and_admin");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
readPermission?: string;
writePermission?: string;
}>
| undefined
)?.[0]?.writePermission,
)
.toBe("admin_only");
await expect
.poll(
() =>
@@ -263,7 +308,7 @@ test.describe("DevFront clients lifecycle", () => {
).toHaveCount(2);
await expect(
page.getByPlaceholder(/e\.g\. locale|예: locale/i).first(),
).toHaveValue("locale");
).toHaveValue("contract_date");
await expect(
page.getByPlaceholder(/e\.g\. locale|예: locale/i).nth(1),
).toHaveValue("tier");
@@ -274,7 +319,7 @@ test.describe("DevFront clients lifecycle", () => {
page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.first(),
).toHaveValue("ko-KR");
).toHaveValue("2026-06-09");
await expect(
page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)

View File

@@ -23,7 +23,27 @@ test.describe("DevFront consents", () => {
test("consent list and revoke flow", async ({ page }) => {
const state = {
clients: [makeClient("client-consent", { name: "Consent app" })],
clients: [
makeClient("client-consent", {
name: "Consent app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "contract_date",
valueType: "date",
value: "2026-06-09",
},
{
namespace: "rp_claims",
key: "approved_at",
valueType: "datetime",
value: "2026-06-09T09:30",
},
],
},
}),
],
consents: [
{
subject: "user-1",
@@ -36,6 +56,13 @@ test.describe("DevFront consents", () => {
status: "active",
tenantId: "tenant-a",
tenantName: "Tenant A",
rpMetadata: {
approvalLevel: "A",
approvalLevel_permissions: {
readPermission: "admin_only",
writePermission: "admin_only",
},
},
},
] as Consent[],
auditLogsByCursor: undefined,
@@ -45,6 +72,36 @@ test.describe("DevFront consents", () => {
await page.goto("/clients/client-consent/consents");
await expect(page.getByText("Alice")).toBeVisible();
await expect(page.getByText("Tenant A")).toBeVisible();
await expect(page.getByText(/approvalLevel:\s*A/)).toBeVisible();
await page.getByRole("button", { name: /Claims|Claim/i }).click();
await expect(page.getByText("RP Custom Claims")).toBeVisible();
await expect(page.getByText("contract_date")).toBeVisible();
await expect(page.getByText("approved_at")).toBeVisible();
await expect(page.locator('input[type="date"]')).toHaveValue("2026-06-09");
await page.locator('input[type="date"]').fill("2026-06-10");
await page.locator('input[type="datetime-local"]').fill("2026-06-09T10:30");
await page
.getByLabel(/쓰기 권한|Write permission/i)
.first()
.selectOption("user_and_admin");
await page.getByRole("button", { name: /Claim 저장|Save Claim/i }).click();
await expect
.poll(() => state.consents[0]?.rpMetadata?.contract_date)
.toBe("2026-06-10");
await expect
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
.toBe("2026-06-09T10:30");
await expect
.poll(
() =>
(
state.consents[0]?.rpMetadata?.contract_date_permissions as
| Record<string, unknown>
| undefined
)?.writePermission,
)
.toBe("user_and_admin");
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();

View File

@@ -51,6 +51,7 @@ export type Consent = {
status: "active" | "revoked";
tenantId: string;
tenantName: string;
rpMetadata?: Record<string, unknown>;
};
export type DeveloperRequestStatus = "pending" | "approved" | "rejected";
@@ -89,6 +90,14 @@ export type DevAssignableUser = {
loginId?: string;
};
export type DevTenantSummary = {
id: string;
name: string;
slug: string;
description?: string;
type?: string;
};
export type AuditLog = {
event_id: string;
timestamp: string;
@@ -106,6 +115,7 @@ export type DevApiMockState = {
developerRequests?: DeveloperRequest[];
relations?: Record<string, ClientRelation[]>;
users?: DevAssignableUser[];
tenants?: DevTenantSummary[];
auditLogsByCursor?: Record<
string,
{ items: AuditLog[]; next_cursor?: string }
@@ -397,9 +407,12 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
}
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
return json(route, [
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
]);
return json(
route,
state.tenants ?? [
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
],
);
}
if (pathname === "/api/v1/dev/stats" && method === "GET") {
@@ -602,6 +615,50 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
pathname.includes("/users/") &&
pathname.endsWith("/metadata") &&
method === "GET"
) {
const parts = pathname.split("/").filter(Boolean);
const clientId = parts[4] ?? "";
const userId = parts[6] ?? "";
const target = state.consents.find(
(row) => row.clientId === clientId && row.subject === userId,
);
return json(route, {
clientId,
userId,
metadata: target?.rpMetadata ?? {},
});
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
pathname.includes("/users/") &&
pathname.endsWith("/metadata") &&
method === "PUT"
) {
const parts = pathname.split("/").filter(Boolean);
const clientId = parts[4] ?? "";
const userId = parts[6] ?? "";
const payload = (request.postDataJSON() as {
metadata?: Record<string, unknown>;
}) || { metadata: {} };
const target = state.consents.find(
(row) => row.clientId === clientId && row.subject === userId,
);
if (target) {
target.rpMetadata = payload.metadata ?? {};
}
return json(route, {
clientId,
userId,
metadata: payload.metadata ?? {},
});
}
if (pathname.startsWith("/api/v1/dev/clients/") && method === "PUT") {
const clientId = parseClientId(pathname);
const payload = (request.postDataJSON() as {