1
0
forked from baron/baron-sso

devfront RP 설정 표 공통화 및 레이아웃 정리

This commit is contained in:
2026-06-17 15:49:54 +09:00
parent 8b183cab61
commit c308d0a7d4
7 changed files with 656 additions and 448 deletions

View File

@@ -29,6 +29,23 @@ function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject {
return result; return result;
} }
function setTomlValue(
target: TomlObject,
path: string[],
value: TomlValue,
): void {
let cursor: TomlObject = target;
for (let index = 0; index < path.length - 1; index += 1) {
const key = path[index];
const current = cursor[key];
if (!current || typeof current === "string") {
cursor[key] = {};
}
cursor = cursor[key] as TomlObject;
}
cursor[path[path.length - 1]] = value;
}
function isSupportedLocale(value: string): value is Locale { function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value); return (SUPPORTED_LOCALES as readonly string[]).includes(value);
} }
@@ -82,7 +99,7 @@ function parseToml(raw: string): TomlObject {
cursor = cursor[section] as TomlObject; cursor = cursor[section] as TomlObject;
} }
cursor[key] = value; setTomlValue(cursor, key.split("."), value);
} }
return root; return root;

View File

@@ -30,14 +30,6 @@ import { CopyButton } from "../../components/ui/copy-button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import { Switch } from "../../components/ui/switch"; import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast"; import { toast } from "../../components/ui/use-toast";
import type { import type {
@@ -64,6 +56,16 @@ import { cn } from "../../lib/utils";
import { fetchMe, type UserProfile } from "../auth/authApi"; import { fetchMe, type UserProfile } from "../auth/authApi";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import { ClientDetailTabs } from "./ClientDetailTabs"; import { ClientDetailTabs } from "./ClientDetailTabs";
import {
SettingsTable,
SettingsTableBody,
SettingsTableCell,
SettingsTableEmptyState,
SettingsTableHead,
SettingsTableHeader,
SettingsTableRow,
SettingsTableShell,
} from "./components/SettingsTable";
import { TenantAccessPicker } from "./components/TenantAccessPicker"; import { TenantAccessPicker } from "./components/TenantAccessPicker";
import { import {
claimDateTimeValueToInputString, claimDateTimeValueToInputString,
@@ -730,7 +732,8 @@ function ClientGeneralPage() {
if ( if (
restricted && restricted &&
!normalized.some( !normalized.some(
(scope) => scope.name.trim() === "tenants" || scope.name.trim() === "tenant", (scope) =>
scope.name.trim() === "tenants" || scope.name.trim() === "tenant",
) )
) { ) {
normalized.push(buildTenantScope(`tenants-${Date.now()}`)); normalized.push(buildTenantScope(`tenants-${Date.now()}`));
@@ -2284,43 +2287,43 @@ function ClientGeneralPage() {
</div> </div>
</div> </div>
<div className="rounded-md border border-border overflow-hidden"> <SettingsTableShell>
<table className="w-full text-sm"> <SettingsTable>
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground"> <SettingsTableHeader>
<tr> <tr>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead>
{t( {t(
"ui.dev.clients.general.scopes.table.name", "ui.dev.clients.general.scopes.table.name",
"Scope Name", "Scope Name",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead>
{t( {t(
"ui.dev.clients.general.scopes.table.description", "ui.dev.clients.general.scopes.table.description",
"Description", "Description",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-center font-bold"> <SettingsTableHead className="text-center">
{t( {t(
"ui.dev.clients.general.scopes.table.mandatory", "ui.dev.clients.general.scopes.table.mandatory",
"Mandatory", "Mandatory",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-right font-bold"> <SettingsTableHead className="text-right">
{t("ui.dev.clients.general.scopes.table.delete", "Delete")} {t("ui.dev.clients.general.scopes.table.delete", "Delete")}
</th> </SettingsTableHead>
</tr> </tr>
</thead> </SettingsTableHeader>
<tbody className="divide-y divide-border"> <SettingsTableBody>
{scopes.map((s) => ( {scopes.length > 0 ? (
<tr scopes.map((s) => (
<SettingsTableRow
key={s.id} key={s.id}
className={cn( className={cn(
"transition-colors", s.locked ? "bg-primary/5" : "hover:bg-muted/20",
s.locked ? "bg-primary/5" : "hover:bg-muted/30",
)} )}
> >
<td className="px-4 py-3"> <SettingsTableCell>
<Input <Input
value={s.name} value={s.name}
onChange={(e) => onChange={(e) =>
@@ -2333,8 +2336,8 @@ function ClientGeneralPage() {
)} )}
disabled={s.locked || isGeneralSettingsReadOnly} disabled={s.locked || isGeneralSettingsReadOnly}
/> />
</td> </SettingsTableCell>
<td className="px-4 py-3"> <SettingsTableCell>
<Input <Input
value={s.description} value={s.description}
onChange={(e) => onChange={(e) =>
@@ -2347,8 +2350,8 @@ function ClientGeneralPage() {
)} )}
disabled={s.locked || isGeneralSettingsReadOnly} disabled={s.locked || isGeneralSettingsReadOnly}
/> />
</td> </SettingsTableCell>
<td className="px-4 py-3 text-center"> <SettingsTableCell className="text-center">
<div className="flex justify-center"> <div className="flex justify-center">
<Switch <Switch
checked={s.mandatory} checked={s.mandatory}
@@ -2358,8 +2361,8 @@ function ClientGeneralPage() {
disabled={s.locked || isGeneralSettingsReadOnly} disabled={s.locked || isGeneralSettingsReadOnly}
/> />
</div> </div>
</td> </SettingsTableCell>
<td className="px-4 py-3 text-right"> <SettingsTableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -2369,25 +2372,20 @@ function ClientGeneralPage() {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</td> </SettingsTableCell>
</tr> </SettingsTableRow>
))} ))
{scopes.length === 0 && ( ) : (
<tr> <SettingsTableEmptyState colSpan={4}>
<td
colSpan={4}
className="px-4 py-8 text-center text-muted-foreground"
>
{t( {t(
"msg.dev.clients.general.scopes.empty", "msg.dev.clients.general.scopes.empty",
"등록된 스코프가 없습니다.", "등록된 스코프가 없습니다.",
)} )}
</td> </SettingsTableEmptyState>
</tr>
)} )}
</tbody> </SettingsTableBody>
</table> </SettingsTable>
</div> </SettingsTableShell>
</CardContent> </CardContent>
</Card> </Card>
@@ -2472,54 +2470,56 @@ function ClientGeneralPage() {
"허용 테넌트", "허용 테넌트",
)} )}
</Label> </Label>
<div className="overflow-hidden rounded-md border border-border bg-background"> <SettingsTableShell bodyClassName="max-h-80">
{allowedTenantIds.length > 0 ? ( <SettingsTable>
<div className="max-h-80 overflow-auto"> <SettingsTableHeader className="sticky top-0 z-10">
<Table> <tr>
<TableHeader className="sticky top-0 z-10 bg-muted/50"> <SettingsTableHead className="w-[28%]">
<TableRow>
<TableHead className="w-[34%] px-4 py-3 text-left font-bold">
{t( {t(
"ui.dev.clients.general.tenant_access.table.name", "ui.dev.clients.general.tenant_access.table.name",
"테넌트명", "테넌트명",
)} )}
</TableHead> </SettingsTableHead>
<TableHead className="w-[18%] px-4 py-3 text-left font-bold"> <SettingsTableHead className="w-[18%]">
{t( {t(
"ui.dev.clients.general.tenant_access.table.slug", "ui.dev.clients.general.tenant_access.table.slug",
"슬러그", "슬러그",
)} )}
</TableHead> </SettingsTableHead>
<TableHead className="px-4 py-3 text-left font-bold"> <SettingsTableHead>
{t( {t(
"ui.dev.clients.general.tenant_access.table.id", "ui.dev.clients.general.tenant_access.table.id",
"테넌트 ID", "테넌트 ID",
)} )}
</TableHead> </SettingsTableHead>
<TableHead className="w-[112px] px-4 py-3 text-right font-bold"> <SettingsTableHead className="w-[112px] text-right">
{t( {t(
"ui.dev.clients.general.tenant_access.table.actions", "ui.dev.clients.general.tenant_access.table.actions",
"작업", "작업",
)} )}
</TableHead> </SettingsTableHead>
</TableRow> </tr>
</TableHeader> </SettingsTableHeader>
<TableBody> <SettingsTableBody>
{selectedAllowedTenants.length > 0 ? (
<>
{selectedAllowedTenants.map((tenant) => ( {selectedAllowedTenants.map((tenant) => (
<TableRow <SettingsTableRow
key={tenant.id} key={tenant.id}
data-testid={`allowed-tenant-${tenant.id}`} data-testid={`allowed-tenant-${tenant.id}`}
> >
<TableCell className="px-4 py-3 font-medium"> <SettingsTableCell className="align-middle font-medium">
<span className="block truncate">
{tenant.name} {tenant.name}
</TableCell> </span>
<TableCell className="px-4 py-3 text-muted-foreground"> </SettingsTableCell>
<SettingsTableCell className="align-middle text-muted-foreground">
{tenant.slug || "-"} {tenant.slug || "-"}
</TableCell> </SettingsTableCell>
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground"> <SettingsTableCell className="align-middle font-mono text-sm text-muted-foreground">
<span className="break-all">{tenant.id}</span> <span className="break-all">{tenant.id}</span>
</TableCell> </SettingsTableCell>
<TableCell className="px-4 py-3 text-right"> <SettingsTableCell className="align-middle text-right">
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<CopyButton <CopyButton
aria-label="테넌트 UUID 복사" aria-label="테넌트 UUID 복사"
@@ -2542,8 +2542,8 @@ function ClientGeneralPage() {
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
</TableCell> </SettingsTableCell>
</TableRow> </SettingsTableRow>
))} ))}
{allowedTenantIds {allowedTenantIds
.filter( .filter(
@@ -2553,20 +2553,22 @@ function ClientGeneralPage() {
), ),
) )
.map((tenantId) => ( .map((tenantId) => (
<TableRow <SettingsTableRow
key={tenantId} key={tenantId}
data-testid={`allowed-tenant-${tenantId}`} data-testid={`allowed-tenant-${tenantId}`}
> >
<TableCell className="px-4 py-3 font-medium"> <SettingsTableCell className="align-middle font-medium">
<span className="block truncate">
{tenantId} {tenantId}
</TableCell> </span>
<TableCell className="px-4 py-3 text-muted-foreground"> </SettingsTableCell>
<SettingsTableCell className="align-middle text-muted-foreground">
- -
</TableCell> </SettingsTableCell>
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground"> <SettingsTableCell className="align-middle font-mono text-sm text-muted-foreground">
<span className="break-all">{tenantId}</span> <span className="break-all">{tenantId}</span>
</TableCell> </SettingsTableCell>
<TableCell className="px-4 py-3 text-right"> <SettingsTableCell className="align-middle text-right">
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<CopyButton <CopyButton
aria-label="테넌트 UUID 복사" aria-label="테넌트 UUID 복사"
@@ -2589,21 +2591,21 @@ function ClientGeneralPage() {
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
</TableCell> </SettingsTableCell>
</TableRow> </SettingsTableRow>
))} ))}
</TableBody> </>
</Table>
</div>
) : ( ) : (
<div className="flex min-h-[99px] items-center px-4 py-3 text-sm text-muted-foreground"> <SettingsTableEmptyState colSpan={4} className="py-4">
{t( {t(
"ui.dev.clients.general.tenant_access.selected_empty", "ui.dev.clients.general.tenant_access.selected_empty",
"아직 선택된 테넌트가 없습니다.", "아직 선택된 테넌트가 없습니다.",
)} )}
</div> </SettingsTableEmptyState>
)} )}
</div> </SettingsTableBody>
</SettingsTable>
</SettingsTableShell>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -2638,70 +2640,74 @@ function ClientGeneralPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,0.75fr)]">
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border border-border overflow-hidden"> <SettingsTableShell>
<table className="w-full text-sm"> <SettingsTable>
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground"> <SettingsTableHeader>
<tr> <tr>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead>
{t( {t(
"ui.dev.clients.general.id_token_claims.table.key", "ui.dev.clients.general.id_token_claims.table.key",
"Claim Key", "Claim Key",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead>
{t( {t(
"ui.dev.clients.general.id_token_claims.table.namespace", "ui.dev.clients.general.id_token_claims.table.namespace",
"Namespace", "Namespace",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead>
{t( {t(
"ui.dev.clients.general.id_token_claims.table.value_type", "ui.dev.clients.general.id_token_claims.table.value_type",
"Value Type", "Value Type",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead className="text-center">
{t( {t(
"ui.dev.clients.general.id_token_claims.table.nullable", "ui.dev.clients.general.id_token_claims.table.nullable",
"Nullable", "Nullable",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead className="text-center">
{t( {t(
"ui.dev.clients.general.id_token_claims.table.read_user_allowed", "ui.dev.clients.general.id_token_claims.table.read_user_allowed",
"User read", "User read",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead className="text-center">
{t( {t(
"ui.dev.clients.general.id_token_claims.table.write_user_allowed", "ui.dev.clients.general.id_token_claims.table.write_user_allowed",
"User write", "User write",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-left font-bold"> <SettingsTableHead>
{t( {t(
"ui.dev.clients.general.id_token_claims.table.default_value", "ui.dev.clients.general.id_token_claims.table.default_value",
"Default Value", "Default Value",
)} )}
</th> </SettingsTableHead>
<th className="px-4 py-3 text-right font-bold"> <SettingsTableHead className="w-[56px] text-center">
{t( {t(
"ui.dev.clients.general.id_token_claims.table.delete", "ui.dev.clients.general.id_token_claims.table.delete",
"Delete", "Delete",
)} )}
</th> </SettingsTableHead>
</tr> </tr>
</thead> </SettingsTableHeader>
<tbody className="divide-y divide-border"> <SettingsTableBody>
{idTokenClaims.map((claim) => { {idTokenClaims.length > 0 ? (
idTokenClaims.map((claim) => {
const defaultValueError = const defaultValueError =
claimDefaultValueValidationError(claim); claimDefaultValueValidationError(claim);
return ( return (
<tr key={claim.id} className="hover:bg-muted/20"> <SettingsTableRow
<td className="px-4 py-3 align-top"> key={claim.id}
className="hover:bg-muted/20"
>
<SettingsTableCell>
<Input <Input
value={claim.key} value={claim.key}
onChange={(e) => onChange={(e) =>
@@ -2711,26 +2717,26 @@ function ClientGeneralPage() {
e.target.value, e.target.value,
) )
} }
className="h-9 font-mono text-xs" className="h-8 font-mono text-xs"
placeholder={t( placeholder={t(
"ui.dev.clients.general.id_token_claims.key_placeholder", "ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale", "e.g. locale",
)} )}
disabled={isGeneralSettingsReadOnly} disabled={isGeneralSettingsReadOnly}
/> />
</td> </SettingsTableCell>
<td className="px-4 py-3 align-top"> <SettingsTableCell>
<Badge <Badge
variant="muted" variant="muted"
className="h-9 rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs" className="h-8 rounded-md border bg-muted/40 px-3 py-1.5 font-mono text-xs"
> >
{t( {t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims", "ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims", "rp_claims",
)} )}
</Badge> </Badge>
</td> </SettingsTableCell>
<td className="px-4 py-3 align-top"> <SettingsTableCell>
<select <select
value={claim.valueType} value={claim.valueType}
onChange={(e) => onChange={(e) =>
@@ -2744,7 +2750,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.id_token_claims.value_type_label", "ui.dev.clients.general.id_token_claims.value_type_label",
"Claim 값 타입", "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" className="h-8 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} disabled={isGeneralSettingsReadOnly}
> >
<option value="text"> <option value="text">
@@ -2796,9 +2802,9 @@ function ClientGeneralPage() {
)} )}
</option> </option>
</select> </select>
</td> </SettingsTableCell>
<td className="px-4 py-3 align-top"> <SettingsTableCell className="text-center">
<div className="flex h-9 items-center"> <div className="flex h-8 items-center justify-center">
<Switch <Switch
checked={claim.nullable} checked={claim.nullable}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
@@ -2815,9 +2821,9 @@ function ClientGeneralPage() {
disabled={isGeneralSettingsReadOnly} disabled={isGeneralSettingsReadOnly}
/> />
</div> </div>
</td> </SettingsTableCell>
<td className="px-4 py-3 align-top"> <SettingsTableCell className="text-center">
<div className="flex h-9 items-center"> <div className="flex h-8 items-center justify-center">
<Switch <Switch
checked={ checked={
claim.readPermission === "user_and_admin" claim.readPermission === "user_and_admin"
@@ -2836,9 +2842,9 @@ function ClientGeneralPage() {
disabled={isGeneralSettingsReadOnly} disabled={isGeneralSettingsReadOnly}
/> />
</div> </div>
</td> </SettingsTableCell>
<td className="px-4 py-3 align-top"> <SettingsTableCell className="text-center">
<div className="flex h-9 items-center"> <div className="flex h-8 items-center justify-center">
<Switch <Switch
checked={ checked={
claim.writePermission === "user_and_admin" claim.writePermission === "user_and_admin"
@@ -2857,8 +2863,8 @@ function ClientGeneralPage() {
disabled={isGeneralSettingsReadOnly} disabled={isGeneralSettingsReadOnly}
/> />
</div> </div>
</td> </SettingsTableCell>
<td className="px-4 py-3 align-top"> <SettingsTableCell>
{claim.valueType === "array" || {claim.valueType === "array" ||
claim.valueType === "object" ? ( claim.valueType === "object" ? (
<Textarea <Textarea
@@ -2890,7 +2896,7 @@ function ClientGeneralPage() {
e.target.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" className="h-8 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} disabled={isGeneralSettingsReadOnly}
> >
<option value="true">true</option> <option value="true">true</option>
@@ -2900,7 +2906,9 @@ function ClientGeneralPage() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Input <Input
key={claim.valueType} key={claim.valueType}
type={claimDefaultInputType(claim.valueType)} type={claimDefaultInputType(
claim.valueType,
)}
inputMode={claimDefaultInputMode( inputMode={claimDefaultInputMode(
claim.valueType, claim.valueType,
)} )}
@@ -2915,7 +2923,7 @@ function ClientGeneralPage() {
e.target.value, e.target.value,
) )
} }
className="h-9 font-mono text-xs" className="h-8 font-mono text-xs"
placeholder={t( placeholder={t(
"ui.dev.clients.general.id_token_claims.value_placeholder", "ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the default value", "Enter the default value",
@@ -2936,7 +2944,7 @@ function ClientGeneralPage() {
event.target.value, 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" className="h-8 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} disabled={isGeneralSettingsReadOnly}
aria-label={t( aria-label={t(
"ui.dev.clients.general.id_token_claims.timezone_label", "ui.dev.clients.general.id_token_claims.timezone_label",
@@ -2957,41 +2965,36 @@ function ClientGeneralPage() {
{defaultValueError} {defaultValueError}
</p> </p>
)} )}
</td> </SettingsTableCell>
<td className="px-4 py-3 text-right align-top"> <SettingsTableCell className="w-[56px] text-center align-top">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => removeIdTokenClaim(claim.id)} onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive" className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={isGeneralSettingsReadOnly} disabled={isGeneralSettingsReadOnly}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</td> </SettingsTableCell>
</tr> </SettingsTableRow>
); );
})} })
{idTokenClaims.length === 0 && ( ) : (
<tr> <SettingsTableEmptyState colSpan={8}>
<td
colSpan={7}
className="px-4 py-8 text-center text-muted-foreground"
>
{t( {t(
"msg.dev.clients.general.id_token_claims.empty", "msg.dev.clients.general.id_token_claims.empty",
"아직 추가된 ID Token claim이 없습니다.", "아직 추가된 ID Token claim이 없습니다.",
)} )}
</td> </SettingsTableEmptyState>
</tr>
)} )}
</tbody> </SettingsTableBody>
</table> </SettingsTable>
</div> </SettingsTableShell>
<p className="text-xs leading-6 text-muted-foreground"> <p className="text-xs leading-6 text-muted-foreground">
{t( {t(
"msg.dev.clients.general.id_token_claims.hint", "msg.dev.clients.general.id_token_claims.hint",
"RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.", "사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
)} )}
</p> </p>
</div> </div>
@@ -3010,7 +3013,7 @@ function ClientGeneralPage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
"msg.dev.clients.general.id_token_claims.preview_hint", "msg.dev.clients.general.id_token_claims.preview_hint",
"저장될 metadata.id_token_claims조를 미리 확인할 수 있습니다.", "설정 저장 시 반영될 claim 구성을 미리 수 있습니다.",
)} )}
</p> </p>
</div> </div>

View File

@@ -0,0 +1,130 @@
import type * as React from "react";
import { cn } from "../../../lib/utils";
interface SettingsTableShellProps {
className?: string;
bodyClassName?: string;
children: React.ReactNode;
}
function SettingsTableShell({
className,
bodyClassName,
children,
}: SettingsTableShellProps) {
return (
<div
className={cn(
"overflow-hidden rounded-md border border-border bg-background",
className,
)}
>
<div className={cn("overflow-auto", bodyClassName)}>{children}</div>
</div>
);
}
function SettingsTable({
className,
...props
}: React.TableHTMLAttributes<HTMLTableElement>) {
return <table className={cn("w-full text-sm", className)} {...props} />;
}
function SettingsTableHeader({
className,
...props
}: React.HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead
className={cn(
"bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground",
className,
)}
{...props}
/>
);
}
function SettingsTableBody({
className,
...props
}: React.HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={cn("divide-y divide-border", className)} {...props} />
);
}
function SettingsTableRow({
className,
...props
}: React.HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn(
"border-b transition-colors hover:bg-muted/20 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function SettingsTableHead({
className,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
"h-12 px-4 text-left text-xs font-bold uppercase tracking-wider text-black align-middle dark:text-foreground",
className,
)}
{...props}
/>
);
}
function SettingsTableCell({
className,
...props
}: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn("px-4 py-3 align-top", className)} {...props} />;
}
interface SettingsTableEmptyStateProps {
colSpan: number;
children: React.ReactNode;
className?: string;
}
function SettingsTableEmptyState({
colSpan,
children,
className,
}: SettingsTableEmptyStateProps) {
return (
<tr>
<td
colSpan={colSpan}
className={cn(
"px-4 py-8 text-center text-sm text-muted-foreground",
className,
)}
>
{children}
</td>
</tr>
);
}
export {
SettingsTable,
SettingsTableBody,
SettingsTableCell,
SettingsTableEmptyState,
SettingsTableHead,
SettingsTableHeader,
SettingsTableRow,
SettingsTableShell,
};

View File

@@ -6,6 +6,32 @@ afterEach(() => {
}); });
describe("i18n", () => { describe("i18n", () => {
it("returns Korean copy for dotted developer claim headers", () => {
window.localStorage.setItem("locale", "ko");
expect(
t("ui.dev.clients.general.id_token_claims.table.key", "Claim Key"),
).toBe("클레임 키");
expect(
t(
"ui.dev.clients.general.id_token_claims.table.value_type",
"Value Type",
),
).toBe("값 유형");
expect(
t(
"msg.dev.clients.general.id_token_claims.hint",
"RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
),
).toBe("사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.");
expect(
t(
"msg.dev.clients.general.id_token_claims.preview_hint",
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
),
).toBe("설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.");
});
it("returns English copy for the developer request and grants screens", () => { it("returns English copy for the developer request and grants screens", () => {
window.localStorage.setItem("locale", "en"); window.localStorage.setItem("locale", "en");
@@ -32,5 +58,27 @@ describe("i18n", () => {
"현재 부여된 개발자 권한 목록입니다.", "현재 부여된 개발자 권한 목록입니다.",
), ),
).toBe("Current developer access grants."); ).toBe("Current developer access grants.");
expect(
t(
"msg.dev.clients.general.id_token_claims.subtitle",
"RP 전용 확장 claim을 구분해서 관리합니다.",
),
).toBe(
"User-specific claim values are edited in the Consent and Claims tabs.",
);
expect(
t(
"msg.dev.clients.general.id_token_claims.hint",
"사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
),
).toBe(
"User-specific claim values are edited in the Consent and Claims tabs.",
);
expect(
t(
"msg.dev.clients.general.id_token_claims.preview_hint",
"설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.",
),
).toBe("Preview the claim set that will be saved with these settings.");
}); });
}); });

View File

@@ -1674,6 +1674,11 @@ value_type_object = "Object"
key_placeholder = "e.g. locale" key_placeholder = "e.g. locale"
value_placeholder = "Enter the default value" value_placeholder = "Enter the default value"
[msg.dev.clients.general.id_token_claims]
subtitle = "User-specific claim values are edited in the Consent and Claims tabs."
hint = "User-specific claim values are edited in the Consent and Claims tabs."
preview_hint = "Preview the claim set that will be saved with these settings."
[ui.dev.clients.general.security] [ui.dev.clients.general.security]
private = "Server Side App" private = "Server Side App"
pkce = "PKCE" pkce = "PKCE"

View File

@@ -463,8 +463,8 @@ offline_access_condition_grant_type = "client grant_types에 refresh_token 포
[msg.dev.clients.general.id_token_claims] [msg.dev.clients.general.id_token_claims]
subtitle = "RP 전용 확장 claim을 구분해서 관리합니다." subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다." empty = "아직 추가된 ID Token claim이 없습니다."
hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다." hint = "사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "저장될 metadata.id_token_claims조를 미리 확인할 수 있습니다." preview_hint = "설정 저장 시 반영될 claim 구성을 미리 수 있습니다."
key_required = "Claim key를 입력해야 합니다." key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다." reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}" duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
@@ -1655,10 +1655,10 @@ namespace_rp_claims = "rp_claims"
nullable_label = "Nullable" nullable_label = "Nullable"
read_user_allowed_label = "사용자 읽기 허용" read_user_allowed_label = "사용자 읽기 허용"
write_user_allowed_label = "사용자 쓰기 허용" write_user_allowed_label = "사용자 쓰기 허용"
table.key = "Claim Key" table.key = "클레임 키"
table.namespace = "Namespace" table.namespace = "네임스페이스"
table.value_type = "Value Type" table.value_type = "값 유형"
table.nullable = "Nullable" table.nullable = "Null 허용"
table.read_user_allowed = "사용자 읽기" table.read_user_allowed = "사용자 읽기"
table.write_user_allowed = "사용자 쓰기" table.write_user_allowed = "사용자 쓰기"
table.default_value = "기본값" table.default_value = "기본값"

View File

@@ -1723,6 +1723,11 @@ value_type_object = ""
key_placeholder = "" key_placeholder = ""
value_placeholder = "" value_placeholder = ""
[msg.dev.clients.general.id_token_claims]
subtitle = ""
hint = ""
preview_hint = ""
[ui.dev.clients.general.security] [ui.dev.clients.general.security]
private = "" private = ""
pkce = "" pkce = ""