1
0
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:
2026-05-18 10:03:29 +09:00
76 changed files with 25774 additions and 2242 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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}>

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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();

View File

@@ -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}
/>

View File

@@ -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", "이름")}

View File

@@ -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", "이름")}

View File

@@ -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", "행")}

View File

@@ -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")}

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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", "대상 테넌트")}

View File

@@ -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">

View File

@@ -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"

View File

@@ -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"

View File

@@ -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]

View File

@@ -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");

View File

@@ -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",

View File

@@ -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)
}
}

View File

@@ -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" {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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,
},

View 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 || "-";
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export * from "./AuditLogTable";

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export { OverviewMetric } from "./OverviewMetric";
export { OverviewAxisNotes } from "./OverviewAxisNotes";
export { OverviewSelectionChips } from "./OverviewSelectionChips";

View 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>
);
}

View File

@@ -0,0 +1 @@
export * from "./PageHeader";

View File

@@ -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 (

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -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
View 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;

View 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>
);
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 /> },

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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")}

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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"

View File

@@ -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축: 기간"

View File

@@ -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 = ""

View File

@@ -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();

View File

@@ -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"

View File

@@ -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 = "내보내기"

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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

View File

@@ -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 ({

View File

@@ -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,

View File

@@ -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,

View 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()));
}

View File

@@ -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'};

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;