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,123 +1,131 @@
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { t } from "../../lib/i18n";
import { Toaster } from "../ui/toaster";
const navItems = [{ label: "Clients", to: "/clients", icon: ShieldHalf }];
const navItems = [
{
labelKey: "ui.dev.nav.clients",
labelFallback: "Clients",
to: "/clients",
icon: ShieldHalf,
},
];
function AppLayout() {
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Baron
</p>
<h1 className="text-lg font-semibold">
Developer Console
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
Scoped to /dev
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
Env: dev
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{label}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p> .</p>
<p>
.
</p>
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
Dev Plane
</p>
<span className="text-lg font-semibold">
Manage your applications
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
aria-label="테마 전환"
>
{theme === "light" ? (
<Sun size={16} />
) : (
<Moon size={16} />
)}
{theme === "light" ? "Light" : "Dark"}
</button>
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<Toaster />
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.dev.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.dev.console_title", "Developer Console")}
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
</div>
</div>
);
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p>
<p>
{t(
"msg.dev.sidebar.notice_detail",
"클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.",
)}
</p>
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.dev.header.plane", "Dev Plane")}
</p>
<span className="text-lg font-semibold">
{t("ui.dev.header.subtitle", "Manage your applications")}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.theme_toggle", "테마 전환")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light"
? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")}
</button>
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
</div>
<Toaster />
</div>
);
}
export default AppLayout;

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { Check, Copy } from "lucide-react";
import { Button, type ButtonProps } from "./button";
import * as React from "react";
import { cn } from "../../lib/utils";
import { Button, type ButtonProps } from "./button";
interface CopyButtonProps extends ButtonProps {
value: string;
@@ -40,10 +40,10 @@ export function CopyButton({
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (!successful) throw new Error('execCommand copy failed');
const successful = document.execCommand("copy");
if (!successful) throw new Error("execCommand copy failed");
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
console.error("Fallback: Oops, unable to copy", err);
throw err;
} finally {
document.body.removeChild(textArea);

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { useToastState } from "./use-toast";
import { CheckCircle2, AlertCircle, Info, X } from "lucide-react";
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { cn } from "../../lib/utils";
import { useToastState } from "./use-toast";
export function Toaster() {
const toasts = useToastState();
@@ -15,12 +14,17 @@ export function Toaster() {
key={t.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
t.type === "success" && "bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
t.type === "error" && "bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
t.type === "info" && "bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200"
t.type === "success" &&
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
t.type === "error" &&
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
t.type === "info" &&
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
)}
>
{t.type === "success" && <CheckCircle2 className="h-5 w-5 shrink-0" />}
{t.type === "success" && (
<CheckCircle2 className="h-5 w-5 shrink-0" />
)}
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
<p className="text-sm font-medium leading-none">{t.message}</p>

View File

@@ -39,4 +39,4 @@ export const useToastState = () => {
}, []);
return state;
};
};

View File

@@ -13,7 +13,6 @@ import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
@@ -27,6 +26,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
import { t } from "../../lib/i18n";
function ClientConsentsPage() {
const params = useParams();
@@ -65,17 +65,20 @@ function ClientConsentsPage() {
<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">
Home
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
Clients
{t("ui.dev.clients.consents.breadcrumb.clients", "Clients")}
</Link>
<span>/</span>
<span>{clientData?.client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
User Consent Grants
{t(
"ui.dev.clients.consents.breadcrumb.current",
"User Consent Grants",
)}
</span>
</nav>
<div className="flex items-center gap-2">
@@ -86,10 +89,13 @@ function ClientConsentsPage() {
</Button>
<div>
<p className="text-3xl font-black leading-tight">
User Consent Grants
{t("ui.dev.clients.consents.title", "User Consent Grants")}
</p>
<p className="text-muted-foreground">
OIDC Relying Party ·.
{t(
"msg.dev.clients.consents.subtitle",
"OIDC Relying Party 사용자 권한을 검토·관리합니다.",
)}
</p>
</div>
</div>
@@ -100,7 +106,9 @@ function ClientConsentsPage() {
clientData?.client?.status === "active" ? "success" : "muted"
}
>
{clientData?.client?.status === "active" ? "Active" : "Inactive"}
{clientData?.client?.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</div>
</div>
@@ -109,16 +117,16 @@ function ClientConsentsPage() {
to={`/clients/${clientId}`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Connection
{t("ui.dev.clients.details.tab.connection", "Connection")}
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
Consent &amp; Users
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
</span>
<Link
to={`/clients/${clientId}/settings`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Settings
{t("ui.dev.clients.details.tab.settings", "Settings")}
</Link>
</div>
</header>
@@ -130,34 +138,48 @@ function ClientConsentsPage() {
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder="사용자 ID, 이름, 이메일로 검색"
placeholder={t(
"ui.dev.clients.consents.search_placeholder",
"사용자 ID, 이름, 이메일로 검색",
)}
value={subjectInput}
onChange={(e) => setSubjectInput(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Status:
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<select className="h-10 rounded-lg border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30">
<option>All Statuses</option>
<option selected>Active</option>
<option>Revoked</option>
<option>
{t("ui.dev.clients.consents.status_all", "All Statuses")}
</option>
<option selected>
{t("ui.common.status.active", "Active")}
</option>
<option>
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" className="gap-1 text-muted-foreground">
<Filter className="h-4 w-4" />
Advanced Filters
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={() => setSubject(subjectInput.trim())}
>
{t("ui.common.search", "검색")}
</Button>
<Button className="shadow-sm shadow-primary/30">
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
</Button>
<Button className="shadow-sm shadow-primary/30">Export CSV</Button>
</div>
</CardContent>
</Card>
@@ -165,32 +187,58 @@ function ClientConsentsPage() {
<Card className="glass-panel">
{error && (
<CardContent className="text-sm text-red-500">
Error loading consents: {(error as Error).message}
{t(
"msg.dev.clients.consents.load_error",
"Error loading consents: {{error}}",
{
error: (error as Error).message,
},
)}
</CardContent>
)}
{isLoading && (
<CardContent className="text-sm text-muted-foreground">
Loading consents...
{t("msg.dev.clients.consents.loading", "Loading consents...")}
</CardContent>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Tenant</TableHead>
<TableHead>Status</TableHead>
<TableHead>Granted Scopes</TableHead>
<TableHead>First Granted</TableHead>
<TableHead>Last Authenticated</TableHead>
<TableHead className="text-right">Action</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.user", "User")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.status", "Status")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.first_granted",
"First Granted",
)}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.last_auth",
"Last Authenticated",
)}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.consents.table.action", "Action")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
No consents found.
{t("msg.dev.clients.consents.empty", "No consents found.")}
</TableCell>
</TableRow>
) : (
@@ -199,11 +247,14 @@ function ClientConsentsPage() {
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{(row.userName || row.subject).slice(0, 2).toUpperCase()}
{(row.userName || row.subject)
.slice(0, 2)
.toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.userName || "Subject"}
{row.userName ||
t("ui.dev.clients.consents.subject", "Subject")}
</span>
<span className="text-xs text-muted-foreground">
{row.subject}
@@ -214,7 +265,7 @@ function ClientConsentsPage() {
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.tenantName || "N/A"}
{row.tenantName || t("ui.common.na", "N/A")}
</span>
<span className="text-xs text-muted-foreground">
{row.tenantId}
@@ -222,7 +273,9 @@ function ClientConsentsPage() {
</div>
</TableCell>
<TableCell>
<Badge variant="success">Active</Badge>
<Badge variant="success">
{t("ui.common.status.active", "Active")}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
@@ -253,7 +306,7 @@ function ClientConsentsPage() {
revokeMutation.mutate({ subject: row.subject })
}
>
Revoke
{t("ui.dev.clients.consents.revoke", "Revoke")}
</Button>
</TableCell>
</TableRow>
@@ -263,15 +316,23 @@ function ClientConsentsPage() {
</Table>
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
<p>
Showing <span className="font-semibold text-foreground">{rows.length > 0 ? 1 : 0}</span> to{" "}
<span className="font-semibold text-foreground">{rows.length}</span> of{" "}
<span className="font-semibold text-foreground">{rows.length}</span> users
{t(
"msg.dev.clients.consents.showing",
"Showing {{from}} to {{to}} of {{total}} users",
{
from: rows.length > 0 ? 1 : 0,
to: rows.length,
total: rows.length,
},
)}
</p>
<div className="flex gap-2">
<Button variant="outline" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm" disabled={rows.length === 0}>1</Button>
<Button size="sm" disabled={rows.length === 0}>
1
</Button>
<Button variant="outline" size="icon" disabled>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -283,7 +344,10 @@ function ClientConsentsPage() {
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Active Grants
{t(
"ui.dev.clients.consents.stats.active_grants",
"Active Grants",
)}
</p>
<CardTitle className="text-2xl font-black">{rows.length}</CardTitle>
</CardHeader>
@@ -291,7 +355,10 @@ function ClientConsentsPage() {
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Total Scopes Issued
{t(
"ui.dev.clients.consents.stats.total_scopes",
"Total Scopes Issued",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
@@ -301,13 +368,18 @@ function ClientConsentsPage() {
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Avg. Scopes per User
{t(
"ui.dev.clients.consents.stats.avg_scopes",
"Avg. Scopes per User",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.length > 0
? (
rows.reduce((acc, row) => acc + row.grantedScopes.length, 0) /
rows.length
rows.reduce(
(acc, row) => acc + row.grantedScopes.length,
0,
) / rows.length
).toFixed(1)
: "0.0"}
</CardTitle>

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>

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Info, Search, Shield, Sparkles, Upload, Plus, Trash2 } from "lucide-react";
import { Plus, 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";
@@ -14,11 +14,15 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import { Textarea } from "../../components/ui/textarea";
import { Switch } from "../../components/ui/switch";
import { Textarea } from "../../components/ui/textarea";
import { createClient, fetchClient, updateClient } from "../../lib/devApi";
import type { ClientStatus, ClientType } from "../../lib/devApi";
import type {
ClientStatus,
ClientType,
ClientUpsertRequest,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
interface ScopeItem {
@@ -46,10 +50,25 @@ function ClientGeneralPage() {
const [clientType, setClientType] = useState<ClientType>("confidential");
const [status, setStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
const [scopes, setScopes] = useState<ScopeItem[]>([
{ id: "1", name: "openid", description: "OIDC 인증 필수 스코프", mandatory: true },
{ id: "2", name: "profile", description: "기본 프로필 정보 접근", mandatory: false },
{ id: "3", name: "email", description: "이메일 주소 접근", mandatory: 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(() => {
@@ -58,43 +77,56 @@ function ClientGeneralPage() {
setName(client.name || client.id);
setClientType(client.type);
setStatus(client.status);
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string") setDescription(metadata.description);
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
// Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성
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"
})));
} else {
setScopes(
client.scopes.map((s, idx) => ({
id: String(idx + 1),
name: s,
description: "",
mandatory: s === "openid",
})),
);
}
}, [data]);
const addScope = () => {
const newId = String(Date.now());
setScopes([...scopes, { id: newId, name: "", description: "", mandatory: false }]);
setScopes([
...scopes,
{ id: newId, name: "", description: "", mandatory: false },
]);
};
const updateScope = (id: string, field: keyof ScopeItem, value: any) => {
setScopes(scopes.map(s => s.id === id ? { ...s, [field]: value } : s));
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));
setScopes(scopes.filter((s) => s.id !== id));
};
const mutation = useMutation({
mutationFn: async () => {
const scopeNames = scopes.map(s => s.name).filter(Boolean);
const payload: any = {
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
const payload: ClientUpsertRequest = {
name,
type: clientType,
status,
@@ -102,16 +134,19 @@ function ClientGeneralPage() {
metadata: {
description,
logo_url: logoUrl,
structured_scopes: scopes // 향후 보존을 위해 metadata에 저장
structured_scopes: scopes, // 향후 보존을 위해 metadata에 저장
},
};
// 생성 시에는 Redirect URIs를 포함해서 전송
if (isCreate) {
payload.redirectUris = redirectUris.split(",").map(u => u.trim()).filter(Boolean);
payload.redirectUris = redirectUris
.split(",")
.map((uri) => uri.trim())
.filter(Boolean);
return createClient(payload);
}
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
return updateClient(clientId as string, payload);
},
@@ -120,17 +155,37 @@ function ClientGeneralPage() {
if (result?.client?.id) {
navigate(`/clients/${result.client.id}/settings`);
}
alert("설정이 저장되었습니다.");
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
},
});
if (!isCreate && isLoading) return <div className="p-8 text-center">Loading client...</div>;
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">Error loading client: {errMsg || "unknown error"}</div>;
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 displayName = isCreate ? "새 클라이언트" : data?.client?.name || data?.client?.id;
const displayName = isCreate
? t("ui.dev.clients.general.display_new", "새 클라이언트")
: data?.client?.name || data?.client?.id;
return (
<div className="space-y-8">
@@ -138,22 +193,45 @@ function ClientGeneralPage() {
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">Applications</Link>
<Link to="/clients" className="text-primary hover:underline">
{t("ui.dev.clients.general.breadcrumb.section", "Applications")}
</Link>
<span>/</span>
<span className="text-foreground">{displayName}</span>
</div>
<h1 className="text-3xl font-black leading-tight">{isCreate ? "Create Client" : "Client Settings"}</h1>
<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>
</div>
<Badge variant={status === "active" ? "success" : "muted"} className="px-3 py-1 text-xs uppercase">
{status === "active" ? "Active" : "Inactive"}
<Badge
variant={status === "active" ? "success" : "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>
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
{!isCreate && (
<>
<Link to={`/clients/${clientId}`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Connection</Link>
<Link to={`/clients/${clientId}/consents`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Consent &amp; Users</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">Settings</span>
<Link
to={`/clients/${clientId}`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.connection", "Connection")}
</Link>
<Link
to={`/clients/${clientId}/consents`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
{t("ui.dev.clients.details.tab.settings", "Settings")}
</span>
</>
)}
</div>
@@ -161,28 +239,83 @@ function ClientGeneralPage() {
{/* 1. Application Identity */}
<div className="glass-panel p-6">
<CardTitle className="text-xl font-bold mb-2">Application Identity</CardTitle>
<CardDescription className="mb-6"> , .</CardDescription>
<CardTitle className="text-xl font-bold mb-2">
{t("ui.dev.clients.general.identity.title", "Application Identity")}
</CardTitle>
<CardDescription className="mb-6">
{t(
"msg.dev.clients.general.identity.subtitle",
"앱 이름과 설명, 로고를 설정합니다.",
)}
</CardDescription>
<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"> <span className="text-destructive">*</span></Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="My Awesome Application" />
<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">Description</Label>
<Textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="앱에 대한 간단한 설명을 입력하세요." />
<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-2">
<Label className="text-sm font-semibold">App Logo URL</Label>
<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)} placeholder="https://example.com/logo.png" />
<p className="text-xs text-muted-foreground"> PNG/SVG URL입니다.</p>
<Input
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
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>
</div>
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
{logoUrl ? <img src={logoUrl} alt="Logo Preview" className="h-full w-full object-contain" /> : <Upload className="h-5 w-5 text-muted-foreground" />}
{logoUrl ? (
<img
src={logoUrl}
alt={t(
"ui.dev.clients.general.identity.logo_preview",
"Logo Preview",
)}
className="h-full w-full object-contain"
/>
) : (
<Upload className="h-5 w-5 text-muted-foreground" />
)}
</div>
</div>
</div>
@@ -193,25 +326,49 @@ function ClientGeneralPage() {
<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">Scopes</CardTitle>
<CardDescription> .</CardDescription>
<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" /> Scope
<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">
{/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */}
{isCreate && (
<div className="space-y-2 border-b border-border pb-6 mb-6">
<Label className="text-sm font-semibold">Redirect URIs <span className="text-destructive">*</span></Label>
<Textarea
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
placeholder="https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
<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"> URI를 . Connection .</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.redirect.help",
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
)}
</p>
</div>
)}
@@ -220,27 +377,76 @@ function ClientGeneralPage() {
<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">Scope Name</th>
<th className="px-4 py-3 text-left font-bold">Description</th>
<th className="px-4 py-3 text-center font-bold">Mandatory</th>
<th className="px-4 py-3 text-right"></th>
<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" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{scopes.map((s) => (
<tr key={s.id} className="hover:bg-muted/30 transition-colors">
<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="e.g. profile" />
<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="권한에 대한 설명" />
<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)} />
<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">
<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>
@@ -248,7 +454,15 @@ function ClientGeneralPage() {
))}
{scopes.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground"> .</td>
<td
colSpan={4}
className="px-4 py-8 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.general.scopes.empty",
"등록된 스코프가 없습니다.",
)}
</td>
</tr>
)}
</tbody>
@@ -260,48 +474,118 @@ function ClientGeneralPage() {
{/* 3. Security Settings (Moved down) */}
<Card className="glass-panel">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold"> </CardTitle>
<CardDescription> . .</CardDescription>
<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", clientType === "confidential" ? "border-primary bg-primary/5" : "border-border bg-card hover:border-muted-foreground/40")}>
<input className="sr-only" type="radio" name="client-type" checked={clientType === "confidential"} onChange={() => setClientType("confidential")} />
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "confidential"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "confidential"}
onChange={() => setClientType("confidential")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" /> Confidential
<Shield className="h-4 w-4 text-primary" />
{t(
"ui.dev.clients.general.security.confidential",
"Confidential",
)}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.confidential_help",
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "confidential" ? "✓" : ""}
</span>
<span className="text-xs text-muted-foreground"> (: Node.js, Java) .</span>
<span className="absolute right-4 top-4 text-primary">{clientType === "confidential" ? "✓" : ""}</span>
</label>
<label className={cn("relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition", clientType === "public" ? "border-primary bg-primary/5" : "border-border bg-card hover:border-muted-foreground/40")}>
<input className="sr-only" type="radio" name="client-type" checked={clientType === "public"} onChange={() => setClientType("public")} />
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "public"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "public"}
onChange={() => setClientType("public")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" /> Public
<Sparkles className="h-4 w-4" />
{t("ui.dev.clients.general.security.public", "Public")}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.public_help",
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "public" ? "✓" : ""}
</span>
<span className="text-xs text-muted-foreground">SPA/ . PKCE를 .</span>
<span className="absolute right-4 top-4 text-primary">{clientType === "public" ? "✓" : ""}</span>
</label>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
<Button variant="outline" onClick={() => navigate("/clients")}></Button>
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending} className="px-8 shadow-lg shadow-primary/20">
{mutation.isPending ? "저장 중..." : (isCreate ? "클라이언트 생성" : "설정 저장")}
<Button variant="outline" onClick={() => navigate("/clients")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="px-8 shadow-lg shadow-primary/20"
>
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: isCreate
? t("ui.dev.clients.general.create", "클라이언트 생성")
: t("ui.dev.clients.general.save", "설정 저장")}
</Button>
</div>
{!isCreate && (
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4 opacity-70">
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Client ID</span>
<span className="text-xs font-semibold uppercase text-muted-foreground">
{t("ui.dev.clients.general.footer.client_id", "Client ID")}
</span>
<span className="font-mono text-sm block">{data?.client?.id}</span>
</div>
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Created On</span>
<span className="text-sm text-muted-foreground block">{data?.client?.created_at ? new Date(data.client.created_at).toLocaleString() : "-"}</span>
<span className="text-xs font-semibold uppercase text-muted-foreground">
{t("ui.dev.clients.general.footer.created_on", "Created On")}
</span>
<span className="text-sm text-muted-foreground block">
{data?.client?.createdAt
? new Date(data.client.createdAt).toLocaleString()
: "-"}
</span>
</div>
</div>
)}

View File

@@ -1,10 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Activity,
BookOpenText,
Copy,
Laptop,
Plus,
Search,
ServerCog,
@@ -25,6 +22,7 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { CopyButton } from "../../components/ui/copy-button";
import { Input } from "../../components/ui/input";
import { Separator } from "../../components/ui/separator";
import { Switch } from "../../components/ui/switch";
@@ -36,14 +34,14 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
deleteClient,
fetchClients,
updateClientStatus,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { CopyButton } from "../../components/ui/copy-button";
import { toast } from "../../components/ui/use-toast";
function ClientsPage() {
const navigate = useNavigate();
@@ -56,15 +54,29 @@ function ClientsPage() {
mutationFn: (payload: { id: string; status: "active" | "inactive" }) =>
updateClientStatus(payload.id, payload.status),
onSuccess: (_, variables) => {
const statusText = variables.status === "active" ? "활성화" : "비활성화";
toast(`클라이언트가 ${statusText}되었습니다.`);
const statusText =
variables.status === "active"
? t("ui.common.status.active", "활성화")
: t("ui.common.status.inactive", "비활성화");
toast(
t(
"msg.dev.clients.status_updated",
"클라이언트가 {{status}}되었습니다.",
{
status: statusText,
},
),
);
queryClient.invalidateQueries({ queryKey: ["clients"] });
},
onError: (error: AxiosError<{ error?: string }>) => {
const errMsg =
error.response?.data?.error ??
error.message ??
"Failed to update client status";
t(
"msg.dev.clients.status_update_error",
"Failed to update client status",
);
toast(errMsg, "error");
},
});
@@ -76,29 +88,49 @@ function ClientsPage() {
const clients = data?.items || [];
const totalClients = clients.length;
// TODO: Add real stats for active sessions and auth failures
const stats = [
type StatTone = "up" | "down" | "stable";
type StatItem = {
labelKey: string;
labelFallback: string;
value: string;
deltaKey: string;
deltaFallback: string;
tone: StatTone;
};
const stats: StatItem[] = [
{
label: "총 클라이언트",
labelKey: "ui.dev.clients.stats.total",
labelFallback: "총 클라이언트",
value: totalClients.toString(),
delta: "Realtime",
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
label: "활성 세션",
labelKey: "ui.dev.clients.stats.active_sessions",
labelFallback: "활성 세션",
value: "-",
delta: "Not impl",
deltaKey: "ui.dev.clients.stats.not_impl",
deltaFallback: "Not impl",
tone: "stable" as const,
},
{
label: "인증 실패 (24h)",
labelKey: "ui.dev.clients.stats.auth_failures",
labelFallback: "인증 실패 (24h)",
value: "0",
delta: "Stable",
deltaKey: "ui.dev.clients.stats.stable",
deltaFallback: "Stable",
tone: "stable" as const,
},
];
if (isLoading) {
return <div className="p-8 text-center">Loading clients...</div>;
return (
<div className="p-8 text-center">
{t("msg.dev.clients.loading", "Loading clients...")}
</div>
);
}
if (error) {
@@ -107,7 +139,9 @@ function ClientsPage() {
(error as Error).message;
return (
<div className="p-8 text-center text-red-500">
Error loading clients: {errMsg}
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
error: errMsg,
})}
</div>
);
}
@@ -119,14 +153,16 @@ function ClientsPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
RP registry
{t("ui.dev.clients.registry.title", "RP registry")}
</p>
<CardTitle className="text-3xl font-black tracking-tight">
Relying Parties
{t("ui.dev.clients.registry.subtitle", "Relying Parties")}
</CardTitle>
<CardDescription>
OIDC , , URI,
.
{t(
"msg.dev.clients.registry.description",
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
)}
</CardDescription>
</div>
<div className="hidden items-center gap-2 md:flex">
@@ -135,7 +171,8 @@ function ClientsPage() {
className="shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
</div>
@@ -144,21 +181,30 @@ function ClientsPage() {
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder="클라이언트 이름/ID로 검색..."
placeholder={t(
"ui.dev.clients.search_placeholder",
"클라이언트 이름/ID로 검색...",
)}
/>
</div>
<div className="flex items-center justify-end gap-2 md:justify-start">
<Badge variant="muted">테넌트: 선택됨</Badge>
<Badge variant="success"> </Badge>
<Badge variant="muted">
{t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")}
</Badge>
<Badge variant="success">
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-3">
{stats.map((item) => (
<Card key={item.label} className="border border-border/60">
<Card key={item.labelKey} className="border border-border/60">
<CardHeader className="pb-2">
<CardDescription>{item.label}</CardDescription>
<CardDescription>
{t(item.labelKey, item.labelFallback)}
</CardDescription>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-bold">{item.value}</span>
<Badge
@@ -176,7 +222,7 @@ function ClientsPage() {
item.tone === "stable" && "bg-muted/40 text-foreground",
)}
>
{item.delta}
{t(item.deltaKey, item.deltaFallback)}
</Badge>
</div>
</CardHeader>
@@ -190,11 +236,12 @@ function ClientsPage() {
<CardHeader className="pb-0">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<div className="flex items-center gap-2 md:hidden">
<Button size="sm" onClick={() => navigate("/clients/new")}>
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
</div>
@@ -203,12 +250,22 @@ function ClientsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Client ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>
{t("ui.dev.clients.table.application", "애플리케이션")}
</TableHead>
<TableHead>
{t("ui.dev.clients.table.client_id", "Client ID")}
</TableHead>
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
<TableHead>
{t("ui.dev.clients.table.status", "상태")}
</TableHead>
<TableHead>
{t("ui.dev.clients.table.created_at", "생성일")}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -225,10 +282,11 @@ function ClientsPage() {
</div>
<div>
<p className="font-semibold">
{client.name || "Untitled"}
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
Tenant-scoped
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
</p>
</div>
</div>
@@ -242,8 +300,18 @@ function ClientsPage() {
value={client.id}
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-primary"
aria-label="Copy client id"
onCopy={() => toast("클라이언트 ID가 복사되었습니다.")}
aria-label={t(
"ui.dev.clients.copy_client_id",
"Copy client id",
)}
onCopy={() =>
toast(
t(
"msg.dev.clients.copy_client_id",
"클라이언트 ID가 복사되었습니다.",
),
)
}
/>
</div>
</TableCell>
@@ -254,8 +322,11 @@ function ClientsPage() {
}
>
{client.type === "confidential"
? "기밀(Confidential)"
: "Public"}
? t(
"ui.dev.clients.type.confidential",
"기밀(Confidential)",
)
: t("ui.dev.clients.type.public", "Public")}
</Badge>
</TableCell>
<TableCell>
@@ -281,7 +352,9 @@ function ClientsPage() {
: "text-muted-foreground",
)}
>
{client.status === "active" ? "활성" : "비활성"}
{client.status === "active"
? t("ui.common.status.active", "활성")
: t("ui.common.status.inactive", "비활성")}
</span>
</div>
</TableCell>
@@ -293,7 +366,9 @@ function ClientsPage() {
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${client.id}`}>Edit</Link>
<Link to={`/clients/${client.id}`}>
{t("ui.common.edit", "Edit")}
</Link>
</Button>
<Button
variant="ghost"
@@ -301,7 +376,7 @@ function ClientsPage() {
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteMutation.mutate(client.id)}
>
Delete
{t("ui.common.delete", "Delete")}
</Button>
</div>
</TableCell>
@@ -311,14 +386,18 @@ function ClientsPage() {
</Table>
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
<span>
Showing {clients.length} of {totalClients} clients
{t(
"msg.dev.clients.showing",
"Showing {{shown}} of {{total}} clients",
{ shown: clients.length, total: totalClients },
)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled>
Previous
{t("ui.common.previous", "Previous")}
</Button>
<Button variant="outline" size="sm" disabled>
Next
{t("ui.common.next", "Next")}
</Button>
</div>
</div>
@@ -329,11 +408,16 @@ function ClientsPage() {
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-bold">
Need help with OIDC configuration?
{t(
"ui.dev.clients.help.title",
"Need help with OIDC configuration?",
)}
</CardTitle>
<CardDescription>
Developer guides for Confidential/Public clients, redirect URIs,
and auth methods.
{t(
"msg.dev.clients.help.subtitle",
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
)}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
@@ -342,40 +426,56 @@ function ClientsPage() {
<BookOpenText className="h-6 w-6" />
</div>
<div>
<p className="font-semibold">Docs &amp; Examples</p>
<p className="font-semibold">
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
</p>
<p className="text-sm text-muted-foreground">
Includes PKCE, client_secret_basic, redirect URI validation
tips.
{t(
"msg.dev.clients.help.docs_body",
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
)}
</p>
</div>
</div>
<Button variant="secondary">View guides</Button>
<Button variant="secondary">
{t("ui.dev.clients.help.view_guides", "View guides")}
</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">Owner</CardTitle>
<CardDescription>Tenant admin on-call</CardDescription>
<CardTitle className="text-lg font-semibold">
{t("ui.dev.clients.owner.title", "Owner")}
</CardTitle>
<CardDescription>
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
alt="ops user"
alt={t("ui.dev.clients.owner.avatar_alt", "ops user")}
/>
<AvatarFallback>AR</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">AI Admin Bot</p>
<p className="text-xs text-muted-foreground">admin@brsw.kr</p>
<p className="font-semibold">
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.owner.email", "admin@brsw.kr")}
</p>
</div>
</div>
<Separator className="mx-4 hidden h-10 w-px md:block" />
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
<span>Role: Tenant Admin</span>
<span>Scope: TENANT-12</span>
<span>
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
</span>
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
</div>
</CardContent>
</Card>

View File

@@ -1,11 +1,11 @@
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
createIdpConfigForClient,
listIdpConfigsForClient,
} from "../../../lib/devApi";
import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/devApi";
import { useState } from "react";
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
// Proper Modal Component with Form
const CreateIdpModal = ({
@@ -185,6 +185,7 @@ export function ClientFederationPage() {
<div className="mb-4">
<button
type="button"
onClick={() => setCreateModalOpen(true)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
@@ -244,10 +245,16 @@ export function ClientFederationPage() {
</span>
</td>
<td className="py-2 px-4 border-b">
<button className="text-blue-500 hover:underline mr-2">
<button
type="button"
className="text-blue-500 hover:underline mr-2"
>
Edit
</button>
<button className="text-red-500 hover:underline">
<button
type="button"
className="text-red-500 hover:underline"
>
Delete
</button>
</td>

View File

@@ -8,37 +8,73 @@ import {
ShieldCheck,
Sparkles,
} from "lucide-react";
import { t } from "../../lib/i18n";
const guardHighlights = [
{
title: "RP 정책 통제",
body: "Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
metric: "Policy",
titleKey: "ui.dev.dashboard.guard.policy.title",
titleFallback: "RP 정책 통제",
bodyKey: "msg.dev.dashboard.guard.policy.body",
bodyFallback:
"Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
metricKey: "ui.dev.dashboard.guard.policy.metric",
metricFallback: "Policy",
},
{
title: "Consent 흐름",
body: "사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
metric: "Consent",
titleKey: "ui.dev.dashboard.guard.consent.title",
titleFallback: "Consent 흐름",
bodyKey: "msg.dev.dashboard.guard.consent.body",
bodyFallback:
"사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
metricKey: "ui.dev.dashboard.guard.consent.metric",
metricFallback: "Consent",
},
{
title: "Hydra Admin",
body: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
metric: "Hydra",
titleKey: "ui.dev.dashboard.guard.hydra.title",
titleFallback: "Hydra Admin",
bodyKey: "msg.dev.dashboard.guard.hydra.body",
bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
metricKey: "ui.dev.dashboard.guard.hydra.metric",
metricFallback: "Hydra",
},
];
const stackReadiness = [
"React 19 + Vite 7, strict TS, Router v6 data router.",
"TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
"Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
"Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
"Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
{
key: "msg.dev.dashboard.stack.react",
fallback: "React 19 + Vite 7, strict TS, Router v6 data router.",
},
{
key: "msg.dev.dashboard.stack.query",
fallback: "TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
},
{
key: "msg.dev.dashboard.stack.axios",
fallback: "Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
},
{
key: "msg.dev.dashboard.stack.tailwind",
fallback: "Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
},
{
key: "msg.dev.dashboard.stack.proxy",
fallback: "Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
},
];
const nextSteps = [
"RP 등록/수정/삭제 워크플로우 추가",
"Consent 검색 필터 고도화 및 CSV 내보내기",
"권한 가드 및 감사 로그 연동",
{
key: "msg.dev.dashboard.next.rp_workflow",
fallback: "RP 등록/수정/삭제 워크플로우 추가",
},
{
key: "msg.dev.dashboard.next.consent_filters",
fallback: "Consent 검색 필터 고도화 및 CSV 내보내기",
},
{
key: "msg.dev.dashboard.next.audit_guard",
fallback: "권한 가드 및 감사 로그 연동",
},
];
function DashboardPage() {
@@ -50,41 +86,63 @@ function DashboardPage() {
<div className="space-y-3 max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
<Sparkles size={14} />
devfront ready
{t("ui.dev.dashboard.ready_badge", "devfront ready")}
</div>
<h2 className="text-3xl font-semibold leading-tight">
RP Consent
<span className="text-[var(--color-accent)]"> </span>
.
{t(
"msg.dev.dashboard.hero.title_prefix",
"RP 등록 현황과 Consent 상태를",
)}
<span className="text-[var(--color-accent)]">
{t("msg.dev.dashboard.hero.title_emphasis", " 하나의 화면")}
</span>
{t("msg.dev.dashboard.hero.title_suffix", "에서 관리합니다.")}
</h2>
<p className="text-[var(--color-muted)]">
Hydra Admin API와 RP , , Consent
devfront에서 .
{t(
"msg.dev.dashboard.hero.body",
"Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
)}
</p>
<div className="flex flex-wrap gap-3 text-sm">
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
RP registry synced
{t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")}
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
Consent guard ready
{t(
"ui.dev.dashboard.badge.consent_guard",
"Consent guard ready",
)}
</span>
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
Policy toggle enabled
{t(
"ui.dev.dashboard.badge.policy_toggle",
"Policy toggle enabled",
)}
</span>
</div>
</div>
<div className="grid gap-3 text-sm">
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<ShieldCheck size={16} />
RP dev scope에서만
{t(
"msg.dev.dashboard.notice.dev_scope",
"RP 정책은 dev scope에서만 적용",
)}
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<KeyRound size={16} />
Consent
{t(
"msg.dev.dashboard.notice.consent_audit",
"Consent 회수는 감사 로그와 연계",
)}
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Database size={16} />
Hydra Admin
{t(
"msg.dev.dashboard.notice.hydra_health",
"Hydra Admin 상태 체크 준비",
)}
</div>
</div>
</div>
@@ -93,21 +151,25 @@ function DashboardPage() {
<section className="grid gap-4 md:grid-cols-3">
{guardHighlights.map((item) => (
<div
key={item.title}
key={item.titleKey}
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
<div className="relative flex items-center justify-between gap-2">
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
{item.metric}
{t(item.metricKey, item.metricFallback)}
</div>
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
active
{t("ui.common.status.active", "active")}
</span>
</div>
<div className="relative mt-3 space-y-2">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
<h3 className="text-lg font-semibold">
{t(item.titleKey, item.titleFallback)}
</h3>
<p className="text-sm text-[var(--color-muted)]">
{t(item.bodyKey, item.bodyFallback)}
</p>
</div>
</div>
))}
@@ -118,29 +180,31 @@ function DashboardPage() {
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Stack readiness
{t("ui.dev.dashboard.stack.title", "Stack readiness")}
</p>
<h3 className="text-xl font-semibold">Devfront baseline</h3>
<h3 className="text-xl font-semibold">
{t("ui.dev.dashboard.stack.subtitle", "Devfront baseline")}
</h3>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Setup notes
{t("ui.dev.dashboard.stack.notes", "Setup notes")}
<ArrowRight size={14} />
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{stackReadiness.map((item) => (
<div
key={item}
key={item.key}
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<CheckCircle2
size={16}
className="text-[var(--color-accent)]"
/>
<p className="text-sm">{item}</p>
<p className="text-sm">{t(item.key, item.fallback)}</p>
</div>
))}
</div>
@@ -148,19 +212,23 @@ function DashboardPage() {
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Next actions
{t("ui.dev.dashboard.next.title", "Next actions")}
</p>
<h3 className="mt-2 text-xl font-semibold">Ship the RP controls</h3>
<h3 className="mt-2 text-xl font-semibold">
{t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")}
</h3>
<div className="mt-4 space-y-3">
{nextSteps.map((item, idx) => (
<div
key={item}
key={item.key}
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
{idx + 1}
</div>
<p className="text-sm text-[var(--color-text)]">{item}</p>
<p className="text-sm text-[var(--color-text)]">
{t(item.key, item.fallback)}
</p>
</div>
))}
</div>
@@ -171,16 +239,18 @@ function DashboardPage() {
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Ops board
{t("ui.dev.dashboard.ops.title", "Ops board")}
</p>
<h3 className="text-xl font-semibold"> </h3>
<h3 className="text-xl font-semibold">
{t("ui.dev.dashboard.ops.subtitle", "현재 관측")}
</h3>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
Consent grants
{t("ui.dev.dashboard.ops.tag.consent", "Consent grants")}
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
RP status
{t("ui.dev.dashboard.ops.tag.rp_status", "RP status")}
</span>
</div>
</div>
@@ -188,23 +258,32 @@ function DashboardPage() {
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<BarChart3 size={16} />
RP
{t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")}
</div>
<p className="mt-3 text-2xl font-semibold"> </p>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.pending", "준비 중")}
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Activity size={16} />
Consent
{t(
"ui.dev.dashboard.ops.card.consent_revoked",
"Consent 회수 건수",
)}
</div>
<p className="mt-3 text-2xl font-semibold"> </p>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.pending", "준비 중")}
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Database size={16} />
Hydra
{t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")}
</div>
<p className="mt-3 text-2xl font-semibold"></p>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.ok", "정상")}
</p>
</div>
</div>
</section>

View File

@@ -29,6 +29,7 @@ export type ClientEndpoints = {
export type ClientDetailResponse = {
client: ClientSummary & {
clientSecret?: string;
metadata?: Record<string, unknown>;
};
endpoints: ClientEndpoints;
@@ -93,7 +94,6 @@ export type IdpConfigCreateRequest = Omit<
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
// --- End Federation Types ---
export async function fetchClients() {
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
return data;
@@ -138,7 +138,7 @@ export async function updateClient(
export async function rotateClientSecret(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/secret/rotate`
`/dev/clients/${clientId}/secret/rotate`,
);
return data;
}
@@ -175,11 +175,13 @@ export async function listIdpConfigsForClient(clientId: string) {
return data;
}
export async function createIdpConfigForClient(payload: IdpConfigCreateRequest) {
export async function createIdpConfigForClient(
payload: IdpConfigCreateRequest,
) {
const { data } = await apiClient.post<IdpConfig>(
`/dev/clients/${payload.client_id}/idps`,
payload,
);
`/dev/clients/${payload.client_id}/idps`,
payload,
);
return data;
}

148
devfront/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,148 @@
const LOCALE_STORAGE_KEY = "locale";
const DEFAULT_LOCALE = "ko";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
type TomlValue = string | TomlObject;
interface TomlObject {
[key: string]: TomlValue;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function parseToml(raw: string): TomlObject {
const lines = raw.split(/\r?\n/);
const root: TomlObject = {};
let currentPath: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const sectionName = line.slice(1, -1).trim();
currentPath = sectionName
? sectionName
.split(".")
.map((part) => part.trim())
.filter(Boolean)
: [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) {
continue;
}
let value = valueRaw;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
let cursor: TomlObject = root;
for (const section of currentPath) {
if (!cursor[section] || typeof cursor[section] === "string") {
cursor[section] = {};
}
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
}
return root;
}
function getValue(target: TomlObject, key: string): string | undefined {
const parts = key.split(".");
let cursor: TomlValue = target;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null) {
return undefined;
}
cursor = (cursor as TomlObject)[part];
if (cursor === undefined) {
return undefined;
}
}
return typeof cursor === "string" ? cursor : undefined;
}
function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isSupportedLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
// eslint-disable-next-line import/no-unresolved
import enRaw from "../../../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../../../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),
en: parseToml(enRaw),
};
function formatTemplate(
template: string,
vars?: Record<string, string | number>,
): string {
if (!vars) {
return template;
}
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
export function t(
key: string,
fallback?: string,
vars?: Record<string, string | number>,
): string {
const locale = detectLocale();
const value = getValue(translations[locale], key);
if (value && value.length > 0) {
return formatTemplate(value, vars);
}
return formatTemplate(fallback ?? key, vars);
}

View File

@@ -2,9 +2,9 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { Toaster } from "./components/ui/toaster";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster";
import "./index.css";
const rootElement = document.getElementById("root");

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -13,7 +13,4 @@ export default defineConfig({
},
},
},
esbuild: {
drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
},
});