forked from baron/baron-sso
offline 스코프 제거, rp_claims 값 표준화
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
32
devfront/src/features/clients/rpClaimDateTime.test.ts
Normal file
32
devfront/src/features/clients/rpClaimDateTime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
137
devfront/src/features/clients/rpClaimDateTime.ts
Normal file
137
devfront/src/features/clients/rpClaimDateTime.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user