forked from baron/baron-sso
feat(headless-login): add jwks cache visibility and refresh flow
- replace inline headless jwks support with jwksUri-only validation - add cached jwks refresh worker, manual refresh/revoke endpoints, and parsed key summaries - expose allowed algorithms and key previews in DevFront with regression coverage
This commit is contained in:
@@ -29,6 +29,8 @@ import {
|
||||
createClient,
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
refreshHeadlessJwksCache,
|
||||
revokeHeadlessJwksCache,
|
||||
updateClient,
|
||||
updateClientStatus,
|
||||
} from "../../lib/devApi";
|
||||
@@ -38,7 +40,6 @@ import type {
|
||||
ClientUpsertRequest,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { tryConvertToJwks } from "../../lib/keyUtils";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface ScopeItem {
|
||||
@@ -54,6 +55,19 @@ type TokenEndpointAuthMethod =
|
||||
| "client_secret_basic"
|
||||
| "private_key_jwt";
|
||||
|
||||
const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"EdDSA",
|
||||
] as const;
|
||||
|
||||
function isTokenEndpointAuthMethod(
|
||||
value: string,
|
||||
): value is TokenEndpointAuthMethod {
|
||||
@@ -72,17 +86,6 @@ function readMetadataString(
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function readMetadataObject(
|
||||
metadata: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const value = metadata[key];
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
@@ -92,14 +95,17 @@ function isValidUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidJson(value: string): boolean {
|
||||
if (!value.trim()) return false;
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} 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 previewKeyMaterial(value?: string) {
|
||||
if (!value) return "-";
|
||||
if (value.length <= 23) return value;
|
||||
return `${value.slice(0, 10)}...${value.slice(-10)}`;
|
||||
}
|
||||
|
||||
function ClientGeneralPage() {
|
||||
@@ -125,9 +131,7 @@ function ClientGeneralPage() {
|
||||
// Public Key Registration States
|
||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||
useState<TokenEndpointAuthMethod>("client_secret_basic");
|
||||
const [jwksSource, setJwksSource] = useState<"uri" | "inline">("inline");
|
||||
const [jwksUri, setJwksUri] = useState("");
|
||||
const [jwksText, setJwksText] = useState("");
|
||||
const [requestObjectSigningAlg, setRequestObjectSigningAlg] =
|
||||
useState("RS256");
|
||||
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
||||
@@ -185,31 +189,12 @@ function ClientGeneralPage() {
|
||||
}
|
||||
|
||||
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
|
||||
const headlessJwks = readMetadataObject(metadata, "headless_jwks");
|
||||
if (headlessJwksUri) {
|
||||
setJwksUri(headlessJwksUri);
|
||||
setJwksText("");
|
||||
setJwksSource("uri");
|
||||
} else if (headlessJwks) {
|
||||
setJwksText(JSON.stringify(headlessJwks, null, 2));
|
||||
setJwksUri("");
|
||||
setJwksSource("inline");
|
||||
} else if (client.jwksUri) {
|
||||
setJwksUri(client.jwksUri);
|
||||
setJwksText("");
|
||||
setJwksSource("uri");
|
||||
} else if (client.jwks) {
|
||||
setJwksText(
|
||||
typeof client.jwks === "string"
|
||||
? client.jwks
|
||||
: JSON.stringify(client.jwks, null, 2),
|
||||
);
|
||||
setJwksUri("");
|
||||
setJwksSource("inline");
|
||||
} else {
|
||||
setJwksUri("");
|
||||
setJwksText("");
|
||||
setJwksSource("inline");
|
||||
}
|
||||
|
||||
// Fallbacks from metadata if top-level fields are empty
|
||||
@@ -223,11 +208,10 @@ function ClientGeneralPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!client.jwksUri && !client.jwks && !headlessEnabled) {
|
||||
if (!client.jwksUri && !headlessEnabled) {
|
||||
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
|
||||
if (metaJwksUri) {
|
||||
setJwksUri(metaJwksUri);
|
||||
setJwksSource("uri");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,46 +303,25 @@ function ClientGeneralPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// Convert on blur or change if desired, here we try to convert before validation
|
||||
const finalJwksText = tryConvertToJwks(jwksText);
|
||||
const validationErrors: string[] = [];
|
||||
const trimmedJwksUri = jwksUri.trim();
|
||||
const trimmedJwksText = finalJwksText.trim();
|
||||
const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim();
|
||||
|
||||
if (headlessLoginEnabled) {
|
||||
if (jwksSource === "uri") {
|
||||
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 형식이 올바르지 않습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (jwksSource === "inline") {
|
||||
if (!trimmedJwksText) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.missing_jwks_inline",
|
||||
"공개키(JWKS 또는 SSH-RSA)를 입력해야 합니다.",
|
||||
),
|
||||
);
|
||||
} else if (!isValidJson(trimmedJwksText)) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.invalid_jwks_inline",
|
||||
"입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
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 (trimmedRequestObjectSigningAlg === "") {
|
||||
@@ -372,20 +335,75 @@ function ClientGeneralPage() {
|
||||
}
|
||||
|
||||
const hasValidationErrors = validationErrors.length > 0;
|
||||
const currentHeadlessJwksCache = data?.headlessJwksCache;
|
||||
|
||||
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 () => {
|
||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||
|
||||
let finalJwks: ClientUpsertRequest["jwks"];
|
||||
if (jwksSource === "inline" && trimmedJwksText) {
|
||||
try {
|
||||
finalJwks = JSON.parse(trimmedJwksText);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid Public Key Format");
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveTokenEndpointAuthMethod =
|
||||
clientType === "pkce" && headlessLoginEnabled
|
||||
? "none"
|
||||
@@ -398,13 +416,9 @@ function ClientGeneralPage() {
|
||||
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
|
||||
jwksUri:
|
||||
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
|
||||
jwksSource === "uri"
|
||||
trimmedJwksUri
|
||||
? trimmedJwksUri
|
||||
: undefined,
|
||||
jwks:
|
||||
effectiveTokenEndpointAuthMethod === "private_key_jwt"
|
||||
? finalJwks
|
||||
: undefined,
|
||||
metadata: {
|
||||
description,
|
||||
logo_url: logoUrl,
|
||||
@@ -418,16 +432,9 @@ function ClientGeneralPage() {
|
||||
: undefined,
|
||||
headless_jwks_uri:
|
||||
clientType === "pkce" &&
|
||||
headlessLoginEnabled &&
|
||||
jwksSource === "uri"
|
||||
headlessLoginEnabled
|
||||
? trimmedJwksUri
|
||||
: undefined,
|
||||
headless_jwks:
|
||||
clientType === "pkce" &&
|
||||
headlessLoginEnabled &&
|
||||
jwksSource === "inline"
|
||||
? finalJwks
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1045,74 +1052,60 @@ function ClientGeneralPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg",
|
||||
"Request Object Signing Algorithm",
|
||||
)}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={requestObjectSigningAlg}
|
||||
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
|
||||
"예: RS256",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.request_object_alg_help",
|
||||
"Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-border bg-muted/5 p-4">
|
||||
<div className="space-y-1 pb-2 border-b border-border/50">
|
||||
<Label className="text-sm font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.source",
|
||||
"Public Key Source",
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.source_help",
|
||||
"OIDC 검증을 위한 공개키 제공 방식을 선택합니다. (운영 환경에서는 JWKS URI 사용을 권장합니다)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="jwksSource"
|
||||
checked={jwksSource === "inline"}
|
||||
onChange={() => setJwksSource("inline")}
|
||||
className="accent-primary"
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold" htmlFor="request-object-signing-alg">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg",
|
||||
"Request Object Signing Algorithm",
|
||||
)}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="request-object-signing-alg"
|
||||
value={requestObjectSigningAlg}
|
||||
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
|
||||
"예: RS256",
|
||||
)}
|
||||
/>
|
||||
<span>Inline Public Key</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="jwksSource"
|
||||
checked={jwksSource === "uri"}
|
||||
onChange={() => setJwksSource("uri")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>JWKS URI</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.request_object_alg_help",
|
||||
"Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-background/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.allowed_algorithms",
|
||||
"Allowed Algorithms",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{HEADLESS_LOGIN_ALLOWED_ALGORITHMS.map((algorithm) => (
|
||||
<Badge
|
||||
key={algorithm}
|
||||
variant="outline"
|
||||
className="font-mono text-[11px]"
|
||||
>
|
||||
{algorithm}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.allowed_algorithms_help",
|
||||
"Headless Login JAR 검증은 이 목록에 있는 서명 알고리즘만 허용합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jwksSource === "uri" && (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.jwks_uri",
|
||||
"JWKS URI",
|
||||
@@ -1120,6 +1113,7 @@ function ClientGeneralPage() {
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="jwks-uri"
|
||||
value={jwksUri}
|
||||
onChange={(e) => setJwksUri(e.target.value)}
|
||||
placeholder={t(
|
||||
@@ -1134,35 +1128,250 @@ function ClientGeneralPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jwksSource === "inline" && (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.jwks_inline",
|
||||
"JWKS 또는 OpenSSH 공개키",
|
||||
)}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={jwksText}
|
||||
onChange={(e) => setJwksText(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.public_key.jwks_inline_placeholder",
|
||||
"JWKS (JSON) 또는 'ssh-rsa AAA...' 형식의 공개키를 붙여넣으세요.",
|
||||
)}
|
||||
className="font-mono text-xs leading-tight"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.jwks_inline_help",
|
||||
"OIDC 표준인 JWKS(JSON) 형식을 권장하지만, SSH-RSA 공개키를 입력하면 자동으로 변환하여 저장합니다.",
|
||||
)}
|
||||
</p>
|
||||
<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>
|
||||
<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="grid gap-3 lg:grid-cols-2">
|
||||
{currentHeadlessJwksCache.parsedKeys.map((key, index) => (
|
||||
<div
|
||||
key={`${key.kid || "key"}-${index}`}
|
||||
className="rounded-xl border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{key.kid || "-"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{key.kty || "-"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{key.use || "-"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{key.alg || "-"}
|
||||
</Badge>
|
||||
</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 Preview",
|
||||
)}
|
||||
</p>
|
||||
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
|
||||
{previewKeyMaterial(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 && (
|
||||
|
||||
Reference in New Issue
Block a user