1
0
forked from baron/baron-sso

custom claim 타입보정 UI. 대표테넌트 노출 보정

This commit is contained in:
2026-06-11 11:27:11 +09:00
parent 0bb3ccb850
commit f60b15a17b
37 changed files with 2952 additions and 417 deletions

View File

@@ -52,6 +52,7 @@ import { ClientDetailTabs } from "./ClientDetailTabs";
type RPClaimValueType =
| "text"
| "number"
| "float"
| "boolean"
| "array"
| "object"
@@ -167,7 +168,9 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
function draftRowValueToMetadataValue(row: MetadataDraftRow) {
const value = row.value.trim();
switch (row.valueType) {
case "number": {
case "number":
return /^-?\d+$/.test(value) ? Number.parseInt(value, 10) : value;
case "float": {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
@@ -200,6 +203,7 @@ function isRPClaimValueType(value: string): value is RPClaimValueType {
return (
value === "text" ||
value === "number" ||
value === "float" ||
value === "boolean" ||
value === "array" ||
value === "object" ||
@@ -268,10 +272,21 @@ function readRPClaimSchemas(
function rpClaimInputType(valueType: RPClaimValueType) {
if (valueType === "date") return "date";
if (valueType === "datetime") return "datetime-local";
if (valueType === "number") return "number";
return "text";
}
function rpClaimInputMode(valueType: RPClaimValueType) {
if (valueType === "number") return "numeric";
if (valueType === "float") return "decimal";
return undefined;
}
function rpClaimInputPattern(valueType: RPClaimValueType) {
if (valueType === "number") return "-?[0-9]*";
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
return undefined;
}
function ClientConsentsPage() {
const params = useParams();
const clientId = params.id ?? "";
@@ -452,25 +467,6 @@ function ClientConsentsPage() {
);
};
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) {
@@ -958,16 +954,6 @@ function ClientConsentsPage() {
</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"
@@ -1008,25 +994,9 @@ function ClientConsentsPage() {
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",
)}
/>
)}
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
{row.key}
</div>
{row.valueType === "boolean" ? (
<select
value={row.value === "false" ? "false" : "true"}
@@ -1061,6 +1031,8 @@ function ClientConsentsPage() {
) : (
<Input
type={rpClaimInputType(row.valueType)}
inputMode={rpClaimInputMode(row.valueType)}
pattern={rpClaimInputPattern(row.valueType)}
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
@@ -1129,22 +1101,12 @@ function ClientConsentsPage() {
)}
</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>
)}
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
>
{row.valueType}
</Badge>
</div>
))
)}

View File

@@ -7,7 +7,7 @@ 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.consents": "Consents & Claims",
"ui.dev.clients.details.tab.settings": "설정",
"ui.dev.clients.details.tab.relationships": "관계",
})[key] ??
@@ -23,7 +23,7 @@ describe("ClientDetailTabs", () => {
</MemoryRouter>,
);
expect(html).toContain("사용자 Claim");
expect(html).toContain("Consents &amp; Claims");
expect(html).toContain('href="/clients/client-a/consents"');
});
});

View File

