1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/clients/ClientGeneralPage.tsx
2026-05-04 13:17:40 +09:00

2853 lines
110 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 {
ArrowLeft,
Check,
ExternalLink,
Info,
Plus,
Save,
Search,
Shield,
Sparkles,
Trash2,
Upload,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
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 ClientRelation,
createClient,
deleteClient,
fetchClient,
fetchClientRelations,
fetchMyTenants,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
updateClient,
updateClientStatus,
} from "../../lib/devApi";
import type {
ClientStatus,
ClientType,
ClientUpsertRequest,
MyTenantSummary,
TenantSummary,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs";
interface ScopeItem {
id: string;
name: string;
description: string;
mandatory: boolean;
locked?: boolean;
}
type ClaimNamespace = "top_level" | "rp_claims";
type ClaimValueType = "text" | "number" | "boolean" | "array" | "object";
interface IdTokenClaimItem {
id: string;
namespace: ClaimNamespace;
key: string;
value: string;
valueType: ClaimValueType;
}
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 === "top_level" || value === "rp_claims";
}
function isClaimValueType(value: string): value is ClaimValueType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "array" ||
value === "object"
);
}
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
return {
id,
namespace: "top_level",
key: "",
value: "",
valueType: "text",
};
}
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
: "top_level";
const keyValue = typeof record.key === "string" ? record.key : "";
const rawValue = record.value;
const valueValue =
typeof rawValue === "string"
? rawValue
: rawValue == null
? ""
: JSON.stringify(rawValue);
const valueTypeValue =
typeof record.valueType === "string" &&
isClaimValueType(record.valueType)
? record.valueType
: "text";
return {
id: `claim-${index + 1}`,
namespace: namespaceValue,
key: keyValue,
value: valueValue,
valueType: valueTypeValue,
};
})
.filter((item): item is IdTokenClaimItem => item !== null);
}
function normalizeClaimPreviewValue(
value: string,
valueType: ClaimValueType,
): unknown {
const trimmed = value.trim();
if (valueType === "number") {
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 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;
}
const target = item.namespace === "rp_claims" ? rpClaims : preview;
target[key] = normalizeClaimPreviewValue(item.value, item.valueType);
}
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;
}
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
} 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 clientId = params.id;
const isCreate = !clientId;
const currentUserId = auth.user?.profile.sub;
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
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: ["my-tenants"],
queryFn: fetchMyTenants,
});
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 [tenantSearch, setTenantSearch] = useState("");
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
const [autoLoginUrl, setAutoLoginUrl] = useState("");
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
useState<TokenEndpointAuthMethod>("client_secret_basic");
const [jwksUri, setJwksUri] = useState("");
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
{
id: "1",
name: "openid",
description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"),
mandatory: true,
},
{
id: "2",
name: "tenant",
description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"),
mandatory: false,
},
{
id: "3",
name: "profile",
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
mandatory: false,
},
{
id: "4",
name: "email",
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
mandatory: false,
},
]);
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
useEffect(() => {
if (!data) return;
const { client } = data;
setName(client.name || client.id);
setClientType(client.type);
setStatus(client.status);
setInitialStatus(client.status);
const metadata = client.metadata ?? {};
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);
const headlessEnabled = !!metadata.headless_login_enabled;
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,
),
);
}
setIdTokenClaims(readIdTokenClaimsMetadata(metadata));
}, [data]);
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const canEditExistingClientGeneralSettings =
systemRole === "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") {
setTokenEndpointAuthMethod(
headlessLoginEnabled ? "private_key_jwt" : "none",
);
} else {
setTokenEndpointAuthMethod("client_secret_basic");
}
};
const handleHeadlessToggle = (enabled: boolean) => {
setHeadlessLoginEnabled(enabled);
if (clientType === "pkce") {
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none");
}
};
const tenantScopeDescription = t(
"msg.dev.clients.scopes.tenant",
"소속 테넌트 정보 접근",
);
const buildTenantScope = (id: string): ScopeItem => ({
id,
name: "tenant",
description: tenantScopeDescription,
mandatory: true,
locked: true,
});
function normalizeScopesForTenantAccess(
nextScopes: ScopeItem[],
restricted: boolean,
): ScopeItem[] {
const normalized = nextScopes.map((scope) => {
if (scope.name.trim() !== "tenant") {
return scope;
}
return {
...scope,
description: scope.description || tenantScopeDescription,
mandatory: restricted,
locked: restricted,
};
});
if (
restricted &&
!normalized.some((scope) => scope.name.trim() === "tenant")
) {
normalized.push(buildTenantScope(`tenant-${Date.now()}`));
}
const openidScopes = normalized.filter(
(scope) => scope.name.trim() === "openid",
);
const tenantScopes = normalized.filter(
(scope) => scope.name.trim() === "tenant",
);
const remainingScopes = normalized.filter((scope) => {
const name = scope.name.trim();
return name !== "openid" && name !== "tenant";
});
return [...openidScopes, ...tenantScopes, ...remainingScopes];
}
const handleTenantAccessToggle = (enabled: boolean) => {
setTenantAccessRestricted(enabled);
setIsTenantSearchOpen(enabled);
if (!enabled) {
setTenantSearch("");
}
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],
);
setTenantSearch("");
setIsTenantSearchOpen(true);
};
const addScope = () => {
const newId = String(Date.now());
setScopes([
...scopes,
{ id: newId, name: "", description: "", mandatory: 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 = () => {
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 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 normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
...claim,
key: claim.key.trim(),
value: claim.value.trim(),
}));
if (headlessLoginEnabled) {
if (!trimmedJwksUri) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.missing_jwks_uri",
"JWKS URI를 입력해야 합니다.",
),
);
} else if (!isValidUrl(trimmedJwksUri)) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.invalid_jwks_uri",
"JWKS URI 형식이 올바르지 않습니다.",
),
);
}
if (unsupportedParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
),
);
}
if (missingParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"msg.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[] = [];
const seenClaimKeys = new Set<string>();
for (const claim of normalizedIdTokenClaims) {
if (!claim.key) {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.key_required",
"Claim key를 입력해야 합니다.",
),
);
continue;
}
if (claim.key === "rp_claims" && claim.namespace === "top_level") {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.reserved_key",
"`rp_claims`는 예약된 namespace 키입니다.",
),
);
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:
claim.namespace === "rp_claims"
? t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)
: t(
"ui.dev.clients.general.id_token_claims.namespace_top_level",
"top-level",
),
key: claim.key,
},
),
);
continue;
}
seenClaimKeys.add(keySignature);
}
validationErrors.push(...claimValidationErrors);
const hasValidationErrors = validationErrors.length > 0;
const idTokenClaimPreview = buildIdTokenClaimsPreview(
normalizedIdTokenClaims,
);
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
tenantData ?? [];
const filteredTenants = tenantOptions.filter((tenant) => {
if (!normalizedTenantSearch) {
return true;
}
const searchable =
`${tenant.name} ${tenant.slug} ${tenant.description ?? ""} ${tenant.type ?? ""}`.toLowerCase();
return searchable.includes(normalizedTenantSearch);
});
const tenantSuggestions = filteredTenants
.filter((tenant) => !allowedTenantIds.includes(tenant.id))
.slice(0, 8);
const selectedAllowedTenants = allowedTenantIds
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
.filter(
(tenant): tenant is TenantSummary | MyTenantSummary => 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의 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 effectiveTokenEndpointAuthMethod =
clientType === "pkce" && headlessLoginEnabled
? "none"
: tokenEndpointAuthMethod;
const payload: ClientUpsertRequest = {
name,
type: clientType,
scopes: scopeNames,
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri:
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: normalizedIdTokenClaims,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method:
clientType === "pkce" && headlessLoginEnabled
? tokenEndpointAuthMethod
: undefined,
headless_jwks_uri:
clientType === "pkce" && 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);
}
const updated = await updateClient(clientId as string, payload);
if (status !== initialStatus) {
await updateClientStatus(clientId as string, status);
}
return updated;
},
onSuccess: (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 && isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.general.loading", "Loading client...")}
</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>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to={isCreate ? "/clients" : `/clients/${clientId}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-3xl font-black leading-tight">
{isCreate
? t("ui.dev.clients.general.title_create", "Create Client")
: t("ui.dev.clients.general.title_edit", "Client Settings")}
</h1>
<p className="text-muted-foreground">
{t(
"ui.dev.clients.general.subtitle",
"앱 정보, 권한 스코프, 보안 설정을 관리합니다.",
)}
</p>
</div>
</div>
</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>
<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>
<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)}
disabled={!autoLoginSupported}
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>
</Card>
{/* 2. Scopes */}
<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="outline"
size="sm"
onClick={addScope}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
</Button>
</CardHeader>
<CardContent className="space-y-6">
{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의 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>
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.scopes.table.name",
"Scope Name",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.scopes.table.description",
"Description",
)}
</th>
<th className="px-4 py-3 text-center font-bold">
{t(
"ui.dev.clients.general.scopes.table.mandatory",
"Mandatory",
)}
</th>
<th className="px-4 py-3 text-right font-bold">
{t("ui.dev.clients.general.scopes.table.delete", "Delete")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{scopes.map((s) => (
<tr
key={s.id}
className={cn(
"transition-colors",
s.locked ? "bg-primary/5" : "hover:bg-muted/30",
)}
>
<td className="px-4 py-3">
<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}
/>
</td>
<td className="px-4 py-3">
<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}
/>
</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center">
<Switch
checked={s.mandatory}
onCheckedChange={(checked) =>
updateScope(s.id, "mandatory", checked)
}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 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>
</td>
</tr>
))}
{scopes.length === 0 && (
<tr>
<td
colSpan={4}
className="px-4 py-8 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.general.scopes.empty",
"등록된 스코프가 없습니다.",
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</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-1">
<CardTitle className="text-xl font-bold">
{t(
"ui.dev.clients.general.tenant_access.title",
"테넌트 접근 제한",
)}
</CardTitle>
<CardDescription>
{t(
"ui.dev.clients.general.tenant_access.subtitle",
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
)}
</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">
{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-5">
<p className="text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.hint",
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
)}
</p>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-3">
<Label htmlFor="tenant-search" className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="tenant-search"
value={tenantSearch}
onFocus={() => {
if (tenantAccessRestricted) {
setIsTenantSearchOpen(true);
}
}}
onBlur={() => {
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
}}
onChange={(e) => {
setTenantSearch(e.target.value);
if (tenantAccessRestricted) {
setIsTenantSearchOpen(true);
}
}}
placeholder={t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
className="pl-10"
disabled={
isGeneralSettingsReadOnly || !tenantAccessRestricted
}
/>
{tenantAccessRestricted && isTenantSearchOpen && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
{tenantSuggestions.length > 0 ? (
tenantSuggestions.map((tenant) => (
<button
key={tenant.id}
type="button"
className="flex w-full items-start justify-between gap-3 border-b border-border/40 px-4 py-3 text-left transition hover:bg-muted/40 last:border-b-0"
onMouseDown={(event) => {
event.preventDefault();
handleSelectAllowedTenant(tenant.id);
}}
disabled={isGeneralSettingsReadOnly}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{tenant.name}
</span>
<Badge variant="outline" className="text-[11px]">
{tenant.slug}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">
{tenant.description || tenant.type}
</p>
</div>
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</button>
))
) : (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.empty",
"검색 결과가 없습니다.",
)}
</div>
)}
</div>
)}
</div>
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.autocomplete_hint",
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.selected_title",
"허용 테넌트",
)}
</Label>
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedAllowedTenants.map((tenant) => (
<Badge
key={tenant.id}
variant="secondary"
className="gap-2 px-3 py-1.5"
>
<Check className="h-3.5 w-3.5" />
<span className="max-w-44 truncate">{tenant.name}</span>
<span className="text-[11px] text-muted-foreground">
{tenant.slug}
</span>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenant.id)}
className="text-muted-foreground transition hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
{allowedTenantIds
.filter(
(tenantId) =>
!selectedAllowedTenants.some(
(tenant) => tenant.id === tenantId,
),
)
.map((tenantId) => (
<Badge
key={tenantId}
variant="secondary"
className="gap-2 px-3 py-1.5"
>
<Check className="h-3.5 w-3.5" />
<span className="max-w-44 truncate">{tenantId}</span>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenantId)}
className="text-muted-foreground transition hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
</div>
) : (
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.selected_empty",
"아직 선택된 테넌트가 없습니다.",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
)}
</div>
</div>
</div>
</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-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",
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
)}
</CardDescription>
</div>
<Button
onClick={addIdTokenClaim}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
<div className="space-y-3">
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.key",
"Claim Key",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.namespace",
"Namespace",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value_type",
"Value Type",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value",
"Value",
)}
</th>
<th className="px-4 py-3 text-right font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.delete",
"Delete",
)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{idTokenClaims.map((claim) => (
<tr key={claim.id} className="hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<Input
value={claim.key}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"key",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.namespace}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"namespace",
e.target.value as ClaimNamespace,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.namespace_label",
"Claim namespace",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="top_level">
{t(
"ui.dev.clients.general.id_token_claims.namespace_top_level",
"top-level",
)}
</option>
<option value="rp_claims">
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.valueType}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"valueType",
e.target.value as ClaimValueType,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.value_type_label",
"Claim value type",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="text">
{t(
"ui.dev.clients.general.id_token_claims.value_type_text",
"Text",
)}
</option>
<option value="number">
{t(
"ui.dev.clients.general.id_token_claims.value_type_number",
"Number",
)}
</option>
<option value="boolean">
{t(
"ui.dev.clients.general.id_token_claims.value_type_boolean",
"Boolean",
)}
</option>
<option value="array">
{t(
"ui.dev.clients.general.id_token_claims.value_type_array",
"Array",
)}
</option>
<option value="object">
{t(
"ui.dev.clients.general.id_token_claims.value_type_object",
"Object",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<Input
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the claim value",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 text-right align-top">
<Button
variant="ghost"
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
{idTokenClaims.length === 0 && (
<tr>
<td
colSpan={5}
className="px-4 py-8 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.general.id_token_claims.empty",
"아직 추가된 ID Token claim이 없습니다.",
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
<p className="text-xs leading-6 text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.hint",
"top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
)}
</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",
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
)}
</p>
</div>
</div>
<Textarea
readOnly
value={idTokenClaimPreviewJson}
className="mt-4 min-h-72 font-mono text-xs"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 3. 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>
</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>
{securityProfile === "pkce" && (
<div
className="mt-4 pt-4 border-t border-primary/20 flex items-center justify-between"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="space-y-0.5">
<Label
className="text-xs font-bold cursor-pointer"
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",
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
)}
</p>
</div>
<Switch
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
disabled={isGeneralSettingsReadOnly}
/>
</div>
)}
</label>
</div>
</CardContent>
</Card>
{/* 4. Public Key Registration (Headless Login) */}
{clientType === "pkce" && 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",
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
)}
</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",
"애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.",
)}
</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, index) => {
const normalizedAlgorithm = key.alg?.trim() ?? "";
const isMissingAlgorithm =
normalizedAlgorithm === "";
const isUnsupportedAlgorithm =
!isMissingAlgorithm &&
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
normalizedAlgorithm,
);
return (
<div
key={`${key.kid || "key"}-${index}`}
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;