1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/clients/ClientDetailsPage.tsx
2026-05-04 11:28:53 +09:00

556 lines
19 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
Eye,
EyeOff,
Link2,
RefreshCw,
Save,
Shield,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, 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 { CopyButton } from "../../components/ui/copy-button";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import {
fetchClient,
rotateClientSecret,
updateClient,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs";
function ClientDetailsPage() {
const params = useParams();
const queryClient = useQueryClient();
const clientId = params.id ?? "";
const { data, error, isLoading } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
const [redirectUris, setRedirectUris] = useState("");
const [showSecret, setShowSecret] = useState(false);
const redirectUrisHydratedRef = useRef(false);
useEffect(() => {
if (
!redirectUrisHydratedRef.current &&
data?.client?.redirectUris &&
redirectUris === ""
) {
setRedirectUris(data.client.redirectUris.join(", "));
redirectUrisHydratedRef.current = true;
}
}, [data, redirectUris]);
const mutation = useMutation({
mutationFn: () => {
const uriList = redirectUris
.split(",")
.map((u) => u.trim())
.filter(Boolean);
return updateClient(clientId, { redirectUris: uriList });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.redirect_saved",
"Redirect URIs가 저장되었습니다.",
),
);
},
onError: (err) => {
const axiosError = err as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
toast(
t(
"msg.dev.clients.details.save_forbidden",
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
),
"error",
);
return;
}
toast(
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
error:
axiosError.response?.data?.error ??
(err as Error).message ??
t("msg.common.unknown_error", "unknown error"),
}),
"error",
);
},
});
const rotateMutation = useMutation({
mutationFn: () => rotateClientSecret(clientId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.secret_rotated",
"Client Secret이 재발급되었습니다.",
),
);
setShowSecret(true); // 재발급 후 바로 보여줌
},
onError: (err) => {
toast(
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
const handleRotateSecret = () => {
if (
window.confirm(
t(
"msg.dev.clients.details.rotate_confirm",
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
),
)
) {
rotateMutation.mutate();
}
};
if (!clientId) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
</div>
);
}
if (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.details.load_error",
"Error loading app: {{error}}",
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
)}
</div>
);
}
if (isLoading && !data) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading app details...")}
</div>
);
}
const client = data?.client;
if (!client) {
return null;
}
const endpointValues = data?.endpoints ?? {
discovery: "-",
issuer: "-",
authorization: "-",
token: "-",
userinfo: "-",
};
const endpoints = [
{
labelKey: "ui.dev.clients.details.endpoint.discovery",
labelFallback: "Discovery Endpoint",
value: endpointValues.discovery,
},
{
labelKey: "ui.dev.clients.details.endpoint.issuer",
labelFallback: "Issuer URL",
value: endpointValues.issuer,
},
{
labelKey: "ui.dev.clients.details.endpoint.authorization",
labelFallback: "Authorization Endpoint",
value: endpointValues.authorization,
},
{
labelKey: "ui.dev.clients.details.endpoint.token",
labelFallback: "Token Endpoint",
value: endpointValues.token,
},
{
labelKey: "ui.dev.clients.details.endpoint.userinfo",
labelFallback: "UserInfo Endpoint",
value: endpointValues.userinfo,
},
];
const hasClientSecret = client.type === "private";
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = hasClientSecret
? client?.clientSecret || secretPlaceholder
: t("ui.common.na", "N/A");
const displaySecret = !hasClientSecret
? t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)
: clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret;
return (
<div className="space-y-8">
<div className="space-y-3">
<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>{client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.connection", "Federation")}
</span>
</nav>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to="/clients">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-4xl font-black leading-tight tracking-tight">
{client?.name || client?.id || clientId}
</h1>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.details.subtitle",
"Manage OIDC credentials and endpoints.",
)}
</p>
</div>
</div>
<Badge
variant={client?.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client?.status === "active"
? t("ui.common.status.active", "Active")
: client?.status === "inactive"
? t("ui.common.status.inactive", "Inactive")
: t("msg.common.loading", "Loading...")}
</Badge>
</div>
<ClientDetailTabs activeTab="connection" clientId={clientId} />
</div>
<div className="grid gap-8 lg:grid-cols-2">
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t(
"ui.dev.clients.details.credentials.title",
"Client Credentials",
)}
</h2>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 p-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_id",
"Client ID",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-lg truncate">
{client?.id || clientId}
</p>
<CopyButton
value={client?.id || clientId}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_id",
"Client ID가 복사되었습니다.",
),
)
}
/>
</div>
</div>
<Separator />
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_secret",
"Client Secret",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"font-mono text-lg",
!showSecret && "tracking-widest",
)}
>
{showSecret ? displaySecret : "••••••••••••••••"}
</p>
<div className="flex gap-2 shrink-0">
{hasClientSecret ? (
<>
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={
showSecret
? t(
"ui.dev.clients.details.secret.hide",
"비밀키 숨기기",
)
: t(
"ui.dev.clients.details.secret.show",
"비밀키 보기",
)
}
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="secondary"
size="icon"
onClick={handleRotateSecret}
disabled={rotateMutation.isPending}
title={t(
"ui.dev.clients.details.secret.rotate",
"비밀키 재발급 (Rotate)",
)}
>
<RefreshCw
className={cn(
"h-4 w-4",
rotateMutation.isPending && "animate-spin",
)}
/>
</Button>
<CopyButton
value={clientSecret}
disabled={
!showSecret && clientSecret === secretPlaceholder
}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_secret",
"Client Secret이 복사되었습니다.",
),
)
}
/>
</>
) : null}
</div>
</div>
{!hasClientSecret ? (
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)}
</p>
) : null}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.endpoints.title", "OIDC 엔드포인트")}
</h2>
<Badge variant="muted" className="gap-1">
<Link2 className="h-3 w-3" />
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
</Badge>
</div>
<Card className="glass-panel">
<Table>
<TableBody>
{endpoints.map((endpoint) => (
<TableRow
key={endpoint.labelKey}
className="border-border/70"
>
<TableCell className="w-1/3">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
{t(endpoint.labelKey, endpoint.labelFallback)}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
<span className="break-all font-mono text-sm">
{endpoint.value}
</span>
<CopyButton
value={endpoint.value}
className="h-8 w-8 shrink-0"
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_endpoint",
"{{label}}가 복사되었습니다.",
{
label: t(
endpoint.labelKey,
endpoint.labelFallback,
),
},
),
)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.redirect.title", "리디렉션 URI 설정")}
</h2>
<Card className="glass-panel border-primary/20">
<CardHeader>
<CardTitle className="text-lg">
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.details.redirect.description",
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label
htmlFor="redirect-uris"
className="text-sm font-semibold"
>
{t(
"ui.dev.clients.details.redirect.callback_label",
"인증 콜백 URL",
)}
</Label>
<Textarea
id="redirect-uris"
placeholder={t(
"ui.dev.clients.details.redirect.placeholder",
"https://your-app.com/callback, http://localhost:3000/auth/callback",
)}
rows={5}
value={redirectUris}
onChange={(e) => {
redirectUrisHydratedRef.current = true;
setRedirectUris(e.target.value);
}}
className="font-mono text-sm"
/>
</div>
<Button
className="w-full gap-2"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Save className="h-4 w-4" />
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: t(
"ui.dev.clients.details.redirect.save",
"Redirect URIs 저장",
)}
</Button>
</CardContent>
</Card>
</div>
<div className="glass-panel p-6 opacity-80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<Shield className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold">
{t("ui.dev.clients.details.security.title", "보안 메모")}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.note",
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
)}
</p>
</div>
</div>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.footer",
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
)}
</p>
</div>
</div>
</div>
</div>
);
}
export default ClientDetailsPage;