forked from baron/baron-sso
custom claim 타입보정 UI. 대표테넌트 노출 보정
This commit is contained in:
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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 & Claims");
|
||||
expect(html).toContain('href="/clients/client-a/consents"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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` },
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "객체"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
"설정",
|
||||
"관계",
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user