1
0
forked from baron/baron-sso

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

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

View File

@@ -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>