@@ -18,7 +18,6 @@ const tabOrder: Array<{
{
key: "consents",
href: (clientId) => `/clients/${clientId}/consents`,
labelKey: "ui.dev.clients.details.tab.user_claims",
},
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
{

View File

@@ -126,6 +126,26 @@ async function setInputValue(input: HTMLInputElement, value: string) {
await flush();
}
async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
);
descriptor?.set?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
}
async function setSelectValue(select: HTMLSelectElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLSelectElement.prototype,
"value",
);
descriptor?.set?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
await flush();
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -229,4 +249,225 @@ describe("ClientGeneralPage RP claims", () => {
},
]);
});
it("forces user read permission on when user write permission is enabled for RP claims", async () => {
const { container } = await renderPage();
const switches = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
);
const readSwitch = switches.find((button) =>
/Read|읽기/.test(button.getAttribute("aria-label") ?? ""),
);
const writeSwitch = switches.find((button) =>
/Write|쓰기/.test(button.getAttribute("aria-label") ?? ""),
);
expect(readSwitch).toBeDefined();
expect(writeSwitch).toBeDefined();
expect(readSwitch?.getAttribute("aria-checked")).toBe("false");
expect(writeSwitch?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
writeSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(readSwitch?.getAttribute("aria-checked")).toBe("true");
expect(writeSwitch?.getAttribute("aria-checked")).toBe("true");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).toHaveBeenCalledWith(
"client-claims",
expect.objectContaining({
metadata: expect.objectContaining({
id_token_claims: [
expect.objectContaining({
readPermission: "user_and_admin",
writePermission: "user_and_admin",
}),
],
}),
}),
);
});
it("keeps nullable and default value as separate RP claim settings", async () => {
const { container } = await renderPage();
expect(container.textContent).toContain("Nullable");
expect(container.textContent).toContain("Default Value");
expect(container.textContent).not.toContain("Nullable/default");
expect(container.textContent).toContain(
"RP 전용 확장 claim을 구분해서 관리합니다",
);
});
it("blocks saving a number RP claim default value that is not numeric", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).not.toHaveBeenCalled();
});
it("blocks saving a number RP claim default value that is not an integer", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
const defaultValueInput = container.querySelector<HTMLInputElement>(
'input[placeholder="Enter the default value"]',
);
expect(defaultValueInput).not.toBeNull();
await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
expect(container.textContent).toContain(
"Claim 기본값이 타입과 맞지 않습니다",
);
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).not.toHaveBeenCalled();
});
it("saves a float RP claim default value", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
expect(
valueTypeSelect?.querySelector('option[value="float"]'),
).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "float");
const defaultValueInput = container.querySelector<HTMLInputElement>(
'input[placeholder="Enter the default value"]',
);
expect(defaultValueInput).not.toBeNull();
expect(defaultValueInput?.getAttribute("inputmode")).toBe("decimal");
await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).toHaveBeenCalledWith(
"client-claims",
expect.objectContaining({
metadata: expect.objectContaining({
id_token_claims: [
expect.objectContaining({
value: "3.14",
valueType: "float",
}),
],
}),
}),
);
});
it("renders constrained default value controls for boolean and date RP claims", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean");
const booleanDefaultSelect = Array.from(
container.querySelectorAll<HTMLSelectElement>("select"),
).find((select) =>
Array.from(select.options).some((option) => option.value === "false"),
);
expect(booleanDefaultSelect).toBeDefined();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
expect(container.querySelector('input[type="date"]')).not.toBeNull();
});
it("blocks saving an object RP claim default value that is not a JSON object", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "object");
const defaultValueInput = container.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="{\\"key\\": \\"value\\"}"]',
);
expect(defaultValueInput).not.toBeNull();
await setTextareaValue(
defaultValueInput as HTMLTextAreaElement,
"not-json",
);
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).not.toHaveBeenCalled();
});
});

View File

