1
0
forked from baron/baron-sso

i18n refresh and frontend fixes

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

View File

@@ -27,6 +27,15 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: i18n resource check
run: |
node tools/i18n-scanner/index.js
- name: Setup Go
uses: actions/setup-go@v5
with:

4
.gitignore vendored
View File

@@ -30,3 +30,7 @@ userfront/.dart_tool/
userfront/.packages
userfront/.pub/
userfront/.env
# Frontend test artifacts
adminfront/test-results/
devfront/test-results/

View File

@@ -231,6 +231,12 @@ KETO_READ_URL = "http://keto:4466"
KETO_WRITE_URL = "http://keto:4467"
```
## 🌐 i18n 구조 (간략)
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다.
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
### 로컬 개발 (Manual)
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
백그라운드로 infra 및 ory stack이 구동중이라는 가정
@@ -247,6 +253,8 @@ go run cmd/server/main.go
cd userfront
flutter pub get
flutter run -d chrome
# 정책: 웹 빌드는 기본적으로 WASM 사용
flutter build web --wasm
```
**adminfront:**

View File

@@ -1,148 +1,161 @@
import {
BadgeCheck,
Building2,
Key,
KeyRound,
LayoutDashboard,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
Users,
BadgeCheck,
Building2,
Key,
KeyRound,
LayoutDashboard,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
{ label: "Tenants", to: "/tenants", icon: Building2 },
{ label: "Users", to: "/users", icon: Users },
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{
label: "ui.admin.nav.tenant_dashboard",
to: "/dashboard",
icon: ShieldHalf,
},
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
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">
Admin Control
</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 /admin
</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">
IDP env: prod
</span>
<span className="rounded-full border border-border px-3 py-1">
Tenant-aware headers
</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> /admin .</p>
<p>
IDP API로만 , ·
.
</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">
Admin Plane
</p>
<span className="text-lg font-semibold">
Tenant isolation & least privilege by default
</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>
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
Session TTL: 15m admin
</span>
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />
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">
{t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</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("msg.admin.scope_admin", "Scoped to /admin")}
</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("msg.admin.idp_env_prod", "IDP env: prod")}
</span>
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
</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>{t(label, label)}</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.admin.notice.scope",
"관리 기능은 /admin 네임스페이스에서만 노출합니다.",
)}
</p>
<p>
{t(
"msg.admin.notice.idp_policy",
"IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.",
)}
</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.admin.header.plane", "Admin Plane")}
</p>
<span className="text-lg font-semibold">
{t(
"msg.admin.header.subtitle",
"Tenant isolation & least privilege by default",
)}
</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>
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
{t("msg.admin.session_ttl", "Session TTL: 15m admin")}
</span>
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />
</div>
</div>
);
}
export default AppLayout;

View File

