1
0
forked from baron/baron-sso

i18n refresh and frontend fixes

This commit is contained in:
Lectom C Han
2026-02-10 19:15:51 +09:00
parent 2441c64598
commit b6d3b69cda
44 changed files with 8603 additions and 1760 deletions

View File

@@ -1,11 +1,19 @@
import React, { useState, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertCircle, Copy, Eye, EyeOff, Link2, Shield, Workflow, Save, RefreshCw } from "lucide-react";
import { Eye, EyeOff, Link2, RefreshCw, Save, Shield } from "lucide-react";
import { useEffect, 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, CardHeader, CardTitle, CardDescription } from "../../components/ui/card";
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,
@@ -14,17 +22,20 @@ import {
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { Label } from "../../components/ui/label";
import { fetchClient, updateClient, rotateClientSecret } from "../../lib/devApi";
import { cn } from "../../lib/utils";
import { CopyButton } from "../../components/ui/copy-button";
import { toast } from "../../components/ui/use-toast";
import {
fetchClient,
rotateClientSecret,
updateClient,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientDetailsPage() {
const params = useParams();
const queryClient = useQueryClient();
const clientId = params.id ?? "";
const { data, isLoading, error } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
@@ -50,10 +61,20 @@ function ClientDetailsPage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast("Redirect URIs가 저장되었습니다.");
toast(
t(
"msg.dev.clients.details.redirect_saved",
"Redirect URIs가 저장되었습니다.",
),
);
},
onError: (err) => {
toast(`저장 실패: ${(err as Error).message}`, "error");
toast(
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
@@ -61,26 +82,51 @@ function ClientDetailsPage() {
mutationFn: () => rotateClientSecret(clientId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast("Client Secret이 재발급되었습니다.");
toast(
t(
"msg.dev.clients.details.secret_rotated",
"Client Secret이 재발급되었습니다.",
),
);
setShowSecret(true); // 재발급 후 바로 보여줌
},
onError: (err) => {
toast(`재발급 실패: ${(err as Error).message}`, "error");
toast(
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
const handleRotateSecret = () => {
if (window.confirm("경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?")) {
if (
window.confirm(
t(
"msg.dev.clients.details.rotate_confirm",
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
),
)
) {
rotateMutation.mutate();
}
};
if (!clientId) {
return <div className="p-8 text-center">Client ID가 .</div>;
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
</div>
);
}
if (isLoading) {
return <div className="p-8 text-center">Loading client...</div>;
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading client...")}
</div>
);
}
if (error || !data) {
@@ -89,31 +135,62 @@ function ClientDetailsPage() {
(error as Error)?.message;
return (
<div className="p-8 text-center text-red-500">
Error loading client: {errMsg || "unknown error"}
{t(
"msg.dev.clients.details.load_error",
"Error loading client: {{error}}",
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
)}
</div>
);
}
const endpoints = [
{ label: "Discovery Endpoint", value: data.endpoints.discovery },
{ label: "Issuer URL", value: data.endpoints.issuer },
{ label: "Authorization Endpoint", value: data.endpoints.authorization },
{ label: "Token Endpoint", value: data.endpoints.token },
{ label: "UserInfo Endpoint", value: data.endpoints.userinfo },
{
labelKey: "ui.dev.clients.details.endpoint.discovery",
labelFallback: "Discovery Endpoint",
value: data.endpoints.discovery,
},
{
labelKey: "ui.dev.clients.details.endpoint.issuer",
labelFallback: "Issuer URL",
value: data.endpoints.issuer,
},
{
labelKey: "ui.dev.clients.details.endpoint.authorization",
labelFallback: "Authorization Endpoint",
value: data.endpoints.authorization,
},
{
labelKey: "ui.dev.clients.details.endpoint.token",
labelFallback: "Token Endpoint",
value: data.endpoints.token,
},
{
labelKey: "ui.dev.clients.details.endpoint.userinfo",
labelFallback: "UserInfo Endpoint",
value: data.endpoints.userinfo,
},
];
// Client Secret from API
const clientSecret = data.client.clientSecret || "SECRET_NOT_AVAILABLE";
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = data.client.clientSecret || secretPlaceholder;
const displaySecret =
clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret;
return (
<div className="space-y-8">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">
Relying Parties
{t("ui.dev.clients.details.breadcrumb.section", "Relying Parties")}
</Link>
<span>/</span>
<span className="text-foreground"> </span>
<span className="text-foreground">
{t("ui.dev.clients.details.breadcrumb.current", "클라이언트 상세")}
</span>
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
@@ -121,14 +198,19 @@ function ClientDetailsPage() {
{data.client.name || data.client.id}
</h1>
<p className="text-muted-foreground">
OIDC .
{t(
"msg.dev.clients.details.subtitle",
"OIDC 자격 증명과 엔드포인트를 관리합니다.",
)}
</p>
</div>
<Badge
variant={data.client.status === "active" ? "success" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{data.client.status === "active" ? "Active" : "Inactive"}
{data.client.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</div>
<div className="flex gap-6 border-b border-border">
@@ -136,19 +218,19 @@ function ClientDetailsPage() {
to={`/clients/${clientId}`}
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
>
Connection
{t("ui.dev.clients.details.tab.connection", "Connection")}
</Link>
<Link
to={`/clients/${clientId}/consents`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
Consent &amp; Users
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
</Link>
<Link
to={`/clients/${clientId}/settings`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
Settings
{t("ui.dev.clients.details.tab.settings", "Settings")}
</Link>
</div>
</div>
@@ -156,18 +238,35 @@ function ClientDetailsPage() {
<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"> </h2>
<h2 className="text-xl font-bold">
{t(
"ui.dev.clients.details.credentials.title",
"클라이언트 자격 증명",
)}
</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">
Client ID
{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">{data.client.id}</p>
<CopyButton
value={data.client.id}
onCopy={() => toast("Client ID가 복사되었습니다.")}
<p className="font-mono text-lg truncate">
{data.client.id}
</p>
<CopyButton
value={data.client.id}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_id",
"Client ID가 복사되었습니다.",
),
)
}
/>
</div>
</div>
@@ -176,37 +275,73 @@ function ClientDetailsPage() {
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Client Secret
{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 ? clientSecret : "••••••••••••••••"}
<p
className={cn(
"font-mono text-lg",
!showSecret && "tracking-widest",
)}
>
{showSecret ? displaySecret : "••••••••••••••••"}
</p>
<div className="flex gap-2 shrink-0">
<Button
variant="secondary"
size="icon"
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={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" />}
{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="비밀키 재발급 (Rotate)"
title={t(
"ui.dev.clients.details.secret.rotate",
"비밀키 재발급 (Rotate)",
)}
>
<RefreshCw className={cn("h-4 w-4", rotateMutation.isPending && "animate-spin")} />
<RefreshCw
className={cn(
"h-4 w-4",
rotateMutation.isPending && "animate-spin",
)}
/>
</Button>
<CopyButton
<CopyButton
value={clientSecret}
disabled={!showSecret && clientSecret === "SECRET_NOT_AVAILABLE"}
onCopy={() => toast("Client Secret이 복사되었습니다.")}
disabled={
!showSecret && clientSecret === secretPlaceholder
}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_secret",
"Client Secret이 복사되었습니다.",
),
)
}
/>
</div>
</div>
@@ -217,20 +352,25 @@ function ClientDetailsPage() {
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">OIDC </h2>
<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.label} className="border-border/70">
<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">
{endpoint.label}
{t(endpoint.labelKey, endpoint.labelFallback)}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
@@ -240,7 +380,20 @@ function ClientDetailsPage() {
<CopyButton
value={endpoint.value}
className="h-8 w-8 shrink-0"
onCopy={() => toast(`${endpoint.label}가 복사되었습니다.`)}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_endpoint",
"{{label}}가 복사되었습니다.",
{
label: t(
endpoint.labelKey,
endpoint.labelFallback,
),
},
),
)
}
/>
</TableCell>
</TableRow>
@@ -253,33 +406,56 @@ function ClientDetailsPage() {
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold"> URI </h2>
<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">Redirect URIs</CardTitle>
<CardTitle className="text-lg">
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
</CardTitle>
<CardDescription>
URL . (,) .
{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"> URL</Label>
<Label
htmlFor="redirect-uris"
className="text-sm font-semibold"
>
{t(
"ui.dev.clients.details.redirect.callback_label",
"인증 콜백 URL",
)}
</Label>
<Textarea
id="redirect-uris"
placeholder="https://your-app.com/callback, http://localhost:3000/auth/callback"
placeholder={t(
"ui.dev.clients.details.redirect.placeholder",
"https://your-app.com/callback, http://localhost:3000/auth/callback",
)}
rows={5}
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
className="font-mono text-sm"
/>
</div>
<Button
className="w-full gap-2"
<Button
className="w-full gap-2"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Save className="h-4 w-4" />
{mutation.isPending ? "저장 중..." : "Redirect URIs 저장"}
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: t(
"ui.dev.clients.details.redirect.save",
"Redirect URIs 저장",
)}
</Button>
</CardContent>
</Card>
@@ -292,18 +468,24 @@ function ClientDetailsPage() {
<Shield className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold"> </p>
<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">
TTL ,
.
{t(
"msg.dev.clients.details.security.footer",
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
)}
</p>
</div>
</div>