1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/clients/ClientGeneralPage.tsx

3688 lines
144 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ExternalLink,
Info,
Plus,
Save,
Shield,
ShieldHalf,
Sparkles,
Trash2,
Upload,
X,
} from "lucide-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";
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { CopyButton } from "../../components/ui/copy-button";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Switch } from "../../components/ui/switch";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import type {
ClientStatus,
ClientType,
ClientUpsertRequest,
TenantSummary,
} from "../../lib/devApi";
import {
type ClientRelation,
createClient,
deleteClient,
fetchClient,
fetchClientRelations,
fetchTenants,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
updateClient,
updateClientStatus,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
import { fetchMe, type UserProfile } from "../auth/authApi";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import { ClientDetailTabs } from "./ClientDetailTabs";
import {
SettingsTable,
SettingsTableBody,
SettingsTableCell,
SettingsTableEmptyState,
SettingsTableHead,
SettingsTableHeader,
SettingsTableRow,
SettingsTableShell,
} from "./components/SettingsTable";
import { TenantAccessPicker } from "./components/TenantAccessPicker";
import {
claimDateTimeValueToInputString,
dateTimeInputToUnixSeconds,
getBrowserTimeZone,
getSupportedTimeZones,
} from "./rpClaimDateTime";
interface ScopeItem {
id: string;
name: string;
description: string;
mandatory: boolean;
locked?: boolean;
}
interface ScopeCandidate {
id: string;
name: string;
description: string;
source: "standard" | "custom_claim" | "manual";
}
type ClaimNamespace = "rp_claims";
type ClaimValueType =
| "text"
| "number"
| "float"
| "boolean"
| "array"
| "object"
| "date"
| "datetime";
type CustomClaimPermission = "admin_only" | "user_and_admin";
interface IdTokenClaimItem {
id: string;
namespace: ClaimNamespace;
key: string;
value: string;
timeZone: string;
valueType: ClaimValueType;
nullable: boolean;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
}
type SecurityProfile = "private" | "pkce";
type TokenEndpointAuthMethod =
| "none"
| "client_secret_basic"
| "private_key_jwt";
const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"EdDSA",
] as const;
const HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET = new Set<string>(
HEADLESS_LOGIN_ALLOWED_ALGORITHMS,
);
function formatHeadlessParsedKeyLabel(
kid: string | undefined,
index: number,
): string {
const trimmedKid = kid?.trim();
if (trimmedKid) {
return trimmedKid;
}
return `key #${index + 1}`;
}
function isTokenEndpointAuthMethod(
value: string,
): value is TokenEndpointAuthMethod {
return (
value === "none" ||
value === "client_secret_basic" ||
value === "private_key_jwt"
);
}
function readMetadataString(
metadata: Record<string, unknown>,
key: string,
): string {
const value = metadata[key];
return typeof value === "string" ? value : "";
}
function isClaimNamespace(value: string): value is ClaimNamespace {
return value === "rp_claims";
}
function isClaimValueType(value: string): value is ClaimValueType {
return (
value === "text" ||
value === "number" ||
value === "float" ||
value === "boolean" ||
value === "array" ||
value === "object" ||
value === "date" ||
value === "datetime"
);
}
function isCustomClaimPermission(
value: unknown,
): value is CustomClaimPermission {
return value === "admin_only" || value === "user_and_admin";
}
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
return {
id,
namespace: "rp_claims",
key: "",
value: "",
timeZone: getBrowserTimeZone(),
valueType: "text",
nullable: false,
readPermission: "admin_only",
writePermission: "admin_only",
};
}
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[] {
const rawClaims = metadata.id_token_claims;
if (!Array.isArray(rawClaims)) {
return [];
}
return rawClaims
.map((item, index) => {
if (!item || typeof item !== "object") {
return null;
}
const record = item as Record<string, unknown>;
const namespaceValue =
typeof record.namespace === "string" &&
isClaimNamespace(record.namespace)
? record.namespace
: null;
if (namespaceValue === null) {
return null;
}
const keyValue = typeof record.key === "string" ? record.key : "";
const rawValue = record.value;
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)
? record.readPermission
: "admin_only",
writePermission: isCustomClaimPermission(record.writePermission)
? record.writePermission
: "admin_only",
});
})
.filter((item): item is IdTokenClaimItem => item !== null);
}
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);
return Number.isFinite(parsed) ? parsed : trimmed;
}
if (valueType === "boolean") {
return ["true", "1", "yes", "on"].includes(trimmed.toLowerCase());
}
if (valueType === "array") {
if (trimmed === "") return [];
try {
if (trimmed.startsWith("[")) {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed : [parsed];
}
} catch {
// Fall through to comma-separated parsing.
}
return trimmed
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
if (valueType === "object") {
if (trimmed === "") return {};
try {
const parsed = JSON.parse(trimmed);
return parsed;
} catch {
return trimmed;
}
}
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 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 === "") {
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> {
const preview: Record<string, unknown> = {};
const rpClaims: Record<string, unknown> = {};
for (const item of items) {
const key = item.key.trim();
if (!key) {
continue;
}
rpClaims[key] = normalizeClaimPreviewValue(
item.value,
item.valueType,
item.nullable,
item.timeZone,
);
}
if (Object.keys(rpClaims).length > 0) {
preview.rp_claims = rpClaims;
}
return preview;
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
function isValidBackchannelLogoutUrl(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {
return true;
}
try {
const url = new URL(trimmed);
if (url.hash) {
return false;
}
if (url.protocol === "https:") {
return true;
}
if (url.protocol !== "http:") {
return false;
}
const host = url.hostname.toLowerCase();
if (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "host.docker.internal"
) {
return true;
}
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
return (
host.startsWith("10.") ||
host.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
host.startsWith("169.254.")
);
}
// Docker service names and other single-label local hosts are allowed
// only for HTTP local development use.
return !host.includes(".");
} catch {
return false;
}
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function ClientGeneralPage() {
const auth = useAuth();
const params = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const hasAccessToken = Boolean(auth.user?.access_token);
const clientId = params.id;
const isCreate = !clientId;
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const systemRole = resolveProfileRole(userProfile);
const { data: me, isLoading: isLoadingMe } = useQuery<UserProfile>({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const currentUserId = me?.id ?? auth.user?.profile.sub;
const effectiveSystemRole = me?.role?.trim() || systemRole;
const {
hasDeveloperAccess: hasClientCreateAccess,
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole: effectiveSystemRole,
tenantId: userProfile?.tenant_id as string | undefined,
requiredPages: ["client_create"],
isLoadingIdentity: isLoadingMe,
});
const { data, isLoading, error } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId as string),
enabled: !isCreate,
});
const { data: relationData } = useQuery({
queryKey: ["client-relations", clientId],
queryFn: () => fetchClientRelations(clientId as string),
enabled: !isCreate,
retry: false,
});
const { data: tenantData } = useQuery({
queryKey: ["tenants", "all"],
queryFn: () => fetchTenants(),
});
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [logoPreviewStatus, setLogoPreviewStatus] = useState<
"idle" | "loading" | "loaded" | "error"
>("idle");
const [clientType, setClientType] = useState<ClientType>("private");
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
const [backchannelLogoutUri, setBackchannelLogoutUri] = useState("");
const [
backchannelLogoutSessionRequired,
setBackchannelLogoutSessionRequired,
] = useState(false);
const [
isBackchannelSessionRequiredInfoOpen,
setIsBackchannelSessionRequiredInfoOpen,
] = useState(false);
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
const [autoLoginUrl, setAutoLoginUrl] = useState("");
const [idTokenClaimsEnabled, setIdTokenClaimsEnabled] = useState(false);
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
useState<TokenEndpointAuthMethod>("client_secret_basic");
const [jwksUri, setJwksUri] = useState("");
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
const [isScopePickerOpen, setIsScopePickerOpen] = useState(false);
const [isOfflineAccessGuideOpen, setIsOfflineAccessGuideOpen] =
useState(false);
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
{
id: "1",
name: "openid",
description: t(
"msg.dev.clients.scopes.openid",
"OIDC 로그인에 필요한 기본 scope",
),
mandatory: true,
},
{
id: "2",
name: "tenants",
description: t(
"msg.dev.clients.scopes.tenants",
"tenant_id, joined_tenants, tenants 상세 및 root/부모 테넌트 접근",
),
mandatory: false,
},
{
id: "3",
name: "profile",
description: t(
"msg.dev.clients.scopes.profile",
"사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근",
),
mandatory: false,
},
{
id: "4",
name: "email",
description: t(
"msg.dev.clients.scopes.email",
"top-level email과 profile.email",
),
mandatory: false,
},
]);
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
const browserTimeZone = useMemo(() => getBrowserTimeZone(), []);
const timeZoneOptions = useMemo(
() => getSupportedTimeZones(browserTimeZone),
[browserTimeZone],
);
const tenantScopeDescription = t(
"msg.dev.clients.scopes.tenants",
"소속 테넌트 정보 접근",
);
const buildTenantScope = useCallback(
(id: string): ScopeItem => ({
id,
name: "tenants",
description: tenantScopeDescription,
mandatory: true,
locked: true,
}),
[tenantScopeDescription],
);
const normalizeScopesForTenantAccess = useCallback(
(nextScopes: ScopeItem[], restricted: boolean): ScopeItem[] => {
const normalized = nextScopes.map((scope) => {
const scopeName = scope.name.trim();
if (scopeName !== "tenants" && scopeName !== "tenant") {
return scope;
}
const canonicalName = "tenants";
if (restricted) {
return {
...scope,
name: canonicalName,
description: scope.description || tenantScopeDescription,
mandatory: true,
locked: true,
};
}
return {
...scope,
name: canonicalName,
description: scope.description || tenantScopeDescription,
locked: false,
};
});
if (
restricted &&
!normalized.some(
(scope) =>
scope.name.trim() === "tenants" || scope.name.trim() === "tenant",
)
) {
normalized.push(buildTenantScope(`tenants-${Date.now()}`));
}
const openidScopes = normalized.filter(
(scope) => scope.name.trim() === "openid",
);
const tenantScopes = normalized.filter(
(scope) =>
scope.name.trim() === "tenants" || scope.name.trim() === "tenant",
);
const remainingScopes = normalized.filter((scope) => {
const name = scope.name.trim();
return name !== "openid" && name !== "tenants" && name !== "tenant";
});
return [...openidScopes, ...tenantScopes, ...remainingScopes];
},
[buildTenantScope, tenantScopeDescription],
);
const supportedScopeCandidates = useMemo<ScopeCandidate[]>(
() => [
{
id: "standard-openid",
name: "openid",
description: t(
"msg.dev.clients.scopes.openid",
"OIDC 로그인에 필요한 기본 scope",
),
source: "standard",
},
{
id: "standard-profile",
name: "profile",
description: t(
"msg.dev.clients.scopes.profile",
"사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근",
),
source: "standard",
},
{
id: "standard-email",
name: "email",
description: t(
"msg.dev.clients.scopes.email",
"top-level email과 profile.email",
),
source: "standard",
},
{
id: "standard-tenant",
name: "tenants",
description: tenantScopeDescription,
source: "standard",
},
{
id: "standard-offline-access",
name: "offline_access",
description: t(
"msg.dev.clients.scopes.offline_access",
"refresh token 발급 요청",
),
source: "standard",
},
],
[tenantScopeDescription],
);
const customClaimScopeCandidates = useMemo<ScopeCandidate[]>(() => {
const seen = new Set<string>();
const candidates: ScopeCandidate[] = [];
for (const claim of idTokenClaims) {
const name = claim.key.trim();
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
candidates.push({
id: `custom-claim-${name}`,
name,
description: t(
"msg.dev.clients.scopes.custom_claim",
"Custom Claim 요청 scope",
),
source: "custom_claim",
});
}
return candidates;
}, [idTokenClaims]);
const scopeCandidates = useMemo<ScopeCandidate[]>(
() => [
...supportedScopeCandidates,
...customClaimScopeCandidates,
{
id: "manual-scope",
name: "",
description: t(
"msg.dev.clients.scopes.manual",
"목록에 없는 scope를 직접 입력합니다.",
),
source: "manual",
},
],
[customClaimScopeCandidates, supportedScopeCandidates],
);
const existingScopeNames = useMemo(() => {
const names = new Set<string>();
for (const scope of scopes) {
const name = scope.name.trim();
if (name) {
names.add(name);
}
}
return names;
}, [scopes]);
useEffect(() => {
if (!data) return;
const { client } = data;
const metadata = client.metadata ?? {};
const headlessEnabled = !!metadata.headless_login_enabled;
setName(client.name || client.id);
setClientType(headlessEnabled ? "private" : client.type);
setStatus(client.status);
setInitialStatus(client.status);
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
setBackchannelLogoutUri(
client.backchannelLogoutUri ||
readMetadataString(metadata, "backchannel_logout_uri"),
);
setBackchannelLogoutSessionRequired(
client.backchannelLogoutSessionRequired === true ||
metadata.backchannel_logout_session_required === true,
);
setAutoLoginSupported(metadata.auto_login_supported === true);
if (typeof metadata.auto_login_url === "string")
setAutoLoginUrl(metadata.auto_login_url);
setHeadlessLoginEnabled(headlessEnabled);
const restrictedTenants = Array.isArray(metadata.allowed_tenants)
? metadata.allowed_tenants
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
setTenantAccessRestricted(
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
);
setAllowedTenantIds(restrictedTenants);
const savedAuthMethod =
client.tokenEndpointAuthMethod ||
(client.type === "pkce" ? "none" : "client_secret_basic");
const headlessAuthMethod = readMetadataString(
metadata,
"headless_token_endpoint_auth_method",
);
const selectedAuthMethod =
headlessEnabled && isTokenEndpointAuthMethod(headlessAuthMethod)
? headlessAuthMethod
: savedAuthMethod;
if (isTokenEndpointAuthMethod(selectedAuthMethod)) {
setTokenEndpointAuthMethod(selectedAuthMethod);
}
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
if (headlessJwksUri) {
setJwksUri(headlessJwksUri);
} else if (client.jwksUri) {
setJwksUri(client.jwksUri);
} else {
setJwksUri("");
}
// Fallbacks from metadata if top-level fields are empty
if (!client.tokenEndpointAuthMethod && !headlessEnabled) {
const metaAuth = readMetadataString(
metadata,
"token_endpoint_auth_method",
);
if (isTokenEndpointAuthMethod(metaAuth)) {
setTokenEndpointAuthMethod(metaAuth);
}
}
if (!client.jwksUri && !headlessEnabled) {
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
if (metaJwksUri) {
setJwksUri(metaJwksUri);
}
}
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
if (savedScopes && Array.isArray(savedScopes)) {
setScopes(
normalizeScopesForTenantAccess(
savedScopes,
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
),
);
} else {
setScopes(
normalizeScopesForTenantAccess(
client.scopes.map((s, idx) => ({
id: String(idx + 1),
name: s,
description: "",
mandatory: s === "openid",
})),
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
),
);
}
const savedIdTokenClaims = readIdTokenClaimsMetadata(metadata);
setIdTokenClaims(savedIdTokenClaims);
setIdTokenClaimsEnabled(
typeof metadata.id_token_claims_enabled === "boolean"
? metadata.id_token_claims_enabled
: savedIdTokenClaims.length > 0,
);
}, [data, normalizeScopesForTenantAccess]);
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const canEditExistingClientGeneralSettings =
effectiveSystemRole === "super_admin" ||
relationData?.items?.some(
(item: ClientRelation) =>
item.subject === `User:${currentUserId}` &&
(item.relation === "admins" || item.relation === "config_editor"),
) === true;
const isGeneralSettingsReadOnly =
!isCreate && relationData != null && !canEditExistingClientGeneralSettings;
const trimmedLogoUrl = logoUrl.trim();
const trimmedAutoLoginUrl = autoLoginUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
const trimmedBackchannelLogoutUri = backchannelLogoutUri.trim();
const hasBackchannelLogoutUri = trimmedBackchannelLogoutUri.length > 0;
const hasValidBackchannelLogoutUri =
!hasBackchannelLogoutUri ||
isValidBackchannelLogoutUrl(trimmedBackchannelLogoutUri);
const hasValidAutoLoginUrl =
!autoLoginSupported ||
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
useEffect(() => {
if (!hasLogoUrl) {
setLogoPreviewStatus("idle");
return;
}
if (!hasValidLogoUrl) {
setLogoPreviewStatus("error");
return;
}
setLogoPreviewStatus("loading");
}, [hasLogoUrl, hasValidLogoUrl]);
const handleSecurityProfileChange = (profile: SecurityProfile) => {
setClientType(profile);
if (profile === "pkce") {
setHeadlessLoginEnabled(false);
setTokenEndpointAuthMethod("none");
} else {
setTokenEndpointAuthMethod(
headlessLoginEnabled ? "private_key_jwt" : "client_secret_basic",
);
}
};
const handleHeadlessToggle = (enabled: boolean) => {
setHeadlessLoginEnabled(enabled);
if (enabled) {
setClientType("private");
setTokenEndpointAuthMethod("private_key_jwt");
return;
}
if (clientType === "private") {
setTokenEndpointAuthMethod("client_secret_basic");
}
};
const handleTenantAccessToggle = (enabled: boolean) => {
setTenantAccessRestricted(enabled);
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
};
const toggleAllowedTenant = (tenantId: string) => {
setAllowedTenantIds((current) =>
current.includes(tenantId)
? current.filter((id) => id !== tenantId)
: [...current, tenantId],
);
};
const handleSelectAllowedTenant = (tenantId: string) => {
setAllowedTenantIds((current) =>
current.includes(tenantId) ? current : [...current, tenantId],
);
};
const addScope = () => {
setIsScopePickerOpen((current) => !current);
};
const selectScopeCandidate = (candidate: ScopeCandidate) => {
const name = candidate.name.trim();
if (name && existingScopeNames.has(name)) {
setIsScopePickerOpen(false);
return;
}
const newScope: ScopeItem = {
id: `scope-${candidate.source}-${name || "manual"}-${Date.now()}`,
name,
description: candidate.source === "manual" ? "" : candidate.description,
mandatory: false,
};
setScopes((current) =>
normalizeScopesForTenantAccess(
[...current, newScope],
tenantAccessRestricted,
),
);
setIsScopePickerOpen(false);
};
const updateScope = <K extends keyof ScopeItem>(
id: string,
field: K,
value: ScopeItem[K],
) => {
setScopes((current) =>
current.map((scope) => {
if (scope.id !== id) {
return scope;
}
if (scope.locked) {
return scope;
}
return { ...scope, [field]: value };
}),
);
};
const removeScope = (id: string) => {
setScopes((current) =>
current.filter((scope) => scope.id !== id || scope.locked === true),
);
};
const addIdTokenClaim = () => {
setIdTokenClaimsEnabled(true);
setIdTokenClaims((current) => [
...current,
createIdTokenClaimItem(`claim-${Date.now()}`),
]);
};
const updateIdTokenClaim = <K extends keyof IdTokenClaimItem>(
id: string,
field: K,
value: IdTokenClaimItem[K],
) => {
setIdTokenClaims((current) =>
current.map((claim) => {
if (claim.id !== id) {
return claim;
}
return { ...claim, [field]: value };
}),
);
};
const setIdTokenClaimPermissionAllowed = (
id: string,
field: "readPermission" | "writePermission",
allowed: boolean,
) => {
const permission = allowed ? "user_and_admin" : "admin_only";
setIdTokenClaims((current) =>
current.map((claim) => {
if (claim.id !== id) {
return claim;
}
return normalizeIdTokenClaimPermissions({
...claim,
[field]: permission,
});
}),
);
};
const removeIdTokenClaim = (id: string) => {
setIdTokenClaims((current) => current.filter((claim) => claim.id !== id));
};
const handleStatusChange = (nextStatus: ClientStatus) => {
setStatus(nextStatus);
const statusLabel =
nextStatus === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive");
toast(
t(
"msg.dev.clients.general.status_changed",
"상태가 {{status}}로 변경되었습니다.",
{ status: statusLabel },
),
);
};
const validationErrors: string[] = [];
const trimmedJwksUri = jwksUri.trim();
const currentHeadlessJwksCache = data?.headlessJwksCache;
const parsedKeysForCurrentJwksUri =
headlessLoginEnabled &&
trimmedJwksUri !== "" &&
currentHeadlessJwksCache?.jwksUri === trimmedJwksUri
? (currentHeadlessJwksCache.parsedKeys ?? [])
: [];
const unsupportedParsedAlgorithms = parsedKeysForCurrentJwksUri
.map((key, index) => ({
alg: key.alg?.trim() ?? "",
label: formatHeadlessParsedKeyLabel(key.kid, index),
}))
.filter(
(entry) =>
entry.alg !== "" &&
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(entry.alg),
);
const missingParsedAlgorithms = parsedKeysForCurrentJwksUri
.map((key, index) => ({
alg: key.alg?.trim() ?? "",
label: formatHeadlessParsedKeyLabel(key.kid, index),
}))
.filter((entry) => entry.alg === "");
const unsupportedParsedAlgorithmSummary = unsupportedParsedAlgorithms
.map((entry) => `${entry.label}: ${entry.alg}`)
.join(", ");
const missingParsedAlgorithmSummary = missingParsedAlgorithms
.map((entry) => entry.label)
.join(", ");
const allowedHeadlessAlgorithmsTooltip = t(
"msg.dev.clients.general.public_key.allowed_algorithms_tooltip",
"허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
);
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) {
validationErrors.push(
t(
"ui.dev.clients.general.public_key.validation.missing_jwks_uri",
"JWKS URI를 입력해야 합니다.",
),
);
} else if (!isValidUrl(trimmedJwksUri)) {
validationErrors.push(
t(
"ui.dev.clients.general.public_key.validation.invalid_jwks_uri",
"JWKS URI 형식이 올바르지 않습니다.",
),
);
}
if (unsupportedParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"ui.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
),
);
}
if (missingParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"ui.dev.clients.general.public_key.validation.missing_parsed_algorithms",
"JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}",
{ details: missingParsedAlgorithmSummary },
),
);
}
}
if (tenantAccessRestricted && allowedTenantIds.length === 0) {
validationErrors.push(
t(
"ui.dev.clients.general.tenant_access.validation_required",
"테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.",
),
);
}
if (autoLoginSupported && !hasValidAutoLoginUrl) {
validationErrors.push(
t(
"msg.dev.clients.general.auto_login.invalid_url",
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
),
);
}
const claimValidationErrors: string[] = [];
if (idTokenClaimsEnabled) {
const seenClaimKeys = new Set<string>();
for (const claim of normalizedIdTokenClaimItems) {
if (!claim.key) {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.key_required",
"Claim key를 입력해야 합니다.",
),
);
continue;
}
const keySignature = `${claim.namespace}:${claim.key}`;
if (seenClaimKeys.has(keySignature)) {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.duplicate_key",
"중복된 claim key가 있습니다: {{namespace}}.{{key}}",
{
namespace: t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
),
key: claim.key,
},
),
);
continue;
}
seenClaimKeys.add(keySignature);
const defaultValueError = claimDefaultValueValidationError(claim);
if (defaultValueError) {
claimValidationErrors.push(defaultValueError);
}
}
}
validationErrors.push(...claimValidationErrors);
const hasValidationErrors = validationErrors.length > 0;
const idTokenClaimPreview = idTokenClaimsEnabled
? buildIdTokenClaimsPreview(normalizedIdTokenClaimItems)
: [];
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
const selectedAllowedTenants = allowedTenantIds
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
.filter((tenant): tenant is TenantSummary => tenant != null);
const refreshHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
if (!clientId) throw new Error("Missing client id");
return refreshHeadlessJwksCache(clientId);
},
onSuccess: (result) => {
if (clientId) {
queryClient.setQueryData(["client", clientId], result);
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
}
toast(
t(
"msg.dev.clients.general.public_key.cache_refreshed",
"JWKS 캐시를 새로 고쳤습니다.",
),
);
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
toast(
t(
"msg.dev.clients.general.public_key.cache_refresh_failed",
"JWKS 캐시 새로고침에 실패했습니다: {{error}}",
{ error: errorMessage },
),
);
},
});
const revokeHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
if (!clientId) throw new Error("Missing client id");
return revokeHeadlessJwksCache(clientId);
},
onSuccess: () => {
if (clientId) {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
}
toast(
t(
"msg.dev.clients.general.public_key.cache_revoked",
"JWKS 캐시를 삭제했습니다.",
),
);
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
toast(
t(
"msg.dev.clients.general.public_key.cache_revoke_failed",
"JWKS 캐시 삭제에 실패했습니다: {{error}}",
{ error: errorMessage },
),
);
},
});
const mutation = useMutation({
mutationFn: async () => {
if (hasLogoUrl && !hasValidLogoUrl) {
throw new Error(
t(
"msg.dev.clients.general.identity.logo_invalid",
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
),
);
}
if (hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri) {
throw new Error(
t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.",
),
);
}
if (isGeneralSettingsReadOnly) {
throw new Error(
t(
"msg.dev.clients.general.read_only_forbidden",
"이 RP의 일반 설정을 수정할 권한이 없습니다.",
),
);
}
if (autoLoginSupported && !hasValidAutoLoginUrl) {
throw new Error(
t(
"msg.dev.clients.general.auto_login.invalid_url",
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
),
);
}
const normalizedScopes = normalizeScopesForTenantAccess(
scopes,
tenantAccessRestricted,
);
const normalizedAllowedTenantIds = Array.from(
new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)),
);
const scopeNames = normalizedScopes
.map((scope) => scope.name.trim())
.filter(Boolean);
const persistedClientType = headlessLoginEnabled ? "private" : clientType;
const effectiveTokenEndpointAuthMethod = tokenEndpointAuthMethod;
const payload: ClientUpsertRequest = {
name,
type: persistedClientType,
scopes: scopeNames,
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri:
(headlessLoginEnabled ||
effectiveTokenEndpointAuthMethod === "private_key_jwt") &&
trimmedJwksUri
? trimmedJwksUri
: undefined,
backchannelLogoutUri: trimmedBackchannelLogoutUri || undefined,
backchannelLogoutSessionRequired:
trimmedBackchannelLogoutUri !== ""
? backchannelLogoutSessionRequired
: false,
metadata: {
description,
logo_url: trimmedLogoUrl,
auto_login_supported: autoLoginSupported,
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
structured_scopes: normalizedScopes,
id_token_claims_enabled: idTokenClaimsEnabled,
id_token_claims: idTokenClaimsEnabled ? normalizedIdTokenClaims : [],
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method: headlessLoginEnabled
? tokenEndpointAuthMethod
: undefined,
headless_jwks_uri: headlessLoginEnabled ? trimmedJwksUri : undefined,
tenant_access_restricted: tenantAccessRestricted,
allowed_tenants: tenantAccessRestricted
? normalizedAllowedTenantIds
: [],
backchannel_logout_uri: trimmedBackchannelLogoutUri || undefined,
backchannel_logout_session_required:
trimmedBackchannelLogoutUri !== ""
? backchannelLogoutSessionRequired
: undefined,
},
};
if (isCreate) {
payload.status = status;
payload.redirectUris = redirectUris
.split(",")
.map((uri) => uri.trim())
.filter(Boolean);
return createClient(payload);
}
await queryClient.cancelQueries({ queryKey: ["client", clientId] });
const updated = await updateClient(clientId as string, payload);
if (status !== initialStatus) {
await updateClientStatus(clientId as string, status);
}
return updated;
},
onSuccess: (result) => {
const resultClientId = result?.client?.id ?? clientId;
if (resultClientId) {
queryClient.setQueryData(["client", resultClientId], result);
}
queryClient.invalidateQueries({ queryKey: ["clients"] });
if (status !== initialStatus) {
setInitialStatus(status);
}
if (result?.client?.id) {
navigate(`/clients/${result.client.id}/settings`);
}
toast(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
},
onError: (err) => {
const axiosError = err as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
alert(
isCreate
? t(
"msg.dev.clients.general.create_forbidden",
"이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.",
)
: t(
"msg.dev.clients.general.save_forbidden",
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
),
);
return;
}
const errorMessage =
axiosError.response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
toast(
t(
"msg.dev.clients.general.save_error",
"저장에 실패했습니다: {{error}}",
{
error: errorMessage,
},
),
"error",
);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteClient(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["clients"] });
toast(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
navigate("/clients");
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message;
toast(
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
error: errorMessage,
}),
"error",
);
},
});
const handleDelete = () => {
if (
clientId &&
window.confirm(
t(
"msg.dev.clients.delete_confirm",
"정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.",
),
)
) {
deleteMutation.mutate(clientId);
}
};
if ((isCreate && isLoadingDeveloperAccessGate) || (!isCreate && isLoading)) {
return (
<div className="p-8 text-center">
{t(
"msg.dev.clients.general.loading",
isCreate ? "Loading client creation..." : "Loading client...",
)}
</div>
);
}
if (isCreate && !hasClientCreateAccess) {
return (
<div className="p-8">
<div className="mx-auto max-w-2xl">
<DeveloperAccessRequestCard
title={t("ui.dev.clients.general.title_create", "Create Client")}
isPending={isDeveloperRequestPending}
canRequest={canRequestDeveloperAccess}
pendingMessage={t(
"msg.dev.clients.general.create_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.clients.general.create_forbidden",
"이 RP를 생성할 권한이 없습니다.",
)}
pendingDetailMessage={t(
"msg.dev.clients.general.create_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.clients.general.create_forbidden_detail",
"개발자 권한 신청에서 연동 앱 추가 권한을 선택한 뒤 승인받아주세요.",
)}
actionLabel={t(
"ui.dev.welcome.btn_request",
"개발자 등록 신청하기",
)}
onAction={() => navigate("/developer-requests")}
/>
</div>
</div>
);
}
if (!isCreate && (error || !data)) {
const errMsg =
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error)?.message;
return (
<div className="p-8 text-center text-red-500">
{t(
"msg.dev.clients.general.load_error",
"Error loading client: {{error}}",
{
error: errMsg || t("msg.common.unknown_error", "unknown error"),
},
)}
</div>
);
}
const publicKeyStatusTone = headlessLoginEnabled
? hasValidationErrors
? "border-destructive/40 bg-destructive/5"
: "border-primary/30 bg-primary/5"
: "border-border bg-muted/20";
const displayName = isCreate
? t("ui.dev.clients.general.display_new", "새 클라이언트")
: data?.client?.name || data?.client?.id;
return (
<div className="space-y-8">
<header className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{displayName}</span>
{!isCreate && (
<>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.settings", "Settings")}
</span>
</>
)}
</nav>
<PageHeader
icon={<ShieldHalf size={20} />}
title={
isCreate
? t("ui.dev.clients.general.title_create", "Create Client")
: t("ui.dev.clients.general.title_edit", "Client Settings")
}
description={t(
"ui.dev.clients.general.subtitle",
"앱 정보, 권한 스코프, 보안 설정을 관리합니다.",
)}
/>
</div>
{!isCreate && (
<Badge
variant={status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
)}
</div>
{!isCreate && (
<ClientDetailTabs activeTab="settings" clientId={clientId} />
)}
{isGeneralSettingsReadOnly && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{t(
"msg.dev.clients.general.read_only_hint",
"이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.",
)}
</div>
)}
</header>
{/* 1. Application Identity */}
<div className="glass-panel p-6">
<div className="flex items-center justify-between mb-6">
<div>
<CardTitle className="text-xl font-bold mb-2">
{t(
"ui.dev.clients.general.identity.title",
"Application Identity",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.identity.subtitle",
"앱 이름과 설명, 로고를 설정합니다.",
)}
</CardDescription>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.general.identity.name", "앱 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.dev.clients.general.identity.name_placeholder",
"My Awesome Application",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.identity.description",
"Description",
)}
</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t(
"ui.dev.clients.general.identity.description_placeholder",
"앱에 대한 간단한 설명을 입력하세요.",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</div>
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.general.identity.logo", "App Logo URL")}
</Label>
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<Input
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
aria-invalid={!hasValidLogoUrl}
className={!hasValidLogoUrl ? "border-destructive" : ""}
placeholder={t(
"ui.dev.clients.general.identity.logo_placeholder",
"https://example.com/logo.png",
)}
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.identity.logo_help",
"인증 화면에 표시될 PNG/SVG URL입니다.",
)}
</p>
{!hasValidLogoUrl ? (
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.identity.logo_invalid",
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
)}
</p>
) : null}
{hasLogoUrl && hasValidLogoUrl ? (
<div className="flex items-center gap-2 text-xs">
<span
className={cn("text-muted-foreground", {
"text-foreground": logoPreviewStatus === "loaded",
"text-destructive": logoPreviewStatus === "error",
})}
>
{logoPreviewStatus === "loading"
? t(
"msg.dev.clients.general.identity.logo_preview_loading",
"로고 미리보기를 불러오는 중입니다.",
)
: logoPreviewStatus === "loaded"
? t(
"msg.dev.clients.general.identity.logo_preview_ready",
"로고 미리보기를 확인했습니다.",
)
: logoPreviewStatus === "error"
? t(
"msg.dev.clients.general.identity.logo_preview_failed",
"로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요.",
)
: null}
</span>
<a
href={trimmedLogoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
<ExternalLink className="h-3 w-3" />
{t(
"ui.dev.clients.general.identity.logo_open",
"새 탭에서 열기",
)}
</a>
</div>
) : null}
</div>
<div
className={cn(
"flex h-20 w-20 shrink-0 items-center justify-center rounded-lg border-2 border-dashed",
hasLogoUrl &&
hasValidLogoUrl &&
logoPreviewStatus !== "error"
? "bg-white"
: "bg-muted/40",
logoPreviewStatus === "error"
? "border-destructive/60"
: "border-border",
)}
>
{hasLogoUrl && hasValidLogoUrl ? (
<img
key={trimmedLogoUrl}
src={trimmedLogoUrl}
alt={t(
"ui.dev.clients.general.identity.logo_preview",
"Logo Preview",
)}
className="h-full w-full object-contain"
onLoad={() => setLogoPreviewStatus("loaded")}
onError={() => setLogoPreviewStatus("error")}
/>
) : (
<div className="flex flex-col items-center justify-center gap-1 px-2 text-center">
<Upload
className={cn("h-5 w-5 text-muted-foreground", {
"text-destructive": logoPreviewStatus === "error",
})}
/>
{logoPreviewStatus === "error" ? (
<span className="text-[10px] leading-tight text-destructive">
{t(
"ui.dev.clients.general.identity.logo_preview_error_badge",
"미리보기 실패",
)}
</span>
) : (
<span className="text-[10px] leading-tight text-muted-foreground">
{t(
"ui.dev.clients.general.identity.logo_preview_empty",
"미리보기",
)}
</span>
)}
</div>
)}
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.table.status", "상태")}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => handleStatusChange("active")}
disabled={isGeneralSettingsReadOnly}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => handleStatusChange("inactive")}
disabled={isGeneralSettingsReadOnly}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</div>
</div>
</div>
{/* 2. Scopes */}
{/* 3. Custom Claims */}
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div>
<CardTitle className="text-xl font-bold">
{t("ui.dev.clients.general.scopes.title", "Scopes")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.scopes.subtitle",
"이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
)}
</CardDescription>
</div>
<Button
variant="default"
size="sm"
onClick={addScope}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
aria-expanded={isScopePickerOpen}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
</Button>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-100">
<Info className="h-4 w-4" />
<span>
{t(
"ui.dev.clients.general.scopes.offline_access_title",
"Refresh token 사용 시 offline_access scope가 필요합니다.",
)}
</span>
</div>
<p className="text-xs leading-relaxed text-amber-950/80 dark:text-amber-50/80">
{t(
"msg.dev.clients.general.scopes.offline_access_summary",
"RP가 refresh token을 사용하려면 scope 목록에 offline_access를 포함하고, consent와 grant type 설정도 함께 맞아야 합니다.",
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 text-amber-900 hover:bg-amber-500/10 hover:text-amber-950 dark:text-amber-100 dark:hover:bg-amber-500/20 dark:hover:text-amber-50"
onClick={() => setIsOfflineAccessGuideOpen((prev) => !prev)}
aria-expanded={isOfflineAccessGuideOpen}
aria-label={t(
"ui.dev.clients.general.scopes.offline_access_toggle",
"상세 안내 보기",
)}
>
{isOfflineAccessGuideOpen ? (
<X className="h-3.5 w-3.5" />
) : (
<Info className="h-3.5 w-3.5" />
)}
{t("ui.common.info", "상세 안내")}
</Button>
</div>
{isOfflineAccessGuideOpen ? (
<div className="mt-3 rounded-lg border border-amber-500/20 bg-background/70 p-3 text-xs leading-relaxed text-foreground shadow-sm">
<p className="font-semibold">
{t(
"msg.dev.clients.general.scopes.offline_access_conditions_title",
"Hydra 기준으로 refresh token 발급 조건",
)}
</p>
<ul className="mt-2 list-disc space-y-1 pl-4">
<li>
{t(
"msg.dev.clients.general.scopes.offline_access_condition_request",
"authorization request scope에 offline 또는 offline_access 포함",
)}
</li>
<li>
{t(
"msg.dev.clients.general.scopes.offline_access_condition_consent",
"consent accept의 granted_scope에 offline 또는 offline_access 포함",
)}
</li>
<li>
{t(
"msg.dev.clients.general.scopes.offline_access_condition_grant_type",
"client grant_types에 refresh_token 포함",
)}
</li>
</ul>
</div>
) : null}
</div>
{isScopePickerOpen && (
<div className="space-y-3 rounded-md border border-border bg-muted/10 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">
{t(
"ui.dev.clients.general.scopes.picker_title",
"Add a scope",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.general.scopes.picker_help",
"Choose a supported scope or custom claim key to add it to the scope list.",
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setIsScopePickerOpen(false)}
aria-label={t(
"ui.dev.clients.general.scopes.close_picker",
"scope 선택 닫기",
)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{scopeCandidates.map((candidate) => {
const isManual = candidate.source === "manual";
const isDuplicate =
candidate.name.trim() !== "" &&
existingScopeNames.has(candidate.name.trim());
return (
<button
key={candidate.id}
type="button"
className={cn(
"flex min-h-16 items-start justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-left text-sm transition-colors",
isDuplicate
? "cursor-not-allowed opacity-50"
: "hover:border-primary/60 hover:bg-primary/5",
)}
onClick={() => selectScopeCandidate(candidate)}
disabled={isDuplicate || isGeneralSettingsReadOnly}
>
<span className="min-w-0 space-y-1">
<span className="block font-mono text-xs font-semibold">
{isManual
? t(
"ui.dev.clients.general.scopes.manual_input",
"직접 입력",
)
: candidate.name}
</span>
<span className="block text-xs text-muted-foreground">
{candidate.description}
</span>
</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{candidate.source === "custom_claim"
? t(
"ui.dev.clients.general.scopes.source_custom_claim",
"Custom Claim",
)
: candidate.source === "manual"
? t(
"ui.dev.clients.general.scopes.source_manual",
"Manual",
)
: t(
"ui.dev.clients.general.scopes.source_standard",
"Standard",
)}
</Badge>
</button>
);
})}
</div>
</div>
)}
{isCreate && (
<div className="space-y-2 border-b border-border pb-6 mb-6">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.general.redirect.label", "Redirect URIs")}{" "}
<span className="text-destructive">*</span>
</Label>
<Textarea
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
placeholder={t(
"ui.dev.clients.general.redirect.placeholder",
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
)}
className="font-mono text-sm"
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.redirect.help",
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
)}
</p>
</div>
)}
<div className="space-y-4 border-b border-border pb-6 mb-6">
<div className="space-y-2">
<Label
className="text-sm font-semibold"
htmlFor="backchannel-logout-uri"
>
{t(
"ui.dev.clients.general.backchannel_logout.uri",
"Back-Channel Logout URI",
)}
</Label>
<Input
id="backchannel-logout-uri"
value={backchannelLogoutUri}
onChange={(e) => setBackchannelLogoutUri(e.target.value)}
placeholder={t(
"ui.dev.clients.general.backchannel_logout.uri_placeholder",
"https://rp.example.com/oidc/backchannel-logout",
)}
className="font-mono text-sm"
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.backchannel_logout.uri_help",
"Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다.",
)}
</p>
{hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri ? (
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.",
)}
</p>
) : null}
</div>
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label
className="text-sm font-semibold"
htmlFor="backchannel-logout-session-required"
>
{t(
"ui.dev.clients.general.backchannel_logout.session_required",
"SID Claim Required",
)}
</Label>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
isBackchannelSessionRequiredInfoOpen
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={() =>
setIsBackchannelSessionRequiredInfoOpen((prev) => !prev)
}
aria-label={t(
"ui.dev.clients.general.backchannel_logout.session_required_info",
"SID Claim Required 설명 보기",
)}
>
{isBackchannelSessionRequiredInfoOpen ? (
<X className="h-3.5 w-3.5" />
) : (
<Info className="h-3.5 w-3.5" />
)}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.backchannel_logout.session_required_help",
"RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다.",
)}
</p>
{isBackchannelSessionRequiredInfoOpen ? (
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
<Info className="h-3 w-3" />
{t("ui.common.info", "상세 안내")}
</div>
<div>
{t(
"msg.dev.clients.general.backchannel_logout.session_required_on",
"켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리",
)}
</div>
<div>
{t(
"msg.dev.clients.general.backchannel_logout.session_required_off",
"끄면: sid가 없어도 sub만으로 로그아웃 처리 가능",
)}
</div>
</div>
) : null}
</div>
<Switch
id="backchannel-logout-session-required"
checked={backchannelLogoutSessionRequired}
onCheckedChange={setBackchannelLogoutSessionRequired}
disabled={isGeneralSettingsReadOnly || !hasBackchannelLogoutUri}
/>
</div>
</div>
<SettingsTableShell>
<SettingsTable>
<SettingsTableHeader>
<tr>
<SettingsTableHead>
{t(
"ui.dev.clients.general.scopes.table.name",
"Scope Name",
)}
</SettingsTableHead>
<SettingsTableHead>
{t(
"ui.dev.clients.general.scopes.table.description",
"Description",
)}
</SettingsTableHead>
<SettingsTableHead className="text-center">
{t(
"ui.dev.clients.general.scopes.table.mandatory",
"Mandatory",
)}
</SettingsTableHead>
<SettingsTableHead className="text-right">
{t("ui.dev.clients.general.scopes.table.delete", "Delete")}
</SettingsTableHead>
</tr>
</SettingsTableHeader>
<SettingsTableBody>
{scopes.length > 0 ? (
scopes.map((s) => (
<SettingsTableRow
key={s.id}
className={cn(
s.locked ? "bg-primary/5" : "hover:bg-muted/20",
)}
>
<SettingsTableCell>
<Input
value={s.name}
onChange={(e) =>
updateScope(s.id, "name", e.target.value)
}
className="h-8 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.scopes.name_placeholder",
"e.g. profile",
)}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</SettingsTableCell>
<SettingsTableCell>
<Input
value={s.description}
onChange={(e) =>
updateScope(s.id, "description", e.target.value)
}
className="h-8 text-xs"
placeholder={t(
"ui.dev.clients.general.scopes.description_placeholder",
"권한에 대한 설명",
)}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</SettingsTableCell>
<SettingsTableCell className="text-center">
<div className="flex justify-center">
<Switch
checked={s.mandatory}
onCheckedChange={(checked) =>
updateScope(s.id, "mandatory", checked)
}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</div>
</SettingsTableCell>
<SettingsTableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => removeScope(s.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={s.locked || isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
</SettingsTableCell>
</SettingsTableRow>
))
) : (
<SettingsTableEmptyState colSpan={4}>
{t(
"msg.dev.clients.general.scopes.empty",
"등록된 스코프가 없습니다.",
)}
</SettingsTableEmptyState>
)}
</SettingsTableBody>
</SettingsTable>
</SettingsTableShell>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-3">
<div className="space-y-1">
<CardTitle className="text-xl font-bold">
{t(
"ui.dev.clients.general.id_token_claims.title",
"Custom Claims",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.id_token_claims.subtitle",
"RP 전용 확장 claim을 구분해서 관리합니다.",
)}
</CardDescription>
</div>
{idTokenClaimsEnabled ? (
<Button
size="sm"
onClick={addIdTokenClaim}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
>
<Plus className="h-4 w-4" />
{t(
"ui.dev.clients.general.id_token_claims.add",
"Claim 추가",
)}
</Button>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-0.5 text-right">
<p className="text-sm font-semibold">
{idTokenClaimsEnabled
? t("ui.common.enabled", "사용")
: t("ui.common.disabled", "사용 안 함")}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.general.id_token_claims.enabled",
"커스텀 클레임 사용",
)}
</p>
</div>
<Switch
checked={idTokenClaimsEnabled}
onCheckedChange={setIdTokenClaimsEnabled}
id="custom-claims-enabled"
aria-label={t(
"ui.dev.clients.general.id_token_claims.enabled",
"커스텀 클레임 사용",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</div>
</CardHeader>
{idTokenClaimsEnabled ? (
<CardContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,0.75fr)]">
<div className="space-y-3">
<SettingsTableShell>
<SettingsTable>
<SettingsTableHeader>
<tr>
<SettingsTableHead>
{t(
"ui.dev.clients.general.id_token_claims.table.key",
"Claim Key",
)}
</SettingsTableHead>
<SettingsTableHead>
{t(
"ui.dev.clients.general.id_token_claims.table.namespace",
"Namespace",
)}
</SettingsTableHead>
<SettingsTableHead>
{t(
"ui.dev.clients.general.id_token_claims.table.value_type",
"Value Type",
)}
</SettingsTableHead>
<SettingsTableHead className="text-center">
{t(
"ui.dev.clients.general.id_token_claims.table.nullable",
"Nullable",
)}
</SettingsTableHead>
<SettingsTableHead className="text-center">
{t(
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
"User read",
)}
</SettingsTableHead>
<SettingsTableHead className="text-center">
{t(
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
"User write",
)}
</SettingsTableHead>
<SettingsTableHead>
{t(
"ui.dev.clients.general.id_token_claims.table.default_value",
"Default Value",
)}
</SettingsTableHead>
<SettingsTableHead className="w-[56px] text-center">
{t(
"ui.dev.clients.general.id_token_claims.table.delete",
"Delete",
)}
</SettingsTableHead>
</tr>
</SettingsTableHeader>
<SettingsTableBody>
{idTokenClaims.length > 0 ? (
idTokenClaims.map((claim) => {
const defaultValueError =
claimDefaultValueValidationError(claim);
return (
<SettingsTableRow
key={claim.id}
className="hover:bg-muted/20"
>
<SettingsTableCell>
<Input
value={claim.key}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"key",
e.target.value,
)
}
className="h-8 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
</SettingsTableCell>
<SettingsTableCell>
<Badge
variant="muted"
className="h-8 rounded-md border bg-muted/40 px-3 py-1.5 font-mono text-xs"
>
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</Badge>
</SettingsTableCell>
<SettingsTableCell>
<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 값 타입",
)}
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}
>
<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>
</SettingsTableCell>
<SettingsTableCell className="text-center">
<div className="flex h-8 items-center justify-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>
</SettingsTableCell>
<SettingsTableCell className="text-center">
<div className="flex h-8 items-center justify-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>
</SettingsTableCell>
<SettingsTableCell className="text-center">
<div className="flex h-8 items-center justify-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>
</SettingsTableCell>
<SettingsTableCell>
{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-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}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<div className="flex flex-col gap-2">
<Input
key={claim.valueType}
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-8 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-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}
aria-label={t(
"ui.dev.clients.general.id_token_claims.timezone_label",
"Claim 기본값 시간대",
)}
>
{timeZoneOptions.map((timeZone) => (
<option
key={timeZone}
value={timeZone}
>
{timeZone}
</option>
))}
</select>
)}
</div>
)}
{defaultValueError && (
<p className="mt-1 text-xs text-destructive">
{defaultValueError}
</p>
)}
</SettingsTableCell>
<SettingsTableCell className="w-[56px] text-center align-top">
<Button
variant="ghost"
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
</SettingsTableCell>
</SettingsTableRow>
);
})
) : (
<SettingsTableEmptyState colSpan={8}>
{t(
"msg.dev.clients.general.id_token_claims.empty",
"아직 추가된 ID Token claim이 없습니다.",
)}
</SettingsTableEmptyState>
)}
</SettingsTableBody>
</SettingsTable>
</SettingsTableShell>
<p className="text-xs leading-6 text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.hint",
"사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
)}
</p>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<div>
<p className="text-sm font-semibold">
{t(
"ui.dev.clients.general.id_token_claims.preview_title",
"Saved JSON Preview",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.preview_hint",
"설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.",
)}
</p>
</div>
</div>
<Textarea
readOnly
value={idTokenClaimPreviewJson}
className="mt-4 min-h-72 font-mono text-xs"
/>
</div>
</div>
</div>
</CardContent>
) : null}
</Card>
{/* 4. Tenant Access Restriction */}
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-bold">
{t(
"ui.dev.clients.general.tenant_access.title",
"테넌트 접근 제한",
)}
</CardTitle>
<div className="text-sm text-muted-foreground">
<p className="leading-relaxed">
{t(
"ui.dev.clients.general.tenant_access.subtitle",
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
)}
</p>
<p className="leading-relaxed">
{t(
"ui.dev.clients.general.tenant_access.hint",
"제한을 켜면 tenants 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-0.5 text-right">
<p className="text-sm font-semibold">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.enabled",
"제한 있음",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.title",
"테넌트 접근 제한",
)}
</p>
</div>
<Switch
checked={tenantAccessRestricted}
onCheckedChange={handleTenantAccessToggle}
id="tenant-access-toggle"
disabled={isGeneralSettingsReadOnly}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{tenantAccessRestricted ? (
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.picker_label",
"Add allowed tenant",
)}{" "}
<span className="text-destructive">*</span>
</Label>
<TenantAccessPicker
disabled={isGeneralSettingsReadOnly}
selectedCount={allowedTenantIds.length}
onSelectTenant={(selection) =>
handleSelectAllowedTenant(selection.id)
}
/>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.selected_title",
"허용 테넌트",
)}
</Label>
<SettingsTableShell bodyClassName="max-h-80">
<SettingsTable>
<SettingsTableHeader className="sticky top-0 z-10">
<tr>
<SettingsTableHead className="w-[28%]">
{t(
"ui.dev.clients.general.tenant_access.table.name",
"테넌트명",
)}
</SettingsTableHead>
<SettingsTableHead className="w-[18%]">
{t(
"ui.dev.clients.general.tenant_access.table.slug",
"슬러그",
)}
</SettingsTableHead>
<SettingsTableHead>
{t(
"ui.dev.clients.general.tenant_access.table.id",
"테넌트 ID",
)}
</SettingsTableHead>
<SettingsTableHead className="w-[112px] text-right">
{t(
"ui.dev.clients.general.tenant_access.table.actions",
"작업",
)}
</SettingsTableHead>
</tr>
</SettingsTableHeader>
<SettingsTableBody>
{selectedAllowedTenants.length > 0 ? (
<>
{selectedAllowedTenants.map((tenant) => (
<SettingsTableRow
key={tenant.id}
data-testid={`allowed-tenant-${tenant.id}`}
>
<SettingsTableCell className="align-middle font-medium">
<span className="block truncate">
{tenant.name}
</span>
</SettingsTableCell>
<SettingsTableCell className="align-middle text-muted-foreground">
{tenant.slug || "-"}
</SettingsTableCell>
<SettingsTableCell className="align-middle font-mono text-sm text-muted-foreground">
<span className="break-all">{tenant.id}</span>
</SettingsTableCell>
<SettingsTableCell className="align-middle text-right">
<div className="inline-flex items-center gap-1.5">
<CopyButton
aria-label="테넌트 UUID 복사"
className="h-8 w-8"
data-testid={`allowed-tenant-copy-${tenant.id}`}
size="icon"
value={tenant.id}
variant="ghost"
/>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() =>
toggleAllowedTenant(tenant.id)
}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
data-testid={`allowed-tenant-remove-${tenant.id}`}
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</SettingsTableCell>
</SettingsTableRow>
))}
{allowedTenantIds
.filter(
(tenantId) =>
!selectedAllowedTenants.some(
(tenant) => tenant.id === tenantId,
),
)
.map((tenantId) => (
<SettingsTableRow
key={tenantId}
data-testid={`allowed-tenant-${tenantId}`}
>
<SettingsTableCell className="align-middle font-medium">
<span className="block truncate">
{tenantId}
</span>
</SettingsTableCell>
<SettingsTableCell className="align-middle text-muted-foreground">
-
</SettingsTableCell>
<SettingsTableCell className="align-middle font-mono text-sm text-muted-foreground">
<span className="break-all">{tenantId}</span>
</SettingsTableCell>
<SettingsTableCell className="align-middle text-right">
<div className="inline-flex items-center gap-1.5">
<CopyButton
aria-label="테넌트 UUID 복사"
className="h-8 w-8"
data-testid={`allowed-tenant-copy-${tenantId}`}
size="icon"
value={tenantId}
variant="ghost"
/>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() =>
toggleAllowedTenant(tenantId)
}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
data-testid={`allowed-tenant-remove-${tenantId}`}
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</SettingsTableCell>
</SettingsTableRow>
))}
</>
) : (
<SettingsTableEmptyState colSpan={4} className="py-4">
{t(
"ui.dev.clients.general.tenant_access.selected_empty",
"아직 선택된 테넌트가 없습니다.",
)}
</SettingsTableEmptyState>
)}
</SettingsTableBody>
</SettingsTable>
</SettingsTableShell>
</div>
</div>
) : null}
</CardContent>
</Card>
{/* 5. Auto Login */}
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-bold">
{t("ui.dev.clients.general.auto_login.title", "자동 로그인")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.auto_login.subtitle",
"RP가 자체 로그인 시작 URL에서 OIDC 요청을 만들 수 있으면 userfront에서 바로 로그인 진입을 제공합니다.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-0.5 text-right">
<p className="text-sm font-semibold">
{autoLoginSupported
? t("ui.common.enabled", "사용")
: t("ui.common.disabled", "사용 안 함")}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.general.auto_login.supported",
"자동 로그인 지원",
)}
</p>
</div>
<Switch
checked={autoLoginSupported}
onCheckedChange={setAutoLoginSupported}
id="auto-login-supported"
aria-label={t(
"ui.dev.clients.general.auto_login.supported",
"자동 로그인 지원",
)}
/>
</div>
</div>
</CardHeader>
{autoLoginSupported ? (
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="auto-login-url" className="text-sm font-semibold">
{t(
"ui.dev.clients.general.auto_login.url",
"자동 로그인 시작 URL",
)}
</Label>
<Input
id="auto-login-url"
value={autoLoginUrl}
onChange={(event) => setAutoLoginUrl(event.target.value)}
aria-invalid={!hasValidAutoLoginUrl}
className={!hasValidAutoLoginUrl ? "border-destructive" : ""}
placeholder={t(
"ui.dev.clients.general.auto_login.url_placeholder",
"https://app.example.com/login?auto=1",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.auto_login.help",
"이 URL은 RP가 state, nonce, PKCE 값을 직접 생성한 뒤 Baron OIDC로 리다이렉트해야 합니다.",
)}
</p>
{!hasValidAutoLoginUrl ? (
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.auto_login.invalid_url",
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
)}
</p>
) : null}
</div>
</CardContent>
) : null}
</Card>
{/* 6. Security Settings */}
<Card className="glass-panel">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold">
{t("ui.dev.clients.general.security.title", "보안 설정")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.security.subtitle",
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
securityProfile === "private"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="security-profile"
checked={securityProfile === "private"}
onChange={() => handleSecurityProfileChange("private")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" />
{t(
"ui.dev.clients.general.security.private",
"Server side App",
)}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.private_help",
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{securityProfile === "private" ? "✓" : ""}
</span>
{securityProfile === "private" && (
<fieldset
className="mt-4 flex items-center justify-between border-t border-primary/20 pt-4"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="space-y-0.5">
<Label
className="cursor-pointer text-xs font-bold"
htmlFor="headless-login-toggle"
>
{t(
"ui.dev.clients.general.security.headless_login_enable",
"Headless Login (자체 로그인 UI 사용)",
)}
</Label>
<p className="text-[10px] text-muted-foreground">
{t(
"ui.dev.clients.general.security.headless_login_enable_help",
"Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key.",
)}
</p>
</div>
<Switch
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
disabled={isGeneralSettingsReadOnly}
/>
</fieldset>
)}
</label>
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
securityProfile === "pkce"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="security-profile"
checked={securityProfile === "pkce"}
onChange={() => handleSecurityProfileChange("pkce")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" />
{t("ui.dev.clients.general.security.pkce", "PKCE")}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.pkce_help",
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{securityProfile === "pkce" ? "✓" : ""}
</span>
</label>
</div>
</CardContent>
</Card>
{/* 4. Public Key Registration (Headless Login) */}
{headlessLoginEnabled && (
<Card className="glass-panel border-primary/20">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
{t(
"ui.dev.clients.general.public_key.title",
"Public Key Registration",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.public_key.subtitle",
"Server side App의 Headless Login capability에 필요한 공개키와 검증 정보를 관리합니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className={cn("rounded-xl border p-4", publicKeyStatusTone)}>
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-bold text-foreground">
{t(
"ui.dev.clients.general.public_key.headless_toggle",
"Headless Login 허용 여부",
)}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.headless_help",
"RP가 자체 로그인 UI를 제공하더라도 실제 인증 흐름은 Baron API와 RP backend의 signed key 검증을 통해 이어집니다.",
)}
</p>
</div>
<Badge
variant="default"
className="bg-primary/20 text-primary border-primary/30"
>
{t("ui.common.enabled", "Enabled")}
</Badge>
</div>
</div>
<div className="space-y-4">
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
{t(
"ui.dev.clients.general.public_key.jwks_uri",
"JWKS URI",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
title={allowedHeadlessAlgorithmsTooltip}
aria-label={t(
"ui.dev.clients.general.public_key.allowed_algorithms_info",
"Headless Login 허용 알고리즘 정보",
)}
>
<Info className="h-4 w-4" />
</Button>
</div>
<Input
id="jwks-uri"
value={jwksUri}
onChange={(e) => setJwksUri(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.jwks_uri_placeholder",
"https://rp.example.com/.well-known/jwks.json",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.jwks_uri_help",
"RP backend가 제공하는 공개키 endpoint URL을 입력하세요.",
)}
</p>
</div>
</div>
<div className="space-y-3 rounded-xl border border-border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<Label className="text-sm font-bold">
{t(
"ui.dev.clients.general.public_key.cache.title",
"JWKS Cache",
)}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache_help",
"백엔드가 마지막으로 검증한 공개키 캐시 상태입니다.",
)}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refreshHeadlessJwksCacheMutation.mutate()}
disabled={refreshHeadlessJwksCacheMutation.isPending}
>
{refreshHeadlessJwksCacheMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t("ui.common.refresh", "Refresh")}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
if (
!currentHeadlessJwksCache ||
revokeHeadlessJwksCacheMutation.isPending
) {
return;
}
const confirmed = window.confirm(
t(
"msg.dev.clients.general.public_key.cache_revoke_confirm",
"JWKS 캐시를 삭제하면 다음 검증 전에 다시 갱신해야 합니다. 계속할까요?",
),
);
if (confirmed) {
revokeHeadlessJwksCacheMutation.mutate();
}
}}
disabled={
!currentHeadlessJwksCache ||
revokeHeadlessJwksCacheMutation.isPending
}
>
{revokeHeadlessJwksCacheMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t(
"ui.dev.clients.general.public_key.revoke_cache",
"Revoke Cache",
)}
</Button>
</div>
</div>
{currentHeadlessJwksCache ? (
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.status",
"Status",
)}
</p>
<Badge variant="info" className="w-fit capitalize">
{currentHeadlessJwksCache.lastRefreshStatus ||
t("ui.common.unknown", "Unknown")}
</Badge>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.uri",
"JWKS URI",
)}
</p>
<p className="break-all font-mono text-xs">
{currentHeadlessJwksCache.jwksUri}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.cached_at",
"Cached At",
)}
</p>
<p>{formatDateTime(currentHeadlessJwksCache.cachedAt)}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.expires_at",
"Expires At",
)}
</p>
<p>
{formatDateTime(currentHeadlessJwksCache.expiresAt)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.last_checked_at",
"Last Checked",
)}
</p>
<p>
{formatDateTime(currentHeadlessJwksCache.lastCheckedAt)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.last_success",
"Last Successful Verification",
)}
</p>
<p>
{formatDateTime(
currentHeadlessJwksCache.lastSuccessfulVerificationAt,
)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.failures",
"Consecutive Failures",
)}
</p>
<p>{currentHeadlessJwksCache.consecutiveFailures ?? 0}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.kids",
"Cached KIDs",
)}
</p>
<p className="font-mono text-xs">
{currentHeadlessJwksCache.cachedKids?.length
? currentHeadlessJwksCache.cachedKids.join(", ")
: "-"}
</p>
</div>
<div className="space-y-1 md:col-span-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.error",
"Last Error",
)}
</p>
<p className="break-words text-xs text-muted-foreground">
{currentHeadlessJwksCache.lastError || "-"}
</p>
</div>
{(unsupportedParsedAlgorithms.length > 0 ||
missingParsedAlgorithms.length > 0) && (
<div className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 md:col-span-2">
<p className="text-sm font-semibold text-destructive">
{unsupportedParsedAlgorithms.length > 0
? t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_title",
"지원하지 않는 알고리즘이 감지되었습니다.",
)
: t(
"msg.dev.clients.general.public_key.cache.missing_algorithms_title",
"알고리즘이 선언되지 않았습니다.",
)}
</p>
<p className="text-xs text-destructive">
{unsupportedParsedAlgorithms.length > 0
? t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_help",
"저장 전 JWKS를 수정해 주세요: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
)
: t(
"msg.dev.clients.general.public_key.cache.missing_algorithms_help",
"저장 전 JWKS 각 키에 `alg`를 명시해 주세요: {{details}}",
{ details: missingParsedAlgorithmSummary },
)}
</p>
</div>
)}
<div className="space-y-3 md:col-span-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.parsed_keys",
"Parsed Keys",
)}
</p>
<p className="text-[11px] text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache.parsed_keys_help",
"Raw JWKS stays hidden. Only parsed key metadata is shown here.",
)}
</p>
</div>
{currentHeadlessJwksCache.parsedKeys?.length ? (
<div className="space-y-3">
{currentHeadlessJwksCache.parsedKeys.map((key) => {
const normalizedAlgorithm = key.alg?.trim() ?? "";
const isMissingAlgorithm =
normalizedAlgorithm === "";
const isUnsupportedAlgorithm =
!isMissingAlgorithm &&
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
normalizedAlgorithm,
);
return (
<div
key={`${key.kid ?? "missing-kid"}-${key.kty ?? ""}-${key.alg ?? ""}-${key.n ?? ""}`}
className={cn(
"rounded-xl border bg-muted/30 p-3",
isUnsupportedAlgorithm || isMissingAlgorithm
? "border-destructive/50 bg-destructive/5"
: "border-border",
)}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KID
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kid || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KTY
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kty || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
USE
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.use || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
ALG
</p>
<p
className={cn(
"break-all rounded-lg border bg-background px-3 py-2 font-mono text-[11px]",
isUnsupportedAlgorithm ||
isMissingAlgorithm
? "border-destructive/50 text-destructive"
: "border-border",
)}
>
{key.alg ||
t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_badge",
"알고리즘 미선언",
)}
</p>
{isMissingAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_reason",
"이 키는 `alg`가 비어 있어서 저장할 수 없습니다.",
)}
</p>
)}
{isUnsupportedAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason",
"이 알고리즘은 Headless Login에서 지원되지 않습니다.",
)}
</p>
)}
</div>
</div>
<div className="mt-3 space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.parsed_key_n",
"N",
)}
</p>
<p className="min-h-16 break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px] leading-5">
{key.n || "-"}
</p>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache.parsed_keys_empty",
"No parsed JWKS keys are available yet.",
)}
</div>
)}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache_empty",
"아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드 캐시 상태를 조회하세요.",
)}
</div>
)}
</div>
</div>
{hasValidationErrors && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4 animate-in fade-in">
<p className="text-sm font-semibold text-destructive flex items-center gap-2">
<span></span>
{t(
"ui.dev.clients.general.public_key.validation_title",
"저장 전 확인 필요",
)}
</p>
<ul className="mt-2 list-disc space-y-1 pl-6 text-sm text-destructive">
{validationErrors.map((errorMessage) => (
<li key={errorMessage}>{errorMessage}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<div>
{!isCreate && (
<Button
variant="destructive"
className="gap-2"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
{deleteMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t("ui.common.delete", "삭제")}
</Button>
)}
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => navigate("/clients")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={
isGeneralSettingsReadOnly ||
mutation.isPending ||
isLoading ||
name.trim() === "" ||
(isCreate && redirectUris.trim() === "") ||
hasValidationErrors
}
className="shadow-lg shadow-primary/20"
>
{mutation.isPending ? (
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
) : (
<Save size={16} className="mr-2" />
)}
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: isCreate
? t("ui.dev.clients.general.create", "클라이언트 생성")
: t("ui.common.save", "저장")}
</Button>
</div>
</div>
</div>
);
}
export default ClientGeneralPage;