forked from baron/baron-sso
devfront RP 설정 표 공통화 및 레이아웃 정리
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
130
devfront/src/features/clients/components/SettingsTable.tsx
Normal file
130
devfront/src/features/clients/components/SettingsTable.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = "기본값"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user