From b6d3b69cdae4a1b5f60bc4f1edff2a8e05b1aeb4 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Tue, 10 Feb 2026 19:15:51 +0900 Subject: [PATCH] i18n refresh and frontend fixes --- .gitea/workflows/code_check.yml | 9 + .gitignore | 4 + README.md | 8 + .../src/components/layout/AppLayout.tsx | 273 ++-- .../src/components/layout/RoleSwitcher.tsx | 90 +- .../features/api-keys/ApiKeyCreatePage.tsx | 267 +++- .../src/features/api-keys/ApiKeyListPage.tsx | 87 +- .../src/features/audit/AuditLogsPage.tsx | 1086 +++++++------- .../features/overview/GlobalOverviewPage.tsx | 133 +- .../tenants/routes/TenantCreatePage.tsx | 85 +- .../tenants/routes/TenantDetailPage.tsx | 7 +- .../tenants/routes/TenantGroupsPage.tsx | 213 ++- .../tenants/routes/TenantListPage.tsx | 85 +- .../tenants/routes/TenantSchemaPage.tsx | 128 +- .../tenants/routes/TenantSubTenantsPage.tsx | 90 +- .../tenants/routes/TenantUsersPage.tsx | 62 +- .../src/features/users/UserCreatePage.tsx | 348 +++-- .../src/features/users/UserDetailPage.tsx | 358 +++-- .../src/features/users/UserListPage.tsx | 120 +- adminfront/src/lib/adminApi.ts | 66 +- adminfront/src/lib/i18n.ts | 148 ++ adminfront/test-results/.last-run.json | 4 - devfront/src/components/layout/AppLayout.tsx | 224 +-- devfront/src/components/ui/copy-button.tsx | 10 +- devfront/src/components/ui/toaster.tsx | 18 +- devfront/src/components/ui/use-toast.ts | 2 +- .../features/clients/ClientConsentsPage.tsx | 158 +- .../features/clients/ClientDetailsPage.tsx | 320 +++- .../features/clients/ClientGeneralPage.tsx | 456 ++++-- devfront/src/features/clients/ClientsPage.tsx | 224 ++- .../clients/routes/ClientFederationPage.tsx | 17 +- .../src/features/dashboard/DashboardPage.tsx | 185 ++- devfront/src/lib/devApi.ts | 14 +- devfront/src/lib/i18n.ts | 148 ++ devfront/src/main.tsx | 2 +- devfront/test-results/.last-run.json | 4 - devfront/vite.config.ts | 3 - docs/i18n.md | 233 +++ locales/en.toml | 1307 +++++++++++++++++ locales/ko.toml | 1307 +++++++++++++++++ locales/template.toml | 1307 +++++++++++++++++ tools/i18n-scanner/gen-flutter-i18n.js | 86 ++ tools/i18n-scanner/index.js | 224 +++ tools/i18n-scanner/translate-locales.js | 443 ++++++ 44 files changed, 8603 insertions(+), 1760 deletions(-) create mode 100644 adminfront/src/lib/i18n.ts delete mode 100644 adminfront/test-results/.last-run.json create mode 100644 devfront/src/lib/i18n.ts delete mode 100644 devfront/test-results/.last-run.json create mode 100644 docs/i18n.md create mode 100644 locales/en.toml create mode 100644 locales/ko.toml create mode 100644 locales/template.toml create mode 100755 tools/i18n-scanner/gen-flutter-i18n.js create mode 100644 tools/i18n-scanner/index.js create mode 100644 tools/i18n-scanner/translate-locales.js diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 3640da92..3c159b23 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -27,6 +27,15 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: i18n resource check + run: | + node tools/i18n-scanner/index.js + - name: Setup Go uses: actions/setup-go@v5 with: diff --git a/.gitignore b/.gitignore index b2ee0f95..31c87998 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ userfront/.dart_tool/ userfront/.packages userfront/.pub/ userfront/.env + +# Frontend test artifacts +adminfront/test-results/ +devfront/test-results/ diff --git a/README.md b/README.md index 6ee5f36b..7a870483 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,12 @@ KETO_READ_URL = "http://keto:4466" KETO_WRITE_URL = "http://keto:4467" ``` +## 🌐 i18n 구조 (간략) +- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다. +- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다. +- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다. +- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다. + ### 로컬 개발 (Manual) Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능. 백그라운드로 infra 및 ory stack이 구동중이라는 가정 @@ -247,6 +253,8 @@ go run cmd/server/main.go cd userfront flutter pub get flutter run -d chrome +# 정책: 웹 빌드는 기본적으로 WASM 사용 +flutter build web --wasm ``` **adminfront:** diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index ffe26ab3..c9ece4f8 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -1,148 +1,161 @@ import { - BadgeCheck, - Building2, - Key, - KeyRound, - LayoutDashboard, - Moon, - NotebookTabs, - ShieldHalf, - Sun, - Users, + BadgeCheck, + Building2, + Key, + KeyRound, + LayoutDashboard, + Moon, + NotebookTabs, + ShieldHalf, + Sun, + Users, } from "lucide-react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; +import { t } from "../../lib/i18n"; import RoleSwitcher from "./RoleSwitcher"; const navItems = [ - { label: "Overview", to: "/", icon: LayoutDashboard }, - { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf }, - { label: "Tenants", to: "/tenants", icon: Building2 }, - { label: "Users", to: "/users", icon: Users }, - { label: "API Keys (M2M)", to: "/api-keys", icon: Key }, - { label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs }, - { label: "Auth Guard", to: "/auth", icon: KeyRound }, + { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, + { + label: "ui.admin.nav.tenant_dashboard", + to: "/dashboard", + icon: ShieldHalf, + }, + { label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 }, + { label: "ui.admin.nav.users", to: "/users", icon: Users }, + { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, + { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, + { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, ]; function AppLayout() { - const [theme, setTheme] = useState<"light" | "dark">(() => { - const stored = window.localStorage.getItem("admin_theme"); - return stored === "dark" ? "dark" : "light"; - }); + const [theme, setTheme] = useState<"light" | "dark">(() => { + const stored = window.localStorage.getItem("admin_theme"); + return stored === "dark" ? "dark" : "light"; + }); - useEffect(() => { - const root = document.documentElement; - root.classList.remove("light", "dark"); - if (theme === "light") { - root.classList.add("light"); - } else { - root.classList.add("dark"); - } - window.localStorage.setItem("admin_theme", theme); - }, [theme]); + useEffect(() => { + const root = document.documentElement; + root.classList.remove("light", "dark"); + if (theme === "light") { + root.classList.add("light"); + } else { + root.classList.add("dark"); + } + window.localStorage.setItem("admin_theme", theme); + }, [theme]); - const toggleTheme = () => { - setTheme((prev) => (prev === "light" ? "dark" : "light")); - }; + const toggleTheme = () => { + setTheme((prev) => (prev === "light" ? "dark" : "light")); + }; - return ( -
- - -
-
-
-
-

