forked from baron/baron-sso
i18n refresh and frontend fixes
This commit is contained in:
@@ -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
4
.gitignore
vendored
@@ -30,3 +30,7 @@ userfront/.dart_tool/
|
||||
userfront/.packages
|
||||
userfront/.pub/
|
||||
userfront/.env
|
||||
|
||||
# Frontend test artifacts
|
||||
adminfront/test-results/
|
||||
devfront/test-results/
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
148
adminfront/src/lib/i18n.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const DEFAULT_LOCALE = "ko";
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
type TomlValue = string | TomlObject;
|
||||
|
||||
interface TomlObject {
|
||||
[key: string]: TomlValue;
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function parseToml(raw: string): TomlObject {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const root: TomlObject = {};
|
||||
let currentPath: string[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
const sectionName = line.slice(1, -1).trim();
|
||||
currentPath = sectionName
|
||||
? sectionName
|
||||
.split(".")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = line.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, eqIndex).trim();
|
||||
const valueRaw = line.slice(eqIndex + 1).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = valueRaw;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
let cursor: TomlObject = root;
|
||||
for (const section of currentPath) {
|
||||
if (!cursor[section] || typeof cursor[section] === "string") {
|
||||
cursor[section] = {};
|
||||
}
|
||||
cursor = cursor[section] as TomlObject;
|
||||
}
|
||||
|
||||
cursor[key] = value;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function getValue(target: TomlObject, key: string): string | undefined {
|
||||
const parts = key.split(".");
|
||||
let cursor: TomlValue = target;
|
||||
for (const part of parts) {
|
||||
if (typeof cursor !== "object" || cursor === null) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = (cursor as TomlObject)[part];
|
||||
if (cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : undefined;
|
||||
}
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof window === "undefined") {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored && isSupportedLocale(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const pathLocale = window.location.pathname.split("/")[1];
|
||||
if (pathLocale && isSupportedLocale(pathLocale)) {
|
||||
return pathLocale;
|
||||
}
|
||||
|
||||
const browserLang = window.navigator.language.toLowerCase();
|
||||
if (browserLang.startsWith("ko")) {
|
||||
return "ko";
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import enRaw from "../../../locales/en.toml?raw";
|
||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import koRaw from "../../../locales/ko.toml?raw";
|
||||
|
||||
const translations: Record<Locale, TomlObject> = {
|
||||
ko: parseToml(koRaw),
|
||||
en: parseToml(enRaw),
|
||||
};
|
||||
|
||||
function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!vars) {
|
||||
return template;
|
||||
}
|
||||
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined || value === null) {
|
||||
return match;
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function t(
|
||||
key: string,
|
||||
fallback?: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const locale = detectLocale();
|
||||
const value = getValue(translations[locale], key);
|
||||
if (value && value.length > 0) {
|
||||
return formatTemplate(value, vars);
|
||||
}
|
||||
return formatTemplate(fallback ?? key, vars);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -1,123 +1,131 @@
|
||||
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { Toaster } from "../ui/toaster";
|
||||
|
||||
const navItems = [{ label: "Clients", to: "/clients", icon: ShieldHalf }];
|
||||
const navItems = [
|
||||
{
|
||||
labelKey: "ui.dev.nav.clients",
|
||||
labelFallback: "Clients",
|
||||
to: "/clients",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
];
|
||||
|
||||
function AppLayout() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Baron 로그인
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
Developer Console
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||
<BadgeCheck size={14} />
|
||||
Scoped to /dev
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
Env: dev
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||
<p>개발자 전용 콘솔입니다.</p>
|
||||
<p>
|
||||
클라이언트 애플리케이션 등록 및 관리를 수행할 수
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Dev Plane
|
||||
</p>
|
||||
<span className="text-lg font-semibold">
|
||||
Manage your applications
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
aria-label="테마 전환"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Sun size={16} />
|
||||
) : (
|
||||
<Moon size={16} />
|
||||
)}
|
||||
{theme === "light" ? "Light" : "Dark"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<Toaster />
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.dev.brand", "Baron 로그인")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.dev.console_title", "Developer Console")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||
<BadgeCheck size={14} />
|
||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
{t("ui.dev.env_badge", "Env: dev")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||
<p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p>
|
||||
<p>
|
||||
{t(
|
||||
"msg.dev.sidebar.notice_detail",
|
||||
"클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.dev.header.plane", "Dev Plane")}
|
||||
</p>
|
||||
<span className="text-lg font-semibold">
|
||||
{t("ui.dev.header.subtitle", "Manage your applications")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
{theme === "light"
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Button, type ButtonProps } from "./button";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button, type ButtonProps } from "./button";
|
||||
|
||||
interface CopyButtonProps extends ButtonProps {
|
||||
value: string;
|
||||
@@ -40,10 +40,10 @@ export function CopyButton({
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (!successful) throw new Error('execCommand copy failed');
|
||||
const successful = document.execCommand("copy");
|
||||
if (!successful) throw new Error("execCommand copy failed");
|
||||
} catch (err) {
|
||||
console.error('Fallback: Oops, unable to copy', err);
|
||||
console.error("Fallback: Oops, unable to copy", err);
|
||||
throw err;
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useToastState } from "./use-toast";
|
||||
import { CheckCircle2, AlertCircle, Info, X } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useToastState } from "./use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const toasts = useToastState();
|
||||
@@ -15,12 +14,17 @@ export function Toaster() {
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
|
||||
t.type === "success" && "bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
|
||||
t.type === "error" && "bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
||||
t.type === "info" && "bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200"
|
||||
t.type === "success" &&
|
||||
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
|
||||
t.type === "error" &&
|
||||
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
||||
t.type === "info" &&
|
||||
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
|
||||
)}
|
||||
>
|
||||
{t.type === "success" && <CheckCircle2 className="h-5 w-5 shrink-0" />}
|
||||
{t.type === "success" && (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
|
||||
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
|
||||
<p className="text-sm font-medium leading-none">{t.message}</p>
|
||||
|
||||
@@ -39,4 +39,4 @@ export const useToastState = () => {
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
function ClientConsentsPage() {
|
||||
const params = useParams();
|
||||
@@ -65,17 +65,20 @@ function ClientConsentsPage() {
|
||||
<div className="space-y-2">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/" className="hover:text-primary">
|
||||
Home
|
||||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-primary">
|
||||
Clients
|
||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Clients")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>{clientData?.client?.name || clientId}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
User Consent Grants
|
||||
{t(
|
||||
"ui.dev.clients.consents.breadcrumb.current",
|
||||
"User Consent Grants",
|
||||
)}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -86,10 +89,13 @@ function ClientConsentsPage() {
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-3xl font-black leading-tight">
|
||||
User Consent Grants
|
||||
{t("ui.dev.clients.consents.title", "User Consent Grants")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
OIDC Relying Party 사용자 권한을 검토·관리합니다.
|
||||
{t(
|
||||
"msg.dev.clients.consents.subtitle",
|
||||
"OIDC Relying Party 사용자 권한을 검토·관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +106,9 @@ function ClientConsentsPage() {
|
||||
clientData?.client?.status === "active" ? "success" : "muted"
|
||||
}
|
||||
>
|
||||
{clientData?.client?.status === "active" ? "Active" : "Inactive"}
|
||||
{clientData?.client?.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,16 +117,16 @@ function ClientConsentsPage() {
|
||||
to={`/clients/${clientId}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Connection
|
||||
{t("ui.dev.clients.details.tab.connection", "Connection")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
Consent & Users
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</span>
|
||||
<Link
|
||||
to={`/clients/${clientId}/settings`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Settings
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
@@ -130,34 +138,48 @@ function ClientConsentsPage() {
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder="사용자 ID, 이름, 이메일로 검색"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.search_placeholder",
|
||||
"사용자 ID, 이름, 이메일로 검색",
|
||||
)}
|
||||
value={subjectInput}
|
||||
onChange={(e) => setSubjectInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Status:
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select className="h-10 rounded-lg border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option>All Statuses</option>
|
||||
<option selected>Active</option>
|
||||
<option>Revoked</option>
|
||||
<option>
|
||||
{t("ui.dev.clients.consents.status_all", "All Statuses")}
|
||||
</option>
|
||||
<option selected>
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option>
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" className="gap-1 text-muted-foreground">
|
||||
<Filter className="h-4 w-4" />
|
||||
Advanced Filters
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
검색
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button className="shadow-sm shadow-primary/30">
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
<Button className="shadow-sm shadow-primary/30">Export CSV</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -165,32 +187,58 @@ function ClientConsentsPage() {
|
||||
<Card className="glass-panel">
|
||||
{error && (
|
||||
<CardContent className="text-sm text-red-500">
|
||||
Error loading consents: {(error as Error).message}
|
||||
{t(
|
||||
"msg.dev.clients.consents.load_error",
|
||||
"Error loading consents: {{error}}",
|
||||
{
|
||||
error: (error as Error).message,
|
||||
},
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
{isLoading && (
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Loading consents...
|
||||
{t("msg.dev.clients.consents.loading", "Loading consents...")}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Tenant</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Granted Scopes</TableHead>
|
||||
<TableHead>First Granted</TableHead>
|
||||
<TableHead>Last Authenticated</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.user", "User")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.dev.clients.consents.table.first_granted",
|
||||
"First Granted",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.dev.clients.consents.table.last_auth",
|
||||
"Last Authenticated",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.clients.consents.table.action", "Action")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
No consents found.
|
||||
{t("msg.dev.clients.consents.empty", "No consents found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -199,11 +247,14 @@ function ClientConsentsPage() {
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
|
||||
{(row.userName || row.subject).slice(0, 2).toUpperCase()}
|
||||
{(row.userName || row.subject)
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">
|
||||
{row.userName || "Subject"}
|
||||
{row.userName ||
|
||||
t("ui.dev.clients.consents.subject", "Subject")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.subject}
|
||||
@@ -214,7 +265,7 @@ function ClientConsentsPage() {
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">
|
||||
{row.tenantName || "N/A"}
|
||||
{row.tenantName || t("ui.common.na", "N/A")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.tenantId}
|
||||
@@ -222,7 +273,9 @@ function ClientConsentsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="success">Active</Badge>
|
||||
<Badge variant="success">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -253,7 +306,7 @@ function ClientConsentsPage() {
|
||||
revokeMutation.mutate({ subject: row.subject })
|
||||
}
|
||||
>
|
||||
Revoke
|
||||
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -263,15 +316,23 @@ function ClientConsentsPage() {
|
||||
</Table>
|
||||
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Showing <span className="font-semibold text-foreground">{rows.length > 0 ? 1 : 0}</span> to{" "}
|
||||
<span className="font-semibold text-foreground">{rows.length}</span> of{" "}
|
||||
<span className="font-semibold text-foreground">{rows.length}</span> users
|
||||
{t(
|
||||
"msg.dev.clients.consents.showing",
|
||||
"Showing {{from}} to {{to}} of {{total}} users",
|
||||
{
|
||||
from: rows.length > 0 ? 1 : 0,
|
||||
to: rows.length,
|
||||
total: rows.length,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" disabled>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" disabled={rows.length === 0}>1</Button>
|
||||
<Button size="sm" disabled={rows.length === 0}>
|
||||
1
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" disabled>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -283,7 +344,10 @@ function ClientConsentsPage() {
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Active Grants
|
||||
{t(
|
||||
"ui.dev.clients.consents.stats.active_grants",
|
||||
"Active Grants",
|
||||
)}
|
||||
</p>
|
||||
<CardTitle className="text-2xl font-black">{rows.length}</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -291,7 +355,10 @@ function ClientConsentsPage() {
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Total Scopes Issued
|
||||
{t(
|
||||
"ui.dev.clients.consents.stats.total_scopes",
|
||||
"Total Scopes Issued",
|
||||
)}
|
||||
</p>
|
||||
<CardTitle className="text-2xl font-black">
|
||||
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
|
||||
@@ -301,13 +368,18 @@ function ClientConsentsPage() {
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Avg. Scopes per User
|
||||
{t(
|
||||
"ui.dev.clients.consents.stats.avg_scopes",
|
||||
"Avg. Scopes per User",
|
||||
)}
|
||||
</p>
|
||||
<CardTitle className="text-2xl font-black">
|
||||
{rows.length > 0
|
||||
? (
|
||||
rows.reduce((acc, row) => acc + row.grantedScopes.length, 0) /
|
||||
rows.length
|
||||
rows.reduce(
|
||||
(acc, row) => acc + row.grantedScopes.length,
|
||||
0,
|
||||
) / rows.length
|
||||
).toFixed(1)
|
||||
: "0.0"}
|
||||
</CardTitle>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { AlertCircle, Copy, Eye, EyeOff, Link2, Shield, Workflow, Save, RefreshCw } from "lucide-react";
|
||||
import { Eye, EyeOff, Link2, RefreshCw, Save, Shield } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
@@ -14,17 +22,20 @@ import {
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { fetchClient, updateClient, rotateClientSecret } from "../../lib/devApi";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
fetchClient,
|
||||
rotateClientSecret,
|
||||
updateClient,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function ClientDetailsPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const clientId = params.id ?? "";
|
||||
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
@@ -50,10 +61,20 @@ function ClientDetailsPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||
toast("Redirect URIs가 저장되었습니다.");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.redirect_saved",
|
||||
"Redirect URIs가 저장되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(`저장 실패: ${(err as Error).message}`, "error");
|
||||
toast(
|
||||
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
|
||||
error: (err as Error).message,
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,26 +82,51 @@ function ClientDetailsPage() {
|
||||
mutationFn: () => rotateClientSecret(clientId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||
toast("Client Secret이 재발급되었습니다.");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.secret_rotated",
|
||||
"Client Secret이 재발급되었습니다.",
|
||||
),
|
||||
);
|
||||
setShowSecret(true); // 재발급 후 바로 보여줌
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(`재발급 실패: ${(err as Error).message}`, "error");
|
||||
toast(
|
||||
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
|
||||
error: (err as Error).message,
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRotateSecret = () => {
|
||||
if (window.confirm("경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?")) {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.details.rotate_confirm",
|
||||
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
rotateMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
return <div className="p-8 text-center">Client ID가 필요합니다.</div>;
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Loading client...</div>;
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.details.loading", "Loading client...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
@@ -89,31 +135,62 @@ function ClientDetailsPage() {
|
||||
(error as Error)?.message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
Error loading client: {errMsg || "unknown error"}
|
||||
{t(
|
||||
"msg.dev.clients.details.load_error",
|
||||
"Error loading client: {{error}}",
|
||||
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const endpoints = [
|
||||
{ label: "Discovery Endpoint", value: data.endpoints.discovery },
|
||||
{ label: "Issuer URL", value: data.endpoints.issuer },
|
||||
{ label: "Authorization Endpoint", value: data.endpoints.authorization },
|
||||
{ label: "Token Endpoint", value: data.endpoints.token },
|
||||
{ label: "UserInfo Endpoint", value: data.endpoints.userinfo },
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
||||
labelFallback: "Discovery Endpoint",
|
||||
value: data.endpoints.discovery,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.issuer",
|
||||
labelFallback: "Issuer URL",
|
||||
value: data.endpoints.issuer,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.authorization",
|
||||
labelFallback: "Authorization Endpoint",
|
||||
value: data.endpoints.authorization,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.token",
|
||||
labelFallback: "Token Endpoint",
|
||||
value: data.endpoints.token,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.userinfo",
|
||||
labelFallback: "UserInfo Endpoint",
|
||||
value: data.endpoints.userinfo,
|
||||
},
|
||||
];
|
||||
|
||||
// Client Secret from API
|
||||
const clientSecret = data.client.clientSecret || "SECRET_NOT_AVAILABLE";
|
||||
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||
const clientSecret = data.client.clientSecret || secretPlaceholder;
|
||||
const displaySecret =
|
||||
clientSecret === secretPlaceholder
|
||||
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||
: clientSecret;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/clients" className="text-primary hover:underline">
|
||||
Relying Parties
|
||||
{t("ui.dev.clients.details.breadcrumb.section", "Relying Parties")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">클라이언트 상세</span>
|
||||
<span className="text-foreground">
|
||||
{t("ui.dev.clients.details.breadcrumb.current", "클라이언트 상세")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -121,14 +198,19 @@ function ClientDetailsPage() {
|
||||
{data.client.name || data.client.id}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
OIDC 자격 증명과 엔드포인트를 관리합니다.
|
||||
{t(
|
||||
"msg.dev.clients.details.subtitle",
|
||||
"OIDC 자격 증명과 엔드포인트를 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={data.client.status === "active" ? "success" : "muted"}
|
||||
className="px-3 py-1 text-xs uppercase"
|
||||
>
|
||||
{data.client.status === "active" ? "Active" : "Inactive"}
|
||||
{data.client.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-6 border-b border-border">
|
||||
@@ -136,19 +218,19 @@ function ClientDetailsPage() {
|
||||
to={`/clients/${clientId}`}
|
||||
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
|
||||
>
|
||||
Connection
|
||||
{t("ui.dev.clients.details.tab.connection", "Connection")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/consents`}
|
||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Consent & Users
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/settings`}
|
||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Settings
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,18 +238,35 @@ function ClientDetailsPage() {
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">클라이언트 자격 증명</h2>
|
||||
<h2 className="text-xl font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.details.credentials.title",
|
||||
"클라이언트 자격 증명",
|
||||
)}
|
||||
</h2>
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="flex flex-col gap-4 p-6">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Client ID
|
||||
{t(
|
||||
"ui.dev.clients.details.credentials.client_id",
|
||||
"Client ID",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-mono text-lg truncate">{data.client.id}</p>
|
||||
<CopyButton
|
||||
value={data.client.id}
|
||||
onCopy={() => toast("Client ID가 복사되었습니다.")}
|
||||
<p className="font-mono text-lg truncate">
|
||||
{data.client.id}
|
||||
</p>
|
||||
<CopyButton
|
||||
value={data.client.id}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_client_id",
|
||||
"Client ID가 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,37 +275,73 @@ function ClientDetailsPage() {
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Client Secret
|
||||
{t(
|
||||
"ui.dev.clients.details.credentials.client_secret",
|
||||
"Client Secret",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className={cn(
|
||||
"font-mono text-lg",
|
||||
!showSecret && "tracking-widest"
|
||||
)}>
|
||||
{showSecret ? clientSecret : "••••••••••••••••"}
|
||||
<p
|
||||
className={cn(
|
||||
"font-mono text-lg",
|
||||
!showSecret && "tracking-widest",
|
||||
)}
|
||||
>
|
||||
{showSecret ? displaySecret : "••••••••••••••••"}
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
aria-label={showSecret ? "비밀키 숨기기" : "비밀키 보기"}
|
||||
aria-label={
|
||||
showSecret
|
||||
? t(
|
||||
"ui.dev.clients.details.secret.hide",
|
||||
"비밀키 숨기기",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.details.secret.show",
|
||||
"비밀키 보기",
|
||||
)
|
||||
}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleRotateSecret}
|
||||
disabled={rotateMutation.isPending}
|
||||
title="비밀키 재발급 (Rotate)"
|
||||
title={t(
|
||||
"ui.dev.clients.details.secret.rotate",
|
||||
"비밀키 재발급 (Rotate)",
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", rotateMutation.isPending && "animate-spin")} />
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
rotateMutation.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<CopyButton
|
||||
<CopyButton
|
||||
value={clientSecret}
|
||||
disabled={!showSecret && clientSecret === "SECRET_NOT_AVAILABLE"}
|
||||
onCopy={() => toast("Client Secret이 복사되었습니다.")}
|
||||
disabled={
|
||||
!showSecret && clientSecret === secretPlaceholder
|
||||
}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_client_secret",
|
||||
"Client Secret이 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,20 +352,25 @@ function ClientDetailsPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold">OIDC 엔드포인트</h2>
|
||||
<h2 className="text-xl font-bold">
|
||||
{t("ui.dev.clients.details.endpoints.title", "OIDC 엔드포인트")}
|
||||
</h2>
|
||||
<Badge variant="muted" className="gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
읽기 전용
|
||||
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Card className="glass-panel">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{endpoints.map((endpoint) => (
|
||||
<TableRow key={endpoint.label} className="border-border/70">
|
||||
<TableRow
|
||||
key={endpoint.labelKey}
|
||||
className="border-border/70"
|
||||
>
|
||||
<TableCell className="w-1/3">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{endpoint.label}
|
||||
{t(endpoint.labelKey, endpoint.labelFallback)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="flex items-center justify-between gap-3">
|
||||
@@ -240,7 +380,20 @@ function ClientDetailsPage() {
|
||||
<CopyButton
|
||||
value={endpoint.value}
|
||||
className="h-8 w-8 shrink-0"
|
||||
onCopy={() => toast(`${endpoint.label}가 복사되었습니다.`)}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_endpoint",
|
||||
"{{label}}가 복사되었습니다.",
|
||||
{
|
||||
label: t(
|
||||
endpoint.labelKey,
|
||||
endpoint.labelFallback,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -253,33 +406,56 @@ function ClientDetailsPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">리디렉션 URI 설정</h2>
|
||||
<h2 className="text-xl font-bold">
|
||||
{t("ui.dev.clients.details.redirect.title", "리디렉션 URI 설정")}
|
||||
</h2>
|
||||
<Card className="glass-panel border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Redirect URIs</CardTitle>
|
||||
<CardTitle className="text-lg">
|
||||
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.
|
||||
{t(
|
||||
"msg.dev.clients.details.redirect.description",
|
||||
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="redirect-uris" className="text-sm font-semibold">인증 콜백 URL</Label>
|
||||
<Label
|
||||
htmlFor="redirect-uris"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t(
|
||||
"ui.dev.clients.details.redirect.callback_label",
|
||||
"인증 콜백 URL",
|
||||
)}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="redirect-uris"
|
||||
placeholder="https://your-app.com/callback, http://localhost:3000/auth/callback"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.details.redirect.placeholder",
|
||||
"https://your-app.com/callback, http://localhost:3000/auth/callback",
|
||||
)}
|
||||
rows={5}
|
||||
value={redirectUris}
|
||||
onChange={(e) => setRedirectUris(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{mutation.isPending ? "저장 중..." : "Redirect URIs 저장"}
|
||||
{mutation.isPending
|
||||
? t("msg.common.saving", "저장 중...")
|
||||
: t(
|
||||
"ui.dev.clients.details.redirect.save",
|
||||
"Redirect URIs 저장",
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -292,18 +468,24 @@ function ClientDetailsPage() {
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold">보안 메모</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{t("ui.dev.clients.details.security.title", "보안 메모")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사
|
||||
로그와 연계하세요.
|
||||
{t(
|
||||
"msg.dev.clients.details.security.note",
|
||||
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을
|
||||
권장합니다.
|
||||
{t(
|
||||
"msg.dev.clients.details.security.footer",
|
||||
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Info, Search, Shield, Sparkles, Upload, Plus, Trash2 } from "lucide-react";
|
||||
import { Plus, Shield, Sparkles, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -14,11 +14,15 @@ import {
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { createClient, fetchClient, updateClient } from "../../lib/devApi";
|
||||
import type { ClientStatus, ClientType } from "../../lib/devApi";
|
||||
import type {
|
||||
ClientStatus,
|
||||
ClientType,
|
||||
ClientUpsertRequest,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface ScopeItem {
|
||||
@@ -46,10 +50,25 @@ function ClientGeneralPage() {
|
||||
const [clientType, setClientType] = useState<ClientType>("confidential");
|
||||
const [status, setStatus] = useState<ClientStatus>("active");
|
||||
const [redirectUris, setRedirectUris] = useState("");
|
||||
const [scopes, setScopes] = useState<ScopeItem[]>([
|
||||
{ id: "1", name: "openid", description: "OIDC 인증 필수 스코프", mandatory: true },
|
||||
{ id: "2", name: "profile", description: "기본 프로필 정보 접근", mandatory: false },
|
||||
{ id: "3", name: "email", description: "이메일 주소 접근", mandatory: false },
|
||||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||||
{
|
||||
id: "1",
|
||||
name: "openid",
|
||||
description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"),
|
||||
mandatory: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "profile",
|
||||
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
|
||||
mandatory: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "email",
|
||||
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
||||
mandatory: false,
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,43 +77,56 @@ function ClientGeneralPage() {
|
||||
setName(client.name || client.id);
|
||||
setClientType(client.type);
|
||||
setStatus(client.status);
|
||||
|
||||
|
||||
const metadata = client.metadata ?? {};
|
||||
if (typeof metadata.description === "string") setDescription(metadata.description);
|
||||
if (typeof metadata.description === "string")
|
||||
setDescription(metadata.description);
|
||||
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
||||
|
||||
// Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성
|
||||
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
||||
if (savedScopes && Array.isArray(savedScopes)) {
|
||||
setScopes(savedScopes);
|
||||
}
|
||||
else {
|
||||
setScopes(client.scopes.map((s, idx) => ({
|
||||
id: String(idx + 1),
|
||||
name: s,
|
||||
description: "",
|
||||
mandatory: s === "openid"
|
||||
})));
|
||||
} else {
|
||||
setScopes(
|
||||
client.scopes.map((s, idx) => ({
|
||||
id: String(idx + 1),
|
||||
name: s,
|
||||
description: "",
|
||||
mandatory: s === "openid",
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const addScope = () => {
|
||||
const newId = String(Date.now());
|
||||
setScopes([...scopes, { id: newId, name: "", description: "", mandatory: false }]);
|
||||
setScopes([
|
||||
...scopes,
|
||||
{ id: newId, name: "", description: "", mandatory: false },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateScope = (id: string, field: keyof ScopeItem, value: any) => {
|
||||
setScopes(scopes.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||
const updateScope = <K extends keyof ScopeItem>(
|
||||
id: string,
|
||||
field: K,
|
||||
value: ScopeItem[K],
|
||||
) => {
|
||||
setScopes(
|
||||
scopes.map((scope) =>
|
||||
scope.id === id ? { ...scope, [field]: value } : scope,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeScope = (id: string) => {
|
||||
setScopes(scopes.filter(s => s.id !== id));
|
||||
setScopes(scopes.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const scopeNames = scopes.map(s => s.name).filter(Boolean);
|
||||
const payload: any = {
|
||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||
const payload: ClientUpsertRequest = {
|
||||
name,
|
||||
type: clientType,
|
||||
status,
|
||||
@@ -102,16 +134,19 @@ function ClientGeneralPage() {
|
||||
metadata: {
|
||||
description,
|
||||
logo_url: logoUrl,
|
||||
structured_scopes: scopes // 향후 보존을 위해 metadata에 저장
|
||||
structured_scopes: scopes, // 향후 보존을 위해 metadata에 저장
|
||||
},
|
||||
};
|
||||
|
||||
// 생성 시에는 Redirect URIs를 포함해서 전송
|
||||
if (isCreate) {
|
||||
payload.redirectUris = redirectUris.split(",").map(u => u.trim()).filter(Boolean);
|
||||
payload.redirectUris = redirectUris
|
||||
.split(",")
|
||||
.map((uri) => uri.trim())
|
||||
.filter(Boolean);
|
||||
return createClient(payload);
|
||||
}
|
||||
|
||||
|
||||
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
|
||||
return updateClient(clientId as string, payload);
|
||||
},
|
||||
@@ -120,17 +155,37 @@ function ClientGeneralPage() {
|
||||
if (result?.client?.id) {
|
||||
navigate(`/clients/${result.client.id}/settings`);
|
||||
}
|
||||
alert("설정이 저장되었습니다.");
|
||||
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
if (!isCreate && isLoading) return <div className="p-8 text-center">Loading client...</div>;
|
||||
if (!isCreate && isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.general.loading", "Loading client...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isCreate && (error || !data)) {
|
||||
const errMsg = (error as AxiosError<{ error?: string }>).response?.data?.error ?? (error as Error)?.message;
|
||||
return <div className="p-8 text-center text-red-500">Error loading client: {errMsg || "unknown error"}</div>;
|
||||
const errMsg =
|
||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(error as Error)?.message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t(
|
||||
"msg.dev.clients.general.load_error",
|
||||
"Error loading client: {{error}}",
|
||||
{
|
||||
error: errMsg || t("msg.common.unknown_error", "unknown error"),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = isCreate ? "새 클라이언트" : data?.client?.name || data?.client?.id;
|
||||
const displayName = isCreate
|
||||
? t("ui.dev.clients.general.display_new", "새 클라이언트")
|
||||
: data?.client?.name || data?.client?.id;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -138,22 +193,45 @@ function ClientGeneralPage() {
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/clients" className="text-primary hover:underline">Applications</Link>
|
||||
<Link to="/clients" className="text-primary hover:underline">
|
||||
{t("ui.dev.clients.general.breadcrumb.section", "Applications")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{displayName}</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-black leading-tight">{isCreate ? "Create Client" : "Client Settings"}</h1>
|
||||
<h1 className="text-3xl font-black leading-tight">
|
||||
{isCreate
|
||||
? t("ui.dev.clients.general.title_create", "Create Client")
|
||||
: t("ui.dev.clients.general.title_edit", "Client Settings")}
|
||||
</h1>
|
||||
</div>
|
||||
<Badge variant={status === "active" ? "success" : "muted"} className="px-3 py-1 text-xs uppercase">
|
||||
{status === "active" ? "Active" : "Inactive"}
|
||||
<Badge
|
||||
variant={status === "active" ? "success" : "muted"}
|
||||
className="px-3 py-1 text-xs uppercase"
|
||||
>
|
||||
{status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
{!isCreate && (
|
||||
<>
|
||||
<Link to={`/clients/${clientId}`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Connection</Link>
|
||||
<Link to={`/clients/${clientId}/consents`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Consent & Users</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">Settings</span>
|
||||
<Link
|
||||
to={`/clients/${clientId}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Connection")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/consents`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -161,28 +239,83 @@ function ClientGeneralPage() {
|
||||
|
||||
{/* 1. Application Identity */}
|
||||
<div className="glass-panel p-6">
|
||||
<CardTitle className="text-xl font-bold mb-2">Application Identity</CardTitle>
|
||||
<CardDescription className="mb-6">앱 이름과 설명, 로고를 설정합니다.</CardDescription>
|
||||
<CardTitle className="text-xl font-bold mb-2">
|
||||
{t("ui.dev.clients.general.identity.title", "Application Identity")}
|
||||
</CardTitle>
|
||||
<CardDescription className="mb-6">
|
||||
{t(
|
||||
"msg.dev.clients.general.identity.subtitle",
|
||||
"앱 이름과 설명, 로고를 설정합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">앱 이름 <span className="text-destructive">*</span></Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="My Awesome Application" />
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.dev.clients.general.identity.name", "앱 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.identity.name_placeholder",
|
||||
"My Awesome Application",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Description</Label>
|
||||
<Textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="앱에 대한 간단한 설명을 입력하세요." />
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.identity.description",
|
||||
"Description",
|
||||
)}
|
||||
</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.identity.description_placeholder",
|
||||
"앱에 대한 간단한 설명을 입력하세요.",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">App Logo URL</Label>
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.dev.clients.general.identity.logo", "App Logo URL")}
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} placeholder="https://example.com/logo.png" />
|
||||
<p className="text-xs text-muted-foreground">인증 화면에 표시될 PNG/SVG URL입니다.</p>
|
||||
<Input
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.identity.logo_placeholder",
|
||||
"https://example.com/logo.png",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.identity.logo_help",
|
||||
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
||||
{logoUrl ? <img src={logoUrl} alt="Logo Preview" className="h-full w-full object-contain" /> : <Upload className="h-5 w-5 text-muted-foreground" />}
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={t(
|
||||
"ui.dev.clients.general.identity.logo_preview",
|
||||
"Logo Preview",
|
||||
)}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,25 +326,49 @@ function ClientGeneralPage() {
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold">Scopes</CardTitle>
|
||||
<CardDescription>이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.</CardDescription>
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{t("ui.dev.clients.general.scopes.title", "Scopes")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.general.scopes.subtitle",
|
||||
"이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addScope} className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Scope 추가
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addScope}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */}
|
||||
{isCreate && (
|
||||
<div className="space-y-2 border-b border-border pb-6 mb-6">
|
||||
<Label className="text-sm font-semibold">Redirect URIs <span className="text-destructive">*</span></Label>
|
||||
<Textarea
|
||||
value={redirectUris}
|
||||
onChange={(e) => setRedirectUris(e.target.value)}
|
||||
placeholder="https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.dev.clients.general.redirect.label", "Redirect URIs")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
value={redirectUris}
|
||||
onChange={(e) => setRedirectUris(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.redirect.placeholder",
|
||||
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
||||
)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.redirect.help",
|
||||
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -220,27 +377,76 @@ function ClientGeneralPage() {
|
||||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-bold">Scope Name</th>
|
||||
<th className="px-4 py-3 text-left font-bold">Description</th>
|
||||
<th className="px-4 py-3 text-center font-bold">Mandatory</th>
|
||||
<th className="px-4 py-3 text-right"></th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.scopes.table.name",
|
||||
"Scope Name",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.scopes.table.description",
|
||||
"Description",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.scopes.table.mandatory",
|
||||
"Mandatory",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{scopes.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30 transition-colors">
|
||||
<tr
|
||||
key={s.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Input value={s.name} onChange={(e) => updateScope(s.id, "name", e.target.value)} className="h-8 font-mono text-xs" placeholder="e.g. profile" />
|
||||
<Input
|
||||
value={s.name}
|
||||
onChange={(e) =>
|
||||
updateScope(s.id, "name", e.target.value)
|
||||
}
|
||||
className="h-8 font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.scopes.name_placeholder",
|
||||
"e.g. profile",
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Input value={s.description} onChange={(e) => updateScope(s.id, "description", e.target.value)} className="h-8 text-xs" placeholder="권한에 대한 설명" />
|
||||
<Input
|
||||
value={s.description}
|
||||
onChange={(e) =>
|
||||
updateScope(s.id, "description", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.scopes.description_placeholder",
|
||||
"권한에 대한 설명",
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex justify-center">
|
||||
<Switch checked={s.mandatory} onCheckedChange={(checked) => updateScope(s.id, "mandatory", checked)} />
|
||||
<Switch
|
||||
checked={s.mandatory}
|
||||
onCheckedChange={(checked) =>
|
||||
updateScope(s.id, "mandatory", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => removeScope(s.id)} className="h-8 w-8 text-muted-foreground hover:text-destructive">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeScope(s.id)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
@@ -248,7 +454,15 @@ function ClientGeneralPage() {
|
||||
))}
|
||||
{scopes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">등록된 스코프가 없습니다.</td>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.dev.clients.general.scopes.empty",
|
||||
"등록된 스코프가 없습니다.",
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -260,48 +474,118 @@ function ClientGeneralPage() {
|
||||
{/* 3. Security Settings (Moved down) */}
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl font-bold">보안 설정</CardTitle>
|
||||
<CardDescription>클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.</CardDescription>
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{t("ui.dev.clients.general.security.title", "보안 설정")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.general.security.subtitle",
|
||||
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className={cn("relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition", clientType === "confidential" ? "border-primary bg-primary/5" : "border-border bg-card hover:border-muted-foreground/40")}>
|
||||
<input className="sr-only" type="radio" name="client-type" checked={clientType === "confidential"} onChange={() => setClientType("confidential")} />
|
||||
<label
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
||||
clientType === "confidential"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="client-type"
|
||||
checked={clientType === "confidential"}
|
||||
onChange={() => setClientType("confidential")}
|
||||
/>
|
||||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||||
<Shield className="h-4 w-4 text-primary" /> Confidential
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
{t(
|
||||
"ui.dev.clients.general.security.confidential",
|
||||
"Confidential",
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.security.confidential_help",
|
||||
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
|
||||
)}
|
||||
</span>
|
||||
<span className="absolute right-4 top-4 text-primary">
|
||||
{clientType === "confidential" ? "✓" : ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.</span>
|
||||
<span className="absolute right-4 top-4 text-primary">{clientType === "confidential" ? "✓" : ""}</span>
|
||||
</label>
|
||||
|
||||
<label className={cn("relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition", clientType === "public" ? "border-primary bg-primary/5" : "border-border bg-card hover:border-muted-foreground/40")}>
|
||||
<input className="sr-only" type="radio" name="client-type" checked={clientType === "public"} onChange={() => setClientType("public")} />
|
||||
<label
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
||||
clientType === "public"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="client-type"
|
||||
checked={clientType === "public"}
|
||||
onChange={() => setClientType("public")}
|
||||
/>
|
||||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||||
<Sparkles className="h-4 w-4" /> Public
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("ui.dev.clients.general.security.public", "Public")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.security.public_help",
|
||||
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
|
||||
)}
|
||||
</span>
|
||||
<span className="absolute right-4 top-4 text-primary">
|
||||
{clientType === "public" ? "✓" : ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.</span>
|
||||
<span className="absolute right-4 top-4 text-primary">{clientType === "public" ? "✓" : ""}</span>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
|
||||
<Button variant="outline" onClick={() => navigate("/clients")}>취소</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending} className="px-8 shadow-lg shadow-primary/20">
|
||||
{mutation.isPending ? "저장 중..." : (isCreate ? "클라이언트 생성" : "설정 저장")}
|
||||
<Button variant="outline" onClick={() => navigate("/clients")}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
className="px-8 shadow-lg shadow-primary/20"
|
||||
>
|
||||
{mutation.isPending
|
||||
? t("msg.common.saving", "저장 중...")
|
||||
: isCreate
|
||||
? t("ui.dev.clients.general.create", "클라이언트 생성")
|
||||
: t("ui.dev.clients.general.save", "설정 저장")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isCreate && (
|
||||
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4 opacity-70">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Client ID</span>
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("ui.dev.clients.general.footer.client_id", "Client ID")}
|
||||
</span>
|
||||
<span className="font-mono text-sm block">{data?.client?.id}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Created On</span>
|
||||
<span className="text-sm text-muted-foreground block">{data?.client?.created_at ? new Date(data.client.created_at).toLocaleString() : "-"}</span>
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("ui.dev.clients.general.footer.created_on", "Created On")}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground block">
|
||||
{data?.client?.createdAt
|
||||
? new Date(data.client.createdAt).toLocaleString()
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Activity,
|
||||
BookOpenText,
|
||||
Copy,
|
||||
Laptop,
|
||||
Plus,
|
||||
Search,
|
||||
ServerCog,
|
||||
@@ -25,6 +22,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
@@ -36,14 +34,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
deleteClient,
|
||||
fetchClients,
|
||||
updateClientStatus,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
|
||||
function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -56,15 +54,29 @@ function ClientsPage() {
|
||||
mutationFn: (payload: { id: string; status: "active" | "inactive" }) =>
|
||||
updateClientStatus(payload.id, payload.status),
|
||||
onSuccess: (_, variables) => {
|
||||
const statusText = variables.status === "active" ? "활성화" : "비활성화";
|
||||
toast(`클라이언트가 ${statusText}되었습니다.`);
|
||||
const statusText =
|
||||
variables.status === "active"
|
||||
? t("ui.common.status.active", "활성화")
|
||||
: t("ui.common.status.inactive", "비활성화");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.status_updated",
|
||||
"클라이언트가 {{status}}되었습니다.",
|
||||
{
|
||||
status: statusText,
|
||||
},
|
||||
),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
const errMsg =
|
||||
error.response?.data?.error ??
|
||||
error.message ??
|
||||
"Failed to update client status";
|
||||
t(
|
||||
"msg.dev.clients.status_update_error",
|
||||
"Failed to update client status",
|
||||
);
|
||||
toast(errMsg, "error");
|
||||
},
|
||||
});
|
||||
@@ -76,29 +88,49 @@ function ClientsPage() {
|
||||
const clients = data?.items || [];
|
||||
const totalClients = clients.length;
|
||||
// TODO: Add real stats for active sessions and auth failures
|
||||
const stats = [
|
||||
type StatTone = "up" | "down" | "stable";
|
||||
type StatItem = {
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
value: string;
|
||||
deltaKey: string;
|
||||
deltaFallback: string;
|
||||
tone: StatTone;
|
||||
};
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{
|
||||
label: "총 클라이언트",
|
||||
labelKey: "ui.dev.clients.stats.total",
|
||||
labelFallback: "총 클라이언트",
|
||||
value: totalClients.toString(),
|
||||
delta: "Realtime",
|
||||
deltaKey: "ui.dev.clients.stats.realtime",
|
||||
deltaFallback: "Realtime",
|
||||
tone: "up" as const,
|
||||
},
|
||||
{
|
||||
label: "활성 세션",
|
||||
labelKey: "ui.dev.clients.stats.active_sessions",
|
||||
labelFallback: "활성 세션",
|
||||
value: "-",
|
||||
delta: "Not impl",
|
||||
deltaKey: "ui.dev.clients.stats.not_impl",
|
||||
deltaFallback: "Not impl",
|
||||
tone: "stable" as const,
|
||||
},
|
||||
{
|
||||
label: "인증 실패 (24h)",
|
||||
labelKey: "ui.dev.clients.stats.auth_failures",
|
||||
labelFallback: "인증 실패 (24h)",
|
||||
value: "0",
|
||||
delta: "Stable",
|
||||
deltaKey: "ui.dev.clients.stats.stable",
|
||||
deltaFallback: "Stable",
|
||||
tone: "stable" as const,
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Loading clients...</div>;
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.loading", "Loading clients...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -107,7 +139,9 @@ function ClientsPage() {
|
||||
(error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
Error loading clients: {errMsg}
|
||||
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,14 +153,16 @@ function ClientsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
RP registry
|
||||
{t("ui.dev.clients.registry.title", "RP registry")}
|
||||
</p>
|
||||
<CardTitle className="text-3xl font-black tracking-tight">
|
||||
Relying Parties
|
||||
{t("ui.dev.clients.registry.subtitle", "Relying Parties")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사
|
||||
로그와 함께 관리합니다.
|
||||
{t(
|
||||
"msg.dev.clients.registry.description",
|
||||
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
@@ -135,7 +171,8 @@ function ClientsPage() {
|
||||
className="shadow-lg shadow-primary/30"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />새 클라이언트
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,21 +181,30 @@ function ClientsPage() {
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder="클라이언트 이름/ID로 검색..."
|
||||
placeholder={t(
|
||||
"ui.dev.clients.search_placeholder",
|
||||
"클라이언트 이름/ID로 검색...",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 md:justify-start">
|
||||
<Badge variant="muted">테넌트: 선택됨</Badge>
|
||||
<Badge variant="success">관리자 세션</Badge>
|
||||
<Badge variant="muted">
|
||||
{t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")}
|
||||
</Badge>
|
||||
<Badge variant="success">
|
||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{stats.map((item) => (
|
||||
<Card key={item.label} className="border border-border/60">
|
||||
<Card key={item.labelKey} className="border border-border/60">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>{item.label}</CardDescription>
|
||||
<CardDescription>
|
||||
{t(item.labelKey, item.labelFallback)}
|
||||
</CardDescription>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{item.value}</span>
|
||||
<Badge
|
||||
@@ -176,7 +222,7 @@ function ClientsPage() {
|
||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.delta}
|
||||
{t(item.deltaKey, item.deltaFallback)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -190,11 +236,12 @@ function ClientsPage() {
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
클라이언트 목록
|
||||
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||
<Plus className="h-4 w-4" />새 클라이언트
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,12 +250,22 @@ function ClientsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>애플리케이션</TableHead>
|
||||
<TableHead>Client ID</TableHead>
|
||||
<TableHead>유형</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead className="text-right">액션</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.application", "애플리케이션")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.client_id", "Client ID")}
|
||||
</TableHead>
|
||||
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.status", "상태")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.created_at", "생성일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.clients.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -225,10 +282,11 @@ function ClientsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
{client.name || "Untitled"}
|
||||
{client.name ||
|
||||
t("ui.dev.clients.untitled", "Untitled")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tenant-scoped
|
||||
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,8 +300,18 @@ function ClientsPage() {
|
||||
value={client.id}
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||
aria-label="Copy client id"
|
||||
onCopy={() => toast("클라이언트 ID가 복사되었습니다.")}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.copy_client_id",
|
||||
"Copy client id",
|
||||
)}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.copy_client_id",
|
||||
"클라이언트 ID가 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -254,8 +322,11 @@ function ClientsPage() {
|
||||
}
|
||||
>
|
||||
{client.type === "confidential"
|
||||
? "기밀(Confidential)"
|
||||
: "Public"}
|
||||
? t(
|
||||
"ui.dev.clients.type.confidential",
|
||||
"기밀(Confidential)",
|
||||
)
|
||||
: t("ui.dev.clients.type.public", "Public")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -281,7 +352,9 @@ function ClientsPage() {
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{client.status === "active" ? "활성" : "비활성"}
|
||||
{client.status === "active"
|
||||
? t("ui.common.status.active", "활성")
|
||||
: t("ui.common.status.inactive", "비활성")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -293,7 +366,9 @@ function ClientsPage() {
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/clients/${client.id}`}>Edit</Link>
|
||||
<Link to={`/clients/${client.id}`}>
|
||||
{t("ui.common.edit", "Edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -301,7 +376,7 @@ function ClientsPage() {
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteMutation.mutate(client.id)}
|
||||
>
|
||||
Delete
|
||||
{t("ui.common.delete", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -311,14 +386,18 @@ function ClientsPage() {
|
||||
</Table>
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {clients.length} of {totalClients} clients
|
||||
{t(
|
||||
"msg.dev.clients.showing",
|
||||
"Showing {{shown}} of {{total}} clients",
|
||||
{ shown: clients.length, total: totalClients },
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Previous
|
||||
{t("ui.common.previous", "Previous")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Next
|
||||
{t("ui.common.next", "Next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,11 +408,16 @@ function ClientsPage() {
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-bold">
|
||||
Need help with OIDC configuration?
|
||||
{t(
|
||||
"ui.dev.clients.help.title",
|
||||
"Need help with OIDC configuration?",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Developer guides for Confidential/Public clients, redirect URIs,
|
||||
and auth methods.
|
||||
{t(
|
||||
"msg.dev.clients.help.subtitle",
|
||||
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
@@ -342,40 +426,56 @@ function ClientsPage() {
|
||||
<BookOpenText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Docs & Examples</p>
|
||||
<p className="font-semibold">
|
||||
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Includes PKCE, client_secret_basic, redirect URI validation
|
||||
tips.
|
||||
{t(
|
||||
"msg.dev.clients.help.docs_body",
|
||||
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary">View guides</Button>
|
||||
<Button variant="secondary">
|
||||
{t("ui.dev.clients.help.view_guides", "View guides")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">Owner</CardTitle>
|
||||
<CardDescription>Tenant admin on-call</CardDescription>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t("ui.dev.clients.owner.title", "Owner")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
||||
alt="ops user"
|
||||
alt={t("ui.dev.clients.owner.avatar_alt", "ops user")}
|
||||
/>
|
||||
<AvatarFallback>AR</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold">AI Admin Bot</p>
|
||||
<p className="text-xs text-muted-foreground">admin@brsw.kr</p>
|
||||
<p className="font-semibold">
|
||||
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("ui.dev.clients.owner.email", "admin@brsw.kr")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
||||
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
||||
<span>Role: Tenant Admin</span>
|
||||
<span>Scope: TENANT-12</span>
|
||||
<span>
|
||||
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
|
||||
</span>
|
||||
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
createIdpConfigForClient,
|
||||
listIdpConfigsForClient,
|
||||
} from "../../../lib/devApi";
|
||||
import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/devApi";
|
||||
import { useState } from "react";
|
||||
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
|
||||
|
||||
// Proper Modal Component with Form
|
||||
const CreateIdpModal = ({
|
||||
@@ -185,6 +185,7 @@ export function ClientFederationPage() {
|
||||
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
@@ -244,10 +245,16 @@ export function ClientFederationPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-4 border-b">
|
||||
<button className="text-blue-500 hover:underline mr-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-500 hover:underline mr-2"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button className="text-red-500 hover:underline">
|
||||
<button
|
||||
type="button"
|
||||
className="text-red-500 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -8,37 +8,73 @@ import {
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const guardHighlights = [
|
||||
{
|
||||
title: "RP 정책 통제",
|
||||
body: "Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
|
||||
metric: "Policy",
|
||||
titleKey: "ui.dev.dashboard.guard.policy.title",
|
||||
titleFallback: "RP 정책 통제",
|
||||
bodyKey: "msg.dev.dashboard.guard.policy.body",
|
||||
bodyFallback:
|
||||
"Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
|
||||
metricKey: "ui.dev.dashboard.guard.policy.metric",
|
||||
metricFallback: "Policy",
|
||||
},
|
||||
{
|
||||
title: "Consent 흐름",
|
||||
body: "사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
|
||||
metric: "Consent",
|
||||
titleKey: "ui.dev.dashboard.guard.consent.title",
|
||||
titleFallback: "Consent 흐름",
|
||||
bodyKey: "msg.dev.dashboard.guard.consent.body",
|
||||
bodyFallback:
|
||||
"사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
|
||||
metricKey: "ui.dev.dashboard.guard.consent.metric",
|
||||
metricFallback: "Consent",
|
||||
},
|
||||
{
|
||||
title: "Hydra Admin",
|
||||
body: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
|
||||
metric: "Hydra",
|
||||
titleKey: "ui.dev.dashboard.guard.hydra.title",
|
||||
titleFallback: "Hydra Admin",
|
||||
bodyKey: "msg.dev.dashboard.guard.hydra.body",
|
||||
bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
|
||||
metricKey: "ui.dev.dashboard.guard.hydra.metric",
|
||||
metricFallback: "Hydra",
|
||||
},
|
||||
];
|
||||
|
||||
const stackReadiness = [
|
||||
"React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||
"TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
|
||||
"Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
|
||||
"Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
|
||||
"Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.react",
|
||||
fallback: "React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.query",
|
||||
fallback: "TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.axios",
|
||||
fallback: "Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.tailwind",
|
||||
fallback: "Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.proxy",
|
||||
fallback: "Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
|
||||
},
|
||||
];
|
||||
|
||||
const nextSteps = [
|
||||
"RP 등록/수정/삭제 워크플로우 추가",
|
||||
"Consent 검색 필터 고도화 및 CSV 내보내기",
|
||||
"권한 가드 및 감사 로그 연동",
|
||||
{
|
||||
key: "msg.dev.dashboard.next.rp_workflow",
|
||||
fallback: "RP 등록/수정/삭제 워크플로우 추가",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.next.consent_filters",
|
||||
fallback: "Consent 검색 필터 고도화 및 CSV 내보내기",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.next.audit_guard",
|
||||
fallback: "권한 가드 및 감사 로그 연동",
|
||||
},
|
||||
];
|
||||
|
||||
function DashboardPage() {
|
||||
@@ -50,41 +86,63 @@ function DashboardPage() {
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
<Sparkles size={14} />
|
||||
devfront ready
|
||||
{t("ui.dev.dashboard.ready_badge", "devfront ready")}
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
RP 등록 현황과 Consent 상태를
|
||||
<span className="text-[var(--color-accent)]"> 하나의 화면</span>
|
||||
에서 관리합니다.
|
||||
{t(
|
||||
"msg.dev.dashboard.hero.title_prefix",
|
||||
"RP 등록 현황과 Consent 상태를",
|
||||
)}
|
||||
<span className="text-[var(--color-accent)]">
|
||||
{t("msg.dev.dashboard.hero.title_emphasis", " 하나의 화면")}
|
||||
</span>
|
||||
{t("msg.dev.dashboard.hero.title_suffix", "에서 관리합니다.")}
|
||||
</h2>
|
||||
<p className="text-[var(--color-muted)]">
|
||||
Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지
|
||||
devfront에서 처리하도록 준비합니다.
|
||||
{t(
|
||||
"msg.dev.dashboard.hero.body",
|
||||
"Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
|
||||
RP registry synced
|
||||
{t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
||||
Consent guard ready
|
||||
{t(
|
||||
"ui.dev.dashboard.badge.consent_guard",
|
||||
"Consent guard ready",
|
||||
)}
|
||||
</span>
|
||||
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
||||
Policy toggle enabled
|
||||
{t(
|
||||
"ui.dev.dashboard.badge.policy_toggle",
|
||||
"Policy toggle enabled",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<ShieldCheck size={16} />
|
||||
RP 정책은 dev scope에서만 적용
|
||||
{t(
|
||||
"msg.dev.dashboard.notice.dev_scope",
|
||||
"RP 정책은 dev scope에서만 적용",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<KeyRound size={16} />
|
||||
Consent 회수는 감사 로그와 연계
|
||||
{t(
|
||||
"msg.dev.dashboard.notice.consent_audit",
|
||||
"Consent 회수는 감사 로그와 연계",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<Database size={16} />
|
||||
Hydra Admin 상태 체크 준비
|
||||
{t(
|
||||
"msg.dev.dashboard.notice.hydra_health",
|
||||
"Hydra Admin 상태 체크 준비",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,21 +151,25 @@ function DashboardPage() {
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{guardHighlights.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
key={item.titleKey}
|
||||
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
{item.metric}
|
||||
{t(item.metricKey, item.metricFallback)}
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
||||
active
|
||||
{t("ui.common.status.active", "active")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-3 space-y-2">
|
||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t(item.titleKey, item.titleFallback)}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(item.bodyKey, item.bodyFallback)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -118,29 +180,31 @@ function DashboardPage() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Stack readiness
|
||||
{t("ui.dev.dashboard.stack.title", "Stack readiness")}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">Devfront baseline</h3>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t("ui.dev.dashboard.stack.subtitle", "Devfront baseline")}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Setup notes
|
||||
{t("ui.dev.dashboard.stack.notes", "Setup notes")}
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{stackReadiness.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
key={item.key}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<CheckCircle2
|
||||
size={16}
|
||||
className="text-[var(--color-accent)]"
|
||||
/>
|
||||
<p className="text-sm">{item}</p>
|
||||
<p className="text-sm">{t(item.key, item.fallback)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -148,19 +212,23 @@ function DashboardPage() {
|
||||
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Next actions
|
||||
{t("ui.dev.dashboard.next.title", "Next actions")}
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">Ship the RP controls</h3>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
{t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")}
|
||||
</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{nextSteps.map((item, idx) => (
|
||||
<div
|
||||
key={item}
|
||||
key={item.key}
|
||||
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text)]">{item}</p>
|
||||
<p className="text-sm text-[var(--color-text)]">
|
||||
{t(item.key, item.fallback)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -171,16 +239,18 @@ function DashboardPage() {
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Ops board
|
||||
{t("ui.dev.dashboard.ops.title", "Ops board")}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">현재 관측</h3>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t("ui.dev.dashboard.ops.subtitle", "현재 관측")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
Consent grants
|
||||
{t("ui.dev.dashboard.ops.tag.consent", "Consent grants")}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
RP status
|
||||
{t("ui.dev.dashboard.ops.tag.rp_status", "RP status")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,23 +258,32 @@ function DashboardPage() {
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<BarChart3 size={16} />
|
||||
RP 요청 추이
|
||||
{t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")}
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-semibold">준비 중</p>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
{t("ui.common.status.pending", "준비 중")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Activity size={16} />
|
||||
Consent 회수 건수
|
||||
{t(
|
||||
"ui.dev.dashboard.ops.card.consent_revoked",
|
||||
"Consent 회수 건수",
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-semibold">준비 중</p>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
{t("ui.common.status.pending", "준비 중")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Database size={16} />
|
||||
Hydra 상태
|
||||
{t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")}
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-semibold">정상</p>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
{t("ui.common.status.ok", "정상")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ClientEndpoints = {
|
||||
|
||||
export type ClientDetailResponse = {
|
||||
client: ClientSummary & {
|
||||
clientSecret?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
endpoints: ClientEndpoints;
|
||||
@@ -93,7 +94,6 @@ export type IdpConfigCreateRequest = Omit<
|
||||
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
|
||||
// --- End Federation Types ---
|
||||
|
||||
|
||||
export async function fetchClients() {
|
||||
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
|
||||
return data;
|
||||
@@ -138,7 +138,7 @@ export async function updateClient(
|
||||
|
||||
export async function rotateClientSecret(clientId: string) {
|
||||
const { data } = await apiClient.post<ClientDetailResponse>(
|
||||
`/dev/clients/${clientId}/secret/rotate`
|
||||
`/dev/clients/${clientId}/secret/rotate`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -175,11 +175,13 @@ export async function listIdpConfigsForClient(clientId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createIdpConfigForClient(payload: IdpConfigCreateRequest) {
|
||||
export async function createIdpConfigForClient(
|
||||
payload: IdpConfigCreateRequest,
|
||||
) {
|
||||
const { data } = await apiClient.post<IdpConfig>(
|
||||
`/dev/clients/${payload.client_id}/idps`,
|
||||
payload,
|
||||
);
|
||||
`/dev/clients/${payload.client_id}/idps`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
148
devfront/src/lib/i18n.ts
Normal file
148
devfront/src/lib/i18n.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const DEFAULT_LOCALE = "ko";
|
||||
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
type TomlValue = string | TomlObject;
|
||||
|
||||
interface TomlObject {
|
||||
[key: string]: TomlValue;
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function parseToml(raw: string): TomlObject {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const root: TomlObject = {};
|
||||
let currentPath: string[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
const sectionName = line.slice(1, -1).trim();
|
||||
currentPath = sectionName
|
||||
? sectionName
|
||||
.split(".")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = line.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, eqIndex).trim();
|
||||
const valueRaw = line.slice(eqIndex + 1).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = valueRaw;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
let cursor: TomlObject = root;
|
||||
for (const section of currentPath) {
|
||||
if (!cursor[section] || typeof cursor[section] === "string") {
|
||||
cursor[section] = {};
|
||||
}
|
||||
cursor = cursor[section] as TomlObject;
|
||||
}
|
||||
|
||||
cursor[key] = value;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function getValue(target: TomlObject, key: string): string | undefined {
|
||||
const parts = key.split(".");
|
||||
let cursor: TomlValue = target;
|
||||
for (const part of parts) {
|
||||
if (typeof cursor !== "object" || cursor === null) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = (cursor as TomlObject)[part];
|
||||
if (cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : undefined;
|
||||
}
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof window === "undefined") {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored && isSupportedLocale(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const pathLocale = window.location.pathname.split("/")[1];
|
||||
if (pathLocale && isSupportedLocale(pathLocale)) {
|
||||
return pathLocale;
|
||||
}
|
||||
|
||||
const browserLang = window.navigator.language.toLowerCase();
|
||||
if (browserLang.startsWith("ko")) {
|
||||
return "ko";
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import enRaw from "../../../locales/en.toml?raw";
|
||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import koRaw from "../../../locales/ko.toml?raw";
|
||||
|
||||
const translations: Record<Locale, TomlObject> = {
|
||||
ko: parseToml(koRaw),
|
||||
en: parseToml(enRaw),
|
||||
};
|
||||
|
||||
function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!vars) {
|
||||
return template;
|
||||
}
|
||||
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined || value === null) {
|
||||
return match;
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function t(
|
||||
key: string,
|
||||
fallback?: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const locale = detectLocale();
|
||||
const value = getValue(translations[locale], key);
|
||||
if (value && value.length > 0) {
|
||||
return formatTemplate(value, vars);
|
||||
}
|
||||
return formatTemplate(fallback ?? key, vars);
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/routes";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import "./index.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -13,7 +13,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
|
||||
},
|
||||
});
|
||||
|
||||
233
docs/i18n.md
Normal file
233
docs/i18n.md
Normal 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
1307
locales/en.toml
Normal file
File diff suppressed because one or more lines are too long
1307
locales/ko.toml
Normal file
1307
locales/ko.toml
Normal file
File diff suppressed because one or more lines are too long
1307
locales/template.toml
Normal file
1307
locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
86
tools/i18n-scanner/gen-flutter-i18n.js
Executable file
86
tools/i18n-scanner/gen-flutter-i18n.js
Executable 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
224
tools/i18n-scanner/index.js
Normal 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();
|
||||
443
tools/i18n-scanner/translate-locales.js
Normal file
443
tools/i18n-scanner/translate-locales.js
Normal 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();
|
||||
Reference in New Issue
Block a user