forked from baron/baron-sso
i18n refresh and frontend fixes
This commit is contained in:
@@ -27,6 +27,15 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -30,3 +30,7 @@ userfront/.dart_tool/
|
|||||||
userfront/.packages
|
userfront/.packages
|
||||||
userfront/.pub/
|
userfront/.pub/
|
||||||
userfront/.env
|
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"
|
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)
|
### 로컬 개발 (Manual)
|
||||||
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
||||||
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
||||||
@@ -247,6 +253,8 @@ go run cmd/server/main.go
|
|||||||
cd userfront
|
cd userfront
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d chrome
|
flutter run -d chrome
|
||||||
|
# 정책: 웹 빌드는 기본적으로 WASM 사용
|
||||||
|
flutter build web --wasm
|
||||||
```
|
```
|
||||||
|
|
||||||
**adminfront:**
|
**adminfront:**
|
||||||
|
|||||||
@@ -1,148 +1,161 @@
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Building2,
|
Building2,
|
||||||
Key,
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Moon,
|
Moon,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||||
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
{
|
||||||
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
label: "ui.admin.nav.tenant_dashboard",
|
||||||
{ label: "Users", to: "/users", icon: Users },
|
to: "/dashboard",
|
||||||
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
icon: ShieldHalf,
|
||||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
},
|
||||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
{ 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() {
|
function AppLayout() {
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
return stored === "dark" ? "dark" : "light";
|
return stored === "dark" ? "dark" : "light";
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("light", "dark");
|
root.classList.remove("light", "dark");
|
||||||
if (theme === "light") {
|
if (theme === "light") {
|
||||||
root.classList.add("light");
|
root.classList.add("light");
|
||||||
} else {
|
} else {
|
||||||
root.classList.add("dark");
|
root.classList.add("dark");
|
||||||
}
|
}
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
window.localStorage.setItem("admin_theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<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">
|
<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 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="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)]">
|
<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} />
|
<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 />
|
|
||||||
</div>
|
</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>
|
</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;
|
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 RoleSwitcher: FC = () => {
|
||||||
const [currentRole, setCurrentRole] = useState<string>('super_admin');
|
const [currentRole, setCurrentRole] = useState<string>("super_admin");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// localStorage에서 역할 읽기
|
// localStorage에서 역할 읽기
|
||||||
const savedRole = window.localStorage.getItem('X-Mock-Role');
|
const savedRole = window.localStorage.getItem("X-Mock-Role");
|
||||||
if (savedRole) {
|
if (savedRole) {
|
||||||
setCurrentRole(savedRole);
|
setCurrentRole(savedRole);
|
||||||
} else {
|
} else {
|
||||||
// 기본값 설정
|
// 기본값 설정
|
||||||
window.localStorage.setItem('X-Mock-Role', 'super_admin');
|
window.localStorage.setItem("X-Mock-Role", "super_admin");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const switchRole = (role: string) => {
|
const switchRole = (role: string) => {
|
||||||
// localStorage 설정
|
// localStorage 설정
|
||||||
window.localStorage.setItem('X-Mock-Role', role);
|
window.localStorage.setItem("X-Mock-Role", role);
|
||||||
setCurrentRole(role);
|
setCurrentRole(role);
|
||||||
// 페이지 새로고침하여 권한 적용
|
// 페이지 새로고침하여 권한 적용
|
||||||
window.location.reload();
|
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 (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
position: 'fixed',
|
style={{
|
||||||
bottom: '20px',
|
position: "fixed",
|
||||||
right: '20px',
|
bottom: "20px",
|
||||||
zIndex: 9999,
|
right: "20px",
|
||||||
background: '#1A1F2C',
|
zIndex: 9999,
|
||||||
color: 'white',
|
background: "#1A1F2C",
|
||||||
padding: '10px',
|
color: "white",
|
||||||
borderRadius: '8px',
|
padding: "10px",
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
borderRadius: "8px",
|
||||||
display: 'flex',
|
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
||||||
flexDirection: 'column',
|
display: "flex",
|
||||||
gap: '8px',
|
flexDirection: "column",
|
||||||
fontSize: '12px'
|
gap: "8px",
|
||||||
}}>
|
fontSize: "12px",
|
||||||
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
|
}}
|
||||||
🛠 DEV Role Switcher
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
borderBottom: "1px solid #444",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("ui.admin.dev_role_switcher", "🛠 DEV Role Switcher")}
|
||||||
</div>
|
</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
|
<button
|
||||||
key={role}
|
key={role}
|
||||||
|
type="button"
|
||||||
onClick={() => switchRole(role)}
|
onClick={() => switchRole(role)}
|
||||||
style={{
|
style={{
|
||||||
background: currentRole === role ? '#3b82f6' : '#333',
|
background: currentRole === role ? "#3b82f6" : "#333",
|
||||||
color: 'white',
|
color: "white",
|
||||||
border: 'none',
|
border: "none",
|
||||||
padding: '4px 8px',
|
padding: "4px 8px",
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
textAlign: 'left',
|
textAlign: "left",
|
||||||
transition: 'background 0.2s'
|
transition: "background 0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{role.toUpperCase().replace('_', ' ')} {currentRole === role ? '✅' : ''}
|
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}{" "}
|
||||||
|
{currentRole === role ? "✅" : ""}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 * as React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@@ -13,24 +21,69 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
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 { cn } from "../../lib/utils";
|
||||||
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
|
|
||||||
|
|
||||||
const AVAILABLE_SCOPES = [
|
const AVAILABLE_SCOPES = [
|
||||||
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
|
{
|
||||||
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
|
id: "audit:read",
|
||||||
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
|
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
|
||||||
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
|
labelFallback: "감사 로그 조회",
|
||||||
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
|
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
|
||||||
{ id: "tenant:write", label: "테넌트 관리", 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() {
|
function ApiKeyCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
|
const [createdResult, setCreatedResult] =
|
||||||
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["audit:read", "user:read"]);
|
React.useState<ApiKeyCreateResponse | null>(null);
|
||||||
|
const [selectedScopes, setSelectedScopes] = React.useState<string[]>([
|
||||||
|
"audit:read",
|
||||||
|
"user:read",
|
||||||
|
]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -47,19 +100,29 @@ function ApiKeyCreatePage() {
|
|||||||
setCreatedResult(data);
|
setCreatedResult(data);
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
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) => {
|
const toggleScope = (scopeId: string) => {
|
||||||
setSelectedScopes((prev) =>
|
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 }) => {
|
const onSubmit = (data: { name: string }) => {
|
||||||
if (selectedScopes.length === 0) {
|
if (selectedScopes.length === 0) {
|
||||||
setError("최소 하나 이상의 권한을 선택해야 합니다.");
|
setError(
|
||||||
|
t(
|
||||||
|
"msg.admin.api_keys.create.scope_required",
|
||||||
|
"최소 하나 이상의 권한을 선택해야 합니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
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">
|
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
|
||||||
<ShieldCheck size={32} />
|
<ShieldCheck size={32} />
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +165,10 @@ function ApiKeyCreatePage() {
|
|||||||
<CardHeader className="bg-primary/5 border-b">
|
<CardHeader className="bg-primary/5 border-b">
|
||||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
<AlertCircle size={16} className="text-primary" />
|
<AlertCircle size={16} className="text-primary" />
|
||||||
보안 시크릿 복사
|
{t(
|
||||||
|
"ui.admin.api_keys.create.success.copy_secret",
|
||||||
|
"보안 시크릿 복사",
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-8 pb-8 space-y-6">
|
<CardContent className="pt-8 pb-8 space-y-6">
|
||||||
@@ -96,14 +177,14 @@ function ApiKeyCreatePage() {
|
|||||||
X-Baron-Key-Secret
|
X-Baron-Key-Secret
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
value={createdResult.clientSecret}
|
value={createdResult.clientSecret}
|
||||||
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
|
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
|
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
|
||||||
onClick={() => handleCopy(createdResult.clientSecret)}
|
onClick={() => handleCopy(createdResult.clientSecret)}
|
||||||
>
|
>
|
||||||
@@ -111,13 +192,21 @@ function ApiKeyCreatePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-center text-muted-foreground italic">
|
<p className="text-[11px] text-center text-muted-foreground italic">
|
||||||
복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.
|
{t(
|
||||||
|
"msg.admin.api_keys.create.success.copy_hint",
|
||||||
|
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 flex flex-col gap-2">
|
<div className="pt-4 flex flex-col gap-2">
|
||||||
<Button size="lg" className="w-full font-bold" asChild>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -130,12 +219,24 @@ function ApiKeyCreatePage() {
|
|||||||
<div className="max-w-3xl mx-auto space-y-10">
|
<div className="max-w-3xl mx-auto space-y-10">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<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" />
|
<ChevronLeft size={16} className="mr-1" />
|
||||||
돌아가기
|
{t("ui.common.back", "돌아가기")}
|
||||||
</Button>
|
</Button>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">새 API 키 생성</h2>
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
<p className="text-muted-foreground">내부 시스템 연동을 위한 보안 인증 키를 구성합니다.</p>
|
{t("ui.admin.api_keys.create.title", "새 API 키 생성")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.api_keys.create.subtitle",
|
||||||
|
"내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -143,20 +244,41 @@ function ApiKeyCreatePage() {
|
|||||||
{/* 섹션 1: 이름 설정 */}
|
{/* 섹션 1: 이름 설정 */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
<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>
|
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||||
<h3 className="font-semibold text-lg">키 이름 지정</h3>
|
1
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{t("ui.admin.api_keys.create.section_name", "키 이름 지정")}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
id="name"
|
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"
|
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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -165,8 +287,15 @@ function ApiKeyCreatePage() {
|
|||||||
{/* 섹션 2: 권한 선택 */}
|
{/* 섹션 2: 권한 선택 */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
<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>
|
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||||
<h3 className="font-semibold text-lg">권한 범위(Scopes) 선택</h3>
|
2
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{t(
|
||||||
|
"ui.admin.api_keys.create.section_scopes",
|
||||||
|
"권한 범위(Scopes) 선택",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{AVAILABLE_SCOPES.map((scope) => {
|
{AVAILABLE_SCOPES.map((scope) => {
|
||||||
@@ -178,24 +307,39 @@ function ApiKeyCreatePage() {
|
|||||||
onClick={() => toggleScope(scope.id)}
|
onClick={() => toggleScope(scope.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
|
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary bg-primary/5 shadow-md"
|
? "border-primary bg-primary/5 shadow-md"
|
||||||
: "border-border bg-card hover:border-muted-foreground/30"
|
: "border-border bg-card hover:border-muted-foreground/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
|
<span
|
||||||
<div className={cn(
|
className={cn(
|
||||||
"h-5 w-5 rounded-md flex items-center justify-center border",
|
"font-bold text-sm",
|
||||||
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
|
isSelected ? "text-primary" : "",
|
||||||
)}>
|
)}
|
||||||
{isSelected && <Check size={12} className="text-primary-foreground" />}
|
>
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
{scope.desc}
|
{t(scope.descKey, scope.descFallback)}
|
||||||
</p>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -210,15 +354,26 @@ function ApiKeyCreatePage() {
|
|||||||
<p className="text-sm font-medium">{error}</p>
|
<p className="text-sm font-medium">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
|
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold">총 {selectedScopes.length}개의 권한이 할당됩니다.</p>
|
<p className="text-sm font-bold">
|
||||||
<p className="text-xs text-muted-foreground">생성 즉시 활성화되어 사용 가능합니다.</p>
|
{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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="px-8 font-bold shadow-lg shadow-primary/20"
|
className="px-8 font-bold shadow-lg shadow-primary/20"
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -227,7 +382,7 @@ function ApiKeyCreatePage() {
|
|||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-5 w-5" />
|
<Save className="mr-2 h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
API 키 발급하기
|
{t("ui.admin.api_keys.create.submit", "API 키 발급하기")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,4 +391,4 @@ function ApiKeyCreatePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ApiKeyCreatePage;
|
export default ApiKeyCreatePage;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
function ApiKeyListPage() {
|
function ApiKeyListPage() {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -37,12 +38,25 @@ function ApiKeyListPage() {
|
|||||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
?.data?.error;
|
?.data?.error;
|
||||||
const fallbackError =
|
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 items = query.data?.items ?? [];
|
||||||
|
|
||||||
const handleDelete = (id: string, name: string) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
deleteMutation.mutate(id);
|
deleteMutation.mutate(id);
|
||||||
@@ -53,14 +67,22 @@ function ApiKeyListPage() {
|
|||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<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>/</span>
|
||||||
<span className="text-foreground">List</span>
|
<span className="text-foreground">
|
||||||
|
{t("ui.admin.api_keys.list.breadcrumb.list", "List")}
|
||||||
|
</span>
|
||||||
</div>
|
</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)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고
|
{t(
|
||||||
관리합니다.
|
"msg.admin.api_keys.list.subtitle",
|
||||||
|
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -70,12 +92,12 @@ function ApiKeyListPage() {
|
|||||||
disabled={query.isFetching}
|
disabled={query.isFetching}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
새로고침
|
{t("ui.common.refresh", "새로고침")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="/api-keys/new">
|
<Link to="/api-keys/new">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
API 키 생성
|
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,12 +106,18 @@ function ApiKeyListPage() {
|
|||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>API Key Registry</CardTitle>
|
<CardTitle>
|
||||||
|
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
총 {query.data?.total ?? 0}개 API 키
|
{t(
|
||||||
|
"msg.admin.api_keys.list.registry.count",
|
||||||
|
"총 {{count}}개 API 키",
|
||||||
|
{ count: query.data?.total ?? 0 },
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="muted">System</Badge>
|
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{(errorMsg || fallbackError) && (
|
{(errorMsg || fallbackError) && (
|
||||||
@@ -101,22 +129,39 @@ function ApiKeyListPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>NAME</TableHead>
|
<TableHead>
|
||||||
<TableHead>CLIENT ID</TableHead>
|
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||||
<TableHead>SCOPES</TableHead>
|
</TableHead>
|
||||||
<TableHead>LAST USED</TableHead>
|
<TableHead>
|
||||||
<TableHead className="text-right">ACTIONS</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
<TableCell colSpan={5}>
|
||||||
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!query.isLoading && items.length === 0 && (
|
{!query.isLoading && items.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>등록된 API 키가 없습니다.</TableCell>
|
<TableCell colSpan={5}>
|
||||||
|
{t(
|
||||||
|
"msg.admin.api_keys.list.empty",
|
||||||
|
"등록된 API 키가 없습니다.",
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{items.map((key) => (
|
{items.map((key) => (
|
||||||
@@ -146,7 +191,7 @@ function ApiKeyListPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{key.lastUsedAt
|
{key.lastUsedAt
|
||||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||||
: "Never"}
|
: t("ui.common.never", "Never")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
@@ -156,7 +201,7 @@ function ApiKeyListPage() {
|
|||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
삭제
|
{t("ui.common.delete", "삭제")}
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,30 +16,39 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
const summaryCards = [
|
const summaryCards = [
|
||||||
{
|
{
|
||||||
label: "Total Tenants",
|
labelKey: "ui.admin.overview.summary.total_tenants",
|
||||||
|
labelFallback: "Total Tenants",
|
||||||
value: "-",
|
value: "-",
|
||||||
hint: "Tenant-aware core",
|
hintKey: "msg.admin.overview.summary.total_tenants",
|
||||||
|
hintFallback: "Tenant-aware core",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "OIDC Clients",
|
labelKey: "ui.admin.overview.summary.oidc_clients",
|
||||||
|
labelFallback: "OIDC Clients",
|
||||||
value: "-",
|
value: "-",
|
||||||
hint: "Hydra registry",
|
hintKey: "msg.admin.overview.summary.oidc_clients",
|
||||||
|
hintFallback: "Hydra registry",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Audit Events (24h)",
|
labelKey: "ui.admin.overview.summary.audit_events_24h",
|
||||||
|
labelFallback: "Audit Events (24h)",
|
||||||
value: "-",
|
value: "-",
|
||||||
hint: "ClickHouse stream",
|
hintKey: "msg.admin.overview.summary.audit_events_24h",
|
||||||
|
hintFallback: "ClickHouse stream",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Policy Gate",
|
labelKey: "ui.admin.overview.summary.policy_gate",
|
||||||
|
labelFallback: "Policy Gate",
|
||||||
value: "Planned",
|
value: "Planned",
|
||||||
hint: "Keto + Admin checks",
|
hintKey: "msg.admin.overview.summary.policy_gate",
|
||||||
|
hintFallback: "Keto + Admin checks",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -50,44 +59,67 @@ function GlobalOverviewPage() {
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
Global Overview
|
{t("ui.admin.overview.kicker", "Global Overview")}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-3xl font-semibold">
|
<h2 className="text-3xl font-semibold">
|
||||||
Tenant-independent control plane
|
{t("ui.admin.overview.title", "Tenant-independent control plane")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.
|
{t(
|
||||||
|
"msg.admin.overview.description",
|
||||||
|
"모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="muted">IDP: Ory primary</Badge>
|
<Badge variant="muted">
|
||||||
<Badge variant="muted">Fallback: Descope</Badge>
|
{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>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
|
{summaryCards.map(
|
||||||
<Card key={label} className="bg-[var(--color-panel)]">
|
({
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
labelKey,
|
||||||
<CardDescription>{label}</CardDescription>
|
labelFallback,
|
||||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
value,
|
||||||
<Icon size={16} />
|
hintKey,
|
||||||
</div>
|
hintFallback,
|
||||||
</CardHeader>
|
icon: Icon,
|
||||||
<CardContent>
|
}) => (
|
||||||
<div className="text-2xl font-semibold">{value}</div>
|
<Card key={labelKey} className="bg-[var(--color-panel)]">
|
||||||
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
</CardContent>
|
<CardDescription>{t(labelKey, labelFallback)}</CardDescription>
|
||||||
</Card>
|
<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>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
|
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
|
||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Admin playbook</CardTitle>
|
<CardTitle className="text-xl">
|
||||||
|
{t("ui.admin.overview.playbook.title", "Admin playbook")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.
|
{t(
|
||||||
|
"msg.admin.overview.playbook.description",
|
||||||
|
"운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
|
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
|
||||||
@@ -97,11 +129,16 @@ function GlobalOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-foreground">
|
<p className="font-semibold text-foreground">
|
||||||
Backend-only IDP access
|
{t(
|
||||||
|
"msg.admin.overview.playbook.idp_title",
|
||||||
|
"Backend-only IDP access",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos
|
{t(
|
||||||
admin 포트는 외부에 노출하지 않습니다.
|
"msg.admin.overview.playbook.idp_body",
|
||||||
|
"모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,11 +148,16 @@ function GlobalOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-foreground">
|
<p className="font-semibold text-foreground">
|
||||||
Tenant isolation
|
{t(
|
||||||
|
"msg.admin.overview.playbook.tenant_title",
|
||||||
|
"Tenant isolation",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto
|
{t(
|
||||||
정책으로 확장 예정입니다.
|
"msg.admin.overview.playbook.tenant_body",
|
||||||
|
"Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,9 +166,14 @@ function GlobalOverviewPage() {
|
|||||||
|
|
||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">빠른 이동</CardTitle>
|
<CardTitle className="text-xl">
|
||||||
|
{t("ui.admin.overview.quick_links.title", "빠른 이동")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
주요 운영 화면으로 바로 이동합니다.
|
{t(
|
||||||
|
"msg.admin.overview.quick_links.description",
|
||||||
|
"주요 운영 화면으로 바로 이동합니다.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@@ -136,7 +183,7 @@ function GlobalOverviewPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Link to="/tenants/new">
|
<Link to="/tenants/new">
|
||||||
테넌트 추가
|
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
|
||||||
<ArrowUpRight size={16} />
|
<ArrowUpRight size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -146,7 +193,10 @@ function GlobalOverviewPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Link to="/audit-logs">
|
<Link to="/audit-logs">
|
||||||
감사 로그 보기
|
{t(
|
||||||
|
"ui.admin.overview.quick_links.view_audit_logs",
|
||||||
|
"감사 로그 보기",
|
||||||
|
)}
|
||||||
<ArrowUpRight size={16} />
|
<ArrowUpRight size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -156,7 +206,10 @@ function GlobalOverviewPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Link to="/dashboard">
|
<Link to="/dashboard">
|
||||||
테넌트 대시보드
|
{t(
|
||||||
|
"ui.admin.overview.quick_links.tenant_dashboard",
|
||||||
|
"테넌트 대시보드",
|
||||||
|
)}
|
||||||
<ArrowUpRight size={16} />
|
<ArrowUpRight size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Input } from "../../../components/ui/input";
|
|||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
import { createTenant } from "../../../lib/adminApi";
|
import { createTenant } from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
function TenantCreatePage() {
|
function TenantCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -49,18 +50,29 @@ function TenantCreatePage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<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>/</span>
|
||||||
<span className="text-foreground">Create</span>
|
<span className="text-foreground">
|
||||||
|
{t("ui.admin.tenants.create.breadcrumb.action", "Create")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<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)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
글로벌 운영 기준의 신규 테넌트를 등록합니다.
|
{t(
|
||||||
|
"msg.admin.tenants.create.subtitle",
|
||||||
|
"글로벌 운영 기준의 신규 테넌트를 등록합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="muted">Admin only</Badge>
|
<Badge variant="muted">
|
||||||
|
{t("ui.common.badge.admin_only", "Admin only")}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -68,29 +80,40 @@ function TenantCreatePage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Building2 size={18} />
|
<Building2 size={18} />
|
||||||
Tenant Profile
|
{t("ui.admin.tenants.create.profile.title", "Tenant Profile")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.
|
{t(
|
||||||
|
"msg.admin.tenants.create.profile.subtitle",
|
||||||
|
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">
|
<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>
|
</Label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(e) => setSlug(e.target.value)}
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
placeholder="tenant-slug"
|
placeholder={t(
|
||||||
|
"ui.admin.tenants.create.form.slug_placeholder",
|
||||||
|
"tenant-slug",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={description}
|
value={description}
|
||||||
@@ -99,34 +122,44 @@ function TenantCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
Allowed Domains (Comma separated)
|
{t(
|
||||||
|
"ui.admin.tenants.create.form.domains_label",
|
||||||
|
"Allowed Domains (Comma separated)",
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={domains}
|
value={domains}
|
||||||
onChange={(e) => setDomains(e.target.value)}
|
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">
|
<p className="text-xs text-muted-foreground">
|
||||||
Users with these email domains will be automatically assigned to
|
{t(
|
||||||
this tenant.
|
"msg.admin.tenants.create.form.domains_help",
|
||||||
|
"Users with these email domains will be automatically assigned to this tenant.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={status === "active" ? "default" : "outline"}
|
variant={status === "active" ? "default" : "outline"}
|
||||||
onClick={() => setStatus("active")}
|
onClick={() => setStatus("active")}
|
||||||
>
|
>
|
||||||
Active
|
{t("ui.common.status.active", "Active")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={status === "inactive" ? "default" : "outline"}
|
variant={status === "inactive" ? "default" : "outline"}
|
||||||
onClick={() => setStatus("inactive")}
|
onClick={() => setStatus("inactive")}
|
||||||
>
|
>
|
||||||
Inactive
|
{t("ui.common.status.inactive", "Inactive")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,26 +176,32 @@ function TenantCreatePage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Sparkles size={18} />
|
<Sparkles size={18} />
|
||||||
정책 메모
|
{t("ui.admin.tenants.create.memo.title", "정책 메모")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.
|
{t(
|
||||||
|
"msg.admin.tenants.create.memo.subtitle",
|
||||||
|
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-[var(--color-muted)]">
|
<CardContent className="text-sm text-[var(--color-muted)]">
|
||||||
생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.
|
{t(
|
||||||
|
"msg.admin.tenants.create.memo.body",
|
||||||
|
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
||||||
취소
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
disabled={mutation.isPending || name.trim() === ""}
|
disabled={mutation.isPending || name.trim() === ""}
|
||||||
>
|
>
|
||||||
생성
|
{t("ui.common.create", "생성")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { Badge } from "../../../components/ui/badge";
|
|||||||
import { fetchTenant } from "../../../lib/adminApi";
|
import { fetchTenant } from "../../../lib/adminApi";
|
||||||
|
|
||||||
function TenantDetailPage() {
|
function TenantDetailPage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
|
const tenantId = params.tenantId ?? "";
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenant", tenantId],
|
queryKey: ["tenant", tenantId],
|
||||||
queryFn: () => fetchTenant(tenantId!),
|
queryFn: () => fetchTenant(tenantId),
|
||||||
enabled: !!tenantId,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFederationTab = location.pathname.includes("/federation");
|
const isFederationTab = location.pathname.includes("/federation");
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import {
|
||||||
import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react";
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
UserMinus,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,6 +19,8 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -19,15 +29,19 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { Input } from "../../../components/ui/input";
|
import {
|
||||||
import { Label } from "../../../components/ui/label";
|
addGroupMember,
|
||||||
import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi";
|
createGroup,
|
||||||
import { Badge } from "../../../components/ui/badge";
|
deleteGroup,
|
||||||
|
fetchGroups,
|
||||||
|
removeGroupMember,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
function TenantGroupsPage() {
|
function TenantGroupsPage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
const queryClient = useQueryClient();
|
const tenantId = params.tenantId ?? "";
|
||||||
|
|
||||||
const [newGroupName, setNewGroupName] = useState("");
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
@@ -35,18 +49,14 @@ function TenantGroupsPage() {
|
|||||||
// 그룹 목록 조회
|
// 그룹 목록 조회
|
||||||
const groupsQuery = useQuery({
|
const groupsQuery = useQuery({
|
||||||
queryKey: ["groups", tenantId],
|
queryKey: ["groups", tenantId],
|
||||||
queryFn: () => fetchGroups(tenantId!),
|
queryFn: () => fetchGroups(tenantId),
|
||||||
enabled: !!tenantId,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 사용자 목록 조회 (멤버 추가용)
|
// 사용자 목록 조회 (멤버 추가용)
|
||||||
const usersQuery = useQuery({
|
|
||||||
queryKey: ["users", { limit: 100 }],
|
|
||||||
queryFn: () => fetchUsers(100, 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
|
mutationFn: () =>
|
||||||
|
createGroup(tenantId, { name: newGroupName, description: newGroupDesc }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
groupsQuery.refetch();
|
groupsQuery.refetch();
|
||||||
setNewGroupName("");
|
setNewGroupName("");
|
||||||
@@ -60,23 +70,30 @@ function TenantGroupsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const addMemberMutation = useMutation({
|
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(),
|
onSuccess: () => groupsQuery.refetch(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeMemberMutation = useMutation({
|
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(),
|
onSuccess: () => groupsQuery.refetch(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAddMember = (groupId: string) => {
|
const handleAddMember = (groupId: string) => {
|
||||||
const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:");
|
const userId = window.prompt(
|
||||||
|
t(
|
||||||
|
"msg.admin.groups.prompt.user_id",
|
||||||
|
"추가할 사용자의 UUID를 입력하세요:",
|
||||||
|
),
|
||||||
|
);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
addMemberMutation.mutate({ groupId, userId });
|
addMemberMutation.mutate({ groupId, userId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId);
|
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 mt-6">
|
<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">
|
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
<Plus size={16} /> 새 그룹 생성
|
<Plus size={16} />{" "}
|
||||||
|
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="name">그룹 이름</Label>
|
<Label htmlFor="name">
|
||||||
<Input
|
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
||||||
id="name"
|
</Label>
|
||||||
value={newGroupName}
|
<Input
|
||||||
onChange={e => setNewGroupName(e.target.value)}
|
id="name"
|
||||||
placeholder="예: 개발팀, 인사팀"
|
value={newGroupName}
|
||||||
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.groups.form.name_placeholder",
|
||||||
|
"예: 개발팀, 인사팀",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="desc">설명</Label>
|
<Label htmlFor="desc">
|
||||||
<Input
|
{t("ui.admin.groups.form.desc_label", "설명")}
|
||||||
id="desc"
|
</Label>
|
||||||
value={newGroupDesc}
|
<Input
|
||||||
onChange={e => setNewGroupNameDesc(e.target.value)}
|
id="desc"
|
||||||
placeholder="그룹 용도 설명"
|
value={newGroupDesc}
|
||||||
|
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.groups.form.desc_placeholder",
|
||||||
|
"그룹 용도 설명",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
disabled={!newGroupName || createMutation.isPending}
|
disabled={!newGroupName || createMutation.isPending}
|
||||||
>
|
>
|
||||||
생성하기
|
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -121,10 +149,21 @@ function TenantGroupsPage() {
|
|||||||
<Card className="bg-[var(--color-panel)] md:col-span-2">
|
<Card className="bg-[var(--color-panel)] md:col-span-2">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>User Groups</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>이 테넌트에 정의된 사용자 그룹 목록입니다.</CardDescription>
|
{t("ui.admin.groups.list.title", "User Groups")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.groups.list.subtitle",
|
||||||
|
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => groupsQuery.refetch()}
|
||||||
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -132,16 +171,22 @@ function TenantGroupsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>NAME</TableHead>
|
<TableHead>
|
||||||
<TableHead>MEMBERS</TableHead>
|
{t("ui.admin.groups.table.name", "NAME")}
|
||||||
<TableHead className="text-right">ACTIONS</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{groupsQuery.data?.map((group) => (
|
{groupsQuery.data?.map((group) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
|
className={`cursor-pointer ${selectedGroupId === group.id ? "bg-primary/5" : ""}`}
|
||||||
onClick={() => setSelectedGroupId(group.id)}
|
onClick={() => setSelectedGroupId(group.id)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -149,17 +194,37 @@ function TenantGroupsPage() {
|
|||||||
<Users size={14} className="text-muted-foreground" />
|
<Users size={14} className="text-muted-foreground" />
|
||||||
{group.name}
|
{group.name}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">{group.description}</p>
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<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} />
|
<UserPlus size={14} />
|
||||||
</Button>
|
</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" />
|
<Trash2 size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,31 +243,53 @@ function TenantGroupsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield size={18} className="text-primary" />
|
<Shield size={18} className="text-primary" />
|
||||||
[{currentGroup.name}] 멤버 관리
|
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
||||||
|
name: currentGroup.name,
|
||||||
|
})}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>이름</TableHead>
|
<TableHead>
|
||||||
<TableHead>이메일</TableHead>
|
{t("ui.admin.groups.members.table.name", "이름")}
|
||||||
<TableHead className="text-right">제거</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("ui.admin.groups.members.table.remove", "제거")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{currentGroup.members?.length === 0 && (
|
{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) => (
|
{currentGroup.members?.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell className="font-medium">{user.name}</TableCell>
|
<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">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
|
onClick={() =>
|
||||||
|
removeMemberMutation.mutate({
|
||||||
|
groupId: currentGroup.id,
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<UserMinus size={14} className="text-destructive" />
|
<UserMinus size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
|
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
function TenantListPage() {
|
function TenantListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -38,12 +39,22 @@ function TenantListPage() {
|
|||||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
?.data?.error;
|
?.data?.error;
|
||||||
const fallbackError =
|
const fallbackError =
|
||||||
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
|
!errorMsg && query.isError
|
||||||
|
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||||
|
: null;
|
||||||
|
|
||||||
const items = query.data?.items ?? [];
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||||
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.tenants.delete_confirm",
|
||||||
|
'테넌트 "{{name}}"를 삭제할까요?',
|
||||||
|
{ name: tenantName },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deleteMutation.mutate(tenantId);
|
deleteMutation.mutate(tenantId);
|
||||||
@@ -54,13 +65,20 @@ function TenantListPage() {
|
|||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<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>/</span>
|
||||||
<span className="text-foreground">List</span>
|
<span className="text-foreground">
|
||||||
|
{t("ui.admin.tenants.breadcrumb.list", "List")}
|
||||||
|
</span>
|
||||||
</div>
|
</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)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
현재 등록된 테넌트를 확인하고 상태를 관리합니다.
|
{t(
|
||||||
|
"msg.admin.tenants.subtitle",
|
||||||
|
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -70,12 +88,12 @@ function TenantListPage() {
|
|||||||
disabled={query.isFetching}
|
disabled={query.isFetching}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
새로고침
|
{t("ui.common.refresh", "새로고침")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="/tenants/new">
|
<Link to="/tenants/new">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
테넌트 추가
|
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,12 +102,18 @@ function TenantListPage() {
|
|||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Tenant registry</CardTitle>
|
<CardTitle>
|
||||||
|
{t("ui.admin.tenants.registry.title", "Tenant registry")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
총 {query.data?.total ?? 0}개 테넌트
|
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||||
|
count: query.data?.total ?? 0,
|
||||||
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="muted">Admin only</Badge>
|
<Badge variant="muted">
|
||||||
|
{t("ui.common.badge.admin_only", "Admin only")}
|
||||||
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{(errorMsg || fallbackError) && (
|
{(errorMsg || fallbackError) && (
|
||||||
@@ -101,23 +125,38 @@ function TenantListPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>NAME</TableHead>
|
<TableHead>
|
||||||
<TableHead>SLUG</TableHead>
|
{t("ui.admin.tenants.table.name", "NAME")}
|
||||||
<TableHead>STATUS</TableHead>
|
</TableHead>
|
||||||
<TableHead>UPDATED</TableHead>
|
<TableHead>
|
||||||
<TableHead className="text-right">ACTIONS</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
<TableCell colSpan={5}>
|
||||||
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!query.isLoading && items.length === 0 && (
|
{!query.isLoading && items.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>
|
<TableCell colSpan={5}>
|
||||||
아직 등록된 테넌트가 없습니다.
|
{t(
|
||||||
|
"msg.admin.tenants.empty",
|
||||||
|
"아직 등록된 테넌트가 없습니다.",
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -131,8 +170,8 @@ function TenantListPage() {
|
|||||||
tenant.status === "active"
|
tenant.status === "active"
|
||||||
? "default"
|
? "default"
|
||||||
: tenant.status === "pending"
|
: tenant.status === "pending"
|
||||||
? "secondary"
|
? "secondary"
|
||||||
: "muted"
|
: "muted"
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
tenant.status === "pending"
|
tenant.status === "pending"
|
||||||
@@ -140,7 +179,7 @@ function TenantListPage() {
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{tenant.status}
|
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -156,7 +195,7 @@ function TenantListPage() {
|
|||||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
편집
|
{t("ui.common.edit", "편집")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -165,7 +204,7 @@ function TenantListPage() {
|
|||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
삭제
|
{t("ui.common.delete", "삭제")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -14,19 +14,34 @@ import {
|
|||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
type SchemaFieldType = "text" | "number" | "boolean";
|
||||||
|
|
||||||
type SchemaField = {
|
type SchemaField = {
|
||||||
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: "text" | "number" | "boolean";
|
type: SchemaFieldType;
|
||||||
required: boolean;
|
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() {
|
export function TenantSchemaPage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenant", tenantId],
|
queryKey: ["tenant", tenantId],
|
||||||
@@ -36,8 +51,20 @@ export function TenantSchemaPage() {
|
|||||||
const [fields, setFields] = useState<SchemaField[]>([]);
|
const [fields, setFields] = useState<SchemaField[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tenantQuery.data?.config?.userSchema) {
|
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||||
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
|
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]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
@@ -51,15 +78,32 @@ export function TenantSchemaPage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
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 }>) => {
|
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 = () => {
|
const addField = () => {
|
||||||
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
|
setFields([
|
||||||
|
...fields,
|
||||||
|
{
|
||||||
|
id: createFieldId(),
|
||||||
|
key: "",
|
||||||
|
label: "",
|
||||||
|
type: "text",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeField = (index: number) => {
|
const removeField = (index: number) => {
|
||||||
@@ -78,51 +122,89 @@ export function TenantSchemaPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>User Schema Extension</CardTitle>
|
<CardTitle>
|
||||||
|
{t("ui.admin.tenants.schema.title", "User Schema Extension")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Define custom attributes for users in this tenant.
|
{t(
|
||||||
|
"msg.admin.tenants.schema.subtitle",
|
||||||
|
"Define custom attributes for users in this tenant.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={addField} size="sm">
|
<Button onClick={addField} size="sm">
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Add Field
|
{t("ui.admin.tenants.schema.add_field", "Add Field")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{fields.map((field, index) => (
|
{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">
|
<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
|
<Input
|
||||||
value={field.key}
|
value={field.key}
|
||||||
onChange={(e) => updateField(index, { key: e.target.value })}
|
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>
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Label>Display Label</Label>
|
<Label>
|
||||||
|
{t("ui.admin.tenants.schema.field.label", "Display Label")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.label}
|
value={field.label}
|
||||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
onChange={(e) =>
|
||||||
placeholder="e.g. 사번"
|
updateField(index, { label: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.tenants.schema.field.label_placeholder",
|
||||||
|
"e.g. 사번",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-32 space-y-2">
|
<div className="w-32 space-y-2">
|
||||||
<Label>Type</Label>
|
<Label>{t("ui.admin.tenants.schema.field.type", "Type")}</Label>
|
||||||
<select
|
<select
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||||
value={field.type}
|
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="text">
|
||||||
<option value="number">Number</option>
|
{t("ui.admin.tenants.schema.field.type_text", "Text")}
|
||||||
<option value="boolean">Boolean</option>
|
</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -144,7 +226,7 @@ export function TenantSchemaPage() {
|
|||||||
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
||||||
>
|
>
|
||||||
<Save size={16} className="mr-2" />
|
<Save size={16} className="mr-2" />
|
||||||
Save Schema Changes
|
{t("ui.admin.tenants.schema.save", "Save Schema Changes")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Building2, Plus, ArrowRight } from "lucide-react";
|
import { ArrowRight, Building2, Plus } from "lucide-react";
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -9,18 +18,16 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} 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 { fetchTenants } from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
function TenantSubTenantsPage() {
|
function TenantSubTenantsPage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["sub-tenants", tenantId],
|
queryKey: ["sub-tenants", tenantId],
|
||||||
queryFn: () => fetchTenants(50, 0, tenantId),
|
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
|
||||||
enabled: !!tenantId,
|
enabled: !!tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,47 +39,76 @@ function TenantSubTenantsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Building2 size={18} className="text-primary" />
|
<Building2 size={18} className="text-primary" />
|
||||||
Sub-tenants ({subTenants.length})
|
{t("ui.admin.tenants.sub.title", "Sub-tenants ({{count}})", {
|
||||||
|
count: subTenants.length,
|
||||||
|
})}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>현재 테넌트 하위에 생성된 조직입니다.</CardDescription>
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.tenants.sub.subtitle",
|
||||||
|
"현재 테넌트 하위에 생성된 조직입니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" asChild>
|
<Button size="sm" asChild>
|
||||||
<Link to={`/tenants/new?parentId=${tenantId}`}>
|
<Link to={`/tenants/new?parentId=${tenantId}`}>
|
||||||
<Plus size={14} className="mr-1" />
|
<Plus size={14} className="mr-1" />
|
||||||
하위 테넌트 추가
|
{t("ui.admin.tenants.sub.add", "하위 테넌트 추가")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>NAME</TableHead>
|
<TableHead>
|
||||||
<TableHead>SLUG</TableHead>
|
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||||
<TableHead>STATUS</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">ACTION</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{subTenants.length === 0 && (
|
{subTenants.length === 0 && (
|
||||||
<TableRow>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{subTenants.map((t) => (
|
{subTenants.map((tenant) => (
|
||||||
<TableRow key={t.id}>
|
<TableRow key={tenant.id}>
|
||||||
<TableCell className="font-semibold">{t.name}</TableCell>
|
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||||
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
|
<TableCell className="text-xs font-mono">
|
||||||
|
{tenant.slug}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={t.status === "active" ? "default" : "secondary"}>
|
<Badge
|
||||||
{t.status}
|
variant={
|
||||||
|
tenant.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
|
<Button
|
||||||
관리 <ArrowRight size={12} className="ml-1" />
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||||
|
>
|
||||||
|
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||||
|
<ArrowRight size={12} className="ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useParams } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -9,18 +16,18 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
|
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { t } from "../../../lib/i18n";
|
||||||
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
|
|
||||||
|
|
||||||
function TenantUsersPage() {
|
function TenantUsersPage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
|
const tenantId = params.tenantId ?? "";
|
||||||
|
|
||||||
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
|
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenant", tenantId],
|
queryKey: ["tenant", tenantId],
|
||||||
queryFn: () => fetchTenant(tenantId!),
|
queryFn: () => fetchTenant(tenantId),
|
||||||
enabled: !!tenantId,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const companyCode = tenantQuery.data?.slug;
|
const companyCode = tenantQuery.data?.slug;
|
||||||
@@ -39,24 +46,40 @@ function TenantUsersPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<User size={18} className="text-primary" />
|
<User size={18} className="text-primary" />
|
||||||
Tenant Members ({users.length})
|
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
|
||||||
|
count: users.length,
|
||||||
|
})}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>NAME</TableHead>
|
<TableHead>
|
||||||
<TableHead>EMAIL</TableHead>
|
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||||
<TableHead>ROLE</TableHead>
|
</TableHead>
|
||||||
<TableHead>STATUS</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.length === 0 && (
|
{users.length === 0 && (
|
||||||
<TableRow>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -71,12 +94,17 @@ function TenantUsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className="capitalize">
|
<Badge variant="outline" className="capitalize">
|
||||||
{user.role.replace("_", " ")}
|
{t(
|
||||||
|
`ui.common.role.${user.role}`,
|
||||||
|
user.role.replace("_", " "),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={user.status === "active" ? "default" : "muted"}>
|
<Badge
|
||||||
{user.status}
|
variant={user.status === "active" ? "default" : "muted"}
|
||||||
|
>
|
||||||
|
{t(`ui.common.status.${user.status}`, user.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -15,18 +15,30 @@ import {
|
|||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
import {
|
import {
|
||||||
createUser,
|
|
||||||
fetchTenants,
|
|
||||||
fetchTenant,
|
|
||||||
type UserCreateRequest,
|
type UserCreateRequest,
|
||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
|
createUser,
|
||||||
|
fetchTenant,
|
||||||
|
fetchTenants,
|
||||||
} from "../../lib/adminApi";
|
} 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() {
|
function UserCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
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 [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||||
|
|
||||||
@@ -41,7 +53,7 @@ function UserCreatePage() {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -57,13 +69,22 @@ function UserCreatePage() {
|
|||||||
const selectedCompanyCode = watch("companyCode");
|
const selectedCompanyCode = watch("companyCode");
|
||||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||||
|
|
||||||
|
const selectedTenantId = selectedTenant?.id ?? "";
|
||||||
|
|
||||||
const { data: tenantDetail } = useQuery({
|
const { data: tenantDetail } = useQuery({
|
||||||
queryKey: ["tenant", selectedTenant?.id],
|
queryKey: ["tenant", selectedTenantId],
|
||||||
queryFn: () => fetchTenant(selectedTenant!.id),
|
queryFn: () => fetchTenant(selectedTenantId),
|
||||||
enabled: !!selectedTenant?.id,
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: createUser,
|
mutationFn: createUser,
|
||||||
@@ -77,7 +98,10 @@ function UserCreatePage() {
|
|||||||
navigate("/users");
|
navigate("/users");
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
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) {
|
if (!data.password) {
|
||||||
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
|
setError(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.create.password_required",
|
||||||
|
"비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,17 +143,21 @@ function UserCreatePage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
<Link to="/users" className="hover:underline">
|
<Link to="/users" className="hover:underline">
|
||||||
Users
|
{t("ui.admin.users.create.breadcrumb.section", "Users")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">New</span>
|
<span className="text-foreground">
|
||||||
|
{t("ui.admin.users.create.breadcrumb.new", "New")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-semibold">사용자 추가</h2>
|
<h2 className="text-3xl font-semibold">
|
||||||
|
{t("ui.admin.users.create.title", "사용자 추가")}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
<Link to="/users">
|
<Link to="/users">
|
||||||
<ArrowLeft size={16} className="mr-2" />
|
<ArrowLeft size={16} className="mr-2" />
|
||||||
목록으로 돌아가기
|
{t("ui.admin.users.create.back", "목록으로 돌아가기")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
@@ -132,9 +165,23 @@ function UserCreatePage() {
|
|||||||
{generatedPassword && (
|
{generatedPassword && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>초기 비밀번호 생성 완료</CardTitle>
|
<CardTitle>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.create.password_generated.title",
|
||||||
|
"초기 비밀번호 생성 완료",
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -142,11 +189,13 @@ function UserCreatePage() {
|
|||||||
<span className="font-mono text-sm">{generatedPassword}</span>
|
<span className="font-mono text-sm">{generatedPassword}</span>
|
||||||
<Button size="sm" variant="outline" onClick={onCopyPassword}>
|
<Button size="sm" variant="outline" onClick={onCopyPassword}>
|
||||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||||
복사
|
{t("ui.common.copy", "복사")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={() => navigate("/users")}>목록으로 이동</Button>
|
<Button onClick={() => navigate("/users")}>
|
||||||
|
{t("ui.admin.users.create.go_list", "목록으로 이동")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -154,8 +203,15 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>계정 정보</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>새로운 사용자를 시스템에 등록합니다.</CardDescription>
|
{t("ui.admin.users.create.account.title", "계정 정보")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.create.account.subtitle",
|
||||||
|
"새로운 사용자를 시스템에 등록합니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
@@ -166,182 +222,200 @@ function UserCreatePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">이메일</Label>
|
<Label htmlFor="email">
|
||||||
|
{t("ui.admin.users.create.form.email", "이메일")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="user@example.com"
|
placeholder={t(
|
||||||
{...register("email", { required: "이메일은 필수입니다." })}
|
"ui.admin.users.create.form.email_placeholder",
|
||||||
|
"user@example.com",
|
||||||
|
)}
|
||||||
|
{...register("email", {
|
||||||
|
required: t(
|
||||||
|
"msg.admin.users.create.form.email_required",
|
||||||
|
"이메일은 필수입니다.",
|
||||||
|
),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-xs text-destructive">{errors.email.message}</p>
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoPassword}
|
checked={autoPassword}
|
||||||
onChange={(event) => setAutoPassword(event.target.checked)}
|
onChange={(event) => setAutoPassword(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
자동 생성
|
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="********"
|
placeholder={t(
|
||||||
|
"ui.admin.users.create.form.password_placeholder",
|
||||||
|
"********",
|
||||||
|
)}
|
||||||
disabled={autoPassword}
|
disabled={autoPassword}
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{autoPassword
|
{autoPassword
|
||||||
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
|
? t(
|
||||||
: "초기 비밀번호를 직접 설정합니다."}
|
"msg.admin.users.create.form.password_auto_help",
|
||||||
|
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.admin.users.create.form.password_manual_help",
|
||||||
|
"초기 비밀번호를 직접 설정합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">이름</Label>
|
<Label htmlFor="name">
|
||||||
|
{t("ui.admin.users.create.form.name", "이름")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="홍길동"
|
placeholder={t(
|
||||||
{...register("name", { required: "이름은 필수입니다." })}
|
"ui.admin.users.create.form.name_placeholder",
|
||||||
|
"홍길동",
|
||||||
|
)}
|
||||||
|
{...register("name", {
|
||||||
|
required: t(
|
||||||
|
"msg.admin.users.create.form.name_required",
|
||||||
|
"이름은 필수입니다.",
|
||||||
|
),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.name.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">전화번호</Label>
|
<Label htmlFor="phone">
|
||||||
|
{t("ui.admin.users.create.form.phone", "전화번호")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
placeholder="010-1234-5678"
|
placeholder={t(
|
||||||
|
"ui.admin.users.create.form.phone_placeholder",
|
||||||
|
"010-1234-5678",
|
||||||
|
)}
|
||||||
{...register("phone")}
|
{...register("phone")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
>
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">
|
||||||
<option value="">시스템 전역 (소속 없음)</option>
|
{t("ui.admin.users.create.form.role", "역할 (Role)")}
|
||||||
|
</Label>
|
||||||
{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="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
id="role"
|
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"
|
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")}
|
{...register("role")}
|
||||||
>
|
>
|
||||||
<option value="user">User</option>
|
<option value="user">
|
||||||
<option value="admin">Admin</option>
|
{t("ui.common.role.user", "User")}
|
||||||
|
</option>
|
||||||
|
<option value="admin">
|
||||||
|
{t("ui.common.role.admin", "Admin")}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
시스템 접근 권한을 결정합니다.
|
{t(
|
||||||
|
"msg.admin.users.create.form.role_help",
|
||||||
|
"시스템 접근 권한을 결정합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,14 +425,14 @@ function UserCreatePage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate("/users")}
|
onClick={() => navigate("/users")}
|
||||||
>
|
>
|
||||||
취소
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
{mutation.isPending && (
|
{mutation.isPending && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
사용자 생성
|
{t("ui.admin.users.create.submit", "사용자 생성")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -15,24 +15,39 @@ import {
|
|||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
import {
|
import {
|
||||||
fetchUser,
|
|
||||||
fetchTenants,
|
|
||||||
fetchTenant,
|
|
||||||
updateUser,
|
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
|
fetchTenant,
|
||||||
|
fetchTenants,
|
||||||
|
fetchUser,
|
||||||
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} 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() {
|
function UserDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const userId = params.id ?? "";
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const { data: user, isLoading, isError } = useQuery({
|
const {
|
||||||
queryKey: ["user", id],
|
data: user,
|
||||||
queryFn: () => fetchUser(id!),
|
isLoading,
|
||||||
enabled: !!id,
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["user", userId],
|
||||||
|
queryFn: () => fetchUser(userId),
|
||||||
|
enabled: userId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
@@ -47,7 +62,7 @@ function UserDetailPage() {
|
|||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
@@ -63,13 +78,22 @@ function UserDetailPage() {
|
|||||||
const selectedCompanyCode = watch("companyCode");
|
const selectedCompanyCode = watch("companyCode");
|
||||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||||
|
|
||||||
|
const selectedTenantId = selectedTenant?.id ?? "";
|
||||||
|
|
||||||
const { data: tenantDetail } = useQuery({
|
const { data: tenantDetail } = useQuery({
|
||||||
queryKey: ["tenant", selectedTenant?.id],
|
queryKey: ["tenant", selectedTenantId],
|
||||||
queryFn: () => fetchTenant(selectedTenant!.id),
|
queryFn: () => fetchTenant(selectedTenantId),
|
||||||
enabled: !!selectedTenant?.id,
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -87,15 +111,26 @@ function UserDetailPage() {
|
|||||||
}, [user, reset]);
|
}, [user, reset]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: UserUpdateRequest) => updateUser(id!, data),
|
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["user", id] });
|
queryClient.invalidateQueries({ queryKey: ["user", userId] });
|
||||||
setSuccessMsg("사용자 정보가 수정되었습니다.");
|
setSuccessMsg(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.detail.update_success",
|
||||||
|
"사용자 정보가 수정되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
setError(err.response?.data?.error || "사용자 수정에 실패했습니다.");
|
setError(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t(
|
||||||
|
"msg.admin.users.detail.update_error",
|
||||||
|
"사용자 수정에 실패했습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
setSuccessMsg(null);
|
setSuccessMsg(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -103,19 +138,23 @@ function UserDetailPage() {
|
|||||||
const onSubmit = (data: UserUpdateRequest) => {
|
const onSubmit = (data: UserUpdateRequest) => {
|
||||||
const payload = { ...data };
|
const payload = { ...data };
|
||||||
if (!payload.password) {
|
if (!payload.password) {
|
||||||
delete payload.password;
|
payload.password = undefined;
|
||||||
}
|
}
|
||||||
mutation.mutate(payload);
|
mutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
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) {
|
if (isError || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-destructive">
|
<div className="p-8 text-center text-destructive">
|
||||||
사용자를 찾을 수 없습니다.
|
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,26 +165,34 @@ function UserDetailPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
<Link to="/users" className="hover:underline">
|
<Link to="/users" className="hover:underline">
|
||||||
Users
|
{t("ui.admin.users.detail.breadcrumb.section", "Users")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">{user.name}</span>
|
<span className="text-foreground">{user.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-semibold">사용자 상세</h2>
|
<h2 className="text-3xl font-semibold">
|
||||||
|
{t("ui.admin.users.detail.title", "사용자 상세")}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
<Link to="/users">
|
<Link to="/users">
|
||||||
<ArrowLeft size={16} className="mr-2" />
|
<ArrowLeft size={16} className="mr-2" />
|
||||||
목록으로 돌아가기
|
{t("ui.admin.users.detail.back", "목록으로 돌아가기")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>정보 수정</CardTitle>
|
<CardTitle>
|
||||||
|
{t("ui.admin.users.detail.edit_title", "정보 수정")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{user.email} 계정의 정보를 수정합니다.
|
{t(
|
||||||
|
"msg.admin.users.detail.edit_subtitle",
|
||||||
|
"{{email}} 계정의 정보를 수정합니다.",
|
||||||
|
{ email: user.email },
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -163,22 +210,39 @@ function UserDetailPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">이름</Label>
|
<Label htmlFor="name">
|
||||||
|
{t("ui.admin.users.detail.form.name", "이름")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="홍길동"
|
placeholder={t(
|
||||||
{...register("name", { required: "이름은 필수입니다." })}
|
"ui.admin.users.detail.form.name_placeholder",
|
||||||
|
"홍길동",
|
||||||
|
)}
|
||||||
|
{...register("name", {
|
||||||
|
required: t(
|
||||||
|
"msg.admin.users.detail.form.name_required",
|
||||||
|
"이름은 필수입니다.",
|
||||||
|
),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.name.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">전화번호</Label>
|
<Label htmlFor="phone">
|
||||||
|
{t("ui.admin.users.detail.form.phone", "전화번호")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
placeholder="010-1234-5678"
|
placeholder={t(
|
||||||
|
"ui.admin.users.detail.form.phone_placeholder",
|
||||||
|
"010-1234-5678",
|
||||||
|
)}
|
||||||
{...register("phone")}
|
{...register("phone")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,149 +250,145 @@ function UserDetailPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="status">상태</Label>
|
<Label htmlFor="status">
|
||||||
|
{t("ui.admin.users.detail.form.status", "상태")}
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
id="status"
|
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"
|
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")}
|
{...register("status")}
|
||||||
>
|
>
|
||||||
<option value="active">Active</option>
|
<option value="active">
|
||||||
<option value="inactive">Inactive</option>
|
{t("ui.common.status.active", "Active")}
|
||||||
<option value="blocked">Blocked</option>
|
</option>
|
||||||
|
<option value="inactive">
|
||||||
|
{t("ui.common.status.inactive", "Inactive")}
|
||||||
|
</option>
|
||||||
|
<option value="blocked">
|
||||||
|
{t("ui.common.status.blocked", "Blocked")}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
id="role"
|
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"
|
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")}
|
{...register("role")}
|
||||||
>
|
>
|
||||||
<option value="user">User</option>
|
<option value="user">
|
||||||
<option value="admin">Admin</option>
|
{t("ui.common.role.user", "User")}
|
||||||
|
</option>
|
||||||
|
<option value="admin">
|
||||||
|
{t("ui.common.role.admin", "Admin")}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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">테넌트 (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="space-y-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
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="변경할 경우에만 입력"
|
placeholder={t(
|
||||||
|
"ui.admin.users.detail.security.password_placeholder",
|
||||||
|
"변경할 경우에만 입력",
|
||||||
|
)}
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.
|
{t(
|
||||||
|
"msg.admin.users.detail.security.password_hint",
|
||||||
|
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,14 +399,14 @@ function UserDetailPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate("/users")}
|
onClick={() => navigate("/users")}
|
||||||
>
|
>
|
||||||
취소
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
{mutation.isPending && (
|
{mutation.isPending && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
저장
|
{t("ui.common.save", "저장")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -356,4 +416,4 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserDetailPage;
|
export default UserDetailPage;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { deleteUser, fetchUsers } from "../../lib/adminApi";
|
import { deleteUser, fetchUsers } from "../../lib/adminApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
function UserListPage() {
|
function UserListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -67,7 +68,12 @@ function UserListPage() {
|
|||||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
?.data?.error;
|
?.data?.error;
|
||||||
const fallbackError =
|
const fallbackError =
|
||||||
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
|
!errorMsg && query.isError
|
||||||
|
? t(
|
||||||
|
"msg.admin.users.list.fetch_error",
|
||||||
|
"사용자 목록 조회에 실패했습니다.",
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
const items = query.data?.items ?? [];
|
const items = query.data?.items ?? [];
|
||||||
const total = query.data?.total ?? 0;
|
const total = query.data?.total ?? 0;
|
||||||
@@ -80,7 +86,15 @@ function UserListPage() {
|
|||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const handleDelete = (userId: string, userName: string) => {
|
const handleDelete = (userId: string, userName: string) => {
|
||||||
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.list.delete_confirm",
|
||||||
|
'사용자 "{{name}}"을(를) 정말 삭제하시겠습니까?',
|
||||||
|
{ name: userName },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deleteMutation.mutate(userId);
|
deleteMutation.mutate(userId);
|
||||||
@@ -91,13 +105,20 @@ function UserListPage() {
|
|||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<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>/</span>
|
||||||
<span className="text-foreground">List</span>
|
<span className="text-foreground">
|
||||||
|
{t("ui.admin.users.list.breadcrumb.list", "List")}
|
||||||
|
</span>
|
||||||
</div>
|
</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)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
시스템 사용자를 조회하고 관리합니다. (Local DB)
|
{t(
|
||||||
|
"msg.admin.users.list.subtitle",
|
||||||
|
"시스템 사용자를 조회하고 관리합니다. (Local DB)",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -107,12 +128,12 @@ function UserListPage() {
|
|||||||
disabled={query.isFetching}
|
disabled={query.isFetching}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
새로고침
|
{t("ui.common.refresh", "새로고침")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="/users/new">
|
<Link to="/users/new">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
사용자 추가
|
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,9 +142,15 @@ function UserListPage() {
|
|||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>User Registry</CardTitle>
|
<CardTitle>
|
||||||
|
{t("ui.admin.users.list.registry.title", "User Registry")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
총 {total}명의 사용자가 등록되어 있습니다.
|
{t(
|
||||||
|
"msg.admin.users.list.registry.count",
|
||||||
|
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
||||||
|
{ count: total },
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -132,7 +159,10 @@ function UserListPage() {
|
|||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="이름 또는 이메일 검색..."
|
placeholder={t(
|
||||||
|
"ui.admin.users.list.search_placeholder",
|
||||||
|
"이름 또는 이메일 검색...",
|
||||||
|
)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={(e) => setSearchDraft(e.target.value)}
|
onChange={(e) => setSearchDraft(e.target.value)}
|
||||||
@@ -140,7 +170,7 @@ function UserListPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={handleSearch}>
|
<Button variant="secondary" onClick={handleSearch}>
|
||||||
검색
|
{t("ui.common.search", "검색")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,26 +184,41 @@ function UserListPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>NAME / EMAIL</TableHead>
|
<TableHead>
|
||||||
<TableHead>ROLE</TableHead>
|
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
|
||||||
<TableHead>STATUS</TableHead>
|
</TableHead>
|
||||||
<TableHead>TENANT / DEPT</TableHead>
|
<TableHead>
|
||||||
<TableHead>CREATED</TableHead>
|
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||||
<TableHead className="text-right">ACTIONS</TableHead>
|
</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-24 text-center">
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
로딩 중...
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!query.isLoading && items.length === 0 && (
|
{!query.isLoading && items.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-24 text-center">
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
검색 결과가 없습니다.
|
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -193,7 +238,9 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{user.role}</Badge>
|
<Badge variant="outline">
|
||||||
|
{t(`ui.common.role.${user.role}`, user.role)}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -201,7 +248,7 @@ function UserListPage() {
|
|||||||
user.status === "active" ? "default" : "secondary"
|
user.status === "active" ? "default" : "secondary"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{user.status}
|
{t(`ui.common.status.${user.status}`, user.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -211,7 +258,13 @@ function UserListPage() {
|
|||||||
</span>
|
</span>
|
||||||
{user.tenant && (
|
{user.tenant && (
|
||||||
<span className="text-[10px] text-muted-foreground uppercase">
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -228,7 +281,11 @@ function UserListPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(`/users/${user.id}`)}
|
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} />
|
<Pencil size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -238,7 +295,11 @@ function UserListPage() {
|
|||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() => handleDelete(user.id, user.name)}
|
onClick={() => handleDelete(user.id, user.name)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
aria-label={`사용자 삭제: ${user.name}`}
|
aria-label={t(
|
||||||
|
"ui.admin.users.list.delete_aria",
|
||||||
|
"사용자 삭제: {{name}}",
|
||||||
|
{ name: user.name },
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -260,10 +321,13 @@ function UserListPage() {
|
|||||||
disabled={page === 1 || query.isFetching}
|
disabled={page === 1 || query.isFetching}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
Previous
|
{t("ui.common.previous", "Previous")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {page} of {totalPages}
|
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
|
||||||
|
page,
|
||||||
|
total: totalPages,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -271,7 +335,7 @@ function UserListPage() {
|
|||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page === totalPages || query.isFetching}
|
disabled={page === totalPages || query.isFetching}
|
||||||
>
|
>
|
||||||
Next
|
{t("ui.common.next", "Next")}
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export type TenantSummary = {
|
|||||||
description: string;
|
description: string;
|
||||||
status: string;
|
status: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
config?: Record<string, any>;
|
config?: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -37,7 +37,7 @@ export type TenantCreateRequest = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
config?: Record<string, any>;
|
config?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantListResponse = {
|
export type TenantListResponse = {
|
||||||
@@ -53,7 +53,7 @@ export type TenantUpdateRequest = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
config?: Record<string, any>;
|
config?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiKeySummary = {
|
export type ApiKeySummary = {
|
||||||
@@ -92,11 +92,11 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
|||||||
return data;
|
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>(
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
"/v1/admin/tenants",
|
"/v1/admin/tenants",
|
||||||
{
|
{
|
||||||
params: { limit, offset },
|
params: { limit, offset, parentId },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
@@ -139,6 +139,58 @@ export async function approveTenant(tenantId: string) {
|
|||||||
return data;
|
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)
|
// API Key Management (M2M)
|
||||||
export type ApiKeyCreateRequest = {
|
export type ApiKeyCreateRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -182,7 +234,7 @@ export type UserSummary = {
|
|||||||
status: string;
|
status: string;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
tenant?: TenantSummary;
|
tenant?: TenantSummary;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
department?: string;
|
department?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -272,7 +324,7 @@ export type HydraClientReq = {
|
|||||||
token_endpoint_auth_method?: string;
|
token_endpoint_auth_method?: string;
|
||||||
grant_types?: string[];
|
grant_types?: string[];
|
||||||
response_types?: string[];
|
response_types?: string[];
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchRelyingParties(tenantId: string) {
|
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 { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
import { Toaster } from "../ui/toaster";
|
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() {
|
function AppLayout() {
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
return stored === "dark" ? "dark" : "light";
|
return stored === "dark" ? "dark" : "light";
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("light", "dark");
|
root.classList.remove("light", "dark");
|
||||||
if (theme === "light") {
|
if (theme === "light") {
|
||||||
root.classList.add("light");
|
root.classList.add("light");
|
||||||
} else {
|
} else {
|
||||||
root.classList.add("dark");
|
root.classList.add("dark");
|
||||||
}
|
}
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
window.localStorage.setItem("admin_theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<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">
|
<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 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="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)]">
|
<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} />
|
<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>
|
|
||||||
</div>
|
</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>
|
</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;
|
export default AppLayout;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { Button, type ButtonProps } from "./button";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Button, type ButtonProps } from "./button";
|
||||||
|
|
||||||
interface CopyButtonProps extends ButtonProps {
|
interface CopyButtonProps extends ButtonProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -40,10 +40,10 @@ export function CopyButton({
|
|||||||
textArea.focus();
|
textArea.focus();
|
||||||
textArea.select();
|
textArea.select();
|
||||||
try {
|
try {
|
||||||
const successful = document.execCommand('copy');
|
const successful = document.execCommand("copy");
|
||||||
if (!successful) throw new Error('execCommand copy failed');
|
if (!successful) throw new Error("execCommand copy failed");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fallback: Oops, unable to copy', err);
|
console.error("Fallback: Oops, unable to copy", err);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as React from "react";
|
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||||
import { useToastState } from "./use-toast";
|
|
||||||
import { CheckCircle2, AlertCircle, Info, X } from "lucide-react";
|
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useToastState } from "./use-toast";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const toasts = useToastState();
|
const toasts = useToastState();
|
||||||
@@ -15,12 +14,17 @@ export function Toaster() {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
|
"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 === "success" &&
|
||||||
t.type === "error" && "bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-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 === "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 === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
|
||||||
{t.type === "info" && <Info 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>
|
<p className="text-sm font-medium leading-none">{t.message}</p>
|
||||||
|
|||||||
@@ -39,4 +39,4 @@ export const useToastState = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Button } from "../../components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
function ClientConsentsPage() {
|
function ClientConsentsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -65,17 +65,20 @@ function ClientConsentsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Link to="/" className="hover:text-primary">
|
<Link to="/" className="hover:text-primary">
|
||||||
Home
|
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link to="/clients" className="hover:text-primary">
|
<Link to="/clients" className="hover:text-primary">
|
||||||
Clients
|
{t("ui.dev.clients.consents.breadcrumb.clients", "Clients")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{clientData?.client?.name || clientId}</span>
|
<span>{clientData?.client?.name || clientId}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground font-semibold">
|
<span className="text-foreground font-semibold">
|
||||||
User Consent Grants
|
{t(
|
||||||
|
"ui.dev.clients.consents.breadcrumb.current",
|
||||||
|
"User Consent Grants",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -86,10 +89,13 @@ function ClientConsentsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-3xl font-black leading-tight">
|
<p className="text-3xl font-black leading-tight">
|
||||||
User Consent Grants
|
{t("ui.dev.clients.consents.title", "User Consent Grants")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
OIDC Relying Party 사용자 권한을 검토·관리합니다.
|
{t(
|
||||||
|
"msg.dev.clients.consents.subtitle",
|
||||||
|
"OIDC Relying Party 사용자 권한을 검토·관리합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +106,9 @@ function ClientConsentsPage() {
|
|||||||
clientData?.client?.status === "active" ? "success" : "muted"
|
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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,16 +117,16 @@ function ClientConsentsPage() {
|
|||||||
to={`/clients/${clientId}`}
|
to={`/clients/${clientId}`}
|
||||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Connection
|
{t("ui.dev.clients.details.tab.connection", "Connection")}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
<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>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
to={`/clients/${clientId}/settings`}
|
to={`/clients/${clientId}/settings`}
|
||||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Settings
|
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
placeholder="사용자 ID, 이름, 이메일로 검색"
|
placeholder={t(
|
||||||
|
"ui.dev.clients.consents.search_placeholder",
|
||||||
|
"사용자 ID, 이름, 이메일로 검색",
|
||||||
|
)}
|
||||||
value={subjectInput}
|
value={subjectInput}
|
||||||
onChange={(e) => setSubjectInput(e.target.value)}
|
onChange={(e) => setSubjectInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
Status:
|
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||||
</span>
|
</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">
|
<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>
|
||||||
<option selected>Active</option>
|
{t("ui.dev.clients.consents.status_all", "All Statuses")}
|
||||||
<option>Revoked</option>
|
</option>
|
||||||
|
<option selected>
|
||||||
|
{t("ui.common.status.active", "Active")}
|
||||||
|
</option>
|
||||||
|
<option>
|
||||||
|
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" className="gap-1 text-muted-foreground">
|
<Button variant="ghost" className="gap-1 text-muted-foreground">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
Advanced Filters
|
{t(
|
||||||
|
"ui.dev.clients.consents.filters.advanced",
|
||||||
|
"Advanced Filters",
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="shadow-sm shadow-primary/30"
|
className="shadow-sm shadow-primary/30"
|
||||||
onClick={() => setSubject(subjectInput.trim())}
|
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>
|
||||||
<Button className="shadow-sm shadow-primary/30">Export CSV</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -165,32 +187,58 @@ function ClientConsentsPage() {
|
|||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
{error && (
|
{error && (
|
||||||
<CardContent className="text-sm text-red-500">
|
<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>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
Loading consents...
|
{t("msg.dev.clients.consents.loading", "Loading consents...")}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>User</TableHead>
|
<TableHead>
|
||||||
<TableHead>Tenant</TableHead>
|
{t("ui.dev.clients.consents.table.user", "User")}
|
||||||
<TableHead>Status</TableHead>
|
</TableHead>
|
||||||
<TableHead>Granted Scopes</TableHead>
|
<TableHead>
|
||||||
<TableHead>First Granted</TableHead>
|
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
|
||||||
<TableHead>Last Authenticated</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">Action</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="h-24 text-center">
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
No consents found.
|
{t("msg.dev.clients.consents.empty", "No consents found.")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -199,11 +247,14 @@ function ClientConsentsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
{row.userName || "Subject"}
|
{row.userName ||
|
||||||
|
t("ui.dev.clients.consents.subject", "Subject")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{row.subject}
|
{row.subject}
|
||||||
@@ -214,7 +265,7 @@ function ClientConsentsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
{row.tenantName || "N/A"}
|
{row.tenantName || t("ui.common.na", "N/A")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{row.tenantId}
|
{row.tenantId}
|
||||||
@@ -222,7 +273,9 @@ function ClientConsentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="success">Active</Badge>
|
<Badge variant="success">
|
||||||
|
{t("ui.common.status.active", "Active")}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -253,7 +306,7 @@ function ClientConsentsPage() {
|
|||||||
revokeMutation.mutate({ subject: row.subject })
|
revokeMutation.mutate({ subject: row.subject })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Revoke
|
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -263,15 +316,23 @@ function ClientConsentsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
|
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
Showing <span className="font-semibold text-foreground">{rows.length > 0 ? 1 : 0}</span> to{" "}
|
{t(
|
||||||
<span className="font-semibold text-foreground">{rows.length}</span> of{" "}
|
"msg.dev.clients.consents.showing",
|
||||||
<span className="font-semibold text-foreground">{rows.length}</span> users
|
"Showing {{from}} to {{to}} of {{total}} users",
|
||||||
|
{
|
||||||
|
from: rows.length > 0 ? 1 : 0,
|
||||||
|
to: rows.length,
|
||||||
|
total: rows.length,
|
||||||
|
},
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="icon" disabled>
|
<Button variant="outline" size="icon" disabled>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
<Button variant="outline" size="icon" disabled>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -283,7 +344,10 @@ function ClientConsentsPage() {
|
|||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
<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>
|
</p>
|
||||||
<CardTitle className="text-2xl font-black">{rows.length}</CardTitle>
|
<CardTitle className="text-2xl font-black">{rows.length}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -291,7 +355,10 @@ function ClientConsentsPage() {
|
|||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
<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>
|
</p>
|
||||||
<CardTitle className="text-2xl font-black">
|
<CardTitle className="text-2xl font-black">
|
||||||
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
|
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
|
||||||
@@ -301,13 +368,18 @@ function ClientConsentsPage() {
|
|||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
<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>
|
</p>
|
||||||
<CardTitle className="text-2xl font-black">
|
<CardTitle className="text-2xl font-black">
|
||||||
{rows.length > 0
|
{rows.length > 0
|
||||||
? (
|
? (
|
||||||
rows.reduce((acc, row) => acc + row.grantedScopes.length, 0) /
|
rows.reduce(
|
||||||
rows.length
|
(acc, row) => acc + row.grantedScopes.length,
|
||||||
|
0,
|
||||||
|
) / rows.length
|
||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
: "0.0"}
|
: "0.0"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 { Link, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
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 { Separator } from "../../components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -14,17 +22,20 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
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 { 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() {
|
function ClientDetailsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clientId = params.id ?? "";
|
const clientId = params.id ?? "";
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["client", clientId],
|
queryKey: ["client", clientId],
|
||||||
queryFn: () => fetchClient(clientId),
|
queryFn: () => fetchClient(clientId),
|
||||||
@@ -50,10 +61,20 @@ function ClientDetailsPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||||
toast("Redirect URIs가 저장되었습니다.");
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.redirect_saved",
|
||||||
|
"Redirect URIs가 저장되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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),
|
mutationFn: () => rotateClientSecret(clientId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||||
toast("Client Secret이 재발급되었습니다.");
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.secret_rotated",
|
||||||
|
"Client Secret이 재발급되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
setShowSecret(true); // 재발급 후 바로 보여줌
|
setShowSecret(true); // 재발급 후 바로 보여줌
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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 = () => {
|
const handleRotateSecret = () => {
|
||||||
if (window.confirm("경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?")) {
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.rotate_confirm",
|
||||||
|
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
rotateMutation.mutate();
|
rotateMutation.mutate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!clientId) {
|
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) {
|
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) {
|
if (error || !data) {
|
||||||
@@ -89,31 +135,62 @@ function ClientDetailsPage() {
|
|||||||
(error as Error)?.message;
|
(error as Error)?.message;
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
{ label: "Discovery Endpoint", value: data.endpoints.discovery },
|
{
|
||||||
{ label: "Issuer URL", value: data.endpoints.issuer },
|
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
||||||
{ label: "Authorization Endpoint", value: data.endpoints.authorization },
|
labelFallback: "Discovery Endpoint",
|
||||||
{ label: "Token Endpoint", value: data.endpoints.token },
|
value: data.endpoints.discovery,
|
||||||
{ label: "UserInfo Endpoint", value: data.endpoints.userinfo },
|
},
|
||||||
|
{
|
||||||
|
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
|
// 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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Link to="/clients" className="text-primary hover:underline">
|
<Link to="/clients" className="text-primary hover:underline">
|
||||||
Relying Parties
|
{t("ui.dev.clients.details.breadcrumb.section", "Relying Parties")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">클라이언트 상세</span>
|
<span className="text-foreground">
|
||||||
|
{t("ui.dev.clients.details.breadcrumb.current", "클라이언트 상세")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -121,14 +198,19 @@ function ClientDetailsPage() {
|
|||||||
{data.client.name || data.client.id}
|
{data.client.name || data.client.id}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
OIDC 자격 증명과 엔드포인트를 관리합니다.
|
{t(
|
||||||
|
"msg.dev.clients.details.subtitle",
|
||||||
|
"OIDC 자격 증명과 엔드포인트를 관리합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={data.client.status === "active" ? "success" : "muted"}
|
variant={data.client.status === "active" ? "success" : "muted"}
|
||||||
className="px-3 py-1 text-xs uppercase"
|
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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 border-b border-border">
|
<div className="flex gap-6 border-b border-border">
|
||||||
@@ -136,19 +218,19 @@ function ClientDetailsPage() {
|
|||||||
to={`/clients/${clientId}`}
|
to={`/clients/${clientId}`}
|
||||||
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
|
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>
|
||||||
<Link
|
<Link
|
||||||
to={`/clients/${clientId}/consents`}
|
to={`/clients/${clientId}/consents`}
|
||||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
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>
|
||||||
<Link
|
<Link
|
||||||
to={`/clients/${clientId}/settings`}
|
to={`/clients/${clientId}/settings`}
|
||||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Settings
|
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,18 +238,35 @@ function ClientDetailsPage() {
|
|||||||
<div className="grid gap-8 lg:grid-cols-2">
|
<div className="grid gap-8 lg:grid-cols-2">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-4">
|
<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">
|
<Card className="glass-panel">
|
||||||
<CardContent className="flex flex-col gap-4 p-6">
|
<CardContent className="flex flex-col gap-4 p-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
<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>
|
</p>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="font-mono text-lg truncate">{data.client.id}</p>
|
<p className="font-mono text-lg truncate">
|
||||||
<CopyButton
|
{data.client.id}
|
||||||
value={data.client.id}
|
</p>
|
||||||
onCopy={() => toast("Client ID가 복사되었습니다.")}
|
<CopyButton
|
||||||
|
value={data.client.id}
|
||||||
|
onCopy={() =>
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.copy_client_id",
|
||||||
|
"Client ID가 복사되었습니다.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,37 +275,73 @@ function ClientDetailsPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
<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>
|
</p>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className={cn(
|
<p
|
||||||
"font-mono text-lg",
|
className={cn(
|
||||||
!showSecret && "tracking-widest"
|
"font-mono text-lg",
|
||||||
)}>
|
!showSecret && "tracking-widest",
|
||||||
{showSecret ? clientSecret : "••••••••••••••••"}
|
)}
|
||||||
|
>
|
||||||
|
{showSecret ? displaySecret : "••••••••••••••••"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowSecret(!showSecret)}
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleRotateSecret}
|
onClick={handleRotateSecret}
|
||||||
disabled={rotateMutation.isPending}
|
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>
|
</Button>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
value={clientSecret}
|
value={clientSecret}
|
||||||
disabled={!showSecret && clientSecret === "SECRET_NOT_AVAILABLE"}
|
disabled={
|
||||||
onCopy={() => toast("Client Secret이 복사되었습니다.")}
|
!showSecret && clientSecret === secretPlaceholder
|
||||||
|
}
|
||||||
|
onCopy={() =>
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.copy_client_secret",
|
||||||
|
"Client Secret이 복사되었습니다.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,20 +352,25 @@ function ClientDetailsPage() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<Badge variant="muted" className="gap-1">
|
||||||
<Link2 className="h-3 w-3" />
|
<Link2 className="h-3 w-3" />
|
||||||
읽기 전용
|
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<Table>
|
<Table>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{endpoints.map((endpoint) => (
|
{endpoints.map((endpoint) => (
|
||||||
<TableRow key={endpoint.label} className="border-border/70">
|
<TableRow
|
||||||
|
key={endpoint.labelKey}
|
||||||
|
className="border-border/70"
|
||||||
|
>
|
||||||
<TableCell className="w-1/3">
|
<TableCell className="w-1/3">
|
||||||
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
{endpoint.label}
|
{t(endpoint.labelKey, endpoint.labelFallback)}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="flex items-center justify-between gap-3">
|
<TableCell className="flex items-center justify-between gap-3">
|
||||||
@@ -240,7 +380,20 @@ function ClientDetailsPage() {
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
value={endpoint.value}
|
value={endpoint.value}
|
||||||
className="h-8 w-8 shrink-0"
|
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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -253,33 +406,56 @@ function ClientDetailsPage() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-4">
|
<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">
|
<Card className="glass-panel border-primary/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Redirect URIs</CardTitle>
|
<CardTitle className="text-lg">
|
||||||
|
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.
|
{t(
|
||||||
|
"msg.dev.clients.details.redirect.description",
|
||||||
|
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<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
|
<Textarea
|
||||||
id="redirect-uris"
|
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}
|
rows={5}
|
||||||
value={redirectUris}
|
value={redirectUris}
|
||||||
onChange={(e) => setRedirectUris(e.target.value)}
|
onChange={(e) => setRedirectUris(e.target.value)}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full gap-2"
|
className="w-full gap-2"
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
<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>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -292,18 +468,24 @@ function ClientDetailsPage() {
|
|||||||
<Shield className="h-6 w-6" />
|
<Shield className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사
|
{t(
|
||||||
로그와 연계하세요.
|
"msg.dev.clients.details.security.note",
|
||||||
|
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을
|
{t(
|
||||||
권장합니다.
|
"msg.dev.clients.details.security.footer",
|
||||||
|
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
@@ -14,11 +14,15 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
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 { Switch } from "../../components/ui/switch";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import { createClient, fetchClient, updateClient } from "../../lib/devApi";
|
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";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
interface ScopeItem {
|
interface ScopeItem {
|
||||||
@@ -46,10 +50,25 @@ function ClientGeneralPage() {
|
|||||||
const [clientType, setClientType] = useState<ClientType>("confidential");
|
const [clientType, setClientType] = useState<ClientType>("confidential");
|
||||||
const [status, setStatus] = useState<ClientStatus>("active");
|
const [status, setStatus] = useState<ClientStatus>("active");
|
||||||
const [redirectUris, setRedirectUris] = useState("");
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
const [scopes, setScopes] = useState<ScopeItem[]>([
|
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||||||
{ id: "1", name: "openid", description: "OIDC 인증 필수 스코프", mandatory: true },
|
{
|
||||||
{ id: "2", name: "profile", description: "기본 프로필 정보 접근", mandatory: false },
|
id: "1",
|
||||||
{ id: "3", name: "email", description: "이메일 주소 접근", mandatory: false },
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -58,43 +77,56 @@ function ClientGeneralPage() {
|
|||||||
setName(client.name || client.id);
|
setName(client.name || client.id);
|
||||||
setClientType(client.type);
|
setClientType(client.type);
|
||||||
setStatus(client.status);
|
setStatus(client.status);
|
||||||
|
|
||||||
const metadata = client.metadata ?? {};
|
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);
|
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
||||||
|
|
||||||
// Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성
|
// Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성
|
||||||
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
||||||
if (savedScopes && Array.isArray(savedScopes)) {
|
if (savedScopes && Array.isArray(savedScopes)) {
|
||||||
setScopes(savedScopes);
|
setScopes(savedScopes);
|
||||||
}
|
} else {
|
||||||
else {
|
setScopes(
|
||||||
setScopes(client.scopes.map((s, idx) => ({
|
client.scopes.map((s, idx) => ({
|
||||||
id: String(idx + 1),
|
id: String(idx + 1),
|
||||||
name: s,
|
name: s,
|
||||||
description: "",
|
description: "",
|
||||||
mandatory: s === "openid"
|
mandatory: s === "openid",
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const addScope = () => {
|
const addScope = () => {
|
||||||
const newId = String(Date.now());
|
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) => {
|
const updateScope = <K extends keyof ScopeItem>(
|
||||||
setScopes(scopes.map(s => s.id === id ? { ...s, [field]: value } : s));
|
id: string,
|
||||||
|
field: K,
|
||||||
|
value: ScopeItem[K],
|
||||||
|
) => {
|
||||||
|
setScopes(
|
||||||
|
scopes.map((scope) =>
|
||||||
|
scope.id === id ? { ...scope, [field]: value } : scope,
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeScope = (id: string) => {
|
const removeScope = (id: string) => {
|
||||||
setScopes(scopes.filter(s => s.id !== id));
|
setScopes(scopes.filter((s) => s.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const scopeNames = scopes.map(s => s.name).filter(Boolean);
|
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||||
const payload: any = {
|
const payload: ClientUpsertRequest = {
|
||||||
name,
|
name,
|
||||||
type: clientType,
|
type: clientType,
|
||||||
status,
|
status,
|
||||||
@@ -102,16 +134,19 @@ function ClientGeneralPage() {
|
|||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
logo_url: logoUrl,
|
logo_url: logoUrl,
|
||||||
structured_scopes: scopes // 향후 보존을 위해 metadata에 저장
|
structured_scopes: scopes, // 향후 보존을 위해 metadata에 저장
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 생성 시에는 Redirect URIs를 포함해서 전송
|
// 생성 시에는 Redirect URIs를 포함해서 전송
|
||||||
if (isCreate) {
|
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);
|
return createClient(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
|
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
|
||||||
return updateClient(clientId as string, payload);
|
return updateClient(clientId as string, payload);
|
||||||
},
|
},
|
||||||
@@ -120,17 +155,37 @@ function ClientGeneralPage() {
|
|||||||
if (result?.client?.id) {
|
if (result?.client?.id) {
|
||||||
navigate(`/clients/${result.client.id}/settings`);
|
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)) {
|
if (!isCreate && (error || !data)) {
|
||||||
const errMsg = (error as AxiosError<{ error?: string }>).response?.data?.error ?? (error as Error)?.message;
|
const errMsg =
|
||||||
return <div className="p-8 text-center text-red-500">Error loading client: {errMsg || "unknown error"}</div>;
|
(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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -138,22 +193,45 @@ function ClientGeneralPage() {
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<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>/</span>
|
||||||
<span className="text-foreground">{displayName}</span>
|
<span className="text-foreground">{displayName}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Badge variant={status === "active" ? "success" : "muted"} className="px-3 py-1 text-xs uppercase">
|
<Badge
|
||||||
{status === "active" ? "Active" : "Inactive"}
|
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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||||
{!isCreate && (
|
{!isCreate && (
|
||||||
<>
|
<>
|
||||||
<Link to={`/clients/${clientId}`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Connection</Link>
|
<Link
|
||||||
<Link to={`/clients/${clientId}/consents`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Consent & Users</Link>
|
to={`/clients/${clientId}`}
|
||||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">Settings</span>
|
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>
|
</div>
|
||||||
@@ -161,28 +239,83 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
{/* 1. Application Identity */}
|
{/* 1. Application Identity */}
|
||||||
<div className="glass-panel p-6">
|
<div className="glass-panel p-6">
|
||||||
<CardTitle className="text-xl font-bold mb-2">Application Identity</CardTitle>
|
<CardTitle className="text-xl font-bold mb-2">
|
||||||
<CardDescription className="mb-6">앱 이름과 설명, 로고를 설정합니다.</CardDescription>
|
{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="grid gap-8 md:grid-cols-2">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">앱 이름 <span className="text-destructive">*</span></Label>
|
<Label className="text-sm font-semibold">
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="My Awesome Application" />
|
{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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">Description</Label>
|
<Label className="text-sm font-semibold">
|
||||||
<Textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="앱에 대한 간단한 설명을 입력하세요." />
|
{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>
|
</div>
|
||||||
<div className="space-y-2">
|
<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 gap-4">
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Input value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} placeholder="https://example.com/logo.png" />
|
<Input
|
||||||
<p className="text-xs text-muted-foreground">인증 화면에 표시될 PNG/SVG URL입니다.</p>
|
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>
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,25 +326,49 @@ function ClientGeneralPage() {
|
|||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl font-bold">Scopes</CardTitle>
|
<CardTitle className="text-xl font-bold">
|
||||||
<CardDescription>이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.</CardDescription>
|
{t("ui.dev.clients.general.scopes.title", "Scopes")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.scopes.subtitle",
|
||||||
|
"이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={addScope} className="gap-2">
|
<Button
|
||||||
<Plus className="h-4 w-4" /> Scope 추가
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addScope}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */}
|
{/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */}
|
||||||
{isCreate && (
|
{isCreate && (
|
||||||
<div className="space-y-2 border-b border-border pb-6 mb-6">
|
<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>
|
<Label className="text-sm font-semibold">
|
||||||
<Textarea
|
{t("ui.dev.clients.general.redirect.label", "Redirect URIs")}{" "}
|
||||||
value={redirectUris}
|
<span className="text-destructive">*</span>
|
||||||
onChange={(e) => setRedirectUris(e.target.value)}
|
</Label>
|
||||||
placeholder="https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
|
<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"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -220,27 +377,76 @@ function ClientGeneralPage() {
|
|||||||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-bold">Scope Name</th>
|
<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-left font-bold">
|
||||||
<th className="px-4 py-3 text-center font-bold">Mandatory</th>
|
{t(
|
||||||
<th className="px-4 py-3 text-right"></th>
|
"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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{scopes.map((s) => (
|
{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">
|
<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>
|
||||||
<td className="px-4 py-3">
|
<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>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<div className="flex justify-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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
@@ -248,7 +454,15 @@ function ClientGeneralPage() {
|
|||||||
))}
|
))}
|
||||||
{scopes.length === 0 && (
|
{scopes.length === 0 && (
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -260,48 +474,118 @@ function ClientGeneralPage() {
|
|||||||
{/* 3. Security Settings (Moved down) */}
|
{/* 3. Security Settings (Moved down) */}
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-xl font-bold">보안 설정</CardTitle>
|
<CardTitle className="text-xl font-bold">
|
||||||
<CardDescription>클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.</CardDescription>
|
{t("ui.dev.clients.general.security.title", "보안 설정")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.security.subtitle",
|
||||||
|
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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")}>
|
<label
|
||||||
<input className="sr-only" type="radio" name="client-type" checked={clientType === "confidential"} onChange={() => setClientType("confidential")} />
|
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">
|
<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>
|
||||||
<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>
|
||||||
|
|
||||||
<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")}>
|
<label
|
||||||
<input className="sr-only" type="radio" name="client-type" checked={clientType === "public"} onChange={() => setClientType("public")} />
|
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">
|
<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>
|
||||||
<span className="text-xs text-muted-foreground">SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.</span>
|
|
||||||
<span className="absolute right-4 top-4 text-primary">{clientType === "public" ? "✓" : ""}</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
|
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
|
||||||
<Button variant="outline" onClick={() => navigate("/clients")}>취소</Button>
|
<Button variant="outline" onClick={() => navigate("/clients")}>
|
||||||
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending} className="px-8 shadow-lg shadow-primary/20">
|
{t("ui.common.cancel", "취소")}
|
||||||
{mutation.isPending ? "저장 중..." : (isCreate ? "클라이언트 생성" : "설정 저장")}
|
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isCreate && (
|
{!isCreate && (
|
||||||
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4 opacity-70">
|
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4 opacity-70">
|
||||||
<div className="space-y-1">
|
<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>
|
<span className="font-mono text-sm block">{data?.client?.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Created On</span>
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||||
<span className="text-sm text-muted-foreground block">{data?.client?.created_at ? new Date(data.client.created_at).toLocaleString() : "-"}</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
Copy,
|
|
||||||
Laptop,
|
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
@@ -25,6 +22,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
|
import { CopyButton } from "../../components/ui/copy-button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Separator } from "../../components/ui/separator";
|
import { Separator } from "../../components/ui/separator";
|
||||||
import { Switch } from "../../components/ui/switch";
|
import { Switch } from "../../components/ui/switch";
|
||||||
@@ -36,14 +34,14 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
deleteClient,
|
deleteClient,
|
||||||
fetchClients,
|
fetchClients,
|
||||||
updateClientStatus,
|
updateClientStatus,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { CopyButton } from "../../components/ui/copy-button";
|
|
||||||
import { toast } from "../../components/ui/use-toast";
|
|
||||||
|
|
||||||
function ClientsPage() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -56,15 +54,29 @@ function ClientsPage() {
|
|||||||
mutationFn: (payload: { id: string; status: "active" | "inactive" }) =>
|
mutationFn: (payload: { id: string; status: "active" | "inactive" }) =>
|
||||||
updateClientStatus(payload.id, payload.status),
|
updateClientStatus(payload.id, payload.status),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
const statusText = variables.status === "active" ? "활성화" : "비활성화";
|
const statusText =
|
||||||
toast(`클라이언트가 ${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"] });
|
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||||
},
|
},
|
||||||
onError: (error: AxiosError<{ error?: string }>) => {
|
onError: (error: AxiosError<{ error?: string }>) => {
|
||||||
const errMsg =
|
const errMsg =
|
||||||
error.response?.data?.error ??
|
error.response?.data?.error ??
|
||||||
error.message ??
|
error.message ??
|
||||||
"Failed to update client status";
|
t(
|
||||||
|
"msg.dev.clients.status_update_error",
|
||||||
|
"Failed to update client status",
|
||||||
|
);
|
||||||
toast(errMsg, "error");
|
toast(errMsg, "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -76,29 +88,49 @@ function ClientsPage() {
|
|||||||
const clients = data?.items || [];
|
const clients = data?.items || [];
|
||||||
const totalClients = clients.length;
|
const totalClients = clients.length;
|
||||||
// TODO: Add real stats for active sessions and auth failures
|
// 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(),
|
value: totalClients.toString(),
|
||||||
delta: "Realtime",
|
deltaKey: "ui.dev.clients.stats.realtime",
|
||||||
|
deltaFallback: "Realtime",
|
||||||
tone: "up" as const,
|
tone: "up" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "활성 세션",
|
labelKey: "ui.dev.clients.stats.active_sessions",
|
||||||
|
labelFallback: "활성 세션",
|
||||||
value: "-",
|
value: "-",
|
||||||
delta: "Not impl",
|
deltaKey: "ui.dev.clients.stats.not_impl",
|
||||||
|
deltaFallback: "Not impl",
|
||||||
tone: "stable" as const,
|
tone: "stable" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "인증 실패 (24h)",
|
labelKey: "ui.dev.clients.stats.auth_failures",
|
||||||
|
labelFallback: "인증 실패 (24h)",
|
||||||
value: "0",
|
value: "0",
|
||||||
delta: "Stable",
|
deltaKey: "ui.dev.clients.stats.stable",
|
||||||
|
deltaFallback: "Stable",
|
||||||
tone: "stable" as const,
|
tone: "stable" as const,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
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) {
|
if (error) {
|
||||||
@@ -107,7 +139,9 @@ function ClientsPage() {
|
|||||||
(error as Error).message;
|
(error as Error).message;
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,14 +153,16 @@ function ClientsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
RP registry
|
{t("ui.dev.clients.registry.title", "RP registry")}
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-3xl font-black tracking-tight">
|
<CardTitle className="text-3xl font-black tracking-tight">
|
||||||
Relying Parties
|
{t("ui.dev.clients.registry.subtitle", "Relying Parties")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사
|
{t(
|
||||||
로그와 함께 관리합니다.
|
"msg.dev.clients.registry.description",
|
||||||
|
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
@@ -135,7 +171,8 @@ function ClientsPage() {
|
|||||||
className="shadow-lg shadow-primary/30"
|
className="shadow-lg shadow-primary/30"
|
||||||
onClick={() => navigate("/clients/new")}
|
onClick={() => navigate("/clients/new")}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />새 클라이언트
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
placeholder="클라이언트 이름/ID로 검색..."
|
placeholder={t(
|
||||||
|
"ui.dev.clients.search_placeholder",
|
||||||
|
"클라이언트 이름/ID로 검색...",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 md:justify-start">
|
<div className="flex items-center justify-end gap-2 md:justify-start">
|
||||||
<Badge variant="muted">테넌트: 선택됨</Badge>
|
<Badge variant="muted">
|
||||||
<Badge variant="success">관리자 세션</Badge>
|
{t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="success">
|
||||||
|
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
{stats.map((item) => (
|
{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">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>{item.label}</CardDescription>
|
<CardDescription>
|
||||||
|
{t(item.labelKey, item.labelFallback)}
|
||||||
|
</CardDescription>
|
||||||
<div className="mt-1 flex items-baseline gap-2">
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
<span className="text-3xl font-bold">{item.value}</span>
|
<span className="text-3xl font-bold">{item.value}</span>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -176,7 +222,7 @@ function ClientsPage() {
|
|||||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.delta}
|
{t(item.deltaKey, item.deltaFallback)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -190,11 +236,12 @@ function ClientsPage() {
|
|||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
클라이언트 목록
|
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,12 +250,22 @@ function ClientsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>애플리케이션</TableHead>
|
<TableHead>
|
||||||
<TableHead>Client ID</TableHead>
|
{t("ui.dev.clients.table.application", "애플리케이션")}
|
||||||
<TableHead>유형</TableHead>
|
</TableHead>
|
||||||
<TableHead>상태</TableHead>
|
<TableHead>
|
||||||
<TableHead>생성일</TableHead>
|
{t("ui.dev.clients.table.client_id", "Client ID")}
|
||||||
<TableHead className="text-right">액션</TableHead>
|
</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -225,10 +282,11 @@ function ClientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{client.name || "Untitled"}
|
{client.name ||
|
||||||
|
t("ui.dev.clients.untitled", "Untitled")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Tenant-scoped
|
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,8 +300,18 @@ function ClientsPage() {
|
|||||||
value={client.id}
|
value={client.id}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||||
aria-label="Copy client id"
|
aria-label={t(
|
||||||
onCopy={() => toast("클라이언트 ID가 복사되었습니다.")}
|
"ui.dev.clients.copy_client_id",
|
||||||
|
"Copy client id",
|
||||||
|
)}
|
||||||
|
onCopy={() =>
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.copy_client_id",
|
||||||
|
"클라이언트 ID가 복사되었습니다.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -254,8 +322,11 @@ function ClientsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{client.type === "confidential"
|
{client.type === "confidential"
|
||||||
? "기밀(Confidential)"
|
? t(
|
||||||
: "Public"}
|
"ui.dev.clients.type.confidential",
|
||||||
|
"기밀(Confidential)",
|
||||||
|
)
|
||||||
|
: t("ui.dev.clients.type.public", "Public")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -281,7 +352,9 @@ function ClientsPage() {
|
|||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{client.status === "active" ? "활성" : "비활성"}
|
{client.status === "active"
|
||||||
|
? t("ui.common.status.active", "활성")
|
||||||
|
: t("ui.common.status.inactive", "비활성")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -293,7 +366,9 @@ function ClientsPage() {
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -301,7 +376,7 @@ function ClientsPage() {
|
|||||||
className="text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => deleteMutation.mutate(client.id)}
|
onClick={() => deleteMutation.mutate(client.id)}
|
||||||
>
|
>
|
||||||
Delete
|
{t("ui.common.delete", "Delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -311,14 +386,18 @@ function ClientsPage() {
|
|||||||
</Table>
|
</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">
|
<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>
|
<span>
|
||||||
Showing {clients.length} of {totalClients} clients
|
{t(
|
||||||
|
"msg.dev.clients.showing",
|
||||||
|
"Showing {{shown}} of {{total}} clients",
|
||||||
|
{ shown: clients.length, total: totalClients },
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Button variant="outline" size="sm" disabled>
|
||||||
Previous
|
{t("ui.common.previous", "Previous")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Button variant="outline" size="sm" disabled>
|
||||||
Next
|
{t("ui.common.next", "Next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,11 +408,16 @@ function ClientsPage() {
|
|||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-lg font-bold">
|
<CardTitle className="text-lg font-bold">
|
||||||
Need help with OIDC configuration?
|
{t(
|
||||||
|
"ui.dev.clients.help.title",
|
||||||
|
"Need help with OIDC configuration?",
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Developer guides for Confidential/Public clients, redirect URIs,
|
{t(
|
||||||
and auth methods.
|
"msg.dev.clients.help.subtitle",
|
||||||
|
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-between">
|
<CardContent className="flex items-center justify-between">
|
||||||
@@ -342,40 +426,56 @@ function ClientsPage() {
|
|||||||
<BookOpenText className="h-6 w-6" />
|
<BookOpenText className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Includes PKCE, client_secret_basic, redirect URI validation
|
{t(
|
||||||
tips.
|
"msg.dev.clients.help.docs_body",
|
||||||
|
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary">View guides</Button>
|
<Button variant="secondary">
|
||||||
|
{t("ui.dev.clients.help.view_guides", "View guides")}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-lg font-semibold">Owner</CardTitle>
|
<CardTitle className="text-lg font-semibold">
|
||||||
<CardDescription>Tenant admin on-call</CardDescription>
|
{t("ui.dev.clients.owner.title", "Owner")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-between">
|
<CardContent className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
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>
|
<AvatarFallback>AR</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">AI Admin Bot</p>
|
<p className="font-semibold">
|
||||||
<p className="text-xs text-muted-foreground">admin@brsw.kr</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
<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">
|
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
||||||
<span>Role: Tenant Admin</span>
|
<span>
|
||||||
<span>Scope: TENANT-12</span>
|
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
|
||||||
|
</span>
|
||||||
|
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
createIdpConfigForClient,
|
createIdpConfigForClient,
|
||||||
listIdpConfigsForClient,
|
listIdpConfigsForClient,
|
||||||
} from "../../../lib/devApi";
|
} from "../../../lib/devApi";
|
||||||
import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/devApi";
|
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
// Proper Modal Component with Form
|
// Proper Modal Component with Form
|
||||||
const CreateIdpModal = ({
|
const CreateIdpModal = ({
|
||||||
@@ -185,6 +185,7 @@ export function ClientFederationPage() {
|
|||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setCreateModalOpen(true)}
|
onClick={() => setCreateModalOpen(true)}
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-4 border-b">
|
<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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button className="text-red-500 hover:underline">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-red-500 hover:underline"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -8,37 +8,73 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
const guardHighlights = [
|
const guardHighlights = [
|
||||||
{
|
{
|
||||||
title: "RP 정책 통제",
|
titleKey: "ui.dev.dashboard.guard.policy.title",
|
||||||
body: "Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
|
titleFallback: "RP 정책 통제",
|
||||||
metric: "Policy",
|
bodyKey: "msg.dev.dashboard.guard.policy.body",
|
||||||
|
bodyFallback:
|
||||||
|
"Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
|
||||||
|
metricKey: "ui.dev.dashboard.guard.policy.metric",
|
||||||
|
metricFallback: "Policy",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Consent 흐름",
|
titleKey: "ui.dev.dashboard.guard.consent.title",
|
||||||
body: "사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
|
titleFallback: "Consent 흐름",
|
||||||
metric: "Consent",
|
bodyKey: "msg.dev.dashboard.guard.consent.body",
|
||||||
|
bodyFallback:
|
||||||
|
"사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
|
||||||
|
metricKey: "ui.dev.dashboard.guard.consent.metric",
|
||||||
|
metricFallback: "Consent",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Hydra Admin",
|
titleKey: "ui.dev.dashboard.guard.hydra.title",
|
||||||
body: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
|
titleFallback: "Hydra Admin",
|
||||||
metric: "Hydra",
|
bodyKey: "msg.dev.dashboard.guard.hydra.body",
|
||||||
|
bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
|
||||||
|
metricKey: "ui.dev.dashboard.guard.hydra.metric",
|
||||||
|
metricFallback: "Hydra",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const stackReadiness = [
|
const stackReadiness = [
|
||||||
"React 19 + Vite 7, strict TS, Router v6 data router.",
|
{
|
||||||
"TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
|
key: "msg.dev.dashboard.stack.react",
|
||||||
"Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
|
fallback: "React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||||
"Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
|
},
|
||||||
"Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
|
{
|
||||||
|
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 = [
|
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() {
|
function DashboardPage() {
|
||||||
@@ -50,41 +86,63 @@ function DashboardPage() {
|
|||||||
<div className="space-y-3 max-w-2xl">
|
<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)]">
|
<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} />
|
<Sparkles size={14} />
|
||||||
devfront ready
|
{t("ui.dev.dashboard.ready_badge", "devfront ready")}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-semibold leading-tight">
|
<h2 className="text-3xl font-semibold leading-tight">
|
||||||
RP 등록 현황과 Consent 상태를
|
{t(
|
||||||
<span className="text-[var(--color-accent)]"> 하나의 화면</span>
|
"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>
|
</h2>
|
||||||
<p className="text-[var(--color-muted)]">
|
<p className="text-[var(--color-muted)]">
|
||||||
Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지
|
{t(
|
||||||
devfront에서 처리하도록 준비합니다.
|
"msg.dev.dashboard.hero.body",
|
||||||
|
"Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3 text-sm">
|
<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)]">
|
<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>
|
||||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
<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>
|
||||||
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm">
|
<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)]">
|
<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} />
|
<ShieldCheck size={16} />
|
||||||
RP 정책은 dev scope에서만 적용
|
{t(
|
||||||
|
"msg.dev.dashboard.notice.dev_scope",
|
||||||
|
"RP 정책은 dev scope에서만 적용",
|
||||||
|
)}
|
||||||
</div>
|
</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)]">
|
<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} />
|
<KeyRound size={16} />
|
||||||
Consent 회수는 감사 로그와 연계
|
{t(
|
||||||
|
"msg.dev.dashboard.notice.consent_audit",
|
||||||
|
"Consent 회수는 감사 로그와 연계",
|
||||||
|
)}
|
||||||
</div>
|
</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)]">
|
<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} />
|
<Database size={16} />
|
||||||
Hydra Admin 상태 체크 준비
|
{t(
|
||||||
|
"msg.dev.dashboard.notice.hydra_health",
|
||||||
|
"Hydra Admin 상태 체크 준비",
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,21 +151,25 @@ function DashboardPage() {
|
|||||||
<section className="grid gap-4 md:grid-cols-3">
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
{guardHighlights.map((item) => (
|
{guardHighlights.map((item) => (
|
||||||
<div
|
<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)]"
|
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="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="relative flex items-center justify-between gap-2">
|
||||||
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
{item.metric}
|
{t(item.metricKey, item.metricFallback)}
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mt-3 space-y-2">
|
<div className="relative mt-3 space-y-2">
|
||||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
<h3 className="text-lg font-semibold">
|
||||||
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
|
{t(item.titleKey, item.titleFallback)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
{t(item.bodyKey, item.bodyFallback)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -118,29 +180,31 @@ function DashboardPage() {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
Stack readiness
|
{t("ui.dev.dashboard.stack.title", "Stack readiness")}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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)]"
|
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} />
|
<ArrowRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
{stackReadiness.map((item) => (
|
{stackReadiness.map((item) => (
|
||||||
<div
|
<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"
|
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
|
<CheckCircle2
|
||||||
size={16}
|
size={16}
|
||||||
className="text-[var(--color-accent)]"
|
className="text-[var(--color-accent)]"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm">{item}</p>
|
<p className="text-sm">{t(item.key, item.fallback)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,19 +212,23 @@ function DashboardPage() {
|
|||||||
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
<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)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
Next actions
|
{t("ui.dev.dashboard.next.title", "Next actions")}
|
||||||
</p>
|
</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">
|
<div className="mt-4 space-y-3">
|
||||||
{nextSteps.map((item, idx) => (
|
{nextSteps.map((item, idx) => (
|
||||||
<div
|
<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"
|
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)]">
|
<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}
|
{idx + 1}
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</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 className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
Ops board
|
{t("ui.dev.dashboard.ops.title", "Ops board")}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="text-xl font-semibold">현재 관측</h3>
|
<h3 className="text-xl font-semibold">
|
||||||
|
{t("ui.dev.dashboard.ops.subtitle", "현재 관측")}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
<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">
|
<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>
|
||||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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="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)]">
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
<BarChart3 size={16} />
|
<BarChart3 size={16} />
|
||||||
RP 요청 추이
|
{t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")}
|
||||||
</div>
|
</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>
|
||||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
<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)]">
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
<Activity size={16} />
|
<Activity size={16} />
|
||||||
Consent 회수 건수
|
{t(
|
||||||
|
"ui.dev.dashboard.ops.card.consent_revoked",
|
||||||
|
"Consent 회수 건수",
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
<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)]">
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
<Database size={16} />
|
<Database size={16} />
|
||||||
Hydra 상태
|
{t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type ClientEndpoints = {
|
|||||||
|
|
||||||
export type ClientDetailResponse = {
|
export type ClientDetailResponse = {
|
||||||
client: ClientSummary & {
|
client: ClientSummary & {
|
||||||
|
clientSecret?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
endpoints: ClientEndpoints;
|
endpoints: ClientEndpoints;
|
||||||
@@ -93,7 +94,6 @@ export type IdpConfigCreateRequest = Omit<
|
|||||||
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
|
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
|
||||||
// --- End Federation Types ---
|
// --- End Federation Types ---
|
||||||
|
|
||||||
|
|
||||||
export async function fetchClients() {
|
export async function fetchClients() {
|
||||||
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
|
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
|
||||||
return data;
|
return data;
|
||||||
@@ -138,7 +138,7 @@ export async function updateClient(
|
|||||||
|
|
||||||
export async function rotateClientSecret(clientId: string) {
|
export async function rotateClientSecret(clientId: string) {
|
||||||
const { data } = await apiClient.post<ClientDetailResponse>(
|
const { data } = await apiClient.post<ClientDetailResponse>(
|
||||||
`/dev/clients/${clientId}/secret/rotate`
|
`/dev/clients/${clientId}/secret/rotate`,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -175,11 +175,13 @@ export async function listIdpConfigsForClient(clientId: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createIdpConfigForClient(payload: IdpConfigCreateRequest) {
|
export async function createIdpConfigForClient(
|
||||||
|
payload: IdpConfigCreateRequest,
|
||||||
|
) {
|
||||||
const { data } = await apiClient.post<IdpConfig>(
|
const { data } = await apiClient.post<IdpConfig>(
|
||||||
`/dev/clients/${payload.client_id}/idps`,
|
`/dev/clients/${payload.client_id}/idps`,
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
import { Toaster } from "./components/ui/toaster";
|
|
||||||
import { queryClient } from "./app/queryClient";
|
import { queryClient } from "./app/queryClient";
|
||||||
import { router } from "./app/routes";
|
import { router } from "./app/routes";
|
||||||
|
import { Toaster } from "./components/ui/toaster";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
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