@@ -71,6 +71,7 @@ type ClaimNamespace = "rp_claims";
type ClaimValueType =
| "text"
| "number"
| "float"
| "boolean"
| "array"
| "object"
@@ -149,6 +150,7 @@ function isClaimValueType(value: string): value is ClaimValueType {
return (
value === "text" ||
value === "number" ||
value === "float" ||
value === "boolean" ||
value === "array" ||
value === "object" ||
@@ -176,6 +178,18 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
};
}
function normalizeIdTokenClaimPermissions(
claim: IdTokenClaimItem,
): IdTokenClaimItem {
if (claim.writePermission !== "user_and_admin") {
return claim;
}
return {
...claim,
readPermission: "user_and_admin",
};
}
function readIdTokenClaimsMetadata(
metadata: Record<string, unknown>,
): IdTokenClaimItem[] {
@@ -213,7 +227,7 @@ function readIdTokenClaimsMetadata(
? record.valueType
: "text";
return {
return normalizeIdTokenClaimPermissions({
id: `claim-${index + 1}`,
namespace: namespaceValue,
key: keyValue,
@@ -226,7 +240,7 @@ function readIdTokenClaimsMetadata(
writePermission: isCustomClaimPermission(record.writePermission)
? record.writePermission
: "admin_only",
};
});
})
.filter((item): item is IdTokenClaimItem => item !== null);
}
@@ -240,7 +254,7 @@ function normalizeClaimPreviewValue(
if (nullable && trimmed === "") {
return null;
}
if (valueType === "number") {
if (valueType === "number" || valueType === "float") {
if (trimmed === "") return "";
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : trimmed;
@@ -279,6 +293,137 @@ function normalizeClaimPreviewValue(
return trimmed;
}
function isJsonObjectValue(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isIntegerClaimDefaultValue(value: string) {
return /^-?\d+$/.test(value);
}
function isFloatClaimDefaultValue(value: string) {
return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value);
}
function isValidDateInputValue(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return false;
return date.toISOString().slice(0, 10) === value;
}
function isValidDateTimeInputValue(value: string) {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(value)) {
return false;
}
const date = new Date(value);
return !Number.isNaN(date.getTime());
}
function claimDefaultValueValidationError(claim: IdTokenClaimItem) {
const value = claim.value.trim();
if (value === "") {
return null;
}
switch (claim.valueType) {
case "number":
return isIntegerClaimDefaultValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "float":
return isFloatClaimDefaultValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "boolean":
return value === "true" || value === "false"
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "array": {
try {
return Array.isArray(JSON.parse(value))
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
} catch {
return t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
}
}
case "object": {
try {
return isJsonObjectValue(JSON.parse(value))
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
} catch {
return t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
}
}
case "date":
return isValidDateInputValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "datetime":
return isValidDateTimeInputValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
default:
return null;
}
}
function claimDefaultInputType(valueType: ClaimValueType) {
if (valueType === "date") return "date";
if (valueType === "datetime") return "datetime-local";
return "text";
}
function claimDefaultInputMode(valueType: ClaimValueType) {
if (valueType === "number") return "numeric";
if (valueType === "float") return "decimal";
return undefined;
}
function claimDefaultInputPattern(valueType: ClaimValueType) {
if (valueType === "number") return "-?[0-9]*";
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
return undefined;
}
function buildIdTokenClaimsPreview(
items: IdTokenClaimItem[],
): Record<string, unknown> {
@@ -777,10 +922,10 @@ function ClientGeneralPage() {
if (claim.id !== id) {
return claim;
}
return {
return normalizeIdTokenClaimPermissions({
...claim,
[field]: permission,
};
});
}),
);
};
@@ -840,11 +985,13 @@ function ClientGeneralPage() {
"허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
);
const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
...claim,
key: claim.key.trim(),
value: claim.value.trim(),
}));
const normalizedIdTokenClaims = idTokenClaims.map((claim) =>
normalizeIdTokenClaimPermissions({
...claim,
key: claim.key.trim(),
value: claim.value.trim(),
}),
);
if (headlessLoginEnabled) {
if (!trimmedJwksUri) {
@@ -930,6 +1077,11 @@ function ClientGeneralPage() {
continue;
}
seenClaimKeys.add(keySignature);
const defaultValueError = claimDefaultValueValidationError(claim);
if (defaultValueError) {
claimValidationErrors.push(defaultValueError);
}
}
validationErrors.push(...claimValidationErrors);
@@ -2103,7 +2255,7 @@ function ClientGeneralPage() {
<CardDescription>
{t(
"msg.dev.clients.general.id_token_claims.subtitle",
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
"RP 전용 확장 claim을 구분해서 관리합니다.",
)}
</CardDescription>
</div>
@@ -2151,13 +2303,13 @@ function ClientGeneralPage() {
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
"Read",
"User read",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
"Write",
"User write",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
@@ -2175,190 +2327,255 @@ function ClientGeneralPage() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{idTokenClaims.map((claim) => (
<tr key={claim.id} className="hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<Input
value={claim.key}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"key",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 align-top">
<Badge
variant="muted"
className="h-9 rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs"
>
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</Badge>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.valueType}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"valueType",
e.target.value as ClaimValueType,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.value_type_label",
"Claim value type",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="text">
{t(
"ui.dev.clients.general.id_token_claims.value_type_text",
"Text",
)}
</option>
<option value="number">
{t(
"ui.dev.clients.general.id_token_claims.value_type_number",
"Number",
)}
</option>
<option value="boolean">
{t(
"ui.dev.clients.general.id_token_claims.value_type_boolean",
"Boolean",
)}
</option>
<option value="array">
{t(
"ui.dev.clients.general.id_token_claims.value_type_array",
"Array",
)}
</option>
<option value="object">
{t(
"ui.dev.clients.general.id_token_claims.value_type_object",
"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">
<div className="flex h-9 items-center">
<Switch
checked={claim.nullable}
onCheckedChange={(checked) =>
{idTokenClaims.map((claim) => {
const defaultValueError =
claimDefaultValueValidationError(claim);
return (
<tr key={claim.id} className="hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<Input
value={claim.key}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"nullable",
checked,
"key",
e.target.value,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.nullable_label",
"Nullable",
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={
claim.readPermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
</td>
<td className="px-4 py-3 align-top">
<Badge
variant="muted"
className="h-9 rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs"
>
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</Badge>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.valueType}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"readPermission",
checked,
"valueType",
e.target.value as ClaimValueType,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
"Read 사용자 허용",
"ui.dev.clients.general.id_token_claims.value_type_label",
"Claim 값 타입",
)}
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}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={
claim.writePermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
claim.id,
"writePermission",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
"Write 사용자 허용",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<Input
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the default value",
>
<option value="text">
{t(
"ui.dev.clients.general.id_token_claims.value_type_text",
"Text",
)}
</option>
<option value="number">
{t(
"ui.dev.clients.general.id_token_claims.value_type_number",
"Number",
)}
</option>
<option value="float">
{t(
"ui.dev.clients.general.id_token_claims.value_type_float",
"Float",
)}
</option>
<option value="boolean">
{t(
"ui.dev.clients.general.id_token_claims.value_type_boolean",
"Boolean",
)}
</option>
<option value="array">
{t(
"ui.dev.clients.general.id_token_claims.value_type_array",
"Array",
)}
</option>
<option value="object">
{t(
"ui.dev.clients.general.id_token_claims.value_type_object",
"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">
<div className="flex h-9 items-center">
<Switch
checked={claim.nullable}
onCheckedChange={(checked) =>
updateIdTokenClaim(
claim.id,
"nullable",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.nullable_label",
"Nullable",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={
claim.readPermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
claim.id,
"readPermission",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
"사용자 읽기 허용",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={
claim.writePermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
claim.id,
"writePermission",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
"사용자 쓰기 허용",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
{claim.valueType === "array" ||
claim.valueType === "object" ? (
<Textarea
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="min-h-9 font-mono text-xs"
placeholder={
claim.valueType === "array"
? `["value"]`
: `{"key": "value"}`
}
disabled={isGeneralSettingsReadOnly}
/>
) : claim.valueType === "boolean" ? (
<select
value={
claim.value === "false" ? "false" : "true"
}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<Input
type={claimDefaultInputType(claim.valueType)}
inputMode={claimDefaultInputMode(
claim.valueType,
)}
pattern={claimDefaultInputPattern(
claim.valueType,
)}
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the default value",
)}
disabled={isGeneralSettingsReadOnly}
aria-invalid={
defaultValueError ? true : undefined
}
/>
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 text-right align-top">
<Button
variant="ghost"
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
{defaultValueError && (
<p className="mt-1 text-xs text-destructive">
{defaultValueError}
</p>
)}
</td>
<td className="px-4 py-3 text-right align-top">
<Button
variant="ghost"
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
{idTokenClaims.length === 0 && (
<tr>
<td
@@ -2378,7 +2595,7 @@ function ClientGeneralPage() {
<p className="text-xs leading-6 text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.hint",
"RP 전용 확장 claim 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
"RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
)}
</p>
</div>

View File

@@ -454,13 +454,14 @@ subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim"
[msg.dev.clients.general.id_token_claims]
subtitle = "Separate shared claims from RP-specific extension claims."
subtitle = "Manage RP-specific extension claims separately."
empty = "No ID Token claims have been added yet."
hint = "Manage RP-specific extension claims only. Arrays accept JSON or comma-separated values, and objects accept JSON."
hint = "Manage RP-specific extension claims separately. Edit per-user claim values in Consents & Claims."
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."
duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}"
invalid_default_value = "The claim default value does not match its type: {{key}} ({{valueType}})"
[msg.dev.clients.general.security]
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
@@ -1537,10 +1538,10 @@ title = "Security Note"
[ui.dev.clients.details.tab]
connection = "Federation"
consents = "Consent & Users"
consents = "Consents & Claims"
settings = "Settings"
relationships = "Relationships"
user_claims = "User Claims"
user_claims = "Consents & Claims"
[ui.dev.clients.general]
create = "Create Application"
@@ -1618,19 +1619,20 @@ namespace_label = "Claim namespace"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
nullable_label = "Nullable"
read_user_allowed_label = "Read user allowed"
write_user_allowed_label = "Write user allowed"
read_user_allowed_label = "Allow user read"
write_user_allowed_label = "Allow user write"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
table.nullable = "Nullable"
table.read_user_allowed = "Read"
table.write_user_allowed = "Write"
table.read_user_allowed = "User read"
table.write_user_allowed = "User write"
table.default_value = "Default Value"
table.delete = "Delete"
value_type_label = "Claim value type"
value_type_text = "Text"
value_type_number = "Number"
value_type_float = "Float"
value_type_boolean = "Boolean"
value_type_array = "Array"
value_type_object = "Object"

View File

@@ -454,13 +454,14 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.id_token_claims]
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다."
hint = "RP 전용 확장 claim 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
invalid_default_value = "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})"
[msg.dev.clients.general.security]
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
@@ -1536,10 +1537,10 @@ title = "보안 메모"
[ui.dev.clients.details.tab]
connection = "연동 설정"
consents = "동의 및 사용자"
consents = "동의 및 Claims"
settings = "설정"
relationships = "관계"
user_claims = "사용자 Claim"
user_claims = "Consents & Claims"
[ui.dev.clients.general]
create = "앱 생성"
@@ -1616,20 +1617,21 @@ preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
nullable_label = "Null 허용"
read_user_allowed_label = "Read 사용자 허용"
write_user_allowed_label = "Write 사용자 허용"
nullable_label = "Nullable"
read_user_allowed_label = "사용자 읽기 허용"
write_user_allowed_label = "사용자 쓰기 허용"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
table.nullable = "Null 허용"
table.read_user_allowed = "Read"
table.write_user_allowed = "Write"
table.nullable = "Nullable"
table.read_user_allowed = "사용자 읽기"
table.write_user_allowed = "사용자 쓰기"
table.default_value = "기본값"
table.delete = "삭제"
value_type_label = "Claim 값 타입"
value_type_text = "텍스트"
value_type_number = "숫자"
value_type_float = "실수"
value_type_boolean = "불리언"
value_type_array = "배열"
value_type_object = "객체"

View File

@@ -445,6 +445,7 @@ preview_hint = ""
key_required = ""
reserved_key = ""
duplicate_key = ""
invalid_default_value = ""
[msg.dev.clients.relationships]
subtitle = ""
@@ -1679,6 +1680,7 @@ table.delete = ""
value_type_label = ""
value_type_text = ""
value_type_number = ""
value_type_float = ""
value_type_boolean = ""
value_type_array = ""
value_type_object = ""

View File

@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import {
type ClientRelation,
type Consent,
installDevApiMock,
makeClient,
@@ -7,6 +8,39 @@ import {
} from "./helpers/devfront-fixtures";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
const editRelations = [
{
relation: "config_editor",
subject: "User:playwright-user",
subjectType: "User",
subjectId: "playwright-user",
},
{
relation: "admins",
subject: "User:playwright-user",
subjectType: "User",
subjectId: "playwright-user",
},
{
relation: "config_editor",
subject: "User:admin-user",
subjectType: "User",
subjectId: "admin-user",
},
{
relation: "config_editor",
subject: "User:undefined",
subjectType: "User",
subjectId: "undefined",
},
{
relation: "config_editor",
subject: "User:",
subjectType: "User",
subjectId: "",
},
] satisfies ClientRelation[];
test.describe("DevFront RP claim cache", () => {
test.beforeEach(async ({ page }) => {
await installDevFrontStaticRoutes(page);
@@ -33,6 +67,9 @@ test.describe("DevFront RP claim cache", () => {
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
@@ -44,6 +81,7 @@ test.describe("DevFront RP claim cache", () => {
.getByPlaceholder(/e\.g\. locale|예: locale/i)
.first();
await expect(claimKeyInput).toHaveValue("old_claim");
await expect(claimKeyInput).toBeEnabled();
await claimKeyInput.fill("new_claim");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
@@ -60,4 +98,208 @@ test.describe("DevFront RP claim cache", () => {
.toBe("new_claim");
await expect(claimKeyInput).toHaveValue("new_claim");
});
test("forces read permission on when write permission is enabled", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "locale",
value: "ko",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
const readSwitch = page
.getByRole("switch", { name: /사용자 읽기|Allow user read/i })
.first();
const writeSwitch = page
.getByRole("switch", { name: /사용자 쓰기|Allow user write/i })
.first();
await expect(readSwitch).toHaveAttribute("aria-checked", "false");
await expect(writeSwitch).toHaveAttribute("aria-checked", "false");
await expect(readSwitch).toBeEnabled();
await expect(writeSwitch).toBeEnabled();
await writeSwitch.click();
await expect(readSwitch).toHaveAttribute("aria-checked", "true");
await expect(writeSwitch).toHaveAttribute("aria-checked", "true");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
readPermission?: string;
writePermission?: string;
}>
| undefined
)?.[0],
)
.toMatchObject({
readPermission: "user_and_admin",
writePermission: "user_and_admin",
});
});
test("blocks saving an RP claim default value that does not match the selected value type", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "profile",
value: "{}",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
await page
.getByLabel(/Claim 값 타입|Claim value type/i)
.first()
.selectOption("object");
await page
.locator('textarea[placeholder="{\\"key\\": \\"value\\"}"]')
.fill("not-json");
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
expect(
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{ valueType?: string; value?: string }>
| undefined
)?.[0],
).toMatchObject({
value: "{}",
valueType: "text",
});
});
test("saves a float RP claim default value and blocks decimal values for integer number claims", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "ratio",
value: "0",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
await page
.getByLabel(/Claim 값 타입|Claim value type/i)
.first()
.selectOption("float");
await page
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("3.14");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{ valueType?: string; value?: string }>
| undefined
)?.[0],
)
.toMatchObject({
value: "3.14",
valueType: "float",
});
const valueTypeSelect = page
.getByLabel(/Claim 값 타입|Claim value type/i)
.first();
await expect(valueTypeSelect).toHaveValue("float");
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeEnabled();
await valueTypeSelect.selectOption("number");
await expect(valueTypeSelect).toHaveValue("number");
await page
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("3.14");
await expect(
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
});
});

View File

@@ -6,6 +6,7 @@ import {
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
return async ({ page }: { page: Page }) => {
@@ -24,9 +25,10 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
},
auditLogsByCursor: undefined,
};
await installDevFrontStaticRoutes(page);
await installDevApiMock(page, state);
await page.goto(pagePath);
await page.goto(`http://devfront.test${pagePath}`);
const header = page
.locator("header")
@@ -38,7 +40,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
await expect(tabs).toHaveText([
"연동 설정",
"사용자 Claim",
"동의 및 Claims",
"설정",
"관계",
]);

View File

@@ -6,6 +6,7 @@ import {
seedAuth,
} from "./helpers/devfront-fixtures";
import { captureEvidence } from "./helpers/evidence";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
test.describe("DevFront consents", () => {
test.afterEach(async ({ page }, testInfo) => {
@@ -15,6 +16,7 @@ test.describe("DevFront consents", () => {
});
test.beforeEach(async ({ page }) => {
await installDevFrontStaticRoutes(page);
page.on("dialog", async (dialog) => {
await dialog.accept();
});
@@ -81,7 +83,7 @@ test.describe("DevFront consents", () => {
};
await installDevApiMock(page, state);
await page.goto("/clients/client-consent/consents");
await page.goto("http://devfront.test/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();
@@ -127,4 +129,43 @@ test.describe("DevFront consents", () => {
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
});
test("does not allow adding undefined RP claims from consents and claims", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-consent", {
name: "Consent app",
metadata: {},
}),
],
consents: [
{
subject: "user-1",
userName: "Alice",
clientId: "client-consent",
clientName: "Consent app",
grantedScopes: ["openid", "profile"],
authenticatedAt: "2026-03-03T08:00:00.000Z",
createdAt: "2026-03-02T08:00:00.000Z",
status: "active",
tenantId: "tenant-a",
tenantName: "Tenant A",
rpMetadata: {},
},
] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-consent/consents");
await page.getByRole("button", { name: /Claims|Claim/i }).click();
await expect(page.getByText("RP Custom Claims")).toBeVisible();
await expect(
page.getByRole("button", { name: /^추가$|^Add$/ }),
).toHaveCount(0);
await expect(page.getByPlaceholder(/claim_key/i)).toHaveCount(0);
});
});