forked from baron/baron-sso
1640 lines
63 KiB
TypeScript
1640 lines
63 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import type { AxiosError } from "axios";
|
||
import {
|
||
ArrowLeft,
|
||
ExternalLink,
|
||
Info,
|
||
Plus,
|
||
Save,
|
||
Shield,
|
||
Sparkles,
|
||
Trash2,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
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 {
|
||
createClient,
|
||
deleteClient,
|
||
fetchClient,
|
||
refreshHeadlessJwksCache,
|
||
revokeHeadlessJwksCache,
|
||
updateClient,
|
||
updateClientStatus,
|
||
} from "../../lib/devApi";
|
||
import type {
|
||
ClientStatus,
|
||
ClientType,
|
||
ClientUpsertRequest,
|
||
} from "../../lib/devApi";
|
||
import { t } from "../../lib/i18n";
|
||
import { cn } from "../../lib/utils";
|
||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||
|
||
interface ScopeItem {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
mandatory: boolean;
|
||
}
|
||
|
||
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 isValidUrl(value: string): boolean {
|
||
try {
|
||
const url = new URL(value);
|
||
return url.protocol === "https:" || url.protocol === "http:";
|
||
} 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 params = useParams();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const clientId = params.id;
|
||
const isCreate = !clientId;
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ["client", clientId],
|
||
queryFn: () => fetchClient(clientId as string),
|
||
enabled: !isCreate,
|
||
});
|
||
|
||
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("");
|
||
|
||
// 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: "profile",
|
||
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
|
||
mandatory: false,
|
||
},
|
||
{
|
||
id: "3",
|
||
name: "email",
|
||
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
||
mandatory: false,
|
||
},
|
||
]);
|
||
|
||
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);
|
||
|
||
const headlessEnabled = !!metadata.headless_login_enabled;
|
||
setHeadlessLoginEnabled(headlessEnabled);
|
||
|
||
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(savedScopes);
|
||
} else {
|
||
setScopes(
|
||
client.scopes.map((s, idx) => ({
|
||
id: String(idx + 1),
|
||
name: s,
|
||
description: "",
|
||
mandatory: s === "openid",
|
||
})),
|
||
);
|
||
}
|
||
}, [data]);
|
||
|
||
const securityProfile: SecurityProfile =
|
||
clientType === "pkce" ? "pkce" : "private";
|
||
const trimmedLogoUrl = logoUrl.trim();
|
||
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
||
|
||
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 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(
|
||
scopes.map((scope) =>
|
||
scope.id === id ? { ...scope, [field]: value } : scope,
|
||
),
|
||
);
|
||
};
|
||
|
||
const removeScope = (id: string) => {
|
||
setScopes(scopes.filter((s) => s.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(", ") },
|
||
);
|
||
|
||
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 },
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
const hasValidationErrors = validationErrors.length > 0;
|
||
|
||
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 주소를 입력하세요.",
|
||
),
|
||
);
|
||
}
|
||
|
||
const scopeNames = scopes.map((scope) => scope.name).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,
|
||
metadata: {
|
||
description,
|
||
logo_url: trimmedLogoUrl,
|
||
structured_scopes: scopes,
|
||
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,
|
||
},
|
||
};
|
||
|
||
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`);
|
||
}
|
||
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
|
||
},
|
||
onError: (err) => {
|
||
const axiosError = err as AxiosError<{ error?: string }>;
|
||
if (axiosError.response?.status === 403) {
|
||
alert(
|
||
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");
|
||
alert(
|
||
t(
|
||
"msg.dev.clients.general.save_error",
|
||
"저장에 실패했습니다: {{error}}",
|
||
{
|
||
error: errorMessage,
|
||
},
|
||
),
|
||
);
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => deleteClient(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||
alert(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
|
||
navigate("/clients");
|
||
},
|
||
onError: (err) => {
|
||
const errorMessage =
|
||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(err as Error)?.message;
|
||
alert(
|
||
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
|
||
error: errorMessage,
|
||
}),
|
||
);
|
||
},
|
||
});
|
||
|
||
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} />
|
||
)}
|
||
</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",
|
||
)}
|
||
/>
|
||
</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",
|
||
"앱에 대한 간단한 설명을 입력하세요.",
|
||
)}
|
||
/>
|
||
</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",
|
||
)}
|
||
/>
|
||
<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")}
|
||
>
|
||
{t("ui.common.status.active", "활성")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={status === "inactive" ? "default" : "outline"}
|
||
onClick={() => handleStatusChange("inactive")}
|
||
>
|
||
{t("ui.common.status.inactive", "비활성")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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"
|
||
>
|
||
<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"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.redirect.help",
|
||
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
|
||
)}
|
||
</p>
|
||
</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="hover:bg-muted/30 transition-colors"
|
||
>
|
||
<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",
|
||
)}
|
||
/>
|
||
</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",
|
||
"권한에 대한 설명",
|
||
)}
|
||
/>
|
||
</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)
|
||
}
|
||
/>
|
||
</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"
|
||
>
|
||
<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>
|
||
|
||
{/* 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}
|
||
/>
|
||
</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={
|
||
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;
|