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