forked from baron/baron-sso
i18n refresh and frontend fixes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,4 +39,4 @@ export const useToastState = () => {
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 & 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>
|
||||
)}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
148
devfront/src/lib/i18n.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -13,7 +13,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user