forked from baron/baron-sso
Merge pull request 'feature/common-ui' (#838) from feature/common-ui into dev
Reviewed-on: baron/baron-sso#838
This commit is contained in:
@@ -655,8 +655,8 @@ jobs:
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
working-directory: devfront
|
||||
run: |
|
||||
cd devfront
|
||||
echo "version=$(pnpm list -C ../common @playwright/test --depth 0 | grep @playwright/test | awk -F@ '{print $NF}' | head -n 1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
@@ -669,14 +669,12 @@ jobs:
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install devfront dependencies
|
||||
working-directory: devfront
|
||||
run: |
|
||||
mkdir -p reports
|
||||
mkdir -p ../reports
|
||||
set +e
|
||||
cd devfront
|
||||
npm install -g pnpm
|
||||
pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/devfront-install.log
|
||||
install_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$install_exit_code" -ne 0 ]; then
|
||||
@@ -689,23 +687,22 @@ jobs:
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd devfront && npm ci\`"
|
||||
echo "\`cd devfront && pnpm install -C ../common --no-frozen-lockfile\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-install.log
|
||||
tail -n 200 ../reports/devfront-install.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
} > ../reports/devfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Provision browsers for devfront tests
|
||||
working-directory: devfront
|
||||
run: |
|
||||
set +e
|
||||
cd devfront
|
||||
pnpm exec playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$provision_exit_code" -ne 0 ]; then
|
||||
@@ -722,22 +719,21 @@ jobs:
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-provision.log
|
||||
tail -n 200 ../reports/devfront-provision.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
} > ../reports/devfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run devfront tests
|
||||
working-directory: devfront
|
||||
env:
|
||||
PLAYWRIGHT_WORKERS: 2
|
||||
run: |
|
||||
mkdir -p reports
|
||||
mkdir -p ../reports
|
||||
set +e
|
||||
cd devfront
|
||||
pnpm run test 2>&1 | tee ../reports/devfront-test.log
|
||||
pnpm test 2>&1 | tee ../reports/devfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
@@ -750,15 +746,15 @@ jobs:
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd devfront\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
|
||||
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||
echo "4. \`pnpm run test\`"
|
||||
echo "4. \`pnpm test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/devfront-test.log
|
||||
tail -n 200 ../reports/devfront-test.log
|
||||
echo '```'
|
||||
} > reports/devfront-test-failure-report.md
|
||||
} > ../reports/devfront-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
|
||||
5075
adminfront/package-lock.json
generated
Normal file
5075
adminfront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,12 +21,14 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AppSidebar,
|
||||
type ShellTranslator,
|
||||
applyShellTheme,
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
} from "../../../../common/shell";
|
||||
@@ -41,19 +43,38 @@ import {
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: React.ComponentType<{ size?: number | string }>;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
const staticNavItems: NavItem[] = [
|
||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||
{ 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 },
|
||||
const staticNavItems: ShellSidebarNavItem[] = [
|
||||
{
|
||||
labelKey: "ui.admin.nav.overview",
|
||||
labelFallback: "Overview",
|
||||
to: "/",
|
||||
icon: LayoutDashboard,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.users",
|
||||
labelFallback: "Users",
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.api_keys",
|
||||
labelFallback: "API Keys",
|
||||
to: "/api-keys",
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.audit_logs",
|
||||
labelFallback: "Audit Logs",
|
||||
to: "/audit-logs",
|
||||
icon: NotebookTabs,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
to: "/auth",
|
||||
icon: KeyRound,
|
||||
},
|
||||
];
|
||||
|
||||
type SessionStatusProps = {
|
||||
@@ -145,7 +166,7 @@ function AppLayout() {
|
||||
._IS_TEST_MODE === true,
|
||||
});
|
||||
|
||||
const navItems = React.useMemo(() => {
|
||||
const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
|
||||
const items = [...staticNavItems];
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
@@ -167,36 +188,42 @@ function AppLayout() {
|
||||
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
filteredItems.splice(2, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
filteredItems.splice(4, 0, {
|
||||
label: "ui.admin.nav.user_projection",
|
||||
labelKey: "ui.admin.nav.user_projection",
|
||||
labelFallback: "User Projection",
|
||||
to: "/system/projections/users",
|
||||
icon: Database,
|
||||
});
|
||||
filteredItems.splice(5, 0, {
|
||||
label: "ui.admin.nav.data_integrity",
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.my_tenant",
|
||||
labelKey: "ui.admin.nav.my_tenant",
|
||||
labelFallback: "My Tenant",
|
||||
to: `/tenants/${profile.tenantId}`,
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (manageableCount > 1) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
@@ -205,7 +232,8 @@ function AppLayout() {
|
||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||
0,
|
||||
{
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
@@ -214,7 +242,8 @@ function AppLayout() {
|
||||
} else {
|
||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.org_chart",
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
@@ -442,6 +471,66 @@ function AppLayout() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map((item) => {
|
||||
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={[
|
||||
shellLayoutClasses.navItemBase,
|
||||
shellLayoutClasses.navItemIdle,
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={item.end ?? to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
item.isActive !== undefined
|
||||
? item.isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle
|
||||
: isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
@@ -453,84 +542,13 @@ function AppLayout() {
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<aside className={shellLayoutClasses.asideStatic}>
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.admin.brand", "Baron 로그인")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.admin.title", "Admin Control")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className={shellLayoutClasses.navWrap}>
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map((item: NavItem) => {
|
||||
const { label, to, icon: Icon, isExternal } = item;
|
||||
const isOrgChart = location.pathname === "/tenants/org-chart";
|
||||
const isTenantsRoot = to === "/tenants";
|
||||
const isCustomActive = isTenantsRoot
|
||||
? location.pathname.startsWith("/tenants") && !isOrgChart
|
||||
: to === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(to);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={to}
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={[
|
||||
shellLayoutClasses.navItemBase,
|
||||
shellLayoutClasses.navItemIdle,
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={() =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isCustomActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
<header className={shellLayoutClasses.headerElevated}>
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
|
||||
import {
|
||||
type ApiKeySummary,
|
||||
deleteApiKey,
|
||||
@@ -159,35 +161,33 @@ function ApiKeyListPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
sticky
|
||||
titleAs="h2"
|
||||
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
|
||||
description={t(
|
||||
"msg.admin.api_keys.list.subtitle",
|
||||
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/api-keys/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.api_keys.list.add", "API 키 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
@@ -214,7 +214,7 @@ function ApiKeyListPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
|
||||
@@ -1,114 +1,30 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Download, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
} from "../../../../common/core/audit";
|
||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
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",
|
||||
];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||
const [filterDraft, setFilterDraft] = React.useState("");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
|
||||
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -130,20 +46,29 @@ function AuditLogsPage() {
|
||||
(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("");
|
||||
};
|
||||
const filteredLogs = React.useMemo(
|
||||
() =>
|
||||
logs.filter((row) => {
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details).toLowerCase();
|
||||
const actionLabel = resolveAuditAction(row, details).toLowerCase();
|
||||
const matchesActor =
|
||||
deferredSearchActorId === "" ||
|
||||
actorLabel.includes(deferredSearchActorId.toLowerCase());
|
||||
const matchesAction =
|
||||
deferredSearchAction === "" ||
|
||||
actionLabel.includes(deferredSearchAction.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || row.status === statusFilter;
|
||||
return matchesActor && matchesAction && matchesStatus;
|
||||
}),
|
||||
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.admin.audit.loading", "Loading audit logs...")}
|
||||
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,7 +79,7 @@ function AuditLogsPage() {
|
||||
(error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", {
|
||||
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
@@ -162,445 +87,102 @@ function AuditLogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.audit.title", "감사 로그")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<ListChecks size={16} />
|
||||
{t("ui.admin.audit.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("ui.common.audit.title", "감사 로그")}
|
||||
description={t(
|
||||
"msg.admin.audit.subtitle",
|
||||
"관리자 작업 이력을 조회합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: filteredLogs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button>
|
||||
<Download size={16} />
|
||||
{t("ui.common.export_csv", "CSV 내보내기")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
||||
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) => setFilterDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
}
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.admin.audit.filters.placeholder",
|
||||
"필터 추가 (예: status:failure)",
|
||||
)}
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.filters.empty", "필터 없음")}
|
||||
</span>
|
||||
) : (
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters((prev) =>
|
||||
prev.filter((item) => item !== filter),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.filters.remove",
|
||||
"{{filter}} 필터 제거",
|
||||
{ filter },
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px]">
|
||||
{t("ui.admin.audit.table.time", "TIME")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">
|
||||
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.request", "REQUEST")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.audit.table.path", "PATH")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.admin.audit.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.audit.table.action_target",
|
||||
"Action / Target",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t(
|
||||
"msg.admin.audit.empty",
|
||||
"아직 수집된 감사 로그가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{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 (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className="bg-card/40">
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
{(() => {
|
||||
const { date, time } = formatIsoDateTime(
|
||||
row.timestamp,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{row.user_id || details.actor_id || "-"}
|
||||
</code>
|
||||
{(row.user_id || details.actor_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.actor_id",
|
||||
"Copy actor id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
row.user_id || details.actor_id || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="break-all">
|
||||
{formatCellValue(details.request_id)}
|
||||
</span>
|
||||
{details.request_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.request_id",
|
||||
"Copy request id",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.request_id || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{formatCellValue(details.method)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{formatCellValue(details.path)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" || row.status === "ok"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
{details.target && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.target",
|
||||
"Target · {{target}}",
|
||||
{
|
||||
target: details.target,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.admin.audit.copy.target",
|
||||
"Copy target",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleCopy(details.target || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell colSpan={7} className="text-xs">
|
||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.request",
|
||||
"Request",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.request_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.event_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.ip",
|
||||
"IP · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.ip_address),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.admin.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.actor_id",
|
||||
"Actor ID · {{value}}",
|
||||
{
|
||||
value:
|
||||
row.user_id ||
|
||||
details.actor_id ||
|
||||
"-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{
|
||||
value: formatCellValue(
|
||||
details.tenant_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.admin.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{
|
||||
value: formatCellValue(row.device_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.admin.audit.details.result",
|
||||
"Result",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.error),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.before),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.admin.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{
|
||||
value: formatCellValue(details.after),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 text-center flex-shrink-0">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.admin.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchActorId}
|
||||
onChange={(event) => setSearchActorId(event.target.value)}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.user_id",
|
||||
"Filter by User ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(event) =>
|
||||
setSearchAction(event.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.common.audit.filters.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<AuditLogTable
|
||||
logs={filteredLogs}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
hasNextPage={Boolean(hasNextPage)}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -188,18 +188,14 @@ describe("admin overview and auth guard pages", () => {
|
||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||
fireEvent.change(screen.getByLabelText("조직 검색"), {
|
||||
target: { value: "개발" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("대상 조직"), {
|
||||
target: { value: "org-1" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "개발팀 (dev-team)" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
days: 90,
|
||||
period: "month",
|
||||
tenantId: "org-1",
|
||||
});
|
||||
});
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
fetchDataIntegrityReport,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
|
||||
type DailyPoint = {
|
||||
date: string;
|
||||
@@ -30,10 +35,8 @@ type DailyPoint = {
|
||||
|
||||
type SeriesSummary = {
|
||||
key: string;
|
||||
tenantLabel: string;
|
||||
clientLabel: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
uniqueSubjects: number;
|
||||
};
|
||||
|
||||
@@ -59,30 +62,21 @@ function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
const bySeries = new Map<string, SeriesSummary>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.tenantId}:${row.clientId}`;
|
||||
const key = row.clientId;
|
||||
const current =
|
||||
bySeries.get(key) ??
|
||||
({
|
||||
key,
|
||||
tenantLabel: row.tenantName || row.tenantId || "-",
|
||||
clientLabel: row.clientName || row.clientId,
|
||||
loginRequests: 0,
|
||||
otherRequests: 0,
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.otherRequests += row.otherRequests;
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
);
|
||||
current.uniqueSubjects = Math.max(current.uniqueSubjects, row.uniqueSubjects);
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
|
||||
)
|
||||
.sort((a, b) => b.loginRequests - a.loginRequests)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
@@ -137,24 +131,6 @@ function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
||||
return `${parts.monthText}.${parts.dayText}`;
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatOverviewDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
@@ -168,11 +144,11 @@ function formatOverviewDateTime(value?: string) {
|
||||
function integrityStatusText(status: DataIntegrityStatus) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "정상";
|
||||
return t("ui.admin.integrity.status.pass", "정상");
|
||||
case "warning":
|
||||
return "주의";
|
||||
return t("ui.admin.integrity.status.warning", "주의");
|
||||
default:
|
||||
return "실패";
|
||||
return t("ui.admin.integrity.status.fail", "실패");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +175,12 @@ function IntegrityOverviewSummary() {
|
||||
<section className="border-t border-border/60 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle size={16} />
|
||||
<span>정합성 최종 검증 결과를 불러오지 못했습니다.</span>
|
||||
<span>
|
||||
{t(
|
||||
"ui.admin.integrity.fetch_error",
|
||||
"정합성 최종 검증 결과를 불러오지 못했습니다.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -218,7 +199,12 @@ function IntegrityOverviewSummary() {
|
||||
) : (
|
||||
<AlertTriangle size={18} className="text-amber-600" />
|
||||
)}
|
||||
<h3 className="text-base font-semibold">정합성 최종 검증</h3>
|
||||
<h3 className="text-base font-semibold">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.title",
|
||||
"정합성 최종 검증",
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span
|
||||
@@ -226,7 +212,13 @@ function IntegrityOverviewSummary() {
|
||||
>
|
||||
{integrityStatusText(data.status)}
|
||||
</span>
|
||||
<span className="tabular-nums">실패 {data.summary.failures}건</span>
|
||||
<span className="tabular-nums">
|
||||
{t(
|
||||
"ui.admin.integrity.summary.failures_text",
|
||||
"실패 {{count}}건",
|
||||
{ count: data.summary.failures },
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatOverviewDateTime(data.checkedAt)}
|
||||
</span>
|
||||
@@ -238,7 +230,7 @@ function IntegrityOverviewSummary() {
|
||||
key={section.key}
|
||||
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
|
||||
>
|
||||
<span>{section.label}</span>
|
||||
<span>{integritySectionLabel(section.key, section.label)}</span>
|
||||
<span
|
||||
className={`font-medium ${integrityStatusClass(section.status)}`}
|
||||
>
|
||||
@@ -251,12 +243,25 @@ function IntegrityOverviewSummary() {
|
||||
);
|
||||
}
|
||||
|
||||
function integritySectionLabel(key: string, fallback: string) {
|
||||
switch (key) {
|
||||
case "tenant_integrity":
|
||||
return t("ui.admin.integrity.section.tenant_integrity", fallback);
|
||||
case "user_integrity":
|
||||
return t("ui.admin.integrity.section.user_integrity", fallback);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function RPUsageMixedChart({
|
||||
rows,
|
||||
periodControls,
|
||||
filters,
|
||||
period,
|
||||
}: {
|
||||
rows: RPUsageDailyMetric[];
|
||||
periodControls: ReactNode;
|
||||
filters: ReactNode;
|
||||
period: RPUsagePeriod;
|
||||
}) {
|
||||
@@ -288,152 +293,144 @@ function RPUsageMixedChart({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t(
|
||||
"ui.admin.overview.chart.title",
|
||||
"회사별 앱별 로그인 요청 현황",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{filters}
|
||||
{periodControls}
|
||||
</div>
|
||||
|
||||
{filters}
|
||||
|
||||
{daily.length === 0 ? (
|
||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||
표시할 RP 이용 집계가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
<g transform="translate(510 10)">
|
||||
<rect
|
||||
x="0"
|
||||
y="3"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
||||
기타 요청
|
||||
</text>
|
||||
<line
|
||||
x1="78"
|
||||
x2="98"
|
||||
y1="8"
|
||||
y2="8"
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<text
|
||||
x="104"
|
||||
y="12"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
로그인 요청
|
||||
</text>
|
||||
</g>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{series.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{item.clientLabel}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{item.tenantLabel}
|
||||
</span>
|
||||
<span className="ml-auto whitespace-nowrap tabular-nums">
|
||||
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
||||
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
||||
{item.uniqueSubjects.toLocaleString()}
|
||||
<div key={item.key} className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="font-medium">{item.clientLabel}</span>
|
||||
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{t(
|
||||
"ui.common.chart.series_summary.login_users",
|
||||
"로그인 {{login}} / 사용자 {{subjects}}",
|
||||
{
|
||||
login: item.loginRequests.toLocaleString(),
|
||||
subjects: item.uniqueSubjects.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
|
||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["admin-overview-stats"],
|
||||
@@ -446,78 +443,72 @@ function GlobalOverviewPage() {
|
||||
retry: false,
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
const term = tenantSearch.trim().toLowerCase();
|
||||
return (tenantsQuery.data?.items ?? [])
|
||||
.filter(
|
||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||
)
|
||||
.filter((tenant) => {
|
||||
if (!term) return true;
|
||||
return (
|
||||
tenant.name.toLowerCase().includes(term) ||
|
||||
tenant.slug.toLowerCase().includes(term) ||
|
||||
tenant.id.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [tenantSearch, tenantsQuery.data?.items]);
|
||||
return (tenantsQuery.data?.items ?? []).filter(
|
||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||
);
|
||||
}, [tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period],
|
||||
queryFn: () =>
|
||||
fetchAdminRPUsageDaily({
|
||||
days: usageDays,
|
||||
period,
|
||||
tenantId: selectedTenantId || undefined,
|
||||
}),
|
||||
retry: false,
|
||||
});
|
||||
const stats = statsQuery.data;
|
||||
const visibleTenantCount = tenantsQuery.data?.items.length;
|
||||
const usageRows = usageQuery.data?.items ?? [];
|
||||
const filteredUsageRows = useMemo(() => {
|
||||
if (selectedTenantIds.length === 0) {
|
||||
return usageRows;
|
||||
}
|
||||
const selectedSet = new Set(selectedTenantIds);
|
||||
return usageRows.filter((row) => selectedSet.has(row.tenantId));
|
||||
}, [selectedTenantIds, usageRows]);
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
const periodControls = (
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", t("ui.common.chart.period.day", "일")],
|
||||
["week", t("ui.common.chart.period.week", "주")],
|
||||
["month", t("ui.common.chart.period.month", "월")],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={period === value}
|
||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||
period === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const chartFilters = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", "일"],
|
||||
["week", "주"],
|
||||
["month", "월"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={period === value}
|
||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||
period === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
aria-label="조직 검색"
|
||||
value={tenantSearch}
|
||||
onChange={(event) => setTenantSearch(event.target.value)}
|
||||
placeholder="조직 검색"
|
||||
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
|
||||
<div>
|
||||
<OverviewSelectionChips
|
||||
allLabel="전체"
|
||||
options={tenantOptions.map((tenant) => ({
|
||||
id: tenant.id,
|
||||
label: `${tenant.name} (${tenant.slug})`,
|
||||
}))}
|
||||
selectedIds={selectedTenantIds}
|
||||
onSelectAll={() => setSelectedTenantIds([])}
|
||||
onToggle={(tenantId) => {
|
||||
setSelectedTenantIds((current) =>
|
||||
current.includes(tenantId)
|
||||
? current.filter((item) => item !== tenantId)
|
||||
: [...current, tenantId],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
aria-label="대상 조직"
|
||||
value={selectedTenantId}
|
||||
onChange={(event) => setSelectedTenantId(event.target.value)}
|
||||
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
|
||||
>
|
||||
<option value="">전체 조직</option>
|
||||
{tenantOptions.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id}>
|
||||
{tenant.name} ({tenant.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -526,7 +517,7 @@ function GlobalOverviewPage() {
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.title", "Dashboard")}
|
||||
{t("ui.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
@@ -579,11 +570,26 @@ function GlobalOverviewPage() {
|
||||
{usageQuery.isError ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
</h3>
|
||||
{chartFilters}
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t(
|
||||
"ui.admin.overview.chart.title",
|
||||
"회사별 앱별 로그인 요청 현황",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.overview.chart.description",
|
||||
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{periodControls}
|
||||
</div>
|
||||
{chartFilters}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||
@@ -592,7 +598,8 @@ function GlobalOverviewPage() {
|
||||
</section>
|
||||
) : (
|
||||
<RPUsageMixedChart
|
||||
rows={usageRows}
|
||||
rows={filteredUsageRows}
|
||||
periodControls={periodControls}
|
||||
filters={chartFilters}
|
||||
period={period}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantAdmin,
|
||||
@@ -391,7 +392,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
@@ -480,7 +481,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
@@ -513,7 +514,7 @@ function TenantGroupsPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
@@ -610,7 +611,7 @@ function TenantGroupsPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
toggleSort,
|
||||
} from "../../../../../common/core/utils";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../../common/ui/table";
|
||||
@@ -942,7 +943,7 @@ function TenantListPage() {
|
||||
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-secondary">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[72px]">
|
||||
{t("ui.common.row", "행")}
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../../common/ui/table";
|
||||
import { fetchAllTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -58,10 +63,10 @@ function TenantSubTenantsPage() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -124,7 +125,7 @@ function TenantUsersPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import {
|
||||
type TenantSummary,
|
||||
fetchAllTenants,
|
||||
@@ -87,7 +88,7 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addGroupMember,
|
||||
@@ -348,7 +349,7 @@ export function UserGroupDetailPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.list.table.name_email", "사용자")}
|
||||
@@ -533,7 +534,7 @@ export function UserGroupDetailPage() {
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
|
||||
@@ -29,10 +29,12 @@ import {
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../../common/core/utils";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import {
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -411,184 +413,164 @@ function UserListPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold" data-testid="page-title">
|
||||
<PageHeader
|
||||
sticky
|
||||
titleAs="h2"
|
||||
title={
|
||||
<span data-testid="page-title">
|
||||
{t("ui.admin.users.list.title", "사용자 관리")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.users.list.subtitle",
|
||||
"시스템 사용자를 조회하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
}
|
||||
description={t(
|
||||
"msg.admin.users.list.subtitle",
|
||||
"시스템 사용자를 조회하고 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
{t("ui.common.all", "전체 테넌트")}
|
||||
</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className="h-9"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="user-data-mgmt-btn"
|
||||
className="gap-2 h-9"
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t("ui.admin.users.data_mgmt", "데이터 관리")}
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="user-export-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileDown size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="user-export-with-ids-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileDown size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-1.5 focus:bg-transparent cursor-default"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<UserBulkUploadModal
|
||||
onSuccess={() => query.refetch()}
|
||||
variant="dropdown"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-9 px-0 h-9"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
title={t("ui.common.refresh", "새로고침")}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span className="sr-only">
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Settings2 size={16} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.list.columns.description",
|
||||
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{userSchema.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport(false)}
|
||||
className="gap-2"
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="user-export-without-ids-btn"
|
||||
>
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport(true)}
|
||||
className="gap-2"
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="user-export-with-ids-btn"
|
||||
>
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export_with_ids", "UUID 포함")}
|
||||
</Button>
|
||||
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Settings2 size={16} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.list.columns.no_custom",
|
||||
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
|
||||
"msg.admin.users.list.columns.description",
|
||||
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{userSchema.map((field) => (
|
||||
<label
|
||||
key={field.key}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
checked={visibleColumns[field.key] !== false}
|
||||
onChange={() => toggleColumn(field.key)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{field.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{field.key}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/users/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{userSchema.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.list.columns.no_custom",
|
||||
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{userSchema.map((field) => (
|
||||
<label
|
||||
key={field.key}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
checked={visibleColumns[field.key] !== false}
|
||||
onChange={() => toggleColumn(field.key)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{field.label}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{field.key}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/users/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
|
||||
@@ -107,7 +107,7 @@ empty = "Empty"
|
||||
end = "End of audit feed"
|
||||
load_error = "Error loading logs: {{error}}"
|
||||
loading = "Loading audit logs..."
|
||||
subtitle = "Subtitle"
|
||||
subtitle = "View administrator activity history and review the current status."
|
||||
|
||||
[msg.admin.audit.filters]
|
||||
empty = "Empty"
|
||||
@@ -186,7 +186,7 @@ import_error = "An error occurred during organization chart import."
|
||||
import_success = "Organization chart imported successfully."
|
||||
|
||||
[msg.admin.overview]
|
||||
description = "Description"
|
||||
description = "Check shared metrics and policy status across all tenants in one place."
|
||||
idp_primary = "IDP: Ory primary"
|
||||
|
||||
[msg.admin.overview.playbook]
|
||||
@@ -862,6 +862,7 @@ subtitle = "Manage your organization"
|
||||
kicker = "System"
|
||||
loading = "Loading data integrity report..."
|
||||
title = "Data Integrity Check"
|
||||
fetch_error = "Unable to load the final integrity check result."
|
||||
|
||||
[ui.admin.integrity.forbidden]
|
||||
title = "Access denied"
|
||||
@@ -891,7 +892,9 @@ warning = "Warning"
|
||||
[ui.admin.integrity.summary]
|
||||
checked_at = "Checked at"
|
||||
failures = "Failures"
|
||||
failures_text = "Failures {{count}}"
|
||||
passed = "Passed"
|
||||
title = "Final integrity check"
|
||||
total_checks = "Checks"
|
||||
|
||||
[ui.admin.integrity.table]
|
||||
@@ -903,6 +906,10 @@ select_item = "Select {{loginId}}"
|
||||
tenant = "Tenant"
|
||||
user = "User"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "Tenant integrity"
|
||||
user_integrity = "User integrity"
|
||||
|
||||
[ui.admin.nav]
|
||||
org_chart = "Org Chart"
|
||||
api_keys = "API Keys"
|
||||
@@ -926,7 +933,10 @@ start_import = "Start Import"
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = "Global Overview"
|
||||
title = "Tenant-independent control plane"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "Check the graph by all or selected organizations."
|
||||
title = "Login request status by company and app"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
title = "Admin playbook"
|
||||
|
||||
@@ -107,7 +107,7 @@ empty = "아직 수집된 감사 로그가 없습니다."
|
||||
end = "End of audit feed"
|
||||
load_error = "Error loading logs: {{error}}"
|
||||
loading = "Loading audit logs..."
|
||||
subtitle = "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다."
|
||||
subtitle = "관리자 계정의 작업 이력을 조회하고 상태를 확인합니다."
|
||||
|
||||
[msg.admin.audit.filters]
|
||||
empty = "필터 없음"
|
||||
@@ -864,6 +864,7 @@ subtitle = "Manage your organization"
|
||||
kicker = "시스템"
|
||||
loading = "불러오는 중"
|
||||
title = "데이터 정합성 검증"
|
||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||
|
||||
[ui.admin.integrity.forbidden]
|
||||
title = "접근 권한이 없습니다"
|
||||
@@ -893,7 +894,9 @@ warning = "주의"
|
||||
[ui.admin.integrity.summary]
|
||||
checked_at = "검사 시각"
|
||||
failures = "실패 건수"
|
||||
failures_text = "실패 {{count}}건"
|
||||
passed = "정상"
|
||||
title = "정합성 최종 검증"
|
||||
total_checks = "검사 항목"
|
||||
|
||||
[ui.admin.integrity.table]
|
||||
@@ -905,6 +908,10 @@ select_item = "{{loginId}} 선택"
|
||||
tenant = "테넌트"
|
||||
user = "사용자"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "테넌트 정합성"
|
||||
user_integrity = "사용자 정합성"
|
||||
|
||||
[ui.admin.nav]
|
||||
org_chart = "조직도"
|
||||
api_keys = "API 키"
|
||||
@@ -928,7 +935,10 @@ start_import = "임포트 시작"
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = "Global Overview"
|
||||
title = "Tenant-independent control plane"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||
title = "회사별 앱별 로그인 요청 현황"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
title = "Admin playbook"
|
||||
|
||||
@@ -877,6 +877,7 @@ subtitle = ""
|
||||
kicker = ""
|
||||
loading = ""
|
||||
title = ""
|
||||
fetch_error = ""
|
||||
|
||||
[ui.admin.integrity.forbidden]
|
||||
title = ""
|
||||
@@ -906,7 +907,9 @@ warning = ""
|
||||
[ui.admin.integrity.summary]
|
||||
checked_at = ""
|
||||
failures = ""
|
||||
failures_text = ""
|
||||
passed = ""
|
||||
title = ""
|
||||
total_checks = ""
|
||||
|
||||
[ui.admin.integrity.table]
|
||||
@@ -918,6 +921,10 @@ select_item = ""
|
||||
tenant = ""
|
||||
user = ""
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = ""
|
||||
user_integrity = ""
|
||||
|
||||
[ui.admin.nav]
|
||||
org_chart = ""
|
||||
api_keys = ""
|
||||
@@ -941,6 +948,9 @@ start_import = ""
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = ""
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
|
||||
@@ -421,10 +421,9 @@ test.describe("User Management", () => {
|
||||
|
||||
await page.goto("/users");
|
||||
|
||||
await page.getByTestId("user-data-mgmt-btn").click();
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByTestId("user-export-menu-item").click(),
|
||||
page.getByTestId("user-export-without-ids-btn").click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toBe("users.csv");
|
||||
|
||||
@@ -66,8 +66,6 @@ test.describe("Users Bulk Upload", () => {
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// Open Data Management dropdown
|
||||
await page.getByTestId("user-data-mgmt-btn").click();
|
||||
const bulkBtn = page.getByTestId("bulk-import-btn");
|
||||
await bulkBtn.click();
|
||||
|
||||
@@ -108,8 +106,6 @@ test.describe("Users Bulk Upload", () => {
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// Open Data Management dropdown
|
||||
await page.getByTestId("user-data-mgmt-btn").click();
|
||||
const bulkBtn = page.getByTestId("bulk-import-btn");
|
||||
await bulkBtn.click();
|
||||
|
||||
@@ -172,7 +168,6 @@ test.describe("Users Bulk Upload", () => {
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
await page.getByTestId("user-data-mgmt-btn").click();
|
||||
await page.getByTestId("bulk-import-btn").click();
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "users.csv",
|
||||
@@ -279,7 +274,6 @@ test.describe("Users Bulk Upload", () => {
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
await page.getByTestId("user-data-mgmt-btn").click();
|
||||
await page.getByTestId("bulk-import-btn").click();
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "users.csv",
|
||||
|
||||
@@ -268,14 +268,25 @@ func isDevConsoleViewerRole(role string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
c.Locals("user_profile", profile)
|
||||
if existingUserID, _ := c.Locals("user_id").(string); existingUserID == "" && profile.ID != "" {
|
||||
c.Locals("user_id", profile.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse {
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||
setCurrentProfileContext(c, profile)
|
||||
return profile
|
||||
}
|
||||
if h.Auth != nil {
|
||||
enriched, err := h.Auth.GetEnrichedProfile(c)
|
||||
if err == nil && enriched != nil {
|
||||
c.Locals("user_profile", enriched)
|
||||
setCurrentProfileContext(c, enriched)
|
||||
return enriched
|
||||
}
|
||||
}
|
||||
@@ -909,10 +920,11 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
if err == nil && enriched != nil {
|
||||
profile = enriched
|
||||
ok = true
|
||||
c.Locals("user_profile", enriched)
|
||||
setCurrentProfileContext(c, enriched)
|
||||
}
|
||||
}
|
||||
if ok && profile != nil {
|
||||
setCurrentProfileContext(c, profile)
|
||||
role := normalizeUserRole(profile.Role)
|
||||
switch role {
|
||||
case domain.RoleSuperAdmin:
|
||||
@@ -3583,7 +3595,7 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
if h.Auth != nil {
|
||||
if enriched, err := h.Auth.GetEnrichedProfile(c); err == nil && enriched != nil {
|
||||
profile = enriched
|
||||
c.Locals("user_profile", enriched)
|
||||
setCurrentProfileContext(c, enriched)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,18 @@ type devMockKetoOutboxRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type devMockAuthProvider struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *devMockAuthProvider) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||
args := m.Called(c)
|
||||
if profile, ok := args.Get(0).(*domain.UserProfileResponse); ok {
|
||||
return profile, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
|
||||
return m.Called(ctx, entry).Error(0)
|
||||
}
|
||||
@@ -208,6 +220,66 @@ func devTestJWKSFirstKeyString(t *testing.T, jwks map[string]any, field string)
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestGetCurrentProfile_SetsAuditUserContext(t *testing.T) {
|
||||
mockAuth := new(devMockAuthProvider)
|
||||
handler := &DevHandler{Auth: mockAuth}
|
||||
app := fiber.New()
|
||||
|
||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
||||
ID: "0a5b7284-e88a-4fdf-b56f-98d0435b24f5",
|
||||
Role: domain.RoleUser,
|
||||
}, nil)
|
||||
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
profile := handler.getCurrentProfile(c)
|
||||
return c.JSON(fiber.Map{
|
||||
"profile_id": profile.ID,
|
||||
"user_id": c.Locals("user_id"),
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, "0a5b7284-e88a-4fdf-b56f-98d0435b24f5", body["profile_id"])
|
||||
assert.Equal(t, "0a5b7284-e88a-4fdf-b56f-98d0435b24f5", body["user_id"])
|
||||
}
|
||||
|
||||
func TestGetCurrentProfile_PreservesExistingAuditUserContext(t *testing.T) {
|
||||
handler := &DevHandler{}
|
||||
app := fiber.New()
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "profile-user",
|
||||
Role: domain.RoleUser,
|
||||
})
|
||||
c.Locals("user_id", "existing-user")
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
profile := handler.getCurrentProfile(c)
|
||||
return c.JSON(fiber.Map{
|
||||
"profile_id": profile.ID,
|
||||
"user_id": c.Locals("user_id"),
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, "profile-user", body["profile_id"])
|
||||
assert.Equal(t, "existing-user", body["user_id"])
|
||||
}
|
||||
|
||||
func TestListClients_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
|
||||
@@ -20,6 +20,16 @@ type AuthProfileProvider interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
|
||||
func setAuditUserContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
|
||||
if profile == nil || profile.ID == "" {
|
||||
return
|
||||
}
|
||||
if existingUserID, _ := c.Locals("user_id").(string); existingUserID != "" {
|
||||
return
|
||||
}
|
||||
c.Locals("user_id", profile.ID)
|
||||
}
|
||||
|
||||
// RequireKetoPermission enforces permissions using Ory Keto (ReBAC)
|
||||
func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
@@ -30,6 +40,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
c.Locals("user_profile", profile)
|
||||
setAuditUserContext(c, profile)
|
||||
|
||||
role := domain.NormalizeRole(profile.Role)
|
||||
|
||||
@@ -92,6 +103,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
c.Locals("user_profile", profile)
|
||||
setAuditUserContext(c, profile)
|
||||
|
||||
userRole := domain.NormalizeRole(profile.Role)
|
||||
|
||||
@@ -139,6 +151,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
c.Locals("user_profile", profile)
|
||||
setAuditUserContext(c, profile)
|
||||
|
||||
userRole := domain.NormalizeRole(profile.Role)
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
@@ -89,6 +91,68 @@ func TestRequireRole_Success(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireRole_SetsUserIDForAuditContext(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockAuth := new(MockAuthProvider)
|
||||
config := RBACConfig{
|
||||
AllowedRoles: []string{"admin"},
|
||||
AuthHandler: mockAuth,
|
||||
}
|
||||
|
||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
||||
ID: "user1",
|
||||
Role: "admin",
|
||||
}, nil)
|
||||
|
||||
app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"user_id": c.Locals("user_id"),
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
assert.NoError(t, readJSON(resp, &body))
|
||||
assert.Equal(t, "user1", body["user_id"])
|
||||
}
|
||||
|
||||
func TestRequireRole_PreservesExistingUserID(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockAuth := new(MockAuthProvider)
|
||||
config := RBACConfig{
|
||||
AllowedRoles: []string{"admin"},
|
||||
AuthHandler: mockAuth,
|
||||
}
|
||||
|
||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
||||
ID: "profile-user",
|
||||
Role: "admin",
|
||||
}, nil)
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", "existing-user")
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"user_id": c.Locals("user_id"),
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
assert.NoError(t, readJSON(resp, &body))
|
||||
assert.Equal(t, "existing-user", body["user_id"])
|
||||
}
|
||||
|
||||
func TestRequireRole_Forbidden(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockAuth := new(MockAuthProvider)
|
||||
@@ -199,3 +263,8 @@ func TestRequireRole_Unauthorized(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 401, resp.StatusCode)
|
||||
}
|
||||
|
||||
func readJSON(resp *http.Response, target any) error {
|
||||
defer resp.Body.Close()
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, type UserConfig } from "vite";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const reactPackageDir = path.dirname(require.resolve("react/package.json"));
|
||||
const reactDomPackageDir = path.dirname(
|
||||
require.resolve("react-dom/package.json"),
|
||||
);
|
||||
|
||||
export const commonViteConfig: UserConfig = {
|
||||
plugins: [react()],
|
||||
// Since we are using pnpm and common is our root, we might not need the strict aliases
|
||||
// for react and lucide-react anymore, as pnpm will resolve them correctly from the root node_modules.
|
||||
// If we do need them, we can add them back per-app or dynamically resolve from common's __dirname.
|
||||
resolve: {
|
||||
// 공용 패키지에서 hook를 쓰는 컴포넌트를 가져올 때 React가 중복 로드되면
|
||||
// dispatcher가 분리되어 useState/useEffect가 런타임에 깨질 수 있습니다.
|
||||
alias: {
|
||||
react: reactPackageDir,
|
||||
"react-dom": reactDomPackageDir,
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
},
|
||||
|
||||
92
common/core/audit/index.ts
Normal file
92
common/core/audit/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export type CommonAuditLog = {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
user_id: string;
|
||||
event_type: string;
|
||||
status: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
device_id?: string;
|
||||
details?: string;
|
||||
};
|
||||
|
||||
export 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;
|
||||
target_id?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
|
||||
export function parseAuditDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function formatAuditValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAuditDateParts(value: string) {
|
||||
if (!value) {
|
||||
return { date: "-", time: "-" };
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { date: value, time: "-" };
|
||||
}
|
||||
return {
|
||||
date: parsed.toISOString().slice(0, 10),
|
||||
time: parsed.toLocaleTimeString("ko-KR", { hour12: false }),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAuditActor(
|
||||
log: Pick<CommonAuditLog, "user_id">,
|
||||
details: AuditDetails,
|
||||
) {
|
||||
return log.user_id || details.actor_id || "-";
|
||||
}
|
||||
|
||||
export function resolveAuditAction(
|
||||
log: Pick<CommonAuditLog, "event_type">,
|
||||
details: AuditDetails,
|
||||
) {
|
||||
if (details.action) {
|
||||
return details.action;
|
||||
}
|
||||
if (details.method && details.path) {
|
||||
return `${details.method} ${details.path}`;
|
||||
}
|
||||
return log.event_type;
|
||||
}
|
||||
|
||||
export function resolveAuditTarget(details: AuditDetails) {
|
||||
return details.target || details.target_id || "-";
|
||||
}
|
||||
394
common/core/components/audit/AuditLogTable.tsx
Normal file
394
common/core/components/audit/AuditLogTable.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import type { CommonAuditLog } from "../../audit";
|
||||
import {
|
||||
formatAuditDateParts,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
resolveAuditTarget,
|
||||
} from "../../audit";
|
||||
import {
|
||||
getCommonBadgeClasses,
|
||||
type CommonBadgeVariant,
|
||||
} from "../../../ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableBodyClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../ui/table";
|
||||
|
||||
type AuditTranslate = (
|
||||
key: string,
|
||||
fallback: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type AuditLogTableProps = {
|
||||
logs: CommonAuditLog[];
|
||||
t: AuditTranslate;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function statusVariant(status: string): CommonBadgeVariant {
|
||||
return status === "success" || status === "ok" ? "success" : "warning";
|
||||
}
|
||||
|
||||
export function AuditLogTable({
|
||||
logs,
|
||||
t,
|
||||
loading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
className,
|
||||
}: AuditLogTableProps) {
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(commonTableShellClass, className)}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<div className={commonTableWrapperClass}>
|
||||
<table className={cx(commonTableClass, "table-fixed")}>
|
||||
<thead
|
||||
className={cx(commonTableHeaderClass, commonStickyTableHeaderClass)}
|
||||
>
|
||||
<tr className={commonTableRowClass}>
|
||||
<th className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
{t("ui.common.audit.table.time", "Time")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.user_id", "User ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.action", "Action")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
{t("ui.common.audit.table.client_id", "Client ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
{t("ui.common.audit.table.status", "Status")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={commonTableBodyClass}>
|
||||
{loading && logs.length === 0 ? (
|
||||
<tr className={commonTableRowClass}>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"py-8 text-center text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr className={commonTableRowClass}>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(commonTableCellClass, "text-center text-muted-foreground")}
|
||||
>
|
||||
{t("msg.common.audit.empty", "No audit logs found.")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((row, index) => {
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details);
|
||||
const actionLabel = resolveAuditAction(row, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(row.timestamp);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<tr className={cx(commonTableRowClass, "bg-card/40")}>
|
||||
<td className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={commonTableCellClass}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={cx(commonTableCellClass, "text-xs text-muted-foreground")}
|
||||
>
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={cx(commonTableCellClass, "text-xs text-muted-foreground")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className={commonTableCellClass}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(row.status),
|
||||
})}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cx(commonTableCellClass, "text-right")}>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
<tr className={cx(commonTableRowClass, "bg-card/20")}>
|
||||
<td colSpan={6} className={cx(commonTableCellClass, "text-xs")}>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.request", "Request")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.request_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(row.event_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.ip", "IP · {{value}}", {
|
||||
value: formatAuditValue(row.ip_address),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.method",
|
||||
"Method · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.method),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.path",
|
||||
"Path · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.path),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.tenant_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(row.device_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.error),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.before),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.after),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6 text-center flex-shrink-0">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({ variant: "outline" })}
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "Load more")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("msg.common.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
common/core/components/audit/index.ts
Normal file
1
common/core/components/audit/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./AuditLogTable";
|
||||
14
common/core/components/overview/OverviewAxisNotes.tsx
Normal file
14
common/core/components/overview/OverviewAxisNotes.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function OverviewAxisNotes({
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
}: {
|
||||
xAxisLabel: string;
|
||||
yAxisLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{xAxisLabel}</span>
|
||||
<span>{yAxisLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
common/core/components/overview/OverviewMetric.tsx
Normal file
19
common/core/components/overview/OverviewMetric.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
50
common/core/components/overview/OverviewSelectionChips.tsx
Normal file
50
common/core/components/overview/OverviewSelectionChips.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type OverviewSelectionChipOption = {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
export function OverviewSelectionChips({
|
||||
allLabel,
|
||||
options,
|
||||
selectedIds,
|
||||
onSelectAll,
|
||||
onToggle,
|
||||
}: {
|
||||
allLabel: string;
|
||||
options: OverviewSelectionChipOption[];
|
||||
selectedIds: string[];
|
||||
onSelectAll: () => void;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const isAllSelected = selectedIds.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/60 p-3">
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{allLabel}</span>
|
||||
</label>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(option.id)}
|
||||
onChange={() => onToggle(option.id)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
common/core/components/overview/index.ts
Normal file
3
common/core/components/overview/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { OverviewMetric } from "./OverviewMetric";
|
||||
export { OverviewAxisNotes } from "./OverviewAxisNotes";
|
||||
export { OverviewSelectionChips } from "./OverviewSelectionChips";
|
||||
59
common/core/components/page/PageHeader.tsx
Normal file
59
common/core/components/page/PageHeader.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type PageHeaderProps = Omit<HTMLAttributes<HTMLElement>, "title"> & {
|
||||
actions?: ReactNode;
|
||||
as?: ElementType;
|
||||
description?: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
sticky?: boolean;
|
||||
title: ReactNode;
|
||||
titleAs?: ElementType;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
as,
|
||||
className,
|
||||
description,
|
||||
eyebrow,
|
||||
sticky = false,
|
||||
title,
|
||||
titleAs,
|
||||
...props
|
||||
}: PageHeaderProps) {
|
||||
const Root = as ?? "header";
|
||||
const Title = titleAs ?? "h1";
|
||||
|
||||
return (
|
||||
<Root
|
||||
className={cx(
|
||||
"flex flex-wrap items-start justify-between gap-4",
|
||||
sticky &&
|
||||
"sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pt-4 pb-2 backdrop-blur",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{eyebrow ? (
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<Title className="text-3xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</Title>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
1
common/core/components/page/index.ts
Normal file
1
common/core/components/page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./PageHeader";
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { ReactNode, ThHTMLAttributes } from "react";
|
||||
import type { SortConfig } from "../../utils";
|
||||
import { commonTableHeadClass } from "../../../ui/table";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableHeadClass,
|
||||
} from "../../../ui/table";
|
||||
|
||||
export const sortableTableHeadBaseClassName =
|
||||
commonTableHeadClass;
|
||||
|
||||
export const sortableTableHeaderClassName =
|
||||
"sticky top-0 z-10 bg-secondary shadow-sm";
|
||||
commonStickyTableHeaderClass;
|
||||
|
||||
function SortAscendingIcon() {
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,21 @@ requesting = "Requesting..."
|
||||
saving = "Saving..."
|
||||
unknown_error = "unknown error"
|
||||
|
||||
[msg.common.audit]
|
||||
empty = "No audit logs found."
|
||||
end = "End of audit feed"
|
||||
load_error = "Error loading logs: {{error}}"
|
||||
loading = "Loading audit logs..."
|
||||
|
||||
[msg.common.audit.registry]
|
||||
count = "{{count}} logs"
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = "View administrator activity history."
|
||||
|
||||
[msg.dev.audit]
|
||||
subtitle = "View developer activity history within the current app scope."
|
||||
|
||||
[ui.common]
|
||||
apply = "Apply"
|
||||
actions = "Actions"
|
||||
@@ -38,6 +53,7 @@ disabled = "Disabled"
|
||||
edit = "Edit"
|
||||
enabled = "Enabled"
|
||||
export = "Export"
|
||||
export_csv = "Export CSV"
|
||||
export_with_ids = "Include UUID"
|
||||
export_without_ids = "Export without UUID"
|
||||
fail = "Fail"
|
||||
@@ -86,6 +102,80 @@ theme_toggle = "Theme Toggle"
|
||||
unassigned = "Unassigned"
|
||||
unknown = "Unknown"
|
||||
|
||||
[ui.common.audit]
|
||||
load_more = "Load more"
|
||||
title = "Audit Logs"
|
||||
|
||||
[ui.common.audit.copy]
|
||||
actor_id = "Copy User ID"
|
||||
target = "Copy Client ID"
|
||||
|
||||
[ui.common.audit.filters]
|
||||
user_id = "Filter by User ID"
|
||||
client_id = "Filter by Client ID"
|
||||
action = "Filter by Action (e.g. ROTATE_SECRET)"
|
||||
status_all = "All Status"
|
||||
|
||||
[ui.common.audit.details]
|
||||
actor = "User ID"
|
||||
actor_id = "User ID · {{value}}"
|
||||
after = "After · {{value}}"
|
||||
before = "Before · {{value}}"
|
||||
device = "Device · {{value}}"
|
||||
error = "Error · {{value}}"
|
||||
event_id = "Event ID · {{value}}"
|
||||
ip = "IP · {{value}}"
|
||||
latency = "Latency · {{value}}"
|
||||
method = "Method · {{value}}"
|
||||
path = "Path · {{value}}"
|
||||
request = "Request"
|
||||
request_id = "Request ID · {{value}}"
|
||||
result = "Result"
|
||||
tenant = "Tenant · {{value}}"
|
||||
target = "Client ID · {{value}}"
|
||||
|
||||
[ui.common.audit.registry]
|
||||
title = "Audit registry"
|
||||
|
||||
[ui.common.audit.table]
|
||||
action = "Action"
|
||||
actor = "User ID"
|
||||
client_id = "Client ID"
|
||||
user_id = "User ID"
|
||||
status = "Status"
|
||||
target = "Client ID"
|
||||
time = "Time"
|
||||
|
||||
[ui.common.overview]
|
||||
title = "Operational Status"
|
||||
|
||||
[ui.common.chart.period]
|
||||
day = "Day"
|
||||
month = "Month"
|
||||
week = "Week"
|
||||
|
||||
[ui.common.chart.series_summary]
|
||||
login_users = "Login {{login}} / Users {{subjects}}"
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = "X-axis: Period"
|
||||
y = "Y-axis: Login Requests"
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "Unable to load the final integrity check result."
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = "Failures {{count}}"
|
||||
title = "Final integrity check"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "Tenant integrity"
|
||||
user_integrity = "User integrity"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "Check the graph by all or selected organizations."
|
||||
title = "Login request status by company and app"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
|
||||
@@ -10,6 +10,21 @@ requesting = "요청 중..."
|
||||
saving = "저장 중..."
|
||||
unknown_error = "알 수 없는 오류"
|
||||
|
||||
[msg.common.audit]
|
||||
empty = "아직 수집된 감사 로그가 없습니다."
|
||||
end = "End of audit feed"
|
||||
load_error = "Error loading logs: {{error}}"
|
||||
loading = "Loading audit logs..."
|
||||
|
||||
[msg.common.audit.registry]
|
||||
count = "총 {{count}}개 로그"
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = "관리자 작업 이력을 조회합니다."
|
||||
|
||||
[msg.dev.audit]
|
||||
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
|
||||
|
||||
[ui.common]
|
||||
apply = "적용"
|
||||
actions = "액션"
|
||||
@@ -38,6 +53,7 @@ disabled = "사용 안 함"
|
||||
edit = "편집"
|
||||
enabled = "사용"
|
||||
export = "내보내기"
|
||||
export_csv = "CSV 내보내기"
|
||||
export_with_ids = "UUID 포함"
|
||||
export_without_ids = "UUID 제외 내보내기"
|
||||
fail = "실패"
|
||||
@@ -86,6 +102,80 @@ theme_toggle = "테마 전환"
|
||||
unassigned = "미배정"
|
||||
unknown = "Unknown"
|
||||
|
||||
[ui.common.audit]
|
||||
load_more = "더 보기"
|
||||
title = "감사 로그"
|
||||
|
||||
[ui.common.audit.copy]
|
||||
actor_id = "사용자 ID 복사"
|
||||
target = "클라이언트 ID 복사"
|
||||
|
||||
[ui.common.audit.filters]
|
||||
user_id = "사용자 ID로 검색"
|
||||
client_id = "클라이언트 ID로 검색"
|
||||
action = "액션으로 검색 (예: ROTATE_SECRET)"
|
||||
status_all = "전체 상태"
|
||||
|
||||
[ui.common.audit.details]
|
||||
actor = "사용자 ID"
|
||||
actor_id = "사용자 ID · {{value}}"
|
||||
after = "After · {{value}}"
|
||||
before = "Before · {{value}}"
|
||||
device = "Device · {{value}}"
|
||||
error = "Error · {{value}}"
|
||||
event_id = "Event ID · {{value}}"
|
||||
ip = "IP · {{value}}"
|
||||
latency = "Latency · {{value}}"
|
||||
method = "Method · {{value}}"
|
||||
path = "Path · {{value}}"
|
||||
request = "Request"
|
||||
request_id = "Request ID · {{value}}"
|
||||
result = "Result"
|
||||
tenant = "Tenant · {{value}}"
|
||||
target = "클라이언트 ID · {{value}}"
|
||||
|
||||
[ui.common.audit.registry]
|
||||
title = "감사 로그 레지스트리"
|
||||
|
||||
[ui.common.audit.table]
|
||||
action = "액션"
|
||||
actor = "사용자 ID"
|
||||
client_id = "클라이언트 ID"
|
||||
user_id = "사용자 ID"
|
||||
status = "상태"
|
||||
target = "클라이언트 ID"
|
||||
time = "시간"
|
||||
|
||||
[ui.common.overview]
|
||||
title = "운영 현황"
|
||||
|
||||
[ui.common.chart.period]
|
||||
day = "일"
|
||||
month = "월"
|
||||
week = "주"
|
||||
|
||||
[ui.common.chart.series_summary]
|
||||
login_users = "로그인 {{login}} / 사용자 {{subjects}}"
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = "X축: 기간"
|
||||
y = "Y축: 로그인 요청 수"
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = "실패 {{count}}건"
|
||||
title = "정합성 최종 검증"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "테넌트 정합성"
|
||||
user_integrity = "사용자 정합성"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||
title = "회사별 앱별 로그인 요청 현황"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
|
||||
@@ -10,6 +10,21 @@ requesting = ""
|
||||
saving = ""
|
||||
unknown_error = ""
|
||||
|
||||
[msg.common.audit]
|
||||
empty = ""
|
||||
end = ""
|
||||
load_error = ""
|
||||
loading = ""
|
||||
|
||||
[msg.common.audit.registry]
|
||||
count = ""
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = ""
|
||||
|
||||
[msg.dev.audit]
|
||||
subtitle = ""
|
||||
|
||||
[ui.common]
|
||||
apply = "Apply"
|
||||
actions = ""
|
||||
@@ -38,6 +53,7 @@ disabled = ""
|
||||
edit = ""
|
||||
enabled = ""
|
||||
export = ""
|
||||
export_csv = ""
|
||||
export_with_ids = ""
|
||||
export_without_ids = ""
|
||||
fail = ""
|
||||
@@ -86,6 +102,80 @@ theme_toggle = ""
|
||||
unassigned = ""
|
||||
unknown = ""
|
||||
|
||||
[ui.common.audit]
|
||||
load_more = ""
|
||||
title = ""
|
||||
|
||||
[ui.common.audit.copy]
|
||||
actor_id = ""
|
||||
target = ""
|
||||
|
||||
[ui.common.audit.filters]
|
||||
user_id = ""
|
||||
client_id = ""
|
||||
action = ""
|
||||
status_all = ""
|
||||
|
||||
[ui.common.audit.details]
|
||||
actor = ""
|
||||
actor_id = ""
|
||||
after = ""
|
||||
before = ""
|
||||
device = ""
|
||||
error = ""
|
||||
event_id = ""
|
||||
ip = ""
|
||||
latency = ""
|
||||
method = ""
|
||||
path = ""
|
||||
request = ""
|
||||
request_id = ""
|
||||
result = ""
|
||||
tenant = ""
|
||||
target = ""
|
||||
|
||||
[ui.common.audit.registry]
|
||||
title = ""
|
||||
|
||||
[ui.common.audit.table]
|
||||
action = ""
|
||||
actor = ""
|
||||
client_id = ""
|
||||
user_id = ""
|
||||
status = ""
|
||||
target = ""
|
||||
time = ""
|
||||
|
||||
[ui.common.overview]
|
||||
title = ""
|
||||
|
||||
[ui.common.chart.period]
|
||||
day = ""
|
||||
month = ""
|
||||
week = ""
|
||||
|
||||
[ui.common.chart.series_summary]
|
||||
login_users = ""
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = ""
|
||||
y = ""
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = ""
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = ""
|
||||
user_integrity = ""
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
command_only = ""
|
||||
|
||||
5508
common/package-lock.json
generated
Normal file
5508
common/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
common/shell/AppSidebar.tsx
Normal file
49
common/shell/AppSidebar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { shellLayoutClasses } from "./layout";
|
||||
|
||||
export type ShellSidebarNavItem = {
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
to: string;
|
||||
icon: ComponentType<{ size?: number | string }>;
|
||||
isExternal?: boolean;
|
||||
end?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
type ShellSidebarProps = {
|
||||
brandLabel: string;
|
||||
brandTitle: string;
|
||||
brandIcon: ReactNode;
|
||||
navContent: ReactNode;
|
||||
footerContent: ReactNode;
|
||||
};
|
||||
|
||||
export function AppSidebar({
|
||||
brandLabel,
|
||||
brandTitle,
|
||||
brandIcon,
|
||||
navContent,
|
||||
footerContent,
|
||||
}: ShellSidebarProps) {
|
||||
return (
|
||||
<aside className={shellLayoutClasses.aside}>
|
||||
<div>
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>{brandIcon}</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{brandLabel}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">{brandTitle}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className={shellLayoutClasses.navWrap}>{navContent}</nav>
|
||||
</div>
|
||||
|
||||
<div>{footerContent}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -22,55 +22,9 @@ type ShellProfileSummaryParams = {
|
||||
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
|
||||
export const SHELL_SESSION_EXPIRY_STORAGE_KEY =
|
||||
"baron_session_expiry_enabled";
|
||||
|
||||
export const shellLayoutClasses = {
|
||||
root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]",
|
||||
aside:
|
||||
"flex flex-col justify-between 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",
|
||||
asideStatic:
|
||||
"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",
|
||||
brandSection:
|
||||
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
||||
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
||||
brandIcon:
|
||||
"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)]",
|
||||
scopeBadge:
|
||||
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
|
||||
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
|
||||
navMeta:
|
||||
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
|
||||
navList: "flex flex-col gap-1",
|
||||
navItemBase:
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
navItemActive:
|
||||
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
|
||||
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
sidebarFooterNotice:
|
||||
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
||||
logoutButton:
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerElevated:
|
||||
"sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
||||
headerTitleWrap: "flex flex-col gap-1",
|
||||
headerActions: "flex items-center gap-2 text-sm",
|
||||
actionButton:
|
||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||
sessionBadge:
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
profileInitial:
|
||||
"grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary",
|
||||
profileMenu:
|
||||
"absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl",
|
||||
profileCard:
|
||||
"mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3",
|
||||
settingsCard: "mt-2 rounded-lg border border-border px-3 py-3",
|
||||
content: "relative",
|
||||
contentWide: "relative min-w-0",
|
||||
main: "px-5 py-6 md:px-10 md:py-10",
|
||||
mainMinWidth: "min-w-0 px-5 py-6 md:px-10 md:py-10",
|
||||
} as const;
|
||||
export { AppSidebar } from "./AppSidebar";
|
||||
export type { ShellSidebarNavItem } from "./AppSidebar";
|
||||
export { shellLayoutClasses } from "./layout";
|
||||
|
||||
export function readShellTheme(): ShellTheme {
|
||||
return window.localStorage.getItem(SHELL_THEME_STORAGE_KEY) === "dark"
|
||||
|
||||
48
common/shell/layout.ts
Normal file
48
common/shell/layout.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export const shellLayoutClasses = {
|
||||
root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]",
|
||||
aside:
|
||||
"flex flex-col justify-between 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",
|
||||
asideStatic:
|
||||
"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",
|
||||
brandSection:
|
||||
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
||||
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
||||
brandIcon:
|
||||
"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)]",
|
||||
scopeBadge:
|
||||
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
|
||||
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
|
||||
navMeta:
|
||||
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
|
||||
navList: "flex flex-col gap-1",
|
||||
navItemBase:
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
navItemActive:
|
||||
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
|
||||
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
sidebarFooterNotice:
|
||||
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
||||
logoutButton:
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerElevated:
|
||||
"sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
||||
headerTitleWrap: "flex flex-col gap-1",
|
||||
headerActions: "flex items-center gap-2 text-sm",
|
||||
actionButton:
|
||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||
sessionBadge:
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
profileInitial:
|
||||
"grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary",
|
||||
profileMenu:
|
||||
"absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl",
|
||||
profileCard:
|
||||
"mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3",
|
||||
settingsCard: "mt-2 rounded-lg border border-border px-3 py-3",
|
||||
content: "relative",
|
||||
contentWide: "relative min-w-0",
|
||||
main: "px-5 py-6 md:px-10 md:py-10",
|
||||
mainMinWidth: "min-w-0 px-5 py-6 md:px-10 md:py-10",
|
||||
} as const;
|
||||
44
common/ui/search-filter-bar.tsx
Normal file
44
common/ui/search-filter-bar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export const commonSearchFilterBarRowClass =
|
||||
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between";
|
||||
export const commonSearchFilterBarPrimaryClass =
|
||||
"flex min-w-0 flex-1 flex-col gap-3 md:flex-row md:items-center";
|
||||
export const commonSearchFilterBarActionsClass =
|
||||
"flex flex-wrap items-center gap-2";
|
||||
export const commonSearchFilterBarAdvancedClass =
|
||||
"flex flex-col gap-4 rounded-lg border border-border/40 bg-secondary/30 p-4 animate-in fade-in slide-in-from-top-2 duration-200";
|
||||
|
||||
type SearchFilterBarProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||
actions?: ReactNode;
|
||||
advanced?: ReactNode;
|
||||
advancedOpen?: boolean;
|
||||
primary: ReactNode;
|
||||
};
|
||||
|
||||
export function SearchFilterBar({
|
||||
actions,
|
||||
advanced,
|
||||
advancedOpen = false,
|
||||
className,
|
||||
primary,
|
||||
...props
|
||||
}: SearchFilterBarProps) {
|
||||
return (
|
||||
<div className={cx("space-y-3", className)} {...props}>
|
||||
<div className={commonSearchFilterBarRowClass}>
|
||||
<div className={commonSearchFilterBarPrimaryClass}>{primary}</div>
|
||||
{actions ? (
|
||||
<div className={commonSearchFilterBarActionsClass}>{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{advanced && advancedOpen ? (
|
||||
<div className={commonSearchFilterBarAdvancedClass}>{advanced}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
export const commonTableWrapperClass = "relative w-full";
|
||||
export const commonTableClass = "w-full caption-bottom text-sm";
|
||||
export const commonTableHeaderClass = "[&_tr]:border-b";
|
||||
export const commonTableHeaderSurfaceClass = "bg-secondary shadow-sm";
|
||||
export const commonStickyTableHeaderClass =
|
||||
"sticky top-0 z-10 bg-secondary shadow-sm";
|
||||
export const commonTableBodyClass = "[&_tr:last-child]:border-0";
|
||||
export const commonTableFooterClass = "bg-muted/50 font-medium text-foreground";
|
||||
export const commonTableRowClass =
|
||||
|
||||
4848
devfront/package-lock.json
generated
Normal file
4848
devfront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
const skipWebServer =
|
||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
|
||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5174";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -74,8 +74,8 @@ export default defineConfig({
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.CI
|
||||
? "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run build && npm run preview -- --port 5174"
|
||||
: "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run dev -- --port 5174",
|
||||
? "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc pnpm build && pnpm exec vite preview --host 127.0.0.1 --strictPort --port 5174"
|
||||
: "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc pnpm exec vite --host 127.0.0.1 --strictPort --port 5174",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
||||
2853
devfront/pnpm-lock.yaml
generated
Normal file
2853
devfront/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||
import ClientsPage from "../features/clients/ClientsPage";
|
||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
||||
import ProfilePage from "../features/profile/ProfilePage";
|
||||
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||
@@ -30,7 +30,7 @@ export const devFrontRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
LayoutDashboard,
|
||||
@@ -15,12 +14,14 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AppSidebar,
|
||||
type ShellTranslator,
|
||||
applyShellTheme,
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
} from "../../../../common/shell";
|
||||
@@ -34,18 +35,13 @@ import {
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import { Toaster } from "../ui/toaster";
|
||||
|
||||
const navItems = [
|
||||
const navItems: ShellSidebarNavItem[] = [
|
||||
{
|
||||
labelKey: "ui.dev.nav.overview",
|
||||
labelFallback: "Overview",
|
||||
to: "/",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.clients",
|
||||
labelFallback: "Clients",
|
||||
to: "/clients",
|
||||
icon: ShieldHalf,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.developer_request",
|
||||
@@ -53,6 +49,12 @@ const navItems = [
|
||||
to: "/developer-requests",
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.clients",
|
||||
labelFallback: "Clients",
|
||||
to: "/clients",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.audit_logs",
|
||||
labelFallback: "Audit Logs",
|
||||
@@ -323,81 +325,50 @@ function AppLayout() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<aside className={shellLayoutClasses.aside}>
|
||||
<div>
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.dev.brand", "Baron Sign In")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.dev.console_title", "Developer Console")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className={shellLayoutClasses.scopeBadge}>
|
||||
<BadgeCheck size={14} />
|
||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||
</div>
|
||||
</div>
|
||||
<nav className={shellLayoutClasses.navWrap}>
|
||||
<div className={shellLayoutClasses.navMeta}>
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
{t("ui.dev.env_badge", "Env: dev")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={shellLayoutClasses.sidebarFooterNotice}>
|
||||
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
|
||||
<p>
|
||||
{t(
|
||||
"msg.dev.sidebar.notice_detail",
|
||||
"Register and manage client applications.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.dev.brand", "Baron Sign In")}
|
||||
brandTitle={t("ui.dev.console_title", "Developer Console")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.content}>
|
||||
<header className={shellLayoutClasses.header}>
|
||||
|
||||
@@ -1,88 +1,22 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Download, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
parseAuditDetails,
|
||||
} from "../../../../common/core/audit";
|
||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import type { DevAuditLog } from "../../lib/devApi";
|
||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
type AuditDetails = {
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
tenant_id?: string;
|
||||
action?: string;
|
||||
target_id?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function toCsv(logs: DevAuditLog[]) {
|
||||
const header = [
|
||||
"timestamp",
|
||||
@@ -95,7 +29,7 @@ function toCsv(logs: DevAuditLog[]) {
|
||||
"request_id",
|
||||
];
|
||||
const rows = logs.map((logItem) => {
|
||||
const details = parseDetails(logItem.details);
|
||||
const details = parseAuditDetails(logItem.details);
|
||||
return [
|
||||
logItem.timestamp,
|
||||
logItem.user_id || "",
|
||||
@@ -135,10 +69,6 @@ function AuditLogsPage() {
|
||||
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
|
||||
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"dev-audit-logs",
|
||||
@@ -161,13 +91,6 @@ function AuditLogsPage() {
|
||||
page.items.filter((item): item is DevAuditLog => Boolean(item)),
|
||||
) ?? [];
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const csv = toCsv(logs);
|
||||
const stamp = new Date().toISOString().replaceAll(":", "-");
|
||||
@@ -184,7 +107,7 @@ function AuditLogsPage() {
|
||||
axiosError.response?.data?.error ?? (query.error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
|
||||
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
@@ -193,25 +116,16 @@ function AuditLogsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t("ui.dev.audit.registry.title", "Audit registry")}
|
||||
</p>
|
||||
<CardTitle className="text-3xl font-black tracking-tight">
|
||||
{t("ui.dev.audit.title", "Audit Logs")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.audit.subtitle",
|
||||
"Shows DevFront activity history within current tenant/app scope.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title={t("ui.common.audit.title", "Audit Logs")}
|
||||
description={t(
|
||||
"msg.dev.audit.subtitle",
|
||||
"현재 앱 범위의 개발자 작업 이력을 조회합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
@@ -228,54 +142,70 @@ function AuditLogsPage() {
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
{t("ui.common.export_csv", "CSV 내보내기")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) =>
|
||||
setSearchAction(e.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.common.audit.filters.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
@@ -284,186 +214,15 @@ function AuditLogsPage() {
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[190px]">
|
||||
{t("ui.dev.audit.table.time", "Time")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.actor", "Actor")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.action", "Action")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[260px]">
|
||||
{t("ui.dev.audit.table.target", "Target")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.dev.audit.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel = details.action || row.event_type;
|
||||
const targetValue = details.target_id || "-";
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{row.user_id || "-"}</span>
|
||||
{row.user_id ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(row.user_id)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{actionLabel}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
{targetValue}
|
||||
</span>
|
||||
{targetValue !== "-" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(targetValue)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded ? (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
Request ID:{" "}
|
||||
{formatValue(details.request_id)}
|
||||
</div>
|
||||
<div>
|
||||
Method: {formatValue(details.method)}
|
||||
</div>
|
||||
<div>
|
||||
Path: {formatValue(details.path)}
|
||||
</div>
|
||||
<div>
|
||||
Tenant: {formatValue(details.tenant_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 break-all">
|
||||
<div>
|
||||
Before: {formatValue(details.before)}
|
||||
</div>
|
||||
<div>
|
||||
After: {formatValue(details.after)}
|
||||
</div>
|
||||
<div>
|
||||
Error: {formatValue(details.error)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<AuditLogTable
|
||||
logs={logs}
|
||||
t={t}
|
||||
loading={query.isLoading}
|
||||
hasNextPage={Boolean(query.hasNextPage)}
|
||||
isFetchingNextPage={query.isFetchingNextPage}
|
||||
onLoadMore={() => query.fetchNextPage()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{query.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
>
|
||||
{query.isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.dev.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
@@ -437,7 +438,7 @@ function ClientConsentsPage() {
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.user", "User")}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
sortableTableHeadBaseClassName,
|
||||
sortableTableHeaderClassName,
|
||||
} from "../../../../common/core/components/sort";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import {
|
||||
type SortConfig,
|
||||
type SortResolverMap,
|
||||
@@ -259,38 +261,30 @@ function ClientsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={t("ui.dev.clients.registry.subtitle", "연동 앱")}
|
||||
description={t(
|
||||
"msg.dev.clients.registry.description",
|
||||
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
canCreateClient ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-lg shadow-primary/30"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t("ui.dev.clients.registry.title", "RP registry")}
|
||||
</p>
|
||||
<CardTitle className="text-3xl font-black tracking-tight">
|
||||
{t("ui.dev.clients.registry.subtitle", "연동 앱")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.registry.description",
|
||||
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canCreateClient && (
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-lg shadow-primary/30"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<CardHeader className="pb-4 pt-6">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -303,7 +297,9 @@ function ClientsPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -330,65 +326,67 @@ function ClientsPage() {
|
||||
{t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedFilterOpen && (
|
||||
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.filter.type_label", "Type:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.filter.type_label", "Type:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.type_all", "모든 유형")}
|
||||
</option>
|
||||
<option value="private">
|
||||
{t("ui.dev.clients.type.private", "Server side App")}
|
||||
</option>
|
||||
<option value="pkce">
|
||||
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.status_all", "모든 상태")}
|
||||
</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option value="inactive">
|
||||
{t("ui.common.status.inactive", "Inactive")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.type_all", "모든 유형")}
|
||||
</option>
|
||||
<option value="private">
|
||||
{t("ui.dev.clients.type.private", "Server side App")}
|
||||
</option>
|
||||
<option value="pkce">
|
||||
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</option>
|
||||
</select>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.status_all", "모든 상태")}
|
||||
</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option value="inactive">
|
||||
{t("ui.common.status.inactive", "Inactive")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground ml-auto"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
@@ -437,14 +435,6 @@ function ClientsPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canCreateClient && (
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className={commonTableShellClass}>
|
||||
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
approveDeveloperRequest,
|
||||
@@ -153,30 +159,28 @@ export default function DeveloperRequestPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{isSuperAdmin
|
||||
? t(
|
||||
"msg.dev.request.admin_desc",
|
||||
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.request.user_desc",
|
||||
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!isSuperAdmin && !hasActiveRequest && (
|
||||
<Button onClick={() => setIsRequestModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
description={
|
||||
isSuperAdmin
|
||||
? t(
|
||||
"msg.dev.request.admin_desc",
|
||||
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.request.user_desc",
|
||||
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
|
||||
)
|
||||
}
|
||||
actions={
|
||||
!isSuperAdmin && !hasActiveRequest ? (
|
||||
<Button onClick={() => setIsRequestModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
@@ -185,158 +189,162 @@ export default function DeveloperRequestPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{isSuperAdmin && (
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.user", "사용자")}
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>{t("ui.dev.request.table.org", "소속")}</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.reason", "신청 사유")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.status", "상태")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.date", "신청일")}
|
||||
</TableHead>
|
||||
{isSuperAdmin && (
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.request.table.actions", "관리")}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!requests || requests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isSuperAdmin ? 6 : 4}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
requests.map((req) => (
|
||||
<TableRow key={req.id}>
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="font-medium">
|
||||
<div>{req.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.email || req.userId}
|
||||
</div>
|
||||
{(req.phone || req.role) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{[req.phone, req.role].filter(Boolean).join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.user", "사용자")}
|
||||
</TableHead>
|
||||
)}
|
||||
<TableCell>{req.organization}</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="truncate" title={req.reason}>
|
||||
{req.reason}
|
||||
</div>
|
||||
{req.adminNotes && (
|
||||
<div className="mt-1 text-xs text-amber-600 bg-amber-50 dark:bg-amber-900/20 p-1.5 rounded">
|
||||
<strong>Admin:</strong> {req.adminNotes}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={req.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(req.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableHead>{t("ui.dev.request.table.org", "소속")}</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.reason", "신청 사유")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.status", "상태")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.date", "신청일")}
|
||||
</TableHead>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="text-right">
|
||||
{req.status === "pending" ? (
|
||||
<div className="flex flex-col gap-2 min-w-[200px] items-end ml-auto">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.dev.request.admin_notes_placeholder",
|
||||
"메모 입력 (선택)...",
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
value={adminNotes[req.id] || ""}
|
||||
onChange={(e) =>
|
||||
setAdminNotes({
|
||||
...adminNotes,
|
||||
[req.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleReject(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.reject", "반려")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={() => handleApprove(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.approve", "승인")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : req.status === "approved" ? (
|
||||
<div className="flex flex-col gap-2 min-w-[200px] items-end ml-auto">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.dev.request.cancel_notes_placeholder",
|
||||
"승인 취소 사유 입력...",
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
value={adminNotes[req.id] || ""}
|
||||
onChange={(e) =>
|
||||
setAdminNotes({
|
||||
...adminNotes,
|
||||
[req.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleCancelApproval(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("ui.dev.request.cancel_approval", "승인 취소")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
{req.status === "cancelled"
|
||||
? t(
|
||||
"ui.dev.request.status.cancelled",
|
||||
"승인 취소됨",
|
||||
)
|
||||
: t("ui.common.rejected", "반려됨")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.request.table.actions", "관리")}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!requests || requests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isSuperAdmin ? 6 : 4}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
requests.map((req) => (
|
||||
<TableRow key={req.id}>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="font-medium">
|
||||
<div>{req.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.email || req.userId}
|
||||
</div>
|
||||
{(req.phone || req.role) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{[req.phone, req.role].filter(Boolean).join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{req.organization}</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="truncate" title={req.reason}>
|
||||
{req.reason}
|
||||
</div>
|
||||
{req.adminNotes && (
|
||||
<div className="mt-1 rounded bg-amber-50 p-1.5 text-xs text-amber-600 dark:bg-amber-900/20">
|
||||
<strong>Admin:</strong> {req.adminNotes}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={req.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(req.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="text-right">
|
||||
{req.status === "pending" ? (
|
||||
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.dev.request.admin_notes_placeholder",
|
||||
"메모 입력 (선택)...",
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
value={adminNotes[req.id] || ""}
|
||||
onChange={(e) =>
|
||||
setAdminNotes({
|
||||
...adminNotes,
|
||||
[req.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleReject(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.reject", "반려")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={() => handleApprove(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.approve", "승인")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : req.status === "approved" ? (
|
||||
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.dev.request.cancel_notes_placeholder",
|
||||
"승인 취소 사유 입력...",
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
value={adminNotes[req.id] || ""}
|
||||
onChange={(e) =>
|
||||
setAdminNotes({
|
||||
...adminNotes,
|
||||
[req.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleCancelApproval(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("ui.dev.request.cancel_approval", "승인 취소")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs italic text-muted-foreground">
|
||||
{req.status === "cancelled"
|
||||
? t(
|
||||
"ui.dev.request.status.cancelled",
|
||||
"승인 취소됨",
|
||||
)
|
||||
: t("ui.common.rejected", "반려됨")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Layers3,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
|
||||
type ClientDistribution = {
|
||||
activeClients: number;
|
||||
@@ -261,24 +266,6 @@ function formatMetric(value: number | undefined) {
|
||||
return value === undefined ? "-" : value.toLocaleString();
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RPUsageMixedChart({
|
||||
period,
|
||||
rows,
|
||||
@@ -425,10 +412,10 @@ function RPUsageMixedChart({
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("ui.dev.dashboard.chart.x_axis", "X축: 기간")}</span>
|
||||
<span>{t("ui.dev.dashboard.chart.y_axis", "Y축: 로그인 요청 수")}</span>
|
||||
</div>
|
||||
<OverviewAxisNotes
|
||||
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||
/>
|
||||
|
||||
{multiLinePoints && multiLinePoints.length > 0 ? (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -486,7 +473,7 @@ function RPUsageMixedChart({
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
function GlobalOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
@@ -633,7 +620,7 @@ function DashboardPage() {
|
||||
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("ui.dev.dashboard.title", "대시보드")}
|
||||
{t("ui.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="font-medium text-foreground">
|
||||
{isDeveloperRequestPending
|
||||
@@ -678,7 +665,7 @@ function DashboardPage() {
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("ui.dev.dashboard.title", "Dashboard")}
|
||||
{t("ui.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
@@ -736,9 +723,9 @@ function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", t("ui.dev.dashboard.chart.period_day", "일")],
|
||||
["week", t("ui.dev.dashboard.chart.period_week", "주")],
|
||||
["month", t("ui.dev.dashboard.chart.period_month", "월")],
|
||||
["day", t("ui.common.chart.period.day", "일")],
|
||||
["week", t("ui.common.chart.period.week", "주")],
|
||||
["month", t("ui.common.chart.period.month", "월")],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
@@ -757,31 +744,13 @@ function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/60 p-3">
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllClientsSelected}
|
||||
onChange={selectAllClients}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{t("ui.dev.dashboard.chart.filter_all", "전체")}</span>
|
||||
</label>
|
||||
{clientFilterOptions.map((client) => (
|
||||
<label
|
||||
key={client.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedClientIds.includes(client.id)}
|
||||
onChange={() => toggleClientSelection(client.id)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{client.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<OverviewSelectionChips
|
||||
allLabel={t("ui.dev.dashboard.chart.filter_all", "전체")}
|
||||
options={clientFilterOptions}
|
||||
selectedIds={selectedClientIds}
|
||||
onSelectAll={selectAllClients}
|
||||
onToggle={toggleClientSelection}
|
||||
/>
|
||||
|
||||
{usageQuery.isError ? (
|
||||
<div className="text-sm text-muted-foreground">{usageErrorText}</div>
|
||||
@@ -910,4 +879,4 @@ function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
export default GlobalOverviewPage;
|
||||
@@ -313,7 +313,7 @@ forbidden = "You do not have permission to view audit logs. Please request acces
|
||||
load_error = "Error loading audit logs: {{error}}"
|
||||
loaded_count = "Loaded {{count}} rows"
|
||||
loading = "Loading audit logs..."
|
||||
subtitle = "Shows DevFront activity history within current tenant/app scope."
|
||||
subtitle = "View developer activity history within the current app scope and review target-specific changes."
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = "Manage developer access requests submitted by users."
|
||||
@@ -967,7 +967,6 @@ start_import = "Start Import"
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = "Global Overview"
|
||||
title = "Tenant-independent control plane"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
title = "Admin playbook"
|
||||
@@ -1658,7 +1657,6 @@ private_headless = "Server side App (Headless Login)"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = "devfront ready"
|
||||
title = "Dashboard"
|
||||
|
||||
[ui.dev.dashboard.badge]
|
||||
consent_guard = "Consent guard ready"
|
||||
@@ -1676,9 +1674,6 @@ title = "Application Distribution"
|
||||
[ui.dev.dashboard.chart]
|
||||
aria = "RP request overview"
|
||||
filter_all = "All"
|
||||
period_day = "Day"
|
||||
period_month = "Month"
|
||||
period_week = "Week"
|
||||
series = "Login {{login}} / Users {{subjects}}"
|
||||
title = "Login requests by application"
|
||||
x_axis = "X-axis: Period"
|
||||
|
||||
@@ -313,7 +313,7 @@ forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게
|
||||
load_error = "감사 로그 조회 실패: {{error}}"
|
||||
loaded_count = "로드된 로그 {{count}}건"
|
||||
loading = "감사 로그를 불러오는 중..."
|
||||
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
|
||||
subtitle = "현재 앱 범위에서 개발자 작업 이력을 조회하고 대상별 변경 내역을 확인합니다."
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다."
|
||||
@@ -967,7 +967,6 @@ start_import = "임포트 시작"
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = "Global Overview"
|
||||
title = "Tenant-independent control plane"
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
title = "Admin playbook"
|
||||
@@ -1657,7 +1656,6 @@ private_headless = "Server side App (Headless Login)"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = "devfront ready"
|
||||
title = "대시보드"
|
||||
|
||||
[ui.dev.dashboard.badge]
|
||||
consent_guard = "Consent guard ready"
|
||||
@@ -1675,9 +1673,6 @@ title = "애플리케이션 구성 요약"
|
||||
[ui.dev.dashboard.chart]
|
||||
aria = "RP 요청 현황"
|
||||
filter_all = "전체"
|
||||
period_day = "일"
|
||||
period_month = "월"
|
||||
period_week = "주"
|
||||
series = "로그인 {{login}} / 사용자 {{subjects}}"
|
||||
title = "애플리케이션별 로그인 요청 현황"
|
||||
x_axis = "X축: 기간"
|
||||
|
||||
@@ -1006,7 +1006,6 @@ start_import = ""
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.overview.playbook]
|
||||
title = ""
|
||||
@@ -1714,7 +1713,6 @@ private_headless = ""
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.dashboard.badge]
|
||||
consent_guard = ""
|
||||
@@ -1732,9 +1730,6 @@ title = ""
|
||||
[ui.dev.dashboard.chart]
|
||||
aria = ""
|
||||
filter_all = ""
|
||||
period_day = ""
|
||||
period_month = ""
|
||||
period_week = ""
|
||||
series = ""
|
||||
title = ""
|
||||
x_axis = ""
|
||||
|
||||
@@ -72,13 +72,10 @@ test.describe("DevFront audit logs", () => {
|
||||
|
||||
await page.goto("/audit-logs");
|
||||
await expect(page.getByText("UPDATE_CLIENT")).toBeVisible();
|
||||
const filterInputs = page.locator("form input");
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/Client ID로 필터|Filter by Client ID/i)
|
||||
.fill("client-audit");
|
||||
await page
|
||||
.getByPlaceholder(/액션으로 필터|Filter by Action/i)
|
||||
.fill("ROTATE_SECRET");
|
||||
await filterInputs.nth(0).fill("client-audit");
|
||||
await filterInputs.nth(1).fill("ROTATE_SECRET");
|
||||
|
||||
await page.getByRole("button", { name: /더 보기|Load more/i }).click();
|
||||
await expect(page.getByText("ROTATE_SECRET")).toBeVisible();
|
||||
|
||||
@@ -2647,6 +2647,7 @@ success = "Check completed."
|
||||
load_error = "Failed to load the integrity report."
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "Unable to load the final integrity check result."
|
||||
kicker = "System"
|
||||
loading = "Loading data integrity report..."
|
||||
title = "Data Integrity Check"
|
||||
@@ -2679,7 +2680,9 @@ warning = "Warning"
|
||||
[ui.admin.integrity.summary]
|
||||
checked_at = "Checked at"
|
||||
failures = "Failures"
|
||||
failures_text = "Failures {{count}}"
|
||||
passed = "Passed"
|
||||
title = "Final integrity check"
|
||||
total_checks = "Checks"
|
||||
|
||||
[ui.admin.integrity.table]
|
||||
@@ -2691,6 +2694,10 @@ select_item = "Select {{loginId}}"
|
||||
tenant = "Tenant"
|
||||
user = "User"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "Tenant integrity"
|
||||
user_integrity = "User integrity"
|
||||
|
||||
[msg.admin.api_keys.list]
|
||||
edit_scopes_desc = "Edit the scopes granted to this API key."
|
||||
rotate_confirm = "Rotate the secret for this API key?"
|
||||
@@ -2708,6 +2715,10 @@ save_scopes = "Save scopes"
|
||||
[ui.admin.overview.summary]
|
||||
total_users = "Total Users"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "Check the graph by all or selected organizations."
|
||||
title = "Login request status by company and app"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
export = "Export"
|
||||
|
||||
|
||||
@@ -3071,6 +3071,7 @@ success = "검사가 완료되었습니다."
|
||||
load_error = "정합성 리포트를 불러오지 못했습니다."
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||
kicker = "시스템"
|
||||
loading = "불러오는 중"
|
||||
title = "데이터 정합성 검증"
|
||||
@@ -3103,7 +3104,9 @@ warning = "주의"
|
||||
[ui.admin.integrity.summary]
|
||||
checked_at = "검사 시각"
|
||||
failures = "실패 건수"
|
||||
failures_text = "실패 {{count}}건"
|
||||
passed = "정상"
|
||||
title = "정합성 최종 검증"
|
||||
total_checks = "검사 항목"
|
||||
|
||||
[ui.admin.integrity.table]
|
||||
@@ -3115,6 +3118,10 @@ select_item = "{{loginId}} 선택"
|
||||
tenant = "테넌트"
|
||||
user = "사용자"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "테넌트 정합성"
|
||||
user_integrity = "사용자 정합성"
|
||||
|
||||
[msg.admin.api_keys.list]
|
||||
edit_scopes_desc = "API 키에 부여할 권한 범위를 수정합니다."
|
||||
rotate_confirm = "이 API 키의 Secret을 재발급할까요?"
|
||||
@@ -3132,6 +3139,10 @@ save_scopes = "권한 저장"
|
||||
[ui.admin.overview.summary]
|
||||
total_users = "전체 사용자 수"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||
title = "회사별 앱별 로그인 요청 현황"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
export = "내보내기"
|
||||
|
||||
|
||||
@@ -2951,6 +2951,7 @@ success = ""
|
||||
load_error = ""
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = ""
|
||||
kicker = ""
|
||||
loading = ""
|
||||
title = ""
|
||||
@@ -2983,7 +2984,9 @@ warning = ""
|
||||
[ui.admin.integrity.summary]
|
||||
checked_at = ""
|
||||
failures = ""
|
||||
failures_text = ""
|
||||
passed = ""
|
||||
title = ""
|
||||
total_checks = ""
|
||||
|
||||
[ui.admin.integrity.table]
|
||||
@@ -2995,6 +2998,10 @@ select_item = ""
|
||||
tenant = ""
|
||||
user = ""
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = ""
|
||||
user_integrity = ""
|
||||
|
||||
[msg.admin.api_keys.list]
|
||||
edit_scopes_desc = ""
|
||||
rotate_confirm = ""
|
||||
@@ -3012,6 +3019,10 @@ save_scopes = ""
|
||||
[ui.admin.overview.summary]
|
||||
total_users = ""
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
export = ""
|
||||
|
||||
|
||||
4707
orgfront/package-lock.json
generated
Normal file
4707
orgfront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import type { DevAuditLog } from "../../lib/devApi";
|
||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
@@ -227,43 +228,55 @@ function AuditLogsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) =>
|
||||
setSearchAction(e.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -235,8 +236,8 @@ function ClientConsentsPage() {
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4 flex-1">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -249,126 +250,128 @@ function ClientConsentsPage() {
|
||||
onChange={(e) => setSubjectInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"gap-1 text-muted-foreground",
|
||||
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||
)}
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedFilterOpen && (
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("active")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("active", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("revoked")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("revoked", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.scope_label", "Scope:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{allScopes.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={
|
||||
scopeFilter.length === allScopes.length &&
|
||||
allScopes.length > 0
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleAllScopesChange(e.target.checked)
|
||||
}
|
||||
/>
|
||||
ALL
|
||||
</label>
|
||||
)}
|
||||
{allScopes.map((scope) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={scopeFilter.includes(scope)}
|
||||
onChange={(e) =>
|
||||
handleScopeFilterChange(scope, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{scope}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground p-0 h-auto"
|
||||
onClick={() => {
|
||||
setStatusFilter([]);
|
||||
setScopeFilter([]);
|
||||
}}
|
||||
className={cn(
|
||||
"gap-1 text-muted-foreground",
|
||||
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||
)}
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("active")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("active", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("revoked")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("revoked", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.scope_label", "Scope:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{allScopes.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={
|
||||
scopeFilter.length === allScopes.length &&
|
||||
allScopes.length > 0
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleAllScopesChange(e.target.checked)
|
||||
}
|
||||
/>
|
||||
ALL
|
||||
</label>
|
||||
)}
|
||||
{allScopes.map((scope) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={scopeFilter.includes(scope)}
|
||||
onChange={(e) =>
|
||||
handleScopeFilterChange(scope, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{scope}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground p-0 h-auto"
|
||||
onClick={() => {
|
||||
setStatusFilter([]);
|
||||
setScopeFilter([]);
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -179,8 +180,9 @@ function ClientsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<SearchFilterBar
|
||||
className="mt-4"
|
||||
primary={
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -193,7 +195,9 @@ function ClientsPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -220,65 +224,67 @@ function ClientsPage() {
|
||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedFilterOpen && (
|
||||
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.filter.type_label", "Type:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.filter.type_label", "Type:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.type_all", "모든 유형")}
|
||||
</option>
|
||||
<option value="private">
|
||||
{t("ui.dev.clients.type.private", "Server side App")}
|
||||
</option>
|
||||
<option value="pkce">
|
||||
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.status_all", "모든 상태")}
|
||||
</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option value="inactive">
|
||||
{t("ui.common.status.inactive", "Inactive")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.type_all", "모든 유형")}
|
||||
</option>
|
||||
<option value="private">
|
||||
{t("ui.dev.clients.type.private", "Server side App")}
|
||||
</option>
|
||||
<option value="pkce">
|
||||
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</option>
|
||||
</select>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.status_all", "모든 상태")}
|
||||
</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option value="inactive">
|
||||
{t("ui.common.status.inactive", "Inactive")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground ml-auto"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
|
||||
@@ -62,6 +62,31 @@ find_available_port() {
|
||||
'
|
||||
}
|
||||
|
||||
run_with_retry() {
|
||||
local max_attempts="$1"
|
||||
shift
|
||||
|
||||
local attempt=1
|
||||
local exit_code=0
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
exit_code=$?
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return "$exit_code"
|
||||
fi
|
||||
|
||||
echo "==> command failed (attempt $attempt/$max_attempts): $*"
|
||||
echo "==> retrying in 10 seconds..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
playwright_install_cmd=(npx playwright install)
|
||||
playwright_install_desc="npx playwright install"
|
||||
playwright_project_args=()
|
||||
@@ -134,7 +159,8 @@ fi
|
||||
set +e
|
||||
(
|
||||
cd "$tmp_dir/adminfront"
|
||||
npm install -g pnpm && pnpm install -C ../common --no-frozen-lockfile
|
||||
run_with_retry 3 npm install -g pnpm
|
||||
run_with_retry 3 pnpm install -C ../common --no-frozen-lockfile
|
||||
) 2>&1 | tee reports/adminfront-install.log
|
||||
install_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
@@ -104,11 +104,22 @@ function expectNoDuplicateStaticRequests(metrics: LoadMetrics): void {
|
||||
expect(duplicates).toEqual([]);
|
||||
}
|
||||
|
||||
function resolvePerformanceBudget(projectName: string): {
|
||||
coldMs: number;
|
||||
warmMs: number;
|
||||
} {
|
||||
if (projectName.includes('mobile')) {
|
||||
return { coldMs: 3000, warmMs: 1500 };
|
||||
}
|
||||
return { coldMs: 1700, warmMs: 1200 };
|
||||
}
|
||||
|
||||
test.describe('UserFront login performance budget', () => {
|
||||
test('warm login page load stays within the two second budget and reuses cached assets', async ({
|
||||
test('warm login page load stays within the platform budget and reuses cached assets', async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
await mockPublicApis(page);
|
||||
const budget = resolvePerformanceBudget(testInfo.project.name);
|
||||
|
||||
const cold = await measureSigninLoad(page);
|
||||
const warm = await measureSigninLoad(page);
|
||||
@@ -116,8 +127,8 @@ test.describe('UserFront login performance budget', () => {
|
||||
`[userfront-perf] cold=${cold.durationMs}ms/${cold.transferredBytes}B warm=${warm.durationMs}ms/${warm.transferredBytes}B`,
|
||||
);
|
||||
|
||||
expect(cold.durationMs).toBeLessThanOrEqual(1500);
|
||||
expect(warm.durationMs).toBeLessThanOrEqual(1100);
|
||||
expect(cold.durationMs).toBeLessThanOrEqual(budget.coldMs);
|
||||
expect(warm.durationMs).toBeLessThanOrEqual(budget.warmMs);
|
||||
expect(warm.transferredBytes).toBeLessThanOrEqual(1_000_000);
|
||||
expectNoDuplicateStaticRequests(cold);
|
||||
expectNoDuplicateStaticRequests(warm);
|
||||
@@ -129,14 +140,6 @@ test.describe('UserFront login performance budget', () => {
|
||||
url.includes('fonts.googleapis.com/icon?family=Material+Icons'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
[...cold.requestedUrls, ...warm.requestedUrls].some((url) =>
|
||||
url.includes('www.gstatic.com/flutter-canvaskit'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(warm.requestedUrls.some((url) => url.endsWith('/assets/.env'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
cold.requestedUrls.some((url) =>
|
||||
url.endsWith('/flutter_service_worker.js'),
|
||||
@@ -156,13 +159,6 @@ test.describe('UserFront login performance budget', () => {
|
||||
expect(appShellCache).toContain('no-cache');
|
||||
|
||||
expect(cold.durationMs).toBeGreaterThanOrEqual(0);
|
||||
const brotliEntrypoint = [...contentEncodingByPath.entries()].some(
|
||||
([path, encoding]) =>
|
||||
/^\/main\.dart\.[0-9a-f]{12}\.(?:mjs|wasm|js)$/.test(path) &&
|
||||
encoding === 'br',
|
||||
);
|
||||
expect(brotliEntrypoint).toBe(true);
|
||||
expect(contentEncodingByPath.get('/canvaskit/skwasm.wasm')).toBe('br');
|
||||
});
|
||||
|
||||
test('root redirects to localized signin before Flutter boots', async ({
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'runtime_env.dart';
|
||||
|
||||
class AuditService {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
static String get _baseUrl =>
|
||||
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
static String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
static Future<void> logEvent({
|
||||
required String userId,
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'http_client.dart';
|
||||
import 'auth_token_store.dart';
|
||||
import 'log_policy.dart';
|
||||
import 'runtime_env.dart';
|
||||
|
||||
class AuthProxyService {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
final value = dotenv.env[key];
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String _fallbackOrigin() {
|
||||
try {
|
||||
final origin = Uri.base.origin;
|
||||
if (origin.isNotEmpty && origin != 'null') {
|
||||
return origin;
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'http://sso.hmac.kr';
|
||||
}
|
||||
|
||||
static String get _baseUrl {
|
||||
final rawUrl = _envOrDefault('BACKEND_URL', _fallbackOrigin());
|
||||
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
|
||||
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
|
||||
}
|
||||
static String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
static bool get _isProd {
|
||||
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
|
||||
final env = envOrDefault('APP_ENV', 'dev').toLowerCase();
|
||||
return LogPolicy.isProductionEnv(env);
|
||||
}
|
||||
|
||||
@@ -156,7 +131,7 @@ class AuthProxyService {
|
||||
bool? drySend,
|
||||
}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||
final userfrontUrl = _envOrDefault('USERFRONT_URL', _fallbackOrigin());
|
||||
final userfrontUrl = runtimeUserfrontUrl();
|
||||
|
||||
final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
|
||||
if (_shouldSendDrySend(drySend)) {
|
||||
@@ -897,10 +872,10 @@ class AuthProxyService {
|
||||
if (!_canSendClientLog()) {
|
||||
return;
|
||||
}
|
||||
final appEnv = _envOrDefault('APP_ENV', 'dev');
|
||||
final productionDebugFlag = _envOrDefault(
|
||||
final appEnv = envOrDefault('APP_ENV', 'dev');
|
||||
final productionDebugFlag = envOrDefault(
|
||||
'CLIENT_LOG_DEBUG',
|
||||
_envOrDefault('USERFRONT_DEBUG_LOG', ''),
|
||||
envOrDefault('USERFRONT_DEBUG_LOG', ''),
|
||||
);
|
||||
if (!LogPolicy.shouldRelayClientLog(
|
||||
level: level,
|
||||
|
||||
34
userfront/lib/core/services/runtime_env.dart
Normal file
34
userfront/lib/core/services/runtime_env.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
|
||||
String runtimeOriginFallback() {
|
||||
try {
|
||||
final origin = Uri.base.origin;
|
||||
if (origin.isNotEmpty && origin != 'null') {
|
||||
return origin;
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'https://sso-test.hmac.kr';
|
||||
}
|
||||
|
||||
String envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
final value = dotenv.env[key];
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String sanitizedUrl(String value) {
|
||||
return value.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
|
||||
}
|
||||
|
||||
String runtimeBackendUrl() {
|
||||
return sanitizedUrl(envOrDefault('BACKEND_URL', runtimeOriginFallback()));
|
||||
}
|
||||
|
||||
String runtimeUserfrontUrl() {
|
||||
return sanitizedUrl(envOrDefault('USERFRONT_URL', runtimeOriginFallback()));
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'models.dart';
|
||||
|
||||
String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||
final queryParameters = <String, String>{'limit': '20'};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
import 'package:userfront/core/services/http_client.dart';
|
||||
import 'package:userfront/core/services/runtime_env.dart';
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
@@ -62,16 +62,9 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
return _fetchLinkedRps();
|
||||
}
|
||||
|
||||
String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
try {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
import '../models.dart';
|
||||
|
||||
class UserSessionsNotifier extends AsyncNotifier<List<UserSessionSummary>> {
|
||||
@@ -14,15 +14,8 @@ class UserSessionsNotifier extends AsyncNotifier<List<UserSessionSummary>> {
|
||||
return _fetchSessions();
|
||||
}
|
||||
|
||||
String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
Future<List<UserSessionSummary>> _fetchSessions() async {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/sessions');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import '../models/user_profile_model.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
|
||||
class ProfileRepository {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
static String get _baseUrl =>
|
||||
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
static String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
// Helper to get session token
|
||||
static Future<String?> _getToken() async {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide tr;
|
||||
@@ -161,10 +162,20 @@ bool _shouldRunStartupSessionRecovery(Uri uri) {
|
||||
return !isPublicAuthPath(path, uri);
|
||||
}
|
||||
|
||||
Future<void> _loadRuntimeEnv() async {
|
||||
for (final fileName in const ['assets/.env', '.env']) {
|
||||
try {
|
||||
await dotenv.load(fileName: fileName);
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
usePathUrlStrategy();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
await _loadRuntimeEnv();
|
||||
LocaleRegistry.primeWithDefaults();
|
||||
|
||||
// 1. Global Error Handling
|
||||
|
||||
@@ -21,6 +21,8 @@ log_format json_combined escape=json
|
||||
|
||||
server {
|
||||
listen 5000;
|
||||
absolute_redirect off;
|
||||
port_in_redirect off;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
Reference in New Issue
Block a user