1
0
forked from baron/baron-sso

offline 스코프 제거, rp_claims 값 표준화

This commit is contained in:
2026-06-11 14:50:26 +09:00
parent f60b15a17b
commit c495e9119b
26 changed files with 1034 additions and 300 deletions

View File

@@ -48,6 +48,12 @@ import {
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs";
import {
claimDateTimeValueToInputString,
dateTimeInputToUnixSeconds,
getBrowserTimeZone,
getSupportedTimeZones,
} from "./rpClaimDateTime";
type RPClaimValueType =
| "text"
@@ -72,6 +78,7 @@ type MetadataDraftRow = {
id: string;
key: string;
value: string;
timeZone: string;
valueType: RPClaimValueType;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
@@ -101,6 +108,7 @@ function readPermissionMetadata(
function metadataToDraftRows(
metadata: Record<string, unknown> | undefined,
schemas: RPClaimSchema[],
defaultTimeZone = getBrowserTimeZone(),
): MetadataDraftRow[] {
if (schemas.length > 0) {
return schemas.map((schema) => ({
@@ -110,7 +118,9 @@ function metadataToDraftRows(
metadata?.[schema.key],
schema.value,
schema.valueType,
defaultTimeZone,
),
timeZone: defaultTimeZone,
valueType: schema.valueType,
readPermission: readPermissionMetadata(
metadata,
@@ -134,6 +144,7 @@ function metadataToDraftRows(
id: `${key}-${index}`,
key,
value: metadataValueToString(value, ""),
timeZone: defaultTimeZone,
valueType: "text",
readPermission: readPermissionMetadata(
metadata,
@@ -176,6 +187,11 @@ function draftRowValueToMetadataValue(row: MetadataDraftRow) {
}
case "boolean":
return value === "true";
case "date":
case "datetime":
return (
dateTimeInputToUnixSeconds(value, row.valueType, row.timeZone) ?? value
);
case "array":
if (value === "") return [];
try {
@@ -225,14 +241,20 @@ function metadataValueToInputString(
value: unknown,
fallback: string,
valueType: RPClaimValueType,
timeZone: string,
) {
const text = metadataValueToString(value, fallback);
if (valueType === "date") {
return text.slice(0, 10);
return claimDateTimeValueToInputString(value, fallback, "date", timeZone);
}
if (valueType === "datetime") {
return text.slice(0, 16);
return claimDateTimeValueToInputString(
value,
fallback,
"datetime",
timeZone,
);
}
const text = metadataValueToString(value, fallback);
return text;
}
@@ -299,6 +321,11 @@ function ClientConsentsPage() {
const [metadataDraftRows, setMetadataDraftRows] = useState<
MetadataDraftRow[]
>([]);
const browserTimeZone = useMemo(() => getBrowserTimeZone(), []);
const timeZoneOptions = useMemo(
() => getSupportedTimeZones(browserTimeZone),
[browserTimeZone],
);
const { data: clientData } = useQuery({
queryKey: ["client", clientId],
@@ -351,10 +378,14 @@ function ClientConsentsPage() {
useEffect(() => {
if (metadataQuery.data) {
setMetadataDraftRows(
metadataToDraftRows(metadataQuery.data.metadata, rpClaimSchemas),
metadataToDraftRows(
metadataQuery.data.metadata,
rpClaimSchemas,
browserTimeZone,
),
);
}
}, [metadataQuery.data, rpClaimSchemas]);
}, [browserTimeZone, metadataQuery.data, rpClaimSchemas]);
const handleRevoke = (sub: string) => {
if (
@@ -1029,23 +1060,44 @@ function ClientConsentsPage() {
aria-label={`${row.key} ${row.valueType}`}
/>
) : (
<Input
type={rpClaimInputType(row.valueType)}
inputMode={rpClaimInputMode(row.valueType)}
pattern={rpClaimInputPattern(row.valueType)}
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
value: event.target.value,
})
}
className="font-mono text-xs"
placeholder={t(
"ui.dev.clients.consents.rp_claims.value_placeholder",
"claim value",
<div className="flex flex-col gap-2">
<Input
type={rpClaimInputType(row.valueType)}
inputMode={rpClaimInputMode(row.valueType)}
pattern={rpClaimInputPattern(row.valueType)}
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
value: event.target.value,
})
}
className="font-mono text-xs"
placeholder={t(
"ui.dev.clients.consents.rp_claims.value_placeholder",
"claim value",
)}
aria-label={`${row.key} ${row.valueType}`}
/>
{(row.valueType === "date" ||
row.valueType === "datetime") && (
<select
value={row.timeZone}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
timeZone: event.target.value,
})
}
className="h-10 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"
aria-label={`${row.key} timezone`}
>
{timeZoneOptions.map((timeZone) => (
<option key={timeZone} value={timeZone}>
{timeZone}
</option>
))}
</select>
)}
aria-label={`${row.key} ${row.valueType}`}
/>
</div>
)}
<select
value={row.readPermission}

View File

@@ -207,6 +207,7 @@ describe("ClientGeneralPage RP claims", () => {
root.unmount();
});
}
vi.restoreAllMocks();
vi.clearAllMocks();
document.body.innerHTML = "";
});
@@ -436,6 +437,56 @@ describe("ClientGeneralPage RP claims", () => {
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
expect(container.querySelector('input[type="date"]')).not.toBeNull();
expect(
container.querySelector('select[aria-label="Claim 기본값 시간대"]'),
).not.toBeNull();
});
it("saves date RP claim default values as Unix seconds for the selected timezone", async () => {
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
locale: "ko-KR",
calendar: "gregory",
numberingSystem: "latn",
timeZone: "Asia/Seoul",
} as Intl.ResolvedDateTimeFormatOptions);
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
const defaultValueInput =
container.querySelector<HTMLInputElement>('input[type="date"]');
expect(defaultValueInput).not.toBeNull();
await setInputValue(defaultValueInput as HTMLInputElement, "2026-06-10");
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: 1781017200,
valueType: "date",
}),
],
}),
}),
);
});
it("blocks saving an object RP claim default value that is not a JSON object", async () => {

View File

@@ -13,7 +13,7 @@ import {
Upload,
X,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate, useParams } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
@@ -58,6 +58,12 @@ import { fetchMe, type UserProfile } from "../auth/authApi";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import { ClientDetailTabs } from "./ClientDetailTabs";
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
import {
claimDateTimeValueToInputString,
dateTimeInputToUnixSeconds,
getBrowserTimeZone,
getSupportedTimeZones,
} from "./rpClaimDateTime";
interface ScopeItem {
id: string;
@@ -84,6 +90,7 @@ interface IdTokenClaimItem {
namespace: ClaimNamespace;
key: string;
value: string;
timeZone: string;
valueType: ClaimValueType;
nullable: boolean;
readPermission: CustomClaimPermission;
@@ -171,6 +178,7 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
namespace: "rp_claims",
key: "",
value: "",
timeZone: getBrowserTimeZone(),
valueType: "text",
nullable: false,
readPermission: "admin_only",
@@ -215,23 +223,32 @@ function readIdTokenClaimsMetadata(
}
const keyValue = typeof record.key === "string" ? record.key : "";
const rawValue = record.value;
const valueValue =
typeof rawValue === "string"
? rawValue
: rawValue == null
? ""
: JSON.stringify(rawValue);
const valueTypeValue =
typeof record.valueType === "string" &&
isClaimValueType(record.valueType)
? record.valueType
: "text";
const timeZoneValue = getBrowserTimeZone();
const valueValue =
valueTypeValue === "date" || valueTypeValue === "datetime"
? claimDateTimeValueToInputString(
rawValue,
"",
valueTypeValue,
timeZoneValue,
)
: typeof rawValue === "string"
? rawValue
: rawValue == null
? ""
: JSON.stringify(rawValue);
return normalizeIdTokenClaimPermissions({
id: `claim-${index + 1}`,
namespace: namespaceValue,
key: keyValue,
value: valueValue,
timeZone: timeZoneValue,
valueType: valueTypeValue,
nullable: record.nullable === true,
readPermission: isCustomClaimPermission(record.readPermission)
@@ -249,11 +266,21 @@ function normalizeClaimPreviewValue(
value: string,
valueType: ClaimValueType,
nullable: boolean,
timeZone: string,
): unknown {
const trimmed = value.trim();
if (nullable && trimmed === "") {
return null;
}
if (valueType === "date" || valueType === "datetime") {
if (trimmed === "") return "";
const unixSeconds = dateTimeInputToUnixSeconds(
trimmed,
valueType,
timeZone,
);
return unixSeconds ?? trimmed;
}
if (valueType === "number" || valueType === "float") {
if (trimmed === "") return "";
const parsed = Number(trimmed);
@@ -320,6 +347,19 @@ function isValidDateTimeInputValue(value: string) {
return !Number.isNaN(date.getTime());
}
function normalizedClaimValue(claim: IdTokenClaimItem): string | number {
const value = claim.value.trim();
if (claim.valueType !== "date" && claim.valueType !== "datetime") {
return value;
}
if (value === "") {
return value;
}
return (
dateTimeInputToUnixSeconds(value, claim.valueType, claim.timeZone) ?? value
);
}
function claimDefaultValueValidationError(claim: IdTokenClaimItem) {
const value = claim.value.trim();
if (value === "") {
@@ -440,6 +480,7 @@ function buildIdTokenClaimsPreview(
item.value,
item.valueType,
item.nullable,
item.timeZone,
);
}
@@ -612,6 +653,11 @@ function ClientGeneralPage() {
},
]);
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
const browserTimeZone = useMemo(() => getBrowserTimeZone(), []);
const timeZoneOptions = useMemo(
() => getSupportedTimeZones(browserTimeZone),
[browserTimeZone],
);
const tenantScopeDescription = t(
"msg.dev.clients.scopes.tenant",
@@ -985,13 +1031,20 @@ function ClientGeneralPage() {
"허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
);
const normalizedIdTokenClaims = idTokenClaims.map((claim) =>
const normalizedIdTokenClaimItems = idTokenClaims.map((claim) =>
normalizeIdTokenClaimPermissions({
...claim,
key: claim.key.trim(),
value: claim.value.trim(),
}),
);
const normalizedIdTokenClaims = normalizedIdTokenClaimItems.map((claim) => {
const { timeZone: _timeZone, value: _value, ...persisted } = claim;
return {
...persisted,
value: normalizedClaimValue(claim),
};
});
if (headlessLoginEnabled) {
if (!trimmedJwksUri) {
@@ -1048,7 +1101,7 @@ function ClientGeneralPage() {
const claimValidationErrors: string[] = [];
const seenClaimKeys = new Set<string>();
for (const claim of normalizedIdTokenClaims) {
for (const claim of normalizedIdTokenClaimItems) {
if (!claim.key) {
claimValidationErrors.push(
t(
@@ -1087,7 +1140,7 @@ function ClientGeneralPage() {
const hasValidationErrors = validationErrors.length > 0;
const idTokenClaimPreview = buildIdTokenClaimsPreview(
normalizedIdTokenClaims,
normalizedIdTokenClaimItems,
);
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
@@ -2529,32 +2582,59 @@ function ClientGeneralPage() {
<option value="false">false</option>
</select>
) : (
<Input
type={claimDefaultInputType(claim.valueType)}
inputMode={claimDefaultInputMode(
claim.valueType,
<div className="flex flex-col gap-2">
<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
}
/>
{(claim.valueType === "date" ||
claim.valueType === "datetime") && (
<select
value={claim.timeZone}
onChange={(event) =>
updateIdTokenClaim(
claim.id,
"timeZone",
event.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}
aria-label={t(
"ui.dev.clients.general.id_token_claims.timezone_label",
"Claim 기본값 시간대",
)}
>
{timeZoneOptions.map((timeZone) => (
<option key={timeZone} value={timeZone}>
{timeZone}
</option>
))}
</select>
)}
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
}
/>
</div>
)}
{defaultValueError && (
<p className="mt-1 text-xs text-destructive">

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
claimDateTimeValueToInputString,
dateTimeInputToUnixSeconds,
unixSecondsToDateTimeInput,
} from "./rpClaimDateTime";
describe("rpClaimDateTime", () => {
it("converts date and datetime input in a selected timezone to Unix seconds", () => {
expect(dateTimeInputToUnixSeconds("2026-06-10", "date", "Asia/Seoul")).toBe(
1781017200,
);
expect(
dateTimeInputToUnixSeconds("2026-06-09T10:30", "datetime", "Asia/Seoul"),
).toBe(1780968600);
});
it("formats stored Unix seconds for the selected timezone", () => {
expect(unixSecondsToDateTimeInput(1781017200, "date", "Asia/Seoul")).toBe(
"2026-06-10",
);
expect(
unixSecondsToDateTimeInput(1780968600, "datetime", "Asia/Seoul"),
).toBe("2026-06-09T10:30");
});
it("uses Unix seconds values when hydrating date inputs", () => {
expect(
claimDateTimeValueToInputString(1780968600, "", "datetime", "Asia/Seoul"),
).toBe("2026-06-09T10:30");
});
});

View File

@@ -0,0 +1,137 @@
export type RPClaimDateTimeValueType = "date" | "datetime";
export const FALLBACK_TIME_ZONE = "UTC";
export function getBrowserTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone || FALLBACK_TIME_ZONE;
}
export function getSupportedTimeZones(currentTimeZone = getBrowserTimeZone()) {
const supported =
typeof Intl.supportedValuesOf === "function"
? Intl.supportedValuesOf("timeZone")
: [];
return Array.from(
new Set([currentTimeZone, FALLBACK_TIME_ZONE, ...supported]),
);
}
function getTimeZoneOffsetMs(date: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(date);
const values = Object.fromEntries(
parts
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
);
const hour = values.hour === "24" ? "00" : values.hour;
const asUTC = Date.UTC(
Number(values.year),
Number(values.month) - 1,
Number(values.day),
Number(hour),
Number(values.minute),
Number(values.second),
);
return asUTC - date.getTime();
}
function zonedDateTimeToUnixSeconds(
year: number,
month: number,
day: number,
hour: number,
minute: number,
timeZone: string,
) {
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0);
let instant = utcGuess - getTimeZoneOffsetMs(new Date(utcGuess), timeZone);
const corrected = utcGuess - getTimeZoneOffsetMs(new Date(instant), timeZone);
if (corrected !== instant) {
instant = corrected;
}
return Math.trunc(instant / 1000);
}
export function dateTimeInputToUnixSeconds(
value: string,
valueType: RPClaimDateTimeValueType,
timeZone: string,
): number | null {
const trimmed = value.trim();
const match =
valueType === "date"
? /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed)
: /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(trimmed);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = valueType === "datetime" ? Number(match[4]) : 0;
const minute = valueType === "datetime" ? Number(match[5]) : 0;
const unixSeconds = zonedDateTimeToUnixSeconds(
year,
month,
day,
hour,
minute,
timeZone || FALLBACK_TIME_ZONE,
);
return Number.isFinite(unixSeconds) ? unixSeconds : null;
}
export function unixSecondsToDateTimeInput(
value: number,
valueType: RPClaimDateTimeValueType,
timeZone: string,
) {
const date = new Date(value * 1000);
if (Number.isNaN(date.getTime())) return "";
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timeZone || FALLBACK_TIME_ZONE,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).formatToParts(date);
const values = Object.fromEntries(
parts
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
);
const hour = values.hour === "24" ? "00" : values.hour;
const dateText = `${values.year}-${values.month}-${values.day}`;
if (valueType === "date") return dateText;
return `${dateText}T${hour}:${values.minute}`;
}
export function claimDateTimeValueToInputString(
value: unknown,
fallback: string,
valueType: RPClaimDateTimeValueType,
timeZone: string,
) {
if (typeof value === "number" && Number.isFinite(value)) {
return unixSecondsToDateTimeInput(value, valueType, timeZone);
}
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
return unixSecondsToDateTimeInput(
Number(value.trim()),
valueType,
timeZone,
);
}
const text = typeof value === "string" ? value : fallback;
return valueType === "date" ? text.slice(0, 10) : text.slice(0, 16);
}

View File

@@ -492,8 +492,8 @@ describe("devfront coverage smoke pages", () => {
expect(settings.textContent).not.toContain("top-level");
expect(settings.textContent).toContain("Date");
expect(settings.textContent).toContain("Datetime");
expect(settings.textContent).toContain("Read");
expect(settings.textContent).toContain("Write");
expect(settings.textContent).toContain("User read");
expect(settings.textContent).toContain("User write");
const consents = await renderPage(<ClientConsentsPage />, {
path: "/clients/:id/consents",

View File

@@ -94,6 +94,13 @@ test.describe("DevFront consents", () => {
await expect(page.getByText("approved_at")).toBeVisible();
await expect(page.getByText("active_member")).toBeVisible();
await expect(page.locator('input[type="date"]')).toHaveValue("2026-06-09");
await expect(
page.getByLabel(/contract_date.*timezone|timezone.*contract_date/i),
).toHaveValue(
await page.evaluate(
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
),
);
await page.locator('input[type="date"]').fill("2026-06-10");
await page.locator('input[type="datetime-local"]').fill("2026-06-09T10:30");
await page
@@ -107,10 +114,10 @@ test.describe("DevFront consents", () => {
await page.getByRole("button", { name: /Claim 저장|Save Claim/i }).click();
await expect
.poll(() => state.consents[0]?.rpMetadata?.contract_date)
.toBe("2026-06-10");
.toBe(1781017200);
await expect
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
.toBe("2026-06-09T10:30");
.toBe(1780968600);
await expect
.poll(() => state.consents[0]?.rpMetadata?.active_member)
.toBe(false);