@@ -1,64 +1,86 @@
import React, { useState, useEffect } from 'react';
import type { FC } from "react";
import { useEffect, useState } from "react";
import { t } from "../../lib/i18n";
const RoleSwitcher: React.FC = () => {
const [currentRole, setCurrentRole] = useState<string>('super_admin');
const RoleSwitcher: FC = () => {
const [currentRole, setCurrentRole] = useState<string>("super_admin");
useEffect(() => {
// localStorage에서 역할 읽기
const savedRole = window.localStorage.getItem('X-Mock-Role');
const savedRole = window.localStorage.getItem("X-Mock-Role");
if (savedRole) {
setCurrentRole(savedRole);
} else {
// 기본값 설정
window.localStorage.setItem('X-Mock-Role', 'super_admin');
window.localStorage.setItem("X-Mock-Role", "super_admin");
}
}, []);
const switchRole = (role: string) => {
// localStorage 설정
window.localStorage.setItem('X-Mock-Role', role);
window.localStorage.setItem("X-Mock-Role", role);
setCurrentRole(role);
// 페이지 새로고침하여 권한 적용
window.location.reload();
};
if (import.meta.env.MODE === 'production') return null;
if (import.meta.env.MODE === "production") return null;
const roleLabels: Record<string, string> = {
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"),
tenant_member: t("ui.admin.role.tenant_member", "TENANT MEMBER"),
};
return (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
background: '#1A1F2C',
color: 'white',
padding: '10px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: '12px'
}}>
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
🛠 DEV Role Switcher
<div
style={{
position: "fixed",
bottom: "20px",
right: "20px",
zIndex: 9999,
background: "#1A1F2C",
color: "white",
padding: "10px",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
display: "flex",
flexDirection: "column",
gap: "8px",
fontSize: "12px",
}}
>
<div
style={{
fontWeight: "bold",
borderBottom: "1px solid #444",
paddingBottom: "4px",
marginBottom: "4px",
}}
>
{t("ui.admin.dev_role_switcher", "🛠 DEV Role Switcher")}
</div>
{(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => (
{(
["super_admin", "tenant_admin", "rp_admin", "tenant_member"] as const
).map((role) => (
<button
key={role}
type="button"
onClick={() => switchRole(role)}
style={{
background: currentRole === role ? '#3b82f6' : '#333',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.2s'
background: currentRole === role ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
}}
>
{role.toUpperCase().replace('_', ' ')} {currentRole === role ? '✅' : ''}
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}{" "}
{currentRole === role ? "✅" : ""}
</button>
))}
</div>

View File

@@ -1,6 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react";
import {
AlertCircle,
Check,
ChevronLeft,
Copy,
Loader2,
Save,
ShieldCheck,
} from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
@@ -13,24 +21,69 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
type ApiKeyCreateRequest,
type ApiKeyCreateResponse,
createApiKey,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
const AVAILABLE_SCOPES = [
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
{ id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." },
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
];
function ApiKeyCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["audit:read", "user:read"]);
const [createdResult, setCreatedResult] =
React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>([
"audit:read",
"user:read",
]);
const {
register,
@@ -47,19 +100,29 @@ function ApiKeyCreatePage() {
setCreatedResult(data);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "API 키 생성에 실패했습니다.");
setError(
err.response?.data?.error ||
t("msg.admin.api_keys.create.error", "API 키 생성에 실패했습니다."),
);
},
});
const toggleScope = (scopeId: string) => {
setSelectedScopes((prev) =>
prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId]
prev.includes(scopeId)
? prev.filter((s) => s !== scopeId)
: [...prev, scopeId],
);
};
const onSubmit = (data: { name: string }) => {
if (selectedScopes.length === 0) {
setError("최소 하나 이상의 권한을 선택해야 합니다.");
setError(
t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
),
);
return;
}
setError(null);
@@ -77,9 +140,24 @@ function ApiKeyCreatePage() {
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
<ShieldCheck size={32} />
</div>
<h2 className="text-3xl font-bold tracking-tight">API </h2>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.success.title", "API 키 생성 완료")}
</h2>
<p className="text-muted-foreground">
(Secret) <span className="text-destructive font-bold"> </span> .
{t(
"msg.admin.api_keys.create.success.notice",
"아래의 비밀번호(Secret)는 보안을 위해 ",
)}
<span className="text-destructive font-bold">
{t(
"msg.admin.api_keys.create.success.notice_emphasis",
"지금 한 번만",
)}
</span>{" "}
{t(
"msg.admin.api_keys.create.success.notice_suffix",
"표시됩니다.",
)}
</p>
</div>
@@ -87,7 +165,10 @@ function ApiKeyCreatePage() {
<CardHeader className="bg-primary/5 border-b">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertCircle size={16} className="text-primary" />
릿
{t(
"ui.admin.api_keys.create.success.copy_secret",
"보안 시크릿 복사",
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-8 pb-8 space-y-6">
@@ -96,14 +177,14 @@ function ApiKeyCreatePage() {
X-Baron-Key-Secret
</Label>
<div className="relative group">
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
/>
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
onClick={() => handleCopy(createdResult.clientSecret)}
>
@@ -111,13 +192,21 @@ function ApiKeyCreatePage() {
</Button>
</div>
<p className="text-[11px] text-center text-muted-foreground italic">
( ) .
{t(
"msg.admin.api_keys.create.success.copy_hint",
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
)}
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">. </Link>
<Link to="/api-keys">
{t(
"ui.admin.api_keys.create.success.go_list",
"저장했습니다. 목록으로 이동",
)}
</Link>
</Button>
</div>
</CardContent>
@@ -130,12 +219,24 @@ function ApiKeyCreatePage() {
<div className="max-w-3xl mx-auto space-y-10">
<header className="flex items-center justify-between">
<div className="space-y-1">
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
<Button
variant="ghost"
size="sm"
className="-ml-3 text-muted-foreground"
onClick={() => navigate("/api-keys")}
>
<ChevronLeft size={16} className="mr-1" />
{t("ui.common.back", "돌아가기")}
</Button>
<h2 className="text-3xl font-bold tracking-tight"> API </h2>
<p className="text-muted-foreground"> .</p>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.title", "새 API 키 생성")}
</h2>
<p className="text-muted-foreground">
{t(
"msg.admin.api_keys.create.subtitle",
"내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
)}
</p>
</div>
</header>
@@ -143,20 +244,41 @@ function ApiKeyCreatePage() {
{/* 섹션 1: 이름 설정 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
<h3 className="font-semibold text-lg"> </h3>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
1
</span>
<h3 className="font-semibold text-lg">
{t("ui.admin.api_keys.create.section_name", "키 이름 지정")}
</h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium"> </Label>
<Label htmlFor="name" className="text-sm font-medium">
{t(
"ui.admin.api_keys.create.name_label",
"서비스 또는 목적 식별 이름",
)}
</Label>
<Input
id="name"
placeholder="예: Jenkins-CI, Grafana-Dashboard"
placeholder={t(
"ui.admin.api_keys.create.name_placeholder",
"예: Jenkins-CI, Grafana-Dashboard",
)}
className="text-base py-5"
{...register("name", { required: "이름은 필수입니다." })}
{...register("name", {
required: t(
"msg.admin.api_keys.create.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
{errors.name && (
<p className="text-sm text-destructive mt-1">
{errors.name.message}
</p>
)}
</div>
</CardContent>
</Card>
@@ -165,8 +287,15 @@ function ApiKeyCreatePage() {
{/* 섹션 2: 권한 선택 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
<h3 className="font-semibold text-lg"> (Scopes) </h3>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
2
</span>
<h3 className="font-semibold text-lg">
{t(
"ui.admin.api_keys.create.section_scopes",
"권한 범위(Scopes) 선택",
)}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_SCOPES.map((scope) => {
@@ -178,24 +307,39 @@ function ApiKeyCreatePage() {
onClick={() => toggleScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30"
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<div className="flex items-center justify-between w-full">
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
<div className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
)}>
{isSelected && <Check size={12} className="text-primary-foreground" />}
<span
className={cn(
"font-bold text-sm",
isSelected ? "text-primary" : "",
)}
>
{t(scope.labelKey, scope.labelFallback)}
</span>
<div
className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected
? "bg-primary border-primary"
: "border-muted-foreground/30",
)}
>
{isSelected && (
<Check size={12} className="text-primary-foreground" />
)}
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{scope.desc}
{t(scope.descKey, scope.descFallback)}
</p>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
@@ -210,15 +354,26 @@ function ApiKeyCreatePage() {
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
<div>
<p className="text-sm font-bold"> {selectedScopes.length} .</p>
<p className="text-xs text-muted-foreground"> .</p>
<p className="text-sm font-bold">
{t(
"msg.admin.api_keys.create.scopes_count",
"총 {{count}}개의 권한이 할당됩니다.",
{ count: selectedScopes.length },
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.api_keys.create.scopes_hint",
"생성 즉시 활성화되어 사용 가능합니다.",
)}
</p>
</div>
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
className="px-8 font-bold shadow-lg shadow-primary/20"
disabled={mutation.isPending}
>
@@ -227,7 +382,7 @@ function ApiKeyCreatePage() {
) : (
<Save className="mr-2 h-5 w-5" />
)}
API
{t("ui.admin.api_keys.create.submit", "API 키 발급하기")}
</Button>
</div>
</div>
@@ -236,4 +391,4 @@ function ApiKeyCreatePage() {
);
}
export default ApiKeyCreatePage;
export default ApiKeyCreatePage;

View File

@@ -20,6 +20,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
function ApiKeyListPage() {
const query = useQuery({
@@ -37,12 +38,25 @@ function ApiKeyListPage() {
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t(
"msg.admin.api_keys.list.fetch_error",
"API 키 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const handleDelete = (id: string, name: string) => {
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.delete_confirm",
'API 키 "{{name}}"를 삭제할까요?',
{ name },
),
)
) {
return;
}
deleteMutation.mutate(id);
@@ -53,14 +67,22 @@ function ApiKeyListPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>API Keys</span>
<span>
{t("ui.admin.api_keys.list.breadcrumb.section", "API Keys")}
</span>
<span>/</span>
<span className="text-foreground">List</span>
<span className="text-foreground">
{t("ui.admin.api_keys.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold">API (M2M)</h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
(Machine-to-Machine) API
.
{t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -70,12 +92,12 @@ function ApiKeyListPage() {
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
API
{t("ui.admin.api_keys.list.add", "API 키 생성")}
</Link>
</Button>
</div>
@@ -84,12 +106,18 @@ function ApiKeyListPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>API Key Registry</CardTitle>
<CardTitle>
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
</CardTitle>
<CardDescription>
{query.data?.total ?? 0} API
{t(
"msg.admin.api_keys.list.registry.count",
"총 {{count}}개 API 키",
{ count: query.data?.total ?? 0 },
)}
</CardDescription>
</div>
<Badge variant="muted">System</Badge>
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
@@ -101,22 +129,39 @@ function ApiKeyListPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>CLIENT ID</TableHead>
<TableHead>SCOPES</TableHead>
<TableHead>LAST USED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}> API .</TableCell>
<TableCell colSpan={5}>
{t(
"msg.admin.api_keys.list.empty",
"등록된 API 키가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((key) => (
@@ -146,7 +191,7 @@ function ApiKeyListPage() {
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: "Never"}
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<Button
@@ -156,7 +201,7 @@ function ApiKeyListPage() {
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>

File diff suppressed because it is too large Load Diff

View File

@@ -16,30 +16,39 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
const summaryCards = [
{
label: "Total Tenants",
labelKey: "ui.admin.overview.summary.total_tenants",
labelFallback: "Total Tenants",
value: "-",
hint: "Tenant-aware core",
hintKey: "msg.admin.overview.summary.total_tenants",
hintFallback: "Tenant-aware core",
icon: Users,
},
{
label: "OIDC Clients",
labelKey: "ui.admin.overview.summary.oidc_clients",
labelFallback: "OIDC Clients",
value: "-",
hint: "Hydra registry",
hintKey: "msg.admin.overview.summary.oidc_clients",
hintFallback: "Hydra registry",
icon: ShieldCheck,
},
{
label: "Audit Events (24h)",
labelKey: "ui.admin.overview.summary.audit_events_24h",
labelFallback: "Audit Events (24h)",
value: "-",
hint: "ClickHouse stream",
hintKey: "msg.admin.overview.summary.audit_events_24h",
hintFallback: "ClickHouse stream",
icon: Activity,
},
{
label: "Policy Gate",
labelKey: "ui.admin.overview.summary.policy_gate",
labelFallback: "Policy Gate",
value: "Planned",
hint: "Keto + Admin checks",
hintKey: "msg.admin.overview.summary.policy_gate",
hintFallback: "Keto + Admin checks",
icon: Database,
},
];
@@ -50,44 +59,67 @@ function GlobalOverviewPage() {
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Global Overview
{t("ui.admin.overview.kicker", "Global Overview")}
</p>
<h2 className="text-3xl font-semibold">
Tenant-independent control plane
{t("ui.admin.overview.title", "Tenant-independent control plane")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.overview.description",
"모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">IDP: Ory primary</Badge>
<Badge variant="muted">Fallback: Descope</Badge>
<Badge variant="muted">
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
</Badge>
<Badge variant="muted">
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
<Card key={label} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{label}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
</CardContent>
</Card>
))}
{summaryCards.map(
({
labelKey,
labelFallback,
value,
hintKey,
hintFallback,
icon: Icon,
}) => (
<Card key={labelKey} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{t(labelKey, labelFallback)}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(hintKey, hintFallback)}
</p>
</CardContent>
</Card>
),
)}
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">Admin playbook</CardTitle>
<CardTitle className="text-xl">
{t("ui.admin.overview.playbook.title", "Admin playbook")}
</CardTitle>
<CardDescription>
, , .
{t(
"msg.admin.overview.playbook.description",
"운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
@@ -97,11 +129,16 @@ function GlobalOverviewPage() {
</div>
<div>
<p className="font-semibold text-foreground">
Backend-only IDP access
{t(
"msg.admin.overview.playbook.idp_title",
"Backend-only IDP access",
)}
</p>
<p>
IDP backend를 , Hydra/Kratos
admin .
{t(
"msg.admin.overview.playbook.idp_body",
"모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
)}
</p>
</div>
</div>
@@ -111,11 +148,16 @@ function GlobalOverviewPage() {
</div>
<div>
<p className="font-semibold text-foreground">
Tenant isolation
{t(
"msg.admin.overview.playbook.tenant_title",
"Tenant isolation",
)}
</p>
<p>
Tenant , Keto
.
{t(
"msg.admin.overview.playbook.tenant_body",
"Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
)}
</p>
</div>
</div>
@@ -124,9 +166,14 @@ function GlobalOverviewPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl"> </CardTitle>
<CardTitle className="text-xl">
{t("ui.admin.overview.quick_links.title", "빠른 이동")}
</CardTitle>
<CardDescription>
.
{t(
"msg.admin.overview.quick_links.description",
"주요 운영 화면으로 바로 이동합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -136,7 +183,7 @@ function GlobalOverviewPage() {
variant="outline"
>
<Link to="/tenants/new">
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
<ArrowUpRight size={16} />
</Link>
</Button>
@@ -146,7 +193,10 @@ function GlobalOverviewPage() {
variant="outline"
>
<Link to="/audit-logs">
{t(
"ui.admin.overview.quick_links.view_audit_logs",
"감사 로그 보기",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
@@ -156,7 +206,10 @@ function GlobalOverviewPage() {
variant="outline"
>
<Link to="/dashboard">
{t(
"ui.admin.overview.quick_links.tenant_dashboard",
"테넌트 대시보드",
)}
<ArrowUpRight size={16} />
</Link>
</Button>

View File

@@ -16,6 +16,7 @@ import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantCreatePage() {
const navigate = useNavigate();
@@ -49,18 +50,29 @@ function TenantCreatePage() {
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>
{t("ui.admin.tenants.create.breadcrumb.section", "Tenants")}
</span>
<span>/</span>
<span className="text-foreground">Create</span>
<span className="text-foreground">
{t("ui.admin.tenants.create.breadcrumb.action", "Create")}
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.create.title", "테넌트 추가")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.tenants.create.subtitle",
"글로벌 운영 기준의 신규 테넌트를 등록합니다.",
)}
</p>
</div>
<Badge variant="muted">Admin only</Badge>
<Badge variant="muted">
{t("ui.common.badge.admin_only", "Admin only")}
</Badge>
</div>
</header>
@@ -68,29 +80,40 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} />
Tenant Profile
{t("ui.admin.tenants.create.profile.title", "Tenant Profile")}
</CardTitle>
<CardDescription>
. Slug는 .
{t(
"msg.admin.tenants.create.profile.subtitle",
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
{t("ui.admin.tenants.create.form.name", "Tenant name")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "Slug")}
</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="tenant-slug"
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.description", "Description")}
</Label>
<Textarea
rows={3}
value={description}
@@ -99,34 +122,44 @@ function TenantCreatePage() {
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
{t(
"ui.admin.tenants.create.form.domains_label",
"Allowed Domains (Comma separated)",
)}
</Label>
<Input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
{t(
"msg.admin.tenants.create.form.domains_help",
"Users with these email domains will be automatically assigned to this tenant.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "Status")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
{t("ui.common.status.active", "Active")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
{t("ui.common.status.inactive", "Inactive")}
</Button>
</div>
</div>
@@ -143,26 +176,32 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
{t("ui.admin.tenants.create.memo.title", "정책 메모")}
</CardTitle>
<CardDescription>
Tenant Keto .
{t(
"msg.admin.tenants.create.memo.subtitle",
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
, .
{t(
"msg.admin.tenants.create.memo.body",
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
)}
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenants")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}
</Button>
</div>
</div>

View File

@@ -5,13 +5,14 @@ import { Badge } from "../../../components/ui/badge";
import { fetchTenant } from "../../../lib/adminApi";
function TenantDetailPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const location = useLocation();
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const isFederationTab = location.pathname.includes("/federation");

View File

@@ -1,8 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Plus,
RefreshCw,
Shield,
Trash2,
UserMinus,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -11,6 +19,8 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Table,
TableBody,
@@ -19,15 +29,19 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi";
import { Badge } from "../../../components/ui/badge";
import {
addGroupMember,
createGroup,
deleteGroup,
fetchGroups,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantGroupsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
@@ -35,18 +49,14 @@ function TenantGroupsPage() {
// 그룹 목록 조회
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0,
});
// 사용자 목록 조회 (멤버 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100 }],
queryFn: () => fetchUsers(100, 0),
});
const createMutation = useMutation({
mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
mutationFn: () =>
createGroup(tenantId, { name: newGroupName, description: newGroupDesc }),
onSuccess: () => {
groupsQuery.refetch();
setNewGroupName("");
@@ -60,23 +70,30 @@ function TenantGroupsPage() {
});
const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(groupId, userId),
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
});
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(groupId, userId),
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
});
const handleAddMember = (groupId: string) => {
const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:");
const userId = window.prompt(
t(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId);
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6">
@@ -85,34 +102,45 @@ function TenantGroupsPage() {
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name"> </Label>
<Input
id="name"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="예: 개발팀, 인사팀"
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="desc"></Label>
<Input
id="desc"
value={newGroupDesc}
onChange={e => setNewGroupNameDesc(e.target.value)}
placeholder="그룹 용도 설명"
<Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
)}
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
@@ -121,10 +149,21 @@ function TenantGroupsPage() {
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Groups</CardTitle>
<CardDescription> .</CardDescription>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</CardHeader>
@@ -132,16 +171,22 @@ function TenantGroupsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>MEMBERS</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.data?.map((group) => (
<TableRow
key={group.id}
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
<TableRow
key={group.id}
className={`cursor-pointer ${selectedGroupId === group.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedGroupId(group.id)}
>
<TableCell>
@@ -149,17 +194,37 @@ function TenantGroupsPage() {
<Users size={14} className="text-muted-foreground" />
{group.name}
</div>
<p className="text-[10px] text-muted-foreground">{group.description}</p>
<p className="text-[10px] text-muted-foreground">
{group.description}
</p>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.members?.length || 0} </Badge>
<Badge variant="secondary">
{t("msg.admin.groups.members.count", "{{count}} 명", {
count: group.members?.length || 0,
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleAddMember(group.id); }}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleAddMember(group.id);
}}
>
<UserPlus size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(group.id); }}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(group.id);
}}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
@@ -178,31 +243,53 @@ function TenantGroupsPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
[{currentGroup.name}]
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground"> .</TableCell></TableRow>
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
<Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
>
<UserMinus size={14} className="text-destructive" />
</Button>

View File

@@ -20,6 +20,7 @@ import {
TableRow,
} from "../../../components/ui/table";
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantListPage() {
const navigate = useNavigate();
@@ -38,12 +39,22 @@ function TenantListPage() {
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null;
const items = query.data?.items ?? [];
const handleDelete = (tenantId: string, tenantName: string) => {
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
if (
!window.confirm(
t(
"msg.admin.tenants.delete_confirm",
'테넌트 "{{name}}"를 삭제할까요?',
{ name: tenantName },
),
)
) {
return;
}
deleteMutation.mutate(tenantId);
@@ -54,13 +65,20 @@ function TenantListPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span>
<span>/</span>
<span className="text-foreground">List</span>
<span className="text-foreground">
{t("ui.admin.tenants.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.title", "테넌트 목록")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.tenants.subtitle",
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -70,12 +88,12 @@ function TenantListPage() {
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
</div>
@@ -84,12 +102,18 @@ function TenantListPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Tenant registry</CardTitle>
<CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant registry")}
</CardTitle>
<CardDescription>
{query.data?.total ?? 0}
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
count: query.data?.total ?? 0,
})}
</CardDescription>
</div>
<Badge variant="muted">Admin only</Badge>
<Badge variant="muted">
{t("ui.common.badge.admin_only", "Admin only")}
</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
@@ -101,23 +125,38 @@ function TenantListPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>UPDATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
.
{t(
"msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
@@ -131,8 +170,8 @@ function TenantListPage() {
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
? "secondary"
: "muted"
}
className={
tenant.status === "pending"
@@ -140,7 +179,7 @@ function TenantListPage() {
: ""
}
>
{tenant.status}
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell>
@@ -156,7 +195,7 @@ function TenantListPage() {
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
@@ -165,7 +204,7 @@ function TenantListPage() {
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>

View File

@@ -14,19 +14,34 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaFieldType = "text" | "number" | "boolean";
type SchemaField = {
id: string;
key: string;
label: string;
type: "text" | "number" | "boolean";
type: SchemaFieldType;
required: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
if (!tenantId) return <div>Tenant ID missing</div>;
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.schema.missing_id", "Tenant ID missing")}</div>
);
}
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
@@ -36,8 +51,20 @@ export function TenantSchemaPage() {
const [fields, setFields] = useState<SchemaField[]>([]);
useEffect(() => {
if (tenantQuery.data?.config?.userSchema) {
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
const rawSchema = tenantQuery.data?.config?.userSchema;
if (Array.isArray(rawSchema)) {
setFields(
rawSchema.map((field) => ({
id: typeof field?.id === "string" ? field.id : createFieldId(),
key: typeof field?.key === "string" ? field.key : "",
label: typeof field?.label === "string" ? field.label : "",
type:
field?.type === "number" || field?.type === "boolean"
? field.type
: "text",
required: Boolean(field?.required),
})),
);
}
}, [tenantQuery.data]);
@@ -51,15 +78,32 @@ export function TenantSchemaPage() {
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Schema updated successfully");
alert(
t(
"msg.admin.tenants.schema.update_success",
"Schema updated successfully",
),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
alert(err.response?.data?.error || "Failed to update schema");
alert(
err.response?.data?.error ||
t("msg.admin.tenants.schema.update_error", "Failed to update schema"),
);
},
});
const addField = () => {
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
setFields([
...fields,
{
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
},
]);
};
const removeField = (index: number) => {
@@ -78,51 +122,89 @@ export function TenantSchemaPage() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Schema Extension</CardTitle>
<CardTitle>
{t("ui.admin.tenants.schema.title", "User Schema Extension")}
</CardTitle>
<CardDescription>
Define custom attributes for users in this tenant.
{t(
"msg.admin.tenants.schema.subtitle",
"Define custom attributes for users in this tenant.",
)}
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
Add Field
{t("ui.admin.tenants.schema.add_field", "Add Field")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
No custom fields defined. Click "Add Field" to begin.
{t(
"msg.admin.tenants.schema.empty",
'No custom fields defined. Click "Add Field" to begin.',
)}
</div>
)}
{fields.map((field, index) => (
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
<div
key={field.id}
className="flex items-end gap-4 p-4 border rounded-md bg-muted/30"
>
<div className="flex-1 space-y-2">
<Label>Field Key (ID)</Label>
<Label>
{t("ui.admin.tenants.schema.field.key", "Field Key (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder="e.g. employee_id"
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"e.g. employee_id",
)}
/>
</div>
<div className="flex-1 space-y-2">
<Label>Display Label</Label>
<Label>
{t("ui.admin.tenants.schema.field.label", "Display Label")}
</Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="e.g. 사번"
onChange={(e) =>
updateField(index, { label: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"e.g. 사번",
)}
/>
</div>
<div className="w-32 space-y-2">
<Label>Type</Label>
<Label>{t("ui.admin.tenants.schema.field.type", "Type")}</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as any })}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean"
) {
updateField(index, { type: nextType });
}
}}
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="text">
{t("ui.admin.tenants.schema.field.type_text", "Text")}
</option>
<option value="number">
{t("ui.admin.tenants.schema.field.type_number", "Number")}
</option>
<option value="boolean">
{t("ui.admin.tenants.schema.field.type_boolean", "Boolean")}
</option>
</select>
</div>
<Button
@@ -144,7 +226,7 @@ export function TenantSchemaPage() {
disabled={updateMutation.isPending || tenantQuery.isLoading}
>
<Save size={16} className="mr-2" />
Save Schema Changes
{t("ui.admin.tenants.schema.save", "Save Schema Changes")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, ArrowRight } from "lucide-react";
import { Link, useParams, useNavigate } from "react-router-dom";
import { ArrowRight, Building2, Plus } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
@@ -9,18 +18,16 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
import { Button } from "../../../components/ui/button";
import { Badge } from "../../../components/ui/badge";
import { fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantSubTenantsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const { data, isLoading } = useQuery({
const { data } = useQuery({
queryKey: ["sub-tenants", tenantId],
queryFn: () => fetchTenants(50, 0, tenantId),
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
enabled: !!tenantId,
});
@@ -32,47 +39,76 @@ function TenantSubTenantsPage() {
<div>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
Sub-tenants ({subTenants.length})
{t("ui.admin.tenants.sub.title", "Sub-tenants ({{count}})", {
count: subTenants.length,
})}
</CardTitle>
<CardDescription> .</CardDescription>
<CardDescription>
{t(
"msg.admin.tenants.sub.subtitle",
"현재 테넌트 하위에 생성된 조직입니다.",
)}
</CardDescription>
</div>
<Button size="sm" asChild>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
</Link>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add", "하위 테넌트 추가")}
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead className="text-right">ACTION</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.sub.table.action", "ACTION")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")}
</TableCell>
</TableRow>
)}
{subTenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-semibold">{t.name}</TableCell>
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
{subTenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell className="text-xs font-mono">
{tenant.slug}
</TableCell>
<TableCell>
<Badge variant={t.status === "active" ? "default" : "secondary"}>
{t.status}
<Badge
variant={
tenant.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
<ArrowRight size={12} className="ml-1" />
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>

View File

@@ -1,6 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { User, Mail, Phone, ShieldCheck } from "lucide-react";
import { Mail, User } from "lucide-react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
@@ -9,18 +16,18 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
import { Badge } from "../../../components/ui/badge";
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const companyCode = tenantQuery.data?.slug;
@@ -39,24 +46,40 @@ function TenantUsersPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
Tenant Members ({users.length})
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
count: users.length,
})}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.email", "EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.members.empty",
"소속된 사용자가 없습니다.",
)}
</TableCell>
</TableRow>
)}
@@ -71,12 +94,17 @@ function TenantUsersPage() {
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{user.role.replace("_", " ")}
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "muted"}>
{user.status}
<Badge
variant={user.status === "active" ? "default" : "muted"}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
</TableRow>

View File

@@ -15,18 +15,30 @@ import {
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
createUser,
fetchTenants,
fetchTenant,
type UserCreateRequest,
type UserCreateResponse,
createUser,
fetchTenant,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
required?: boolean;
};
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
function UserCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<
string | null
>(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true);
@@ -41,7 +53,7 @@ function UserCreatePage() {
handleSubmit,
watch,
formState: { errors },
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
} = useForm<UserFormValues>({
defaultValues: {
email: "",
password: "",
@@ -57,13 +69,22 @@ function UserCreatePage() {
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
const mutation = useMutation({
mutationFn: createUser,
@@ -77,7 +98,10 @@ function UserCreatePage() {
navigate("/users");
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
setError(
err.response?.data?.error ||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
);
},
});
@@ -92,7 +116,12 @@ function UserCreatePage() {
}
if (!data.password) {
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
setError(
t(
"msg.admin.users.create.password_required",
"비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
),
);
return;
}
@@ -114,17 +143,21 @@ function UserCreatePage() {
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
{t("ui.admin.users.create.breadcrumb.section", "Users")}
</Link>
<span>/</span>
<span className="text-foreground">New</span>
<span className="text-foreground">
{t("ui.admin.users.create.breadcrumb.new", "New")}
</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.create.title", "사용자 추가")}
</h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
{t("ui.admin.users.create.back", "목록으로 돌아가기")}
</Link>
</Button>
</header>
@@ -132,9 +165,23 @@ function UserCreatePage() {
{generatedPassword && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardTitle>
{t(
"ui.admin.users.create.password_generated.title",
"초기 비밀번호 생성 완료",
)}
</CardTitle>
<CardDescription>
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
{createdEmail
? t(
"msg.admin.users.create.password_generated.with_email",
"{{email}} 계정의 초기 비밀번호입니다.",
{ email: createdEmail },
)
: t(
"msg.admin.users.create.password_generated.default",
"초기 비밀번호가 생성되었습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -142,11 +189,13 @@ function UserCreatePage() {
<span className="font-mono text-sm">{generatedPassword}</span>
<Button size="sm" variant="outline" onClick={onCopyPassword}>
<ClipboardCopy className="mr-2 h-4 w-4" />
{t("ui.common.copy", "복사")}
</Button>
</div>
<div className="flex justify-end">
<Button onClick={() => navigate("/users")}> </Button>
<Button onClick={() => navigate("/users")}>
{t("ui.admin.users.create.go_list", "목록으로 이동")}
</Button>
</div>
</CardContent>
</Card>
@@ -154,8 +203,15 @@ function UserCreatePage() {
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
<CardTitle>
{t("ui.admin.users.create.account.title", "계정 정보")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.create.account.subtitle",
"새로운 사용자를 시스템에 등록합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
@@ -166,182 +222,200 @@ function UserCreatePage() {
)}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Label htmlFor="email">
{t("ui.admin.users.create.form.email", "이메일")}
</Label>
<Input
id="email"
placeholder="user@example.com"
{...register("email", { required: "이메일은 필수입니다." })}
placeholder={t(
"ui.admin.users.create.form.email_placeholder",
"user@example.com",
)}
{...register("email", {
required: t(
"msg.admin.users.create.form.email_required",
"이메일은 필수입니다.",
),
})}
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
<p className="text-xs text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password"></Label>
<Label htmlFor="password">
{t("ui.admin.users.create.form.password", "비밀번호")}
</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)}
/>
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
</label>
</div>
<Input
id="password"
type="password"
placeholder="********"
placeholder={t(
"ui.admin.users.create.form.password_placeholder",
"********",
)}
disabled={autoPassword}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
{autoPassword
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
: "초기 비밀번호를 직접 설정합니다."}
? t(
"msg.admin.users.create.form.password_auto_help",
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
)
: t(
"msg.admin.users.create.form.password_manual_help",
"초기 비밀번호를 직접 설정합니다.",
)}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">
{t("ui.admin.users.create.form.name", "이름")}
</Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
placeholder={t(
"ui.admin.users.create.form.name_placeholder",
"홍길동",
)}
{...register("name", {
required: t(
"msg.admin.users.create.form.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
<p className="text-xs text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Label htmlFor="phone">
{t("ui.admin.users.create.form.phone", "전화번호")}
</Label>
<Input
id="phone"
placeholder="010-1234-5678"
placeholder={t(
"ui.admin.users.create.form.phone_placeholder",
"010-1234-5678",
)}
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode">
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="space-y-2">
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value="">
{t(
"ui.admin.users.create.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
<Label htmlFor="companyCode"> (Tenant)</Label>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="relative">
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.create.form.department", "부서")}
</Label>
<select
<Input
id="department"
placeholder={t(
"ui.admin.users.create.form.department_placeholder",
"개발팀",
)}
{...register("department")}
/>
</div>
</div>
id="companyCode"
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t(
"ui.admin.users.create.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
)}
</h3>
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
{...register("companyCode")}
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
/>
</div>
))}
</div>
</div>
)}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="space-y-2">
<Label htmlFor="role">
{t("ui.admin.users.create.form.role", "역할 (Role)")}
</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">
{t("ui.common.role.user", "User")}
</option>
<option value="admin">
{t("ui.common.role.admin", "Admin")}
</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
.
{t(
"msg.admin.users.create.form.role_help",
"시스템 접근 권한을 결정합니다.",
)}
</p>
</div>
@@ -351,14 +425,14 @@ function UserCreatePage() {
variant="outline"
onClick={() => navigate("/users")}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
{t("ui.admin.users.create.submit", "사용자 생성")}
</Button>
</div>
</form>

View File

@@ -15,24 +15,39 @@ import {
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
fetchUser,
fetchTenants,
fetchTenant,
updateUser,
type UserUpdateRequest,
fetchTenant,
fetchTenants,
fetchUser,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
required?: boolean;
};
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const params = useParams<{ id: string }>();
const userId = params.id ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id!),
enabled: !!id,
const {
data: user,
isLoading,
isError,
} = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
enabled: userId.length > 0,
});
const { data: tenantsData } = useQuery({
@@ -47,7 +62,7 @@ function UserDetailPage() {
reset,
watch,
formState: { errors },
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
} = useForm<UserFormValues>({
defaultValues: {
name: "",
phone: "",
@@ -63,13 +78,22 @@ function UserDetailPage() {
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
React.useEffect(() => {
if (user) {
@@ -87,15 +111,26 @@ function UserDetailPage() {
}, [user, reset]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(id!, data),
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", id] });
setSuccessMsg("사용자 정보가 수정되었습니다.");
queryClient.invalidateQueries({ queryKey: ["user", userId] });
setSuccessMsg(
t(
"msg.admin.users.detail.update_success",
"사용자 정보가 수정되었습니다.",
),
);
setError(null);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 수정에 실패했습니다.");
setError(
err.response?.data?.error ||
t(
"msg.admin.users.detail.update_error",
"사용자 수정에 실패했습니다.",
),
);
setSuccessMsg(null);
},
});
@@ -103,19 +138,23 @@ function UserDetailPage() {
const onSubmit = (data: UserUpdateRequest) => {
const payload = { ...data };
if (!payload.password) {
delete payload.password;
payload.password = undefined;
}
mutation.mutate(payload);
};
if (isLoading) {
return <div className="p-8 text-center">Loading...</div>;
return (
<div className="p-8 text-center">
{t("msg.common.loading", "Loading...")}
</div>
);
}
if (isError || !user) {
return (
<div className="p-8 text-center text-destructive">
.
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
</div>
);
}
@@ -126,26 +165,34 @@ function UserDetailPage() {
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
{t("ui.admin.users.detail.breadcrumb.section", "Users")}
</Link>
<span>/</span>
<span className="text-foreground">{user.name}</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.detail.title", "사용자 상세")}
</h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
{t("ui.admin.users.detail.back", "목록으로 돌아가기")}
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardTitle>
{t("ui.admin.users.detail.edit_title", "정보 수정")}
</CardTitle>
<CardDescription>
{user.email} .
{t(
"msg.admin.users.detail.edit_subtitle",
"{{email}} 계정의 정보를 수정합니다.",
{ email: user.email },
)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -163,22 +210,39 @@ function UserDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">
{t("ui.admin.users.detail.form.name", "이름")}
</Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
placeholder={t(
"ui.admin.users.detail.form.name_placeholder",
"홍길동",
)}
{...register("name", {
required: t(
"msg.admin.users.detail.form.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
<p className="text-xs text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Label htmlFor="phone">
{t("ui.admin.users.detail.form.phone", "전화번호")}
</Label>
<Input
id="phone"
placeholder="010-1234-5678"
placeholder={t(
"ui.admin.users.detail.form.phone_placeholder",
"010-1234-5678",
)}
{...register("phone")}
/>
</div>
@@ -186,149 +250,145 @@ function UserDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Label htmlFor="status">
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<div className="relative">
<select
id="status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("status")}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="blocked">Blocked</option>
<option value="active">
{t("ui.common.status.active", "Active")}
</option>
<option value="inactive">
{t("ui.common.status.inactive", "Inactive")}
</option>
<option value="blocked">
{t("ui.common.status.blocked", "Blocked")}
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<Label htmlFor="role">
{t("ui.admin.users.detail.form.role", "역할 (Role)")}
</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">
{t("ui.common.role.user", "User")}
</option>
<option value="admin">
{t("ui.common.role.admin", "Admin")}
</option>
</select>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password"> </Label>
<Label htmlFor="companyCode">
{t("ui.admin.users.detail.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.detail.form.department", "부서")}
</Label>
<Input
id="department"
placeholder={t(
"ui.admin.users.detail.form.department_placeholder",
"개발팀",
)}
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t(
"ui.admin.users.detail.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t("ui.admin.users.detail.security.title", "보안 설정")}
</h3>
<div className="space-y-2">
<Label htmlFor="password">
{t(
"ui.admin.users.detail.security.password",
"비밀번호 변경",
)}
</Label>
<Input
id="password"
type="password"
placeholder="변경할 경우에만 입력"
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
. .
{t(
"msg.admin.users.detail.security.password_hint",
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
)}
</p>
</div>
</div>
@@ -339,14 +399,14 @@ function UserDetailPage() {
variant="outline"
onClick={() => navigate("/users")}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
{t("ui.common.save", "저장")}
</Button>
</div>
</form>
@@ -356,4 +416,4 @@ function UserDetailPage() {
);
}
export default UserDetailPage;
export default UserDetailPage;

View File

@@ -31,6 +31,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { deleteUser, fetchUsers } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
function UserListPage() {
const navigate = useNavigate();
@@ -67,7 +68,12 @@ function UserListPage() {
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t(
"msg.admin.users.list.fetch_error",
"사용자 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
@@ -80,7 +86,15 @@ function UserListPage() {
}, [items]);
const handleDelete = (userId: string, userName: string) => {
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
if (
!window.confirm(
t(
"msg.admin.users.list.delete_confirm",
'사용자 "{{name}}"을(를) 정말 삭제하시겠습니까?',
{ name: userName },
),
)
) {
return;
}
deleteMutation.mutate(userId);
@@ -91,13 +105,20 @@ function UserListPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Users</span>
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
<span>/</span>
<span className="text-foreground">List</span>
<span className="text-foreground">
{t("ui.admin.users.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.list.title", "사용자 관리")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
. (Local DB)
{t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다. (Local DB)",
)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -107,12 +128,12 @@ function UserListPage() {
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
</div>
@@ -121,9 +142,15 @@ function UserListPage() {
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Registry</CardTitle>
<CardTitle>
{t("ui.admin.users.list.registry.title", "User Registry")}
</CardTitle>
<CardDescription>
{total} .
{t(
"msg.admin.users.list.registry.count",
"총 {{count}}명의 사용자가 등록되어 있습니다.",
{ count: total },
)}
</CardDescription>
</div>
</CardHeader>
@@ -132,7 +159,10 @@ function UserListPage() {
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="이름 또는 이메일 검색..."
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
@@ -140,7 +170,7 @@ function UserListPage() {
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
{t("ui.common.search", "검색")}
</Button>
</div>
@@ -154,26 +184,41 @@ function UserListPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME / EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>TENANT / DEPT</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
<TableHead>
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.created", "CREATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
...
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
.
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
</TableCell>
</TableRow>
)}
@@ -193,7 +238,9 @@ function UserListPage() {
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
<Badge variant="outline">
{t(`ui.common.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<Badge
@@ -201,7 +248,7 @@ function UserListPage() {
user.status === "active" ? "default" : "secondary"
}
>
{user.status}
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
@@ -211,7 +258,13 @@ function UserListPage() {
</span>
{user.tenant && (
<span className="text-[10px] text-muted-foreground uppercase">
Slug: {user.tenant.slug}
{t(
"ui.admin.users.list.tenant_slug",
"Slug: {{slug}}",
{
slug: user.tenant.slug,
},
)}
</span>
)}
<span className="text-xs text-muted-foreground">
@@ -228,7 +281,11 @@ function UserListPage() {
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
aria-label={`사용자 수정: ${user.name}`}
aria-label={t(
"ui.admin.users.list.edit_aria",
"사용자 수정: {{name}}",
{ name: user.name },
)}
>
<Pencil size={16} />
</Button>
@@ -238,7 +295,11 @@ function UserListPage() {
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
aria-label={`사용자 삭제: ${user.name}`}
aria-label={t(
"ui.admin.users.list.delete_aria",
"사용자 삭제: {{name}}",
{ name: user.name },
)}
>
<Trash2 size={16} />
</Button>
@@ -260,10 +321,13 @@ function UserListPage() {
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
Previous
{t("ui.common.previous", "Previous")}
</Button>
<div className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
page,
total: totalPages,
})}
</div>
<Button
variant="outline"
@@ -271,7 +335,7 @@ function UserListPage() {
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
Next
{t("ui.common.next", "Next")}
<ChevronRight size={16} />
</Button>
</div>

View File

@@ -26,7 +26,7 @@ export type TenantSummary = {
description: string;
status: string;
domains?: string[];
config?: Record<string, any>;
config?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
@@ -37,7 +37,7 @@ export type TenantCreateRequest = {
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
config?: Record<string, unknown>;
};
export type TenantListResponse = {
@@ -53,7 +53,7 @@ export type TenantUpdateRequest = {
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
config?: Record<string, unknown>;
};
export type ApiKeySummary = {
@@ -92,11 +92,11 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data;
}
export async function fetchTenants(limit = 50, offset = 0) {
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset },
params: { limit, offset, parentId },
},
);
return data;
@@ -139,6 +139,58 @@ export async function approveTenant(tenantId: string) {
return data;
}
// Group Management
export type GroupMember = {
id: string;
name: string;
email: string;
};
export type GroupSummary = {
id: string;
tenantId: string;
name: string;
description?: string;
members?: GroupMember[];
createdAt?: string;
updatedAt?: string;
};
export type GroupCreateRequest = {
name: string;
description?: string;
};
export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get<GroupSummary[]>(
`/v1/admin/tenants/${tenantId}/groups`,
);
return data;
}
export async function createGroup(
tenantId: string,
payload: GroupCreateRequest,
) {
const { data } = await apiClient.post<GroupSummary>(
`/v1/admin/tenants/${tenantId}/groups`,
payload,
);
return data;
}
export async function deleteGroup(groupId: string) {
await apiClient.delete(`/v1/admin/groups/${groupId}`);
}
export async function addGroupMember(groupId: string, userId: string) {
await apiClient.post(`/v1/admin/groups/${groupId}/members`, { userId });
}
export async function removeGroupMember(groupId: string, userId: string) {
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
@@ -182,7 +234,7 @@ export type UserSummary = {
status: string;
companyCode?: string;
tenant?: TenantSummary;
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
department?: string;
createdAt: string;
updatedAt: string;
@@ -272,7 +324,7 @@ export type HydraClientReq = {
token_endpoint_auth_method?: string;
grant_types?: string[];
response_types?: string[];
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
};
export async function fetchRelyingParties(tenantId: string) {

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

233
docs/i18n.md Normal file
View File

@@ -0,0 +1,233 @@
# Baron SSO i18n(국제화) 처리 정책
## 1. 개요 (Overview)
본 문서는 Baron SSO 시스템(Backend, UserFront, AdminFront, DevFront)의 다국어 지원을 위한 표준 정책을 정의합니다.
모든 UI 텍스트와 서버 메시지는 **하드코딩을 지양하고, 약속된 코드값(Key)을 기반으로 렌더링**해야 합니다.
---
## 2. 기본 원칙 (Core Principles)
1. **Backend는 Key만 전달한다**: API 응답에는 사용자에게 보여질 텍스트(메시지)를 포함하지 않는다. 대신 기계가 해석 가능한 `code`를 반환한다.
2. **Frontend가 렌더링 주체다**: 모든 번역 데이터(Dictionary)는 프론트엔드 애플리케이션이 보유하며, 백엔드로부터 받은 `code`나 UI의 키값을 매핑하여 적절한 언어로 표시한다.
3. **URL 기반 로케일 격리**: 언어 설정은 URL 경로(`/{locale}/...`)에 명시적으로 드러나야 한다.
4. **포맷 및 관리**:
* 모든 리소스는 **TOML** 포맷을 사용한다. (주석 가능, 가독성 우수)
* **`template.toml`**을 기준(Master)으로 삼아, 각 언어 파일(`ko.toml`, `en.toml`)이 키를 빠짐없이 구현했는지 관리한다.
---
## 3. 키(Key) 명명 규칙 (Naming Convention)
메시지 키는 계층 구조를 가지며 `snake_case``dot(.)` 표기법을 혼용하여 체계적으로 관리합니다.
TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
### 3.1 카테고리 (Category)
가장 상위 레벨에서 메시지의 성격을 정의합니다.
| Prefix (Section) | 설명 | 예시 |
| :--- | :--- | :--- |
| **`[ui]`** | 버튼, 레이블, 메뉴 등 짧은 단어 | `ui.btn.save`, `ui.nav.dashboard` |
| **`[msg]`** | 문장형 알림, 설명 텍스트, 다이얼로그 내용 | `msg.info.saved_success` |
| **`[err]`** | 백엔드 에러 코드 또는 프론트 유효성 검사 에러 | `err.auth.user_not_found` |
| **`[domain]`** | 비즈니스 도메인 용어 (명사 위주) | `domain.user.role` |
| **`[unit]`** | 시간, 화폐, 데이터 단위 | `unit.time.sec` |
### 3.2 TOML 예시 (`template.toml`)
```toml
# UI Elements
[ui]
[ui.btn]
save = ""
cancel = ""
[ui.label]
password = ""
# Messages
[msg]
[msg.info]
saved_success = ""
# Errors (Backend Codes)
[err]
[err.auth]
user_not_found = ""
```
---
## 4. 로케일 및 라우팅 정책 (Locale & Routing)
### 4.1 지원 언어 및 Fallback
* **기본 언어(Default)**: `en` (영어)
* **지원 언어**: `ko` (한국어), `en` (영어)
* **매핑 로직**:
* 브라우저 설정이 `ko`, `ko-KR` 인 경우 → **한국어 (`ko`)** 노출
* 그 외 모든 언어(`ja`, `zh`, `en-US` 등) → **영어 (`en`)** 노출
### 4.2 URL 구조
모든 페이지는 URL의 첫 번째 경로(Path Segment)로 언어를 구분합니다.
* `https://sso.baron.io/ko/login` (한국어)
* `https://sso.baron.io/en/dashboard` (영어)
### 4.3 라우팅 및 리다이렉트 동작
1. **Root 접속 시 (`/`)**:
* 사용자의 브라우저 `navigator.language`를 감지합니다.
* `ko` 계열이면 `/ko/dashboard`로 리다이렉트합니다.
* 그 외에는 `/en/dashboard`로 리다이렉트합니다.
* *단, 이전에 언어를 선택한 쿠키나 로컬스토리지 값이 있다면 그 값을 우선합니다.*
2. **언어 변경 시**:
* 모든 화면에는 **언어 선택기(Language Selector)**가 노출되어야 합니다.
* 변경 시 해당 언어의 URL로 이동(`window.location` 변경 혹은 Router Push)하며, 선택된 언어를 로컬스토리지에 저장합니다.
---
## 5. 아키텍처 및 데이터 흐름
### 5.1 백엔드 (Go Fiber)
백엔드는 번역을 수행하지 않습니다. 오직 상황에 맞는 **Error Code**만 반환합니다.
**응답 예시 (401 Unauthorized):**
```json
{
"error": "Invalid password",
"code": "auth.invalid_credentials", // TOML의 [err.auth] invalid_credentials 와 매핑
"details": null
}
```
### 5.2 프론트엔드 (React / Flutter)
#### 5.2.1 리소스 공유 및 변환
* TOML은 개발 및 관리 편의를 위한 포맷입니다.
* **Build Time** 또는 **Runtime**에 TOML 파일을 로드하여 사용합니다.
* **React (Vite)**: `vite-plugin-toml` 등을 사용하거나, 빌드 스크립트로 JSON 변환 후 `i18next`에 주입.
* **Flutter**: `toml` 패키지를 사용하여 로드하거나, 전처리 스크립트로 ARB/JSON 변환 후 `easy_localization` 사용.
#### 5.2.2 관리 프로세스 (Template & CI)
1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다.
2. **`ko.toml`, `en.toml`**: 템플릿의 키를 바탕으로 실제 번역 값을 채워 넣습니다.
3. **CI 검증 (Verification)**:
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
* 누락 시 빌드 실패.
* **Level 2: 코드 사용성 검사 (`code` vs `template`)**
* 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다.
* **Missing Key**: 코드에는 있는데 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다.
* **Unused Key**: `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다.
#### 5.2.3 React (Admin/Dev) 구현 가이드
* **패키지 설치**:
```bash
npm install i18next react-i18next i18next-browser-languagedetector
npm install -D vite-plugin-toml
```
* **Vite 설정 (`vite.config.ts`)**:
```ts
import { defineConfig } from 'vite';
import { plugin as toml } from 'vite-plugin-toml';
export default defineConfig({
plugins: [toml], // .toml 파일을 모듈로 import 가능하게 함
});
```
* **i18n 초기화 (`src/i18n.ts`)**:
```ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// TOML 파일 직접 import (JSON 객체로 변환됨)
import ko from './locales/ko.toml';
import en from './locales/en.toml';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
ko: { translation: ko },
en: { translation: en },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
export default i18n;
```
#### 5.2.4 Flutter (User) 구현 가이드
Flutter는 런타임에 TOML을 파싱하기 위해 `toml` 패키지와 `easy_localization`의 커스텀 로더를 사용합니다.
* **패키지 추가 (`pubspec.yaml`)**:
```yaml
dependencies:
easy_localization: ^3.0.0
toml: ^0.14.0
flutter:
assets:
- assets/translations/
```
* **Custom AssetLoader 구현 (`lib/core/i18n/toml_asset_loader.dart`)**:
```dart
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:toml/toml.dart';
class TomlAssetLoader extends AssetLoader {
const TomlAssetLoader();
@override
Future<Map<String, dynamic>> load(String path, Locale locale) async {
final assetPath = '$path/${locale.languageCode}.toml';
try {
// 1. Asset 파일 읽기
final String content = await rootBundle.loadString(assetPath);
// 2. TOML 파싱
final TomlDocument document = TomlDocument.parse(content);
// 3. Map으로 변환하여 반환
return document.toMap();
} catch (e) {
// 로깅 또는 빈 맵 반환
print('Error loading TOML asset: $assetPath, error: $e');
return {};
}
}
}
```
* **초기화 (`main.dart`)**:
```dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
runApp(
EasyLocalization(
supportedLocales: const [Locale('en'), Locale('ko')],
path: 'assets/translations',
fallbackLocale: const Locale('en'),
assetLoader: const TomlAssetLoader(), // 커스텀 로더 적용
child: const MyApp(),
),
);
}
```
---
## 6. 작업 체크리스트
1. [ ] **공통**: `locales/template.toml` 정의 및 초기 키셋 구성.
2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`).
3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동.
4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동.

1307
locales/en.toml Normal file

File diff suppressed because one or more lines are too long

1307
locales/ko.toml Normal file

File diff suppressed because one or more lines are too long

1307
locales/template.toml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const ROOT = process.cwd();
const LOCALES_DIR = path.join(ROOT, "locales");
const KO_PATH = path.join(LOCALES_DIR, "ko.toml");
const EN_PATH = path.join(LOCALES_DIR, "en.toml");
const OUT_PATH = path.join(ROOT, "userfront", "lib", "i18n_data.dart");
function parseToml(filePath) {
if (!fs.existsSync(filePath)) return new Map();
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split(/\r?\n/);
let section = [];
const map = new Map();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
if (line.startsWith("[[") && line.endsWith("]]")) {
const name = line.slice(2, -2).trim();
section = name ? name.split(".").map((p) => p.trim()).filter(Boolean) : [];
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const name = line.slice(1, -1).trim();
section = name ? name.split(".").map((p) => p.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 = "";
if (valueRaw.startsWith('"')) {
try {
value = JSON.parse(valueRaw);
} catch {
value = valueRaw.slice(1, -1);
}
} else if (valueRaw.startsWith("'") && valueRaw.endsWith("'")) {
value = valueRaw.slice(1, -1);
} else {
value = valueRaw;
}
const fullKey = [...section, key].join(".");
map.set(fullKey, value);
}
return map;
}
function dartStringLiteral(value) {
return JSON.stringify(value).replace(/\$/g, "\\$");
}
function renderDartMap(name, map) {
const keys = Array.from(map.keys()).sort();
const lines = [];
lines.push(`const Map<String, String> ${name} = {`);
for (const key of keys) {
const value = map.get(key) ?? "";
lines.push(` ${dartStringLiteral(key)}: ${dartStringLiteral(value)},`);
}
lines.push("};");
return lines.join("\n");
}
const koMap = parseToml(KO_PATH);
const enMap = parseToml(EN_PATH);
const output = [
"// locales/*.toml에서 생성됨",
renderDartMap("koStrings", koMap),
"",
renderDartMap("enStrings", enMap),
"",
].join("\n");
fs.writeFileSync(OUT_PATH, output, "utf8");

224
tools/i18n-scanner/index.js Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT_DIR = process.cwd();
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const LANG_FILES = ['ko.toml', 'en.toml'];
const FAIL_UNUSED = process.argv.includes('--fail-unused');
const SKIP_DIRS = new Set([
'.git',
'node_modules',
'dist',
'build',
'.dart_tool',
'.idea',
'.vscode',
'coverage',
'.next',
'.cache',
'tmp',
'logs',
]);
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.dart']);
const CODE_PATTERNS = [
/\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]/g,
/\btr\s*\(\s*['"]([^'"]+)['"]/g,
/['"]([^'"]+)['"]\s*\.tr\s*\(/g,
];
function readFileRequired(filePath) {
if (!fs.existsSync(filePath)) {
return { ok: false, error: `파일이 없습니다: ${filePath}` };
}
return { ok: true, value: fs.readFileSync(filePath, 'utf8') };
}
function parseTomlKeys(filePath) {
const result = readFileRequired(filePath);
if (!result.ok) {
return { ok: false, error: result.error, keys: new Set() };
}
const keys = new Set();
const lines = result.value.split(/\r?\n/);
let currentSection = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
if (line.startsWith('[[') && line.endsWith(']]')) {
const sectionName = line.slice(2, -2).trim();
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const sectionName = line.slice(1, -1).trim();
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
continue;
}
const eqIndex = line.indexOf('=');
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
if (!key) {
continue;
}
const fullKey = [...currentSection, key].join('.');
keys.add(fullKey);
}
return { ok: true, keys };
}
function walkDir(dirPath, files) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) {
continue;
}
walkDir(path.join(dirPath, entry.name), files);
continue;
}
if (!entry.isFile()) {
continue;
}
const ext = path.extname(entry.name).toLowerCase();
if (!CODE_EXTENSIONS.has(ext)) {
continue;
}
files.push(path.join(dirPath, entry.name));
}
}
function collectCodeKeys() {
const files = [];
walkDir(ROOT_DIR, files);
const keys = new Set();
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
for (const pattern of CODE_PATTERNS) {
let match;
while ((match = pattern.exec(content)) !== null) {
if (match[1]) {
keys.add(match[1]);
}
}
}
}
return keys;
}
function difference(aSet, bSet) {
const result = [];
for (const item of aSet) {
if (!bSet.has(item)) {
result.push(item);
}
}
return result.sort();
}
function printList(title, items) {
if (items.length === 0) {
return;
}
console.log(`\n${title}`);
for (const item of items) {
console.log(`- ${item}`);
}
}
function main() {
const errors = [];
const warnings = [];
const templateResult = parseTomlKeys(TEMPLATE_PATH);
if (!templateResult.ok) {
errors.push(templateResult.error);
}
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {
const langPath = path.join(LOCALES_DIR, fileName);
const langResult = parseTomlKeys(langPath);
if (!langResult.ok) {
errors.push(langResult.error);
continue;
}
langKeyMap.set(fileName, langResult.keys);
}
if (errors.length > 0) {
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
const templateKeys = templateResult.keys;
const codeKeys = collectCodeKeys();
for (const [fileName, langKeys] of langKeyMap.entries()) {
const missingInLang = difference(templateKeys, langKeys);
if (missingInLang.length > 0) {
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}`);
printList(`${fileName}에 없는 키`, missingInLang);
}
}
const missingInTemplate = difference(codeKeys, templateKeys);
if (missingInTemplate.length > 0) {
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}`);
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
}
const unusedInTemplate = difference(templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}`);
printList('코드에서 사용되지 않는 키', unusedInTemplate);
}
if (errors.length > 0) {
console.error('\n요약');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
if (warnings.length > 0) {
console.warn('\n요약');
for (const warning of warnings) {
console.warn(`- ${warning}`);
}
if (FAIL_UNUSED) {
process.exit(1);
}
}
console.log('\n✅ i18n 검증 완료');
}
main();

View File

@@ -0,0 +1,443 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = process.cwd();
const LOCALES_DIR = path.join(ROOT, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const KO_PATH = path.join(LOCALES_DIR, 'ko.toml');
const EN_PATH = path.join(LOCALES_DIR, 'en.toml');
const SKIP_DIRS = new Set([
'.git',
'node_modules',
'dist',
'build',
'.dart_tool',
'.idea',
'.vscode',
'coverage',
'.next',
'.cache',
'tmp',
'logs',
]);
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.dart']);
function walkDir(dirPath, files) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walkDir(path.join(dirPath, entry.name), files);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!CODE_EXTENSIONS.has(ext)) continue;
files.push(path.join(dirPath, entry.name));
}
}
function parseToml(filePath) {
if (!fs.existsSync(filePath)) return new Map();
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
let section = [];
const map = new Map();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('[[') && line.endsWith(']]')) {
const name = line.slice(2, -2).trim();
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const name = line.slice(1, -1).trim();
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
continue;
}
const eqIndex = line.indexOf('=');
if (eqIndex === -1) continue;
const key = line.slice(0, eqIndex).trim();
if (!key) continue;
let valueRaw = line.slice(eqIndex + 1).trim();
let value = '';
if (
(valueRaw.startsWith('"') && valueRaw.endsWith('"')) ||
(valueRaw.startsWith("'") && valueRaw.endsWith("'"))
) {
value = valueRaw.slice(1, -1);
} else {
value = valueRaw;
}
const fullKey = [...section, key].join('.');
map.set(fullKey, value);
}
return map;
}
function buildTree(keys, valuesMap) {
const root = {};
for (const key of keys) {
const parts = key.split('.');
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!node[part]) node[part] = {};
node = node[part];
}
const leaf = parts[parts.length - 1];
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
node[leaf] = value;
}
return root;
}
function renderToml(tree) {
const lines = [];
function walk(node, path) {
if (path.length) {
lines.push(`[${path.join('.')}]`);
}
const keys = Object.keys(node).sort();
const leafKeys = keys.filter((k) => typeof node[k] === 'string');
const childKeys = keys.filter((k) => typeof node[k] === 'object');
for (const key of leafKeys) {
const value = node[key];
lines.push(`${key} = ${JSON.stringify(value)}`);
}
for (const key of childKeys) {
lines.push('');
walk(node[key], [...path, key]);
}
}
walk(tree, []);
return lines.join('\n').trimEnd() + '\n';
}
function extractFallbacks() {
const files = [];
walkDir(ROOT, files);
const map = new Map();
const tsRegex = /\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]\s*,\s*(['"])([\s\S]*?)\2/g;
const dartRegex = /\btr\s*\(\s*['"]([^'"]+)['"][\s\S]*?fallback\s*:\s*(?:(\"\"\"[\s\S]*?\"\"\")|('(?:\\.|[^'])*')|(\"(?:\\.|[^\"])*\"))/g;
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
let match;
while ((match = tsRegex.exec(content)) !== null) {
const key = match[1];
const raw = match[3] ?? '';
if (!map.has(key)) map.set(key, raw);
}
while ((match = dartRegex.exec(content)) !== null) {
const key = match[1];
let raw = match[2] || match[3] || match[4] || '';
if (raw.startsWith('"""') && raw.endsWith('"""')) {
raw = raw.slice(3, -3);
} else if (
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"))
) {
raw = raw.slice(1, -1);
}
if (!map.has(key)) map.set(key, raw);
}
}
return map;
}
function isLongText(value) {
if (!value) return false;
if (value.includes('\n')) return true;
if (value.length > 220) return true;
if (/제\\d+조/.test(value)) return true;
return false;
}
function isMostlyAscii(value) {
if (!value) return false;
const ascii = value.replace(/[^\x00-\x7F]/g, '');
return ascii.length / value.length > 0.85;
}
const phraseMap = [
['로딩 중...', 'Loading...'],
['저장 중...', 'Saving...'],
['가족사 임직원', 'Affiliate employees'],
['일반 User', 'General user'],
['로그인 링크 전송', 'Send sign-in link'],
['링크 로그인 완료', 'Link sign-in complete'],
['링크 로그인', 'Link sign-in'],
['로그인 완료', 'Sign-in complete'],
['로그인 화면으로 이동', 'Go to sign-in'],
['회원가입 하기', 'Sign up'],
['회원가입', 'Sign up'],
['가입 완료', 'Sign-up complete'],
['미등록 회원', 'Unregistered member'],
['본인인증', 'Identity verification'],
['로그인', 'Sign in'],
['로그아웃', 'Sign out'],
['대시보드', 'Dashboard'],
['프로필', 'Profile'],
['내 정보', 'My profile'],
['QR 코드', 'QR code'],
['QR 인증', 'QR verification'],
['QR 스캔', 'Scan QR'],
['카메라 켜기', 'Turn on camera'],
['카메라', 'Camera'],
['스캔', 'Scan'],
['링크', 'Link'],
['승인', 'Approve'],
['거부', 'Reject'],
['완료', 'Complete'],
['실패', 'Failed'],
['성공', 'Success'],
['인증번호', 'Verification code'],
['인증', 'Verification'],
['비밀번호', 'Password'],
['재설정', 'Reset'],
['재설정', 'Reset'],
['재발송', 'Resend'],
['발송', 'Send'],
['요청', 'Request'],
['확인', 'Confirm'],
['취소', 'Cancel'],
['저장', 'Save'],
['삭제', 'Delete'],
['생성', 'Create'],
['수정', 'Edit'],
['등록', 'Register'],
['조회', 'Fetch'],
['목록', 'List'],
['상세', 'Details'],
['설정', 'Settings'],
['권한', 'Permission'],
['범위', 'Scope'],
['보안', 'Security'],
['사용자', 'User'],
['테넌트', 'Tenant'],
['그룹', 'Group'],
['이름', 'Name'],
['설명', 'Description'],
['상태', 'Status'],
['활성', 'Active'],
['비활성', 'Inactive'],
['복사', 'Copy'],
['정보', 'Information'],
['소속', 'Affiliation'],
['부서', 'Department'],
['부서명', 'Department name'],
['회사코드', 'Company code'],
['회사', 'Company'],
['조직', 'Organization'],
['약관동의', 'Agreements'],
['약관', 'Terms'],
['개인정보', 'Privacy'],
['동의', 'Consent'],
['접속이력', 'Access history'],
['접속일자', 'Access date'],
['접속환경', 'Access environment'],
['인증수단', 'Authentication method'],
['현황', 'Overview'],
['세션', 'Session'],
['없는', 'None'],
['없음', 'None'],
['없습니다', 'Not available'],
['없습니다.', 'Not available.'],
['알 수 없음', 'Unknown'],
['준비중', 'Pending'],
['남은 시간', 'Time remaining'],
['유효시간', 'Valid for'],
['숫자', 'Digits'],
['영문', 'Letters'],
['필수', 'Required'],
['선택', 'Optional'],
['이메일', 'Email'],
['휴대폰', 'Mobile'],
['전화번호', 'Phone number'],
['내용', 'Details'],
['추가', 'Add'],
['편집', 'Edit'],
['관리', 'Manage'],
['바론', 'Baron'],
['한라', 'Halla'],
['한맥', 'Hanmac'],
['장헌', 'Jangheon'],
['삼안', 'Saman'],
['준비 중', 'Pending'],
['새로고침', 'Refresh'],
['검색', 'Search'],
['추가', 'Add'],
['삭제', 'Delete'],
['수정', 'Edit'],
['편집', 'Edit'],
['생성', 'Create'],
['등록', 'Register'],
['관리', 'Manage'],
['목록', 'List'],
['상세', 'Details'],
['설정', 'Settings'],
['권한', 'Permission'],
['범위', 'Scope'],
['보안', 'Security'],
['비밀번호', 'Password'],
['이메일', 'Email'],
['전화번호', 'Phone number'],
['휴대폰', 'Mobile'],
['사용자', 'User'],
['테넌트', 'Tenant'],
['그룹', 'Group'],
['이름', 'Name'],
['설명', 'Description'],
['상태', 'Status'],
['활성', 'Active'],
['비활성', 'Inactive'],
['승인', 'Approve'],
['해지', 'Revoke'],
['복사', 'Copy'],
['요청', 'Request'],
['확인', 'Confirm'],
['취소', 'Cancel'],
['저장', 'Save'],
];
const keyTokenMap = [
['qr', 'QR'],
['idp', 'IDP'],
['oidc', 'OIDC'],
['api', 'API'],
['app', 'App'],
['m2m', 'M2M'],
['ui', 'UI'],
['otp', 'OTP'],
];
function translateNoun(text) {
let result = text;
for (const [from, to] of phraseMap) {
result = result.split(from).join(to);
}
result = result.replace(/\s+/g, ' ').trim();
return result;
}
function translateKorean(text) {
if (!text) return text;
const trimmed = text.trim();
const patterns = [
[/^(.+?)에 실패했습니다\.$/, (m, p1) => `Failed to ${translateNoun(p1)}.`],
[/^(.+?)에 실패했습니다$/, (m, p1) => `Failed to ${translateNoun(p1)}.`],
[/^(.+?)가 필요합니다\.$/, (m, p1) => `${translateNoun(p1)} is required.`],
[/^(.+?)가 필요합니다$/, (m, p1) => `${translateNoun(p1)} is required.`],
[/^(.+?)을 입력해 주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)을 입력해 주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해 주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해 주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)을 확인해 주세요\.$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)을 확인해 주세요$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)를 확인해 주세요\.$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)를 확인해 주세요$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)가 없습니다\.$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)가 없습니다$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)이 없습니다\.$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)이 없습니다$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)가 복사되었습니다\.$/, (m, p1) => `${translateNoun(p1)} copied.`],
[/^(.+?)가 저장되었습니다\.$/, (m, p1) => `${translateNoun(p1)} saved.`],
[/^(.+?)가 생성되었습니다\.$/, (m, p1) => `${translateNoun(p1)} created.`],
[/^(.+?)가 수정되었습니다\.$/, (m, p1) => `${translateNoun(p1)} updated.`],
[/^(.+?)가 삭제되었습니다\.$/, (m, p1) => `${translateNoun(p1)} deleted.`],
[/^(.+?)를 삭제할까요\?$/, (m, p1) => `Delete ${translateNoun(p1)}?`],
];
for (const [regex, fn] of patterns) {
const match = trimmed.match(regex);
if (match) return fn(...match);
}
let result = trimmed;
const sortedPhraseMap = [...phraseMap].sort((a, b) => b[0].length - a[0].length);
for (const [from, to] of sortedPhraseMap) {
result = result.split(from).join(to);
}
return result;
}
function containsHangul(text) {
return /[가-힣]/.test(text);
}
function keyToEnglish(key) {
if (!key) return 'Unknown';
const segment = key.split('.').pop() || key;
let result = segment.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
result = result.replace(/\b([a-z])/g, (m) => m.toUpperCase());
for (const [from, to] of keyTokenMap) {
const regex = new RegExp(`\\b${from}\\b`, 'gi');
result = result.replace(regex, to);
}
return result || 'Unknown';
}
function main() {
const templateMap = parseToml(TEMPLATE_PATH);
const koMap = parseToml(KO_PATH);
const enMap = parseToml(EN_PATH);
const fallbacks = extractFallbacks();
const allKeys = new Set([
...templateMap.keys(),
...koMap.keys(),
...enMap.keys(),
]);
for (const key of allKeys) {
const fallback = fallbacks.get(key);
const currentKo = koMap.get(key) ?? '';
const currentEn = enMap.get(key) ?? '';
let nextKo = currentKo;
if (!nextKo && fallback) {
nextKo = fallback;
}
if (!nextKo) {
nextKo = key;
}
let nextEn = currentEn;
if (!nextEn) {
const source = fallback || nextKo || key;
if (isLongText(source)) {
nextEn = source;
} else if (isMostlyAscii(source)) {
nextEn = source;
} else {
nextEn = translateKorean(source);
}
}
if (!nextEn) {
nextEn = key;
}
if (!isLongText(nextEn) && containsHangul(nextEn)) {
nextEn = keyToEnglish(key);
}
koMap.set(key, nextKo);
enMap.set(key, nextEn);
}
const keys = Array.from(allKeys).sort();
fs.writeFileSync(KO_PATH, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(EN_PATH, renderToml(buildTree(keys, enMap)));
}
main();