- Admin Plane -

- - Tenant isolation & least privilege by default - -
-
- - - Session TTL: 15m admin - -
-
-
-
- -
- + return ( +
+ + +
+
+
+
+

+ {t("ui.admin.header.plane", "Admin Plane")} +

+ + {t( + "msg.admin.header.subtitle", + "Tenant isolation & least privilege by default", + )} + +
+
+ + + {t("msg.admin.session_ttl", "Session TTL: 15m admin")} + +
+
+
+
+ +
+ +
+
+ ); } export default AppLayout; diff --git a/adminfront/src/components/layout/RoleSwitcher.tsx b/adminfront/src/components/layout/RoleSwitcher.tsx index 3245ad01..ea4cb2c8 100644 --- a/adminfront/src/components/layout/RoleSwitcher.tsx +++ b/adminfront/src/components/layout/RoleSwitcher.tsx @@ -1,64 +1,86 @@ -import React, { useState, useEffect } from 'react'; +import type { FC } from "react"; +import { useEffect, useState } from "react"; +import { t } from "../../lib/i18n"; -const RoleSwitcher: React.FC = () => { - const [currentRole, setCurrentRole] = useState('super_admin'); +const RoleSwitcher: FC = () => { + const [currentRole, setCurrentRole] = useState("super_admin"); useEffect(() => { // localStorage에서 역할 읽기 - const savedRole = window.localStorage.getItem('X-Mock-Role'); + const savedRole = window.localStorage.getItem("X-Mock-Role"); if (savedRole) { setCurrentRole(savedRole); } else { // 기본값 설정 - window.localStorage.setItem('X-Mock-Role', 'super_admin'); + window.localStorage.setItem("X-Mock-Role", "super_admin"); } }, []); const switchRole = (role: string) => { // localStorage 설정 - window.localStorage.setItem('X-Mock-Role', role); + window.localStorage.setItem("X-Mock-Role", role); setCurrentRole(role); // 페이지 새로고침하여 권한 적용 window.location.reload(); }; - if (import.meta.env.MODE === 'production') return null; + if (import.meta.env.MODE === "production") return null; + + const roleLabels: Record = { + 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 ( -
-
- 🛠 DEV Role Switcher +
+
+ {t("ui.admin.dev_role_switcher", "🛠 DEV Role Switcher")}
- {(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => ( + {( + ["super_admin", "tenant_admin", "rp_admin", "tenant_member"] as const + ).map((role) => ( ))}
diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx index 4ad2fe0e..fd87b545 100644 --- a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx @@ -1,6 +1,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react"; +import { + AlertCircle, + Check, + ChevronLeft, + Copy, + Loader2, + Save, + ShieldCheck, +} from "lucide-react"; import * as React from "react"; import { useForm } from "react-hook-form"; import { Link, useNavigate } from "react-router-dom"; @@ -13,24 +21,69 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { + type ApiKeyCreateRequest, + type ApiKeyCreateResponse, + createApiKey, +} from "../../lib/adminApi"; +import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; -import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi"; const AVAILABLE_SCOPES = [ - { id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." }, - { id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." }, - { id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." }, - { id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." }, - { id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." }, - { id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." }, + { + id: "audit:read", + labelKey: "ui.admin.api_keys.scopes.audit_read.title", + labelFallback: "감사 로그 조회", + descKey: "msg.admin.api_keys.scopes.audit_read.desc", + descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.", + }, + { + id: "audit:write", + labelKey: "ui.admin.api_keys.scopes.audit_write.title", + labelFallback: "감사 로그 생성", + descKey: "msg.admin.api_keys.scopes.audit_write.desc", + descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.", + }, + { + id: "user:read", + labelKey: "ui.admin.api_keys.scopes.user_read.title", + labelFallback: "사용자 조회", + descKey: "msg.admin.api_keys.scopes.user_read.desc", + descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.", + }, + { + id: "user:write", + labelKey: "ui.admin.api_keys.scopes.user_write.title", + labelFallback: "사용자 관리", + descKey: "msg.admin.api_keys.scopes.user_write.desc", + descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.", + }, + { + id: "tenant:read", + labelKey: "ui.admin.api_keys.scopes.tenant_read.title", + labelFallback: "테넌트 조회", + descKey: "msg.admin.api_keys.scopes.tenant_read.desc", + descFallback: "등록된 모든 조직 정보를 조회합니다.", + }, + { + id: "tenant:write", + labelKey: "ui.admin.api_keys.scopes.tenant_write.title", + labelFallback: "테넌트 관리", + descKey: "msg.admin.api_keys.scopes.tenant_write.desc", + descFallback: "테넌트 정보를 직접 제어합니다.", + }, ]; function ApiKeyCreatePage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [error, setError] = React.useState(null); - const [createdResult, setCreatedResult] = React.useState(null); - const [selectedScopes, setSelectedScopes] = React.useState(["audit:read", "user:read"]); + const [createdResult, setCreatedResult] = + React.useState(null); + const [selectedScopes, setSelectedScopes] = React.useState([ + "audit:read", + "user:read", + ]); const { register, @@ -47,19 +100,29 @@ function ApiKeyCreatePage() { setCreatedResult(data); }, onError: (err: AxiosError<{ error?: string }>) => { - setError(err.response?.data?.error || "API 키 생성에 실패했습니다."); + setError( + err.response?.data?.error || + t("msg.admin.api_keys.create.error", "API 키 생성에 실패했습니다."), + ); }, }); const toggleScope = (scopeId: string) => { setSelectedScopes((prev) => - prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId] + prev.includes(scopeId) + ? prev.filter((s) => s !== scopeId) + : [...prev, scopeId], ); }; const onSubmit = (data: { name: string }) => { if (selectedScopes.length === 0) { - setError("최소 하나 이상의 권한을 선택해야 합니다."); + setError( + t( + "msg.admin.api_keys.create.scope_required", + "최소 하나 이상의 권한을 선택해야 합니다.", + ), + ); return; } setError(null); @@ -77,9 +140,24 @@ function ApiKeyCreatePage() {
-

API 키 생성 완료

+

+ {t("ui.admin.api_keys.create.success.title", "API 키 생성 완료")} +

- 아래의 비밀번호(Secret)는 보안을 위해 지금 한 번만 표시됩니다. + {t( + "msg.admin.api_keys.create.success.notice", + "아래의 비밀번호(Secret)는 보안을 위해 ", + )} + + {t( + "msg.admin.api_keys.create.success.notice_emphasis", + "지금 한 번만", + )} + {" "} + {t( + "msg.admin.api_keys.create.success.notice_suffix", + "표시됩니다.", + )}

@@ -87,7 +165,10 @@ function ApiKeyCreatePage() { - 보안 시크릿 복사 + {t( + "ui.admin.api_keys.create.success.copy_secret", + "보안 시크릿 복사", + )} @@ -96,14 +177,14 @@ function ApiKeyCreatePage() { X-Baron-Key-Secret
- -

- 복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요. + {t( + "msg.admin.api_keys.create.success.copy_hint", + "복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.", + )}

@@ -130,12 +219,24 @@ function ApiKeyCreatePage() {
- -

새 API 키 생성

-

내부 시스템 연동을 위한 보안 인증 키를 구성합니다.

+

+ {t("ui.admin.api_keys.create.title", "새 API 키 생성")} +

+

+ {t( + "msg.admin.api_keys.create.subtitle", + "내부 시스템 연동을 위한 보안 인증 키를 구성합니다.", + )} +

@@ -143,20 +244,41 @@ function ApiKeyCreatePage() { {/* 섹션 1: 이름 설정 */}
- 1 -

키 이름 지정

+ + 1 + +

+ {t("ui.admin.api_keys.create.section_name", "키 이름 지정")} +

- + - {errors.name &&

{errors.name.message}

} + {errors.name && ( +

+ {errors.name.message} +

+ )}
@@ -165,8 +287,15 @@ function ApiKeyCreatePage() { {/* 섹션 2: 권한 선택 */}
- 2 -

권한 범위(Scopes) 선택

+ + 2 + +

+ {t( + "ui.admin.api_keys.create.section_scopes", + "권한 범위(Scopes) 선택", + )} +

{AVAILABLE_SCOPES.map((scope) => { @@ -178,24 +307,39 @@ function ApiKeyCreatePage() { onClick={() => toggleScope(scope.id)} className={cn( "flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all", - isSelected - ? "border-primary bg-primary/5 shadow-md" - : "border-border bg-card hover:border-muted-foreground/30" + isSelected + ? "border-primary bg-primary/5 shadow-md" + : "border-border bg-card hover:border-muted-foreground/30", )} >
- {scope.label} -
- {isSelected && } + + {t(scope.labelKey, scope.labelFallback)} + +
+ {isSelected && ( + + )}

- {scope.desc} + {t(scope.descKey, scope.descFallback)}

- ID: {scope.id} + + ID: {scope.id} + ); })} @@ -210,15 +354,26 @@ function ApiKeyCreatePage() {

{error}

)} - +
-

총 {selectedScopes.length}개의 권한이 할당됩니다.

-

생성 즉시 활성화되어 사용 가능합니다.

+

+ {t( + "msg.admin.api_keys.create.scopes_count", + "총 {{count}}개의 권한이 할당됩니다.", + { count: selectedScopes.length }, + )} +

+

+ {t( + "msg.admin.api_keys.create.scopes_hint", + "생성 즉시 활성화되어 사용 가능합니다.", + )} +

-
@@ -236,4 +391,4 @@ function ApiKeyCreatePage() { ); } -export default ApiKeyCreatePage; \ No newline at end of file +export default ApiKeyCreatePage; diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx index 1be12120..9e67663e 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -20,6 +20,7 @@ import { TableRow, } from "../../components/ui/table"; import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi"; +import { t } from "../../lib/i18n"; function ApiKeyListPage() { const query = useQuery({ @@ -37,12 +38,25 @@ function ApiKeyListPage() { const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response ?.data?.error; const fallbackError = - !errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null; + !errorMsg && query.isError + ? t( + "msg.admin.api_keys.list.fetch_error", + "API 키 목록 조회에 실패했습니다.", + ) + : null; const items = query.data?.items ?? []; const handleDelete = (id: string, name: string) => { - if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) { + if ( + !window.confirm( + t( + "msg.admin.api_keys.list.delete_confirm", + 'API 키 "{{name}}"를 삭제할까요?', + { name }, + ), + ) + ) { return; } deleteMutation.mutate(id); @@ -53,14 +67,22 @@ function ApiKeyListPage() {
- API Keys + + {t("ui.admin.api_keys.list.breadcrumb.section", "API Keys")} + / - List + + {t("ui.admin.api_keys.list.breadcrumb.list", "List")} +
-

API 키 관리 (M2M)

+

+ {t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")} +

- 서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 - 관리합니다. + {t( + "msg.admin.api_keys.list.subtitle", + "서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.", + )}

@@ -70,12 +92,12 @@ function ApiKeyListPage() { disabled={query.isFetching} > - 새로고침 + {t("ui.common.refresh", "새로고침")}
@@ -84,12 +106,18 @@ function ApiKeyListPage() {
- API Key Registry + + {t("ui.admin.api_keys.list.registry.title", "API Key Registry")} + - 총 {query.data?.total ?? 0}개 API 키 + {t( + "msg.admin.api_keys.list.registry.count", + "총 {{count}}개 API 키", + { count: query.data?.total ?? 0 }, + )}
- System + {t("ui.common.badge.system", "System")}
{(errorMsg || fallbackError) && ( @@ -101,22 +129,39 @@ function ApiKeyListPage() { - NAME - CLIENT ID - SCOPES - LAST USED - ACTIONS + + {t("ui.admin.api_keys.list.table.name", "NAME")} + + + {t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")} + + + {t("ui.admin.api_keys.list.table.scopes", "SCOPES")} + + + {t("ui.admin.api_keys.list.table.last_used", "LAST USED")} + + + {t("ui.admin.api_keys.list.table.actions", "ACTIONS")} + {query.isLoading && ( - 로딩 중... + + {t("msg.common.loading", "로딩 중...")} + )} {!query.isLoading && items.length === 0 && ( - 등록된 API 키가 없습니다. + + {t( + "msg.admin.api_keys.list.empty", + "등록된 API 키가 없습니다.", + )} + )} {items.map((key) => ( @@ -146,7 +191,7 @@ function ApiKeyListPage() { {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString("ko-KR") - : "Never"} + : t("ui.common.never", "Never")} diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 11522c12..a10c5675 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -1,562 +1,604 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ChevronDown, - ChevronUp, - Copy, - ListChecks, - RefreshCw, - Search, - Terminal, + ChevronDown, + ChevronUp, + Copy, + ListChecks, + RefreshCw, + Search, + Terminal, } from "lucide-react"; import * as React from "react"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "../../components/ui/card"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "../../components/ui/table"; import type { AuditLog } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi"; +import { t } from "../../lib/i18n"; const defaultAuditFilters = [ - "method:POST path:/api/v1/*", - "status:failure", - "latency_ms:>1000", + "method:POST path:/api/v1/*", + "status:failure", + "latency_ms:>1000", ]; type AuditDetails = { - request_id?: string; - method?: string; - path?: string; - status?: number; - latency_ms?: number; - error?: string; - tenant_id?: string; - actor_id?: string; - action?: string; - target?: string; - before?: unknown; - after?: unknown; + request_id?: string; + method?: string; + path?: string; + status?: number; + latency_ms?: number; + error?: string; + tenant_id?: string; + actor_id?: string; + action?: string; + target?: string; + before?: unknown; + after?: unknown; }; function parseDetails(details?: string): AuditDetails { - if (!details) { - return {}; - } - try { - const parsed = JSON.parse(details); - if (parsed && typeof parsed === "object") { - return parsed as AuditDetails; - } - } catch {} + if (!details) { return {}; + } + try { + const parsed = JSON.parse(details); + if (parsed && typeof parsed === "object") { + return parsed as AuditDetails; + } + } catch {} + return {}; } function formatCellValue(value: unknown) { - if (value === null || value === undefined || value === "") { - return "-"; - } - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } + if (value === null || value === undefined || value === "") { + return "-"; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } } function formatIsoDateTime(value: string) { - if (!value) { - return { date: "-", time: "-" }; - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return { date: value, time: "-" }; - } - const date = parsed.toISOString().slice(0, 10); - const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); - return { date, time }; + if (!value) { + return { date: "-", time: "-" }; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return { date: value, time: "-" }; + } + const date = parsed.toISOString().slice(0, 10); + const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); + return { date, time }; } function AuditLogsPage() { - const [filters, setFilters] = React.useState(defaultAuditFilters); - const [filterDraft, setFilterDraft] = React.useState(""); - const [expandedRows, setExpandedRows] = React.useState< - Record - >({}); + const [filters, setFilters] = React.useState(defaultAuditFilters); + const [filterDraft, setFilterDraft] = React.useState(""); + const [expandedRows, setExpandedRows] = React.useState< + Record + >({}); - const handleCopy = (value: string) => { - if (!value) { - return; - } - navigator.clipboard.writeText(value); - }; - const { - data, - isLoading, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isFetching, - refetch, - } = useInfiniteQuery({ - queryKey: ["audit-logs"], - queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, - }); - - const logs = - data?.pages?.flatMap( - (page) => - page?.items?.filter((item): item is AuditLog => - Boolean(item), - ) ?? [], - ) ?? []; - - const handleAddFilter = () => { - const trimmed = filterDraft.trim(); - if (!trimmed) { - return; - } - setFilters((prev) => - prev.includes(trimmed) ? prev : [...prev, trimmed], - ); - setFilterDraft(""); - }; - - if (isLoading) { - return
Loading audit logs...
; + const handleCopy = (value: string) => { + if (!value) { + return; } + navigator.clipboard.writeText(value); + }; + const { + data, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + refetch, + } = useInfiniteQuery({ + queryKey: ["audit-logs"], + queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, + }); - if (error) { - const errMsg = - (error as AxiosError<{ error?: string }>).response?.data?.error ?? - (error as Error).message; - return ( -
- Error loading logs: {errMsg} -
- ); + const logs = + data?.pages?.flatMap( + (page) => + page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], + ) ?? []; + + const handleAddFilter = () => { + const trimmed = filterDraft.trim(); + if (!trimmed) { + return; } + setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); + setFilterDraft(""); + }; + if (isLoading) { return ( -
-
-
-
- Audit - / - Logs -
-

감사 로그

-

- Command 요청 기반 ClickHouse 로그를 조회합니다. - 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다. -

-
-
- - -
-
- -
- - -
- Audit registry - - 로드된 로그 {logs.length}건 - -
- Command only -
- -
-
- - - setFilterDraft(event.target.value) - } - onKeyDown={(event) => { - if (event.key === "Enter") { - handleAddFilter(); - } - }} - placeholder="필터 추가 (예: status:failure)" - className="w-full bg-transparent text-sm text-foreground outline-none" - /> - -
- {filters.length === 0 ? ( - - 필터 없음 - - ) : ( - filters.map((filter) => ( - - - {filter} - - - )) - )} -
-
- - - - TIME - - - ACTOR (ID) - - REQUEST - PATH - - STATUS - - Action / Target - - - - - {isLoading && ( - - - 로딩 중... - - - )} - {!isLoading && logs.length === 0 && ( - - - 아직 수집된 감사 로그가 없습니다. - - - )} - {logs.map((row, index) => { - const details = parseDetails(row.details); - const actionLabel = - details.action || - (details.method && details.path - ? `${details.method} ${details.path}` - : row.event_type); - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const isExpanded = Boolean( - expandedRows[rowKey], - ); - return ( - - - - {(() => { - const { date, time } = - formatIsoDateTime( - row.timestamp, - ); - return ( -
-
- {date} -
-
- {time} -
-
- ); - })()} -
- -
- - {row.user_id || - details.actor_id || - "-"} - - {(row.user_id || - details.actor_id) && ( - - )} -
-
- -
- - {formatCellValue( - details.request_id, - )} - - {details.request_id && ( - - )} -
-
- -
- {formatCellValue( - details.method, - )} -
-
- {formatCellValue( - details.path, - )} -
-
- - - {row.status} - - - -
- {actionLabel} -
- {details.target && ( -
- - Target ·{" "} - {details.target} - - -
- )} -
- - - -
- {isExpanded && ( - - -
-
-
- Request -
-
- Request ID ·{" "} - {formatCellValue( - details.request_id, - )} -
-
- Event ID ·{" "} - {formatCellValue( - row.event_id, - )} -
-
- IP ·{" "} - {formatCellValue( - row.ip_address, - )} -
-
- Latency ·{" "} - {details.latency_ms !== - undefined - ? `${details.latency_ms}ms` - : "-"} -
-
-
-
- Actor -
-
- Actor ID ·{" "} - {row.user_id || - details.actor_id || - "-"} -
-
- Tenant ·{" "} - {formatCellValue( - details.tenant_id, - )} -
-
- Device ·{" "} - {formatCellValue( - row.device_id, - )} -
-
-
-
- Result -
-
- Error ·{" "} - {formatCellValue( - details.error, - )} -
-
- Before ·{" "} - {formatCellValue( - details.before, - )} -
-
- After ·{" "} - {formatCellValue( - details.after, - )} -
-
-
-
-
- )} -
- ); - })} -
-
-
- {hasNextPage ? ( - - ) : ( - - End of audit feed - - )} -
-
-
-
-
+
+ {t("msg.admin.audit.loading", "Loading audit logs...")} +
); + } + + if (error) { + const errMsg = + (error as AxiosError<{ error?: string }>).response?.data?.error ?? + (error as Error).message; + return ( +
+ {t("msg.admin.audit.load_error", "Error loading logs: {{error}}", { + error: errMsg, + })} +
+ ); + } + + return ( +
+
+
+
+ {t("ui.admin.audit.breadcrumb.section", "Audit")} + / + + {t("ui.admin.audit.breadcrumb.logs", "Logs")} + +
+

+ {t("ui.admin.audit.title", "감사 로그")} +

+

+ {t( + "msg.admin.audit.subtitle", + "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.", + )} +

+
+
+ + +
+
+ +
+ + +
+ + {t("ui.admin.audit.registry.title", "Audit registry")} + + + {t( + "msg.admin.audit.registry.count", + "로드된 로그 {{count}}건", + { count: logs.length }, + )} + +
+ + {t("ui.common.badge.command_only", "Command only")} + +
+ +
+
+ + setFilterDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAddFilter(); + } + }} + placeholder={t( + "ui.admin.audit.filters.placeholder", + "필터 추가 (예: status:failure)", + )} + className="w-full bg-transparent text-sm text-foreground outline-none" + /> + +
+ {filters.length === 0 ? ( + + {t("msg.admin.audit.filters.empty", "필터 없음")} + + ) : ( + filters.map((filter) => ( + + + {filter} + + + )) + )} +
+ + + + + {t("ui.admin.audit.table.time", "TIME")} + + + {t("ui.admin.audit.table.actor", "ACTOR (ID)")} + + + {t("ui.admin.audit.table.request", "REQUEST")} + + + {t("ui.admin.audit.table.path", "PATH")} + + + {t("ui.admin.audit.table.status", "STATUS")} + + + {t("ui.admin.audit.table.action_target", "Action / Target")} + + + + + + {isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!isLoading && logs.length === 0 && ( + + + {t( + "msg.admin.audit.empty", + "아직 수집된 감사 로그가 없습니다.", + )} + + + )} + {logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = + details.action || + (details.method && details.path + ? `${details.method} ${details.path}` + : row.event_type); + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const isExpanded = Boolean(expandedRows[rowKey]); + return ( + + + + {(() => { + const { date, time } = formatIsoDateTime( + row.timestamp, + ); + return ( +
+
{date}
+
{time}
+
+ ); + })()} +
+ +
+ + {row.user_id || details.actor_id || "-"} + + {(row.user_id || details.actor_id) && ( + + )} +
+
+ +
+ + {formatCellValue(details.request_id)} + + {details.request_id && ( + + )} +
+
+ +
+ {formatCellValue(details.method)} +
+
+ {formatCellValue(details.path)} +
+
+ + + {row.status} + + + +
+ {actionLabel} +
+ {details.target && ( +
+ + {t( + "ui.admin.audit.target", + "Target · {{target}}", + { + target: details.target, + }, + )} + + +
+ )} +
+ + + +
+ {isExpanded && ( + + +
+
+
+ {t( + "ui.admin.audit.details.request", + "Request", + )} +
+
+ {t( + "ui.admin.audit.details.request_id", + "Request ID · {{value}}", + { + value: formatCellValue( + details.request_id, + ), + }, + )} +
+
+ {t( + "ui.admin.audit.details.event_id", + "Event ID · {{value}}", + { + value: formatCellValue(row.event_id), + }, + )} +
+
+ {t( + "ui.admin.audit.details.ip", + "IP · {{value}}", + { + value: formatCellValue(row.ip_address), + }, + )} +
+
+ {t( + "ui.admin.audit.details.latency", + "Latency · {{value}}", + { + value: + details.latency_ms !== undefined + ? `${details.latency_ms}ms` + : "-", + }, + )} +
+
+
+
+ {t("ui.admin.audit.details.actor", "Actor")} +
+
+ {t( + "ui.admin.audit.details.actor_id", + "Actor ID · {{value}}", + { + value: + row.user_id || details.actor_id || "-", + }, + )} +
+
+ {t( + "ui.admin.audit.details.tenant", + "Tenant · {{value}}", + { + value: formatCellValue(details.tenant_id), + }, + )} +
+
+ {t( + "ui.admin.audit.details.device", + "Device · {{value}}", + { + value: formatCellValue(row.device_id), + }, + )} +
+
+
+
+ {t("ui.admin.audit.details.result", "Result")} +
+
+ {t( + "ui.admin.audit.details.error", + "Error · {{value}}", + { + value: formatCellValue(details.error), + }, + )} +
+
+ {t( + "ui.admin.audit.details.before", + "Before · {{value}}", + { + value: formatCellValue(details.before), + }, + )} +
+
+ {t( + "ui.admin.audit.details.after", + "After · {{value}}", + { + value: formatCellValue(details.after), + }, + )} +
+
+
+
+
+ )} +
+ ); + })} +
+
+
+ {hasNextPage ? ( + + ) : ( + + {t("msg.admin.audit.end", "End of audit feed")} + + )} +
+
+
+
+
+ ); } -export default AuditLogsPage; \ No newline at end of file +export default AuditLogsPage; diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index b8a05e6e..61a0af2d 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -16,30 +16,39 @@ import { CardHeader, CardTitle, } from "../../components/ui/card"; +import { t } from "../../lib/i18n"; const summaryCards = [ { - label: "Total Tenants", + labelKey: "ui.admin.overview.summary.total_tenants", + labelFallback: "Total Tenants", value: "-", - hint: "Tenant-aware core", + hintKey: "msg.admin.overview.summary.total_tenants", + hintFallback: "Tenant-aware core", icon: Users, }, { - label: "OIDC Clients", + labelKey: "ui.admin.overview.summary.oidc_clients", + labelFallback: "OIDC Clients", value: "-", - hint: "Hydra registry", + hintKey: "msg.admin.overview.summary.oidc_clients", + hintFallback: "Hydra registry", icon: ShieldCheck, }, { - label: "Audit Events (24h)", + labelKey: "ui.admin.overview.summary.audit_events_24h", + labelFallback: "Audit Events (24h)", value: "-", - hint: "ClickHouse stream", + hintKey: "msg.admin.overview.summary.audit_events_24h", + hintFallback: "ClickHouse stream", icon: Activity, }, { - label: "Policy Gate", + labelKey: "ui.admin.overview.summary.policy_gate", + labelFallback: "Policy Gate", value: "Planned", - hint: "Keto + Admin checks", + hintKey: "msg.admin.overview.summary.policy_gate", + hintFallback: "Keto + Admin checks", icon: Database, }, ]; @@ -50,44 +59,67 @@ function GlobalOverviewPage() {

- Global Overview + {t("ui.admin.overview.kicker", "Global Overview")}

- Tenant-independent control plane + {t("ui.admin.overview.title", "Tenant-independent control plane")}

- 모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다. + {t( + "msg.admin.overview.description", + "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.", + )}

- IDP: Ory primary - Fallback: Descope + + {t("msg.admin.overview.idp_primary", "IDP: Ory primary")} + + + {t("msg.admin.overview.idp_fallback", "Fallback: Descope")} +
- {summaryCards.map(({ label, value, hint, icon: Icon }) => ( - - - {label} -
- -
-
- -
{value}
-

{hint}

-
-
- ))} + {summaryCards.map( + ({ + labelKey, + labelFallback, + value, + hintKey, + hintFallback, + icon: Icon, + }) => ( + + + {t(labelKey, labelFallback)} +
+ +
+
+ +
{value}
+

+ {t(hintKey, hintFallback)} +

+
+
+ ), + )}
- Admin playbook + + {t("ui.admin.overview.playbook.title", "Admin playbook")} + - 운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다. + {t( + "msg.admin.overview.playbook.description", + "운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.", + )} @@ -97,11 +129,16 @@ function GlobalOverviewPage() {

- Backend-only IDP access + {t( + "msg.admin.overview.playbook.idp_title", + "Backend-only IDP access", + )}

- 모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos - admin 포트는 외부에 노출하지 않습니다. + {t( + "msg.admin.overview.playbook.idp_body", + "모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.", + )}

@@ -111,11 +148,16 @@ function GlobalOverviewPage() {

- Tenant isolation + {t( + "msg.admin.overview.playbook.tenant_title", + "Tenant isolation", + )}

- Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto - 정책으로 확장 예정입니다. + {t( + "msg.admin.overview.playbook.tenant_body", + "Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.", + )}

@@ -124,9 +166,14 @@ function GlobalOverviewPage() { - 빠른 이동 + + {t("ui.admin.overview.quick_links.title", "빠른 이동")} + - 주요 운영 화면으로 바로 이동합니다. + {t( + "msg.admin.overview.quick_links.description", + "주요 운영 화면으로 바로 이동합니다.", + )} @@ -136,7 +183,7 @@ function GlobalOverviewPage() { variant="outline" > - 테넌트 추가 + {t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")} @@ -146,7 +193,10 @@ function GlobalOverviewPage() { variant="outline" > - 감사 로그 보기 + {t( + "ui.admin.overview.quick_links.view_audit_logs", + "감사 로그 보기", + )} @@ -156,7 +206,10 @@ function GlobalOverviewPage() { variant="outline" > - 테넌트 대시보드 + {t( + "ui.admin.overview.quick_links.tenant_dashboard", + "테넌트 대시보드", + )} diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index cfe52198..b5af1921 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -16,6 +16,7 @@ import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { Textarea } from "../../../components/ui/textarea"; import { createTenant } from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; function TenantCreatePage() { const navigate = useNavigate(); @@ -49,18 +50,29 @@ function TenantCreatePage() {
- Tenants + + {t("ui.admin.tenants.create.breadcrumb.section", "Tenants")} + / - Create + + {t("ui.admin.tenants.create.breadcrumb.action", "Create")} +
-

테넌트 추가

+

+ {t("ui.admin.tenants.create.title", "테넌트 추가")} +

- 글로벌 운영 기준의 신규 테넌트를 등록합니다. + {t( + "msg.admin.tenants.create.subtitle", + "글로벌 운영 기준의 신규 테넌트를 등록합니다.", + )}

- Admin only + + {t("ui.common.badge.admin_only", "Admin only")} +
@@ -68,29 +80,40 @@ function TenantCreatePage() { - Tenant Profile + {t("ui.admin.tenants.create.profile.title", "Tenant Profile")} - 필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다. + {t( + "msg.admin.tenants.create.profile.subtitle", + "필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.", + )}
setName(e.target.value)} />
- + setSlug(e.target.value)} - placeholder="tenant-slug" + placeholder={t( + "ui.admin.tenants.create.form.slug_placeholder", + "tenant-slug", + )} />
- +