1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/dev' into fix/issue-637

This commit is contained in:
2026-05-07 13:53:14 +09:00
103 changed files with 6332 additions and 1539 deletions

View File

@@ -1,109 +1,25 @@
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
const flows = [
{
title: "Admin login",
description:
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
pill: "15m TTL",
},
{
title: "Tenant pick",
description:
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
pill: "Header-ready",
},
{
title: "Device approval",
description:
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
pill: "App session",
},
];
import { KeyRound } from "lucide-react";
import PermissionChecker from "./components/PermissionChecker";
function AuthPage() {
return (
<div className="space-y-8">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Admin auth
</p>
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
<p className="text-sm text-[var(--color-muted)]">
Build the admin-only login flow first, keeping app login separate.
Respect the fallback only when user chooses rule for SMS/email
vs app approval.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
IDP session placeholder
</span>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
>
<Sparkles size={14} />
Connect auth layer
</button>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{flows.map((flow) => (
<div
key={flow.title}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
>
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
<span>{flow.pill}</span>
<Fingerprint size={14} />
</div>
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
<p className="text-sm text-[var(--color-muted)]">
{flow.description}
</p>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Smartphone size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
App-based approvals
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
App session as MFA replacement
</h3>
<p className="text-sm text-[var(--color-muted)]">
If the admin keeps the mobile app signed in and opts in, use
push/deeplink approval instead of OTP. Otherwise fall back to
SMS/email based on user choice.
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Admin auth
</p>
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<KeyRound size={22} className="text-primary" />
</h2>
<p className="text-sm text-muted-foreground">
ReBAC .
</p>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ArrowRight size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
TTL discipline
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
Keep admin sessions short
</h3>
<p className="text-sm text-[var(--color-muted)]">
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
with step-up MFA when critical actions (rotate secret, export logs)
happen.
</p>
</div>
</section>
</div>
<PermissionChecker />
</div>
);
}

View File

@@ -44,7 +44,7 @@ function PermissionChecker() {
const result = checkMutation.data;
return (
<Card className="bg-[var(--color-panel)] border-primary/20">
<Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" />
@@ -100,7 +100,7 @@ function PermissionChecker() {
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12"
className="w-full px-12 md:w-auto"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
@@ -108,17 +108,17 @@ function PermissionChecker() {
{checkMutation.isSuccess && result && (
<div
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
result.allowed
? "bg-green-500/10 border-green-500/50 text-green-600"
: "bg-destructive/10 border-destructive/50 text-destructive"
? "border-green-500/50 bg-green-500/10 text-green-600"
: "border-destructive/50 bg-destructive/10 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center">
<p className="text-center text-sm opacity-80">
. (
)
</p>
@@ -127,7 +127,7 @@ function PermissionChecker() {
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center">
<p className="text-center text-sm opacity-80">
.
</p>
</>

View File

@@ -0,0 +1,186 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAdminRPUsageDaily } from "../../lib/adminApi";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
oidcClients: 3,
auditEvents24h: 18,
})),
fetchTenants: vi.fn(async () => ({
items: [
{
id: "company-1",
type: "COMPANY",
name: "한맥",
slug: "hanmac",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "개발팀",
slug: "dev-team",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "personal-1",
type: "PERSONAL",
name: "개인",
slug: "personal",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
],
limit: 1000,
offset: 0,
total: 3,
})),
fetchAdminRPUsageDaily: vi.fn(async () => ({
days: 14,
period: "day",
items: [
{
date: "2026-05-05",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "orgfront",
clientName: "OrgFront",
loginRequests: 12,
otherRequests: 4,
uniqueSubjects: 8,
},
{
date: "2026-05-06",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "adminfront",
clientName: "AdminFront",
loginRequests: 7,
otherRequests: 3,
uniqueSubjects: 5,
},
{
date: "2026-09-28",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "devfront",
clientName: "DevFront",
loginRequests: 2,
otherRequests: 1,
uniqueSubjects: 2,
},
],
})),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin overview and auth guard pages", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders usage trend chart without quick navigation or permission checker", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
).toBeInTheDocument();
expect(
await screen.findByLabelText("일 단위 RP 요청 현황"),
).toBeInTheDocument();
expect(await screen.findByText("05.05")).toBeInTheDocument();
expect(await screen.findByText("05.06")).toBeInTheDocument();
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
});
it("renders overview summary metrics from the admin stats API", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
});
it("changes the RP usage perspective and targets a permitted organization", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.change(screen.getByLabelText("조직 검색"), {
target: { value: "개발" },
});
fireEvent.change(screen.getByLabelText("대상 조직"), {
target: { value: "org-1" },
});
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90,
period: "month",
tenantId: "org-1",
});
});
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
});
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />);
expect(screen.getByText("인증가드")).toBeInTheDocument();
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
expect(
screen.queryByText("IDP session placeholder"),
).not.toBeInTheDocument();
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
});
});

View File

@@ -1,33 +1,433 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArrowUpRight,
BarChart3,
Database,
Key,
PlusCircle,
ShieldCheck,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { type ReactNode, useMemo, useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
type RPUsageDailyMetric,
type RPUsagePeriod,
type TenantSummary,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
tenantLabel: string;
clientLabel: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
const byDate = new Map<string, DailyPoint>();
for (const row of rows) {
const current =
byDate.get(row.date) ??
({
date: row.date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
byDate.set(row.date, current);
}
return Array.from(byDate.values()).sort((a, b) =>
a.date.localeCompare(b.date),
);
}
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>();
for (const row of rows) {
const key = `${row.tenantId}:${row.clientId}`;
const current =
bySeries.get(key) ??
({
key,
tenantLabel: row.tenantName || row.tenantId || "-",
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
otherRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort(
(a, b) =>
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
)
.slice(0, 5);
}
function parseDateParts(date: string) {
const parts = date.split("-");
if (parts.length === 3) {
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
monthText: parts[1],
dayText: parts[2],
};
}
return null;
}
function getISOWeekNumber(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeekThursday(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
return date;
}
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
const parts = parseDateParts(date);
if (!parts) {
return date;
}
if (period === "month") {
return `${parts.monthText}`;
}
if (period === "week") {
const weekNumber = String(
getISOWeekNumber(parts.year, parts.month, parts.day),
).padStart(2, "0");
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
const weekMonth = weekThursday.getUTCMonth() + 1;
const weekDay = weekThursday.getUTCDate();
const weekMonthText = String(weekMonth).padStart(2, "0");
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
return `${weekNumber}(${weekMonthText}${weekOfMonth}주)`;
}
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 RPUsageMixedChart({
rows,
filters,
period,
}: {
rows: RPUsageDailyMetric[];
filters: ReactNode;
period: RPUsagePeriod;
}) {
const daily = summarizeDaily(rows);
const series = summarizeSeries(rows);
const chartWidth = 720;
const chartHeight = 230;
const padX = 48;
const padTop = 32;
const padBottom = 34;
const innerWidth = chartWidth - padX * 2;
const innerHeight = chartHeight - padTop - padBottom;
const maxValue = Math.max(
1,
...daily.map((point) => point.loginRequests + point.otherRequests),
...daily.map((point) => point.loginRequests),
);
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
const y = (value: number) =>
padTop + innerHeight - (value / maxValue) * innerHeight;
const x = (index: number) => padX + slot * index + slot / 2;
const linePoints = daily
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
.join(" ");
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-primary" />
<h3 className="text-base font-semibold">
/
</h3>
</div>
{filters}
</div>
{daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP .
</div>
) : (
<div className="overflow-x-auto">
<svg
role="img"
aria-label="일 단위 RP 요청 현황"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title> RP </title>
<g transform="translate(510 10)">
<rect
x="0"
y="3"
width="10"
height="10"
rx="2"
className="fill-sky-500/70"
/>
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
</text>
<line
x1="78"
x2="98"
y1="8"
y2="8"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
/>
<text
x="104"
y="12"
className="fill-muted-foreground text-[11px]"
>
</text>
</g>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
return (
<g key={ratio}>
<line
x1={padX}
x2={chartWidth - padX}
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
className="fill-emerald-500 stroke-background"
strokeWidth="2"
/>
))}
</svg>
</div>
)}
{series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => (
<div key={item.key} className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{item.clientLabel}</span>
<span className="truncate text-muted-foreground">
{item.tenantLabel}
</span>
<span className="ml-auto whitespace-nowrap tabular-nums">
{item.loginRequests.toLocaleString()} / {" "}
{item.otherRequests.toLocaleString()} / {" "}
{item.uniqueSubjects.toLocaleString()}
</span>
</div>
))}
</div>
)}
</section>
);
}
function GlobalOverviewPage() {
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [tenantSearch, setTenantSearch] = useState("");
const [selectedTenantId, setSelectedTenantId] = useState("");
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const statsQuery = useQuery({
queryKey: ["admin-overview-stats"],
queryFn: fetchAdminOverviewStats,
retry: false,
});
const tenantsQuery = useQuery({
queryKey: ["admin-overview-tenant-options"],
queryFn: () => fetchTenants(1000, 0),
retry: false,
});
const tenantOptions = useMemo(() => {
const term = tenantSearch.trim().toLowerCase();
return (tenantsQuery.data?.items ?? [])
.filter(
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
)
.filter((tenant) => {
if (!term) return true;
return (
tenant.name.toLowerCase().includes(term) ||
tenant.slug.toLowerCase().includes(term) ||
tenant.id.toLowerCase().includes(term)
);
});
}, [tenantSearch, tenantsQuery.data?.items]);
const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
queryFn: () =>
fetchAdminRPUsageDaily({
days: usageDays,
period,
tenantId: selectedTenantId || undefined,
}),
retry: false,
});
const stats = statsQuery.data;
const usageRows = usageQuery.data?.items ?? [];
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const chartFilters = (
<div className="flex flex-wrap items-center gap-2">
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", "일"],
["week", "주"],
["month", "월"],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
<input
aria-label="조직 검색"
value={tenantSearch}
onChange={(event) => setTenantSearch(event.target.value)}
placeholder="조직 검색"
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
/>
<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>
);
return (
<div className="space-y-8 animate-in fade-in duration-500">
<div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")}
</h2>
<p className="text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
@@ -36,166 +436,61 @@ function GlobalOverviewPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<RoleGuard roles={["super_admin"]}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
</CardTitle>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Users size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
</CardTitle>
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
<ShieldCheck size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
OIDC
</p>
</CardContent>
</Card>
<OverviewMetric
icon={<Users size={14} />}
label={t(
"ui.admin.overview.summary.total_tenants",
"전체 테넌트 수",
)}
value={metric(stats?.totalTenants)}
/>
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t(
"ui.admin.overview.summary.oidc_clients",
"OIDC 클라이언트",
)}
value={metric(stats?.oidcClients)}
/>
</RoleGuard>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t(
"ui.admin.overview.summary.audit_events_24h",
"최근 감사 로그 (24h)",
)}
</CardTitle>
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
<Activity size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
</CardTitle>
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
<Database size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
Active
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<OverviewMetric
icon={<Activity size={14} />}
label={t(
"ui.admin.overview.summary.audit_events_24h",
"24시간 이벤트",
)}
value={metric(stats?.auditEvents24h)}
/>
<OverviewMetric
icon={<Database size={14} />}
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
value="Active"
/>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold tracking-tight">
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<Link
to="/tenants/new"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<PlusCircle size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-primary">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/users"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
<Users size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
<RoleGuard roles={["super_admin"]}>
<Link
to="/api-keys"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
<Key size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
API
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/audit-logs"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
<Activity size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</div>
</div>
<RoleGuard roles={["super_admin"]}>
<div className="pt-4">
<PermissionChecker />
</div>
</RoleGuard>
{usageQuery.isError ? (
<section className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold">
/
</h3>
{chartFilters}
</div>
<div className="text-sm text-muted-foreground">
RP Query API . backend
`rp_usage_daily_aggregate`
.
</div>
</section>
) : (
<RPUsageMixedChart
rows={usageRows}
filters={chartFilters}
period={period}
/>
)}
</div>
);
}

View File

@@ -556,10 +556,10 @@ function TenantListPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<Table className="min-w-[1180px]">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[40px]">
<TableHead className="w-[48px] whitespace-nowrap">
<Checkbox
checked={
tenants.length > 0 &&
@@ -634,7 +634,7 @@ function TenantListPage() {
{getSortIcon("updatedAt")}
</div>
</TableHead>
<TableHead className="text-right">
<TableHead className="w-[160px] whitespace-nowrap text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
@@ -690,21 +690,18 @@ function TenantListPage() {
)}
</div>
</TableCell>
<TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant={
tenant.status === "active"
@@ -723,12 +720,12 @@ function TenantListPage() {
<TableCell className="font-medium">
{tenant.recursiveMemberCount}
</TableCell>
<TableCell className="text-xs">
<TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<TableCell className="whitespace-nowrap text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"

View File

@@ -1,10 +1,17 @@
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
canOpenWorksmobilePasswordManage,
canCreateWorksmobileRow,
canSelectWorksmobileRow,
filterWorksmobileComparisonRows,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getDefaultWorksmobileComparisonColumns,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileComparisonStatusLabel,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./TenantWorksmobilePage";
@@ -69,6 +76,121 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toBe(false);
});
it("allows selection for Baron-only, WORKS-only, and matched rows", () => {
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "matched",
baronId: "user-2",
worksmobileId: "works-user-2",
}),
).toBe(true);
});
it("does not allow selection for immutable WORKS accounts", () => {
expect(
isImmutableWorksmobileAccount({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "cyhan@samaneng.com",
worksmobileId: "works-cyhan",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "CYHAN1@HANMACENG.CO.KR",
worksmobileId: "works-cyhan1",
}),
).toBe(false);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
}),
).toBe(true);
});
it("does not allow password management for immutable WORKS accounts", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileId: "works-su",
},
"works-tenant-1",
),
).toBe(false);
});
it("keeps row selection keys separate from Baron action ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched-baron",
worksmobileId: "matched-works",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(selectedKeys).toEqual([
"USER:baron:baron-only",
"USER:works:works-only",
"USER:baron:matched-baron",
]);
expect(getWorksmobileSelectedActionIds(rows, selectedKeys)).toEqual([
"baron-only",
"matched-baron",
]);
});
it("uses compact comparison columns by default", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
});
});
it("filters user comparison rows by selected relationship", () => {
const rows = [
{
@@ -149,4 +271,77 @@ describe("TenantWorksmobilePage comparison helpers", () => {
}),
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
});
it("builds the WORKS admin password management URL from remote user identifiers", () => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: " works-tenant-1 ",
domainId: 300285955,
userIdNo: " works-user-1 ",
});
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
"https://auth.worksmobile.com/integrate/password/manage",
);
expect(parsed.searchParams.get("usage")).toBe("admin");
expect(parsed.searchParams.get("targetUserTenantId")).toBe(
"works-tenant-1",
);
expect(parsed.searchParams.get("targetUserDomainId")).toBe("300285955");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
expect(parsed.searchParams.get("accessUrl")).toBe(
"https://admin.worksmobile.com/assets/self-close.html",
);
});
it("does not open WORKS password management without required identifiers", () => {
const row = {
resourceType: "USER",
status: "matched",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
};
expect(canOpenWorksmobilePasswordManage(row, "works-tenant-1")).toBe(true);
expect(canOpenWorksmobilePasswordManage(row, undefined)).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileDomainId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, resourceType: "GROUP" },
"works-tenant-1",
),
).toBe(false);
expect(
buildWorksmobilePasswordManageUrl({
tenantId: "works-tenant-1",
domainId: 0,
userIdNo: "works-user-1",
}),
).toBe("");
});
it("allows WORKS password management for WORKS-only user rows", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
},
"works-tenant-1",
),
).toBe(true);
});
});

View File

@@ -1,5 +1,11 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, RefreshCw, RotateCcw } from "lucide-react";
import {
Download,
KeyRound,
RefreshCw,
RotateCcw,
Settings2,
} from "lucide-react";
import * as React from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
@@ -12,6 +18,15 @@ import {
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
@@ -47,8 +62,18 @@ export function TenantWorksmobilePage() {
const [userFilters, setUserFilters] = React.useState<
WorksmobileComparisonFilter[]
>(["baron_only", "works_only"]);
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
const [selectedUserRowKeys, setSelectedUserRowKeys] = React.useState<
string[]
>([]);
const [selectedGroupRowKeys, setSelectedGroupRowKeys] = React.useState<
string[]
>([]);
const [userVisibleColumns, setUserVisibleColumns] = React.useState(
getDefaultWorksmobileComparisonColumns,
);
const [groupVisibleColumns, setGroupVisibleColumns] = React.useState(
getDefaultWorksmobileComparisonColumns,
);
const overviewQuery = useQuery({
queryKey: ["worksmobile", tenantId],
@@ -152,9 +177,9 @@ export function TenantWorksmobilePage() {
},
onSuccess: ({ resourceKind, count }) => {
if (resourceKind === "users") {
setSelectedUserIds([]);
setSelectedUserRowKeys([]);
} else {
setSelectedGroupIds([]);
setSelectedGroupRowKeys([]);
}
toast.success("WORKS 생성 작업을 등록했습니다.", {
description: `${count}`,
@@ -198,7 +223,7 @@ export function TenantWorksmobilePage() {
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
return (
<div className="space-y-6">
<div className="min-w-0 max-w-full space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">
@@ -247,7 +272,7 @@ export function TenantWorksmobilePage() {
</div>
</header>
<Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-base">
@@ -261,7 +286,7 @@ export function TenantWorksmobilePage() {
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="min-w-0 space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<ComparisonSummary
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
@@ -275,41 +300,26 @@ export function TenantWorksmobilePage() {
summary={groupSummary}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{userFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
userFilters.includes(option.value) ? "default" : "outline"
}
aria-pressed={userFilters.includes(option.value)}
onClick={() => {
setUserFilters((current) =>
current.includes(option.value)
? current.filter((value) => value !== option.value)
: [...current, option.value],
);
setSelectedUserIds([]);
}}
>
{option.label}
</Button>
))}
</div>
<ComparisonTable
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
rows={filteredComparisonUsers}
loading={comparisonQuery.isLoading}
selectedIds={selectedUserIds}
onSelectedIdsChange={setSelectedUserIds}
selectedKeys={selectedUserRowKeys}
onSelectedKeysChange={setSelectedUserRowKeys}
filters={userFilters}
onFiltersChange={(nextFilters) => {
setUserFilters(nextFilters);
setSelectedUserRowKeys([]);
}}
visibleColumns={userVisibleColumns}
onVisibleColumnsChange={setUserVisibleColumns}
passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
onCreateSelected={() =>
onCreateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids: selectedUserIds,
ids,
})
}
/>
@@ -320,16 +330,21 @@ export function TenantWorksmobilePage() {
)}
rows={comparisonGroups}
loading={comparisonQuery.isLoading}
selectedIds={selectedGroupIds}
onSelectedIdsChange={setSelectedGroupIds}
selectedKeys={selectedGroupRowKeys}
onSelectedKeysChange={setSelectedGroupRowKeys}
filters={undefined}
onFiltersChange={undefined}
visibleColumns={groupVisibleColumns}
onVisibleColumnsChange={setGroupVisibleColumns}
passwordManageTenantId={undefined}
actionLabel="선택 조직 WORKS에 생성"
actionDisabled={
isCreatingGroups || createSelectedMutation.isPending
}
onCreateSelected={() =>
onCreateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "groups",
ids: selectedGroupIds,
ids,
})
}
/>
@@ -435,6 +450,54 @@ export type WorksmobileComparisonSummary = {
missingExternalKey: number;
};
export type WorksmobileComparisonColumnKey =
| "status"
| "baronId"
| "baron"
| "baronOrg"
| "worksmobileId"
| "externalKey"
| "worksmobileDomain"
| "worksmobile"
| "worksmobileOrg"
| "manage";
export type WorksmobileComparisonColumnVisibility = Record<
WorksmobileComparisonColumnKey,
boolean
>;
const worksmobileComparisonColumnOptions: Array<{
key: WorksmobileComparisonColumnKey;
label: string;
}> = [
{ key: "status", label: "상태" },
{ key: "baronId", label: "Baron ID" },
{ key: "baron", label: "Baron" },
{ key: "baronOrg", label: "Baron 조직" },
{ key: "worksmobileId", label: "WORKS ID" },
{ key: "externalKey", label: "external_key" },
{ key: "worksmobileDomain", label: "WORKS 도메인" },
{ key: "worksmobile", label: "WORKS" },
{ key: "worksmobileOrg", label: "WORKS 조직" },
{ key: "manage", label: "관리" },
];
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
};
}
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
@@ -480,6 +543,54 @@ export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
}
const immutableWorksmobileAccountEmails = new Set([
"cyhan@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
"su-@samaneng.com",
]);
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
return (
row.resourceType === "USER" &&
immutableWorksmobileAccountEmails.has(
row.worksmobileEmail?.trim().toLowerCase() ?? "",
)
);
}
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
if (row.baronId) {
return `${row.resourceType}:baron:${row.baronId}`;
}
if (row.worksmobileId) {
return `${row.resourceType}:works:${row.worksmobileId}`;
}
if (row.externalKey) {
return `${row.resourceType}:external:${row.externalKey}`;
}
return "";
}
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
return (
Boolean(getWorksmobileRowSelectionKey(row)) &&
!isImmutableWorksmobileAccount(row)
);
}
export function getWorksmobileSelectedActionIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
@@ -519,13 +630,61 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,
userIdNo,
}: {
tenantId?: string;
domainId?: number;
userIdNo?: string;
}) {
const normalizedTenantId = tenantId?.trim();
const normalizedUserIdNo = userIdNo?.trim();
if (
!normalizedTenantId ||
!domainId ||
domainId <= 0 ||
!normalizedUserIdNo
) {
return "";
}
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
url.searchParams.set("usage", "admin");
url.searchParams.set("targetUserTenantId", normalizedTenantId);
url.searchParams.set("targetUserDomainId", String(domainId));
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
url.searchParams.set(
"accessUrl",
"https://admin.worksmobile.com/assets/self-close.html",
);
return url.toString();
}
export function canOpenWorksmobilePasswordManage(
row: WorksmobileComparisonItem,
tenantId?: string,
) {
return (
row.resourceType === "USER" &&
!isImmutableWorksmobileAccount(row) &&
Boolean(
buildWorksmobilePasswordManageUrl({
tenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
}),
)
);
}
export const userFilterOptions: Array<{
value: WorksmobileComparisonFilter;
label: string;
}> = [
{ value: "baron_only", label: "Baron에만 있음" },
{ value: "works_only", label: "WORKS에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
{ value: "baron_only", label: "바론에만 있음" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
];
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
@@ -605,8 +764,13 @@ function ComparisonTable({
title,
rows,
loading,
selectedIds,
onSelectedIdsChange,
selectedKeys,
onSelectedKeysChange,
filters,
onFiltersChange,
visibleColumns,
onVisibleColumnsChange,
passwordManageTenantId,
actionLabel,
actionDisabled,
onCreateSelected,
@@ -614,101 +778,235 @@ function ComparisonTable({
title: string;
rows: WorksmobileComparisonItem[];
loading: boolean;
selectedIds: string[];
onSelectedIdsChange: (ids: string[]) => void;
selectedKeys: string[];
onSelectedKeysChange: (ids: string[]) => void;
filters?: WorksmobileComparisonFilter[];
onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void;
visibleColumns: WorksmobileComparisonColumnVisibility;
onVisibleColumnsChange: React.Dispatch<
React.SetStateAction<WorksmobileComparisonColumnVisibility>
>;
passwordManageTenantId?: string;
actionLabel: string;
actionDisabled: boolean;
onCreateSelected: () => void;
onCreateSelected: (ids: string[]) => void;
}) {
const creatableIds = rows
.filter(canCreateWorksmobileRow)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
const allCreatableSelected =
creatableIds.length > 0 &&
creatableIds.every((id) => selectedIds.includes(id));
const selectableKeys = rows
.filter(canSelectWorksmobileRow)
.map(getWorksmobileRowSelectionKey)
.filter(Boolean);
const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys);
const allSelectableSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key));
const visibleColumnCount = worksmobileComparisonColumnOptions.filter(
(column) => visibleColumns[column.key] !== false,
).length;
const tableColSpan = visibleColumnCount + 1;
const toggleAll = (checked: boolean | "indeterminate") => {
onSelectedIdsChange(checked === true ? creatableIds : []);
onSelectedKeysChange(checked === true ? selectableKeys : []);
};
const toggleRow = (
id: string | undefined,
row: WorksmobileComparisonItem,
checked: boolean | "indeterminate",
) => {
if (!id) {
const key = getWorksmobileRowSelectionKey(row);
if (!key) {
return;
}
if (checked === true) {
onSelectedIdsChange([...new Set([...selectedIds, id])]);
onSelectedKeysChange([...new Set([...selectedKeys, key])]);
return;
}
onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id));
onSelectedKeysChange(
selectedKeys.filter((selectedKey) => selectedKey !== key),
);
};
const openPasswordManage = (row: WorksmobileComparisonItem) => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: passwordManageTenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
});
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
};
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
onVisibleColumnsChange((current) => ({
...current,
[key]: current[key] === false,
}));
};
const isColumnVisible = (key: WorksmobileComparisonColumnKey) =>
visibleColumns[key] !== false;
const toggleFilter = (filter: WorksmobileComparisonFilter) => {
if (!filters || !onFiltersChange) {
return;
}
onFiltersChange(
filters.includes(filter)
? filters.filter((value) => value !== filter)
: [...filters, filter],
);
};
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-medium">{title}</h4>
<Button
type="button"
size="sm"
onClick={onCreateSelected}
disabled={selectedIds.length === 0 || actionDisabled}
>
{actionLabel}
</Button>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<h4 className="text-lg font-semibold leading-none">{title}</h4>
{filters && onFiltersChange && (
<div className="flex flex-wrap items-center gap-2">
{userFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
filters.includes(option.value) ? "default" : "outline"
}
aria-pressed={filters.includes(option.value)}
onClick={() => toggleFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
)}
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Settings2 size={16} />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title} </DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
{worksmobileComparisonColumnOptions.map((column) => (
<label
key={column.key}
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={isColumnVisible(column.key)}
onChange={() => toggleColumn(column.key)}
/>
<span className="text-sm font-medium">{column.label}</span>
</label>
))}
</div>
<DialogFooter>
<DialogTrigger asChild>
<Button type="button" variant="secondary">
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
type="button"
size="sm"
onClick={() => onCreateSelected(selectedActionIds)}
disabled={selectedActionIds.length === 0 || actionDisabled}
>
{actionLabel}
</Button>
</div>
</div>
<div className="overflow-x-auto">
<Table>
<div className="w-full max-w-full overflow-x-auto rounded-md border">
<Table className="min-w-max">
<TableHeader>
<TableRow>
<TableHead className="w-10 whitespace-nowrap">
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allCreatableSelected}
disabled={creatableIds.length === 0}
checked={allSelectableSelected}
disabled={selectableKeys.length === 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS ID
</TableHead>
<TableHead className="min-w-40 whitespace-nowrap">
external_key
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-52 whitespace-nowrap">
WORKS
</TableHead>
{isColumnVisible("status") && (
<TableHead className="w-24 whitespace-nowrap"></TableHead>
)}
{isColumnVisible("baronId") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
</TableHead>
)}
{isColumnVisible("baron") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
)}
{isColumnVisible("baronOrg") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
)}
{isColumnVisible("worksmobileId") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS ID
</TableHead>
)}
{isColumnVisible("externalKey") && (
<TableHead className="min-w-40 whitespace-nowrap">
external_key
</TableHead>
)}
{isColumnVisible("worksmobileDomain") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("worksmobile") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("worksmobileOrg") && (
<TableHead className="min-w-52 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("manage") && (
<TableHead className="w-14 whitespace-nowrap"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell
colSpan={tableColSpan}
className="text-muted-foreground"
>
...
</TableCell>
</TableRow>
)}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell
colSpan={tableColSpan}
className="text-muted-foreground"
>
.
</TableCell>
</TableRow>
@@ -720,87 +1018,126 @@ function ComparisonTable({
<TableCell className="whitespace-nowrap">
<Checkbox
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
checked={Boolean(
row.baronId && selectedIds.includes(row.baronId),
checked={selectedKeys.includes(
getWorksmobileRowSelectionKey(row),
)}
disabled={!canCreateWorksmobileRow(row)}
onCheckedChange={(checked) =>
toggleRow(row.baronId, checked)
}
disabled={!canSelectWorksmobileRow(row)}
onCheckedChange={(checked) => toggleRow(row, checked)}
/>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className="whitespace-nowrap"
variant={getWorksmobileComparisonStatusVariant(row.status)}
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{row.baronId ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{row.baronName ?? "-"}</div>
<div className="text-xs text-muted-foreground">
{row.baronEmail ?? ""}
{isColumnVisible("status") && (
<TableCell className="whitespace-nowrap">
<Badge
className="whitespace-nowrap"
variant={getWorksmobileComparisonStatusVariant(
row.status,
)}
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
</TableCell>
)}
{isColumnVisible("baronId") && (
<TableCell className="font-mono text-xs">
{row.baronId ?? "-"}
</TableCell>
)}
{isColumnVisible("baron") && (
<TableCell>
<div className="space-y-1">
<div>{row.baronName ?? "-"}</div>
<div className="text-xs text-muted-foreground">
{row.baronEmail ?? ""}
</div>
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.baronParentName
: row.baronPrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.baronParentId
: row.baronPrimaryOrgId
}
/>
</TableCell>
<TableCell className="font-mono text-xs">
{row.worksmobileId ?? "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{row.externalKey ?? "-"}
</TableCell>
<TableCell>
<ComparisonDomainCell
name={row.worksmobileDomainName}
id={row.worksmobileDomainId}
/>
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{formatWorksmobilePersonName(row) || "-"}</div>
<div className="text-xs text-muted-foreground">
{row.worksmobileEmail ?? ""}
</TableCell>
)}
{isColumnVisible("baronOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.baronParentName
: row.baronPrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.baronParentId
: row.baronPrimaryOrgId
}
/>
</TableCell>
)}
{isColumnVisible("worksmobileId") && (
<TableCell className="font-mono text-xs">
{row.worksmobileId ?? "-"}
</TableCell>
)}
{isColumnVisible("externalKey") && (
<TableCell className="font-mono text-xs">
{row.externalKey ?? "-"}
</TableCell>
)}
{isColumnVisible("worksmobileDomain") && (
<TableCell>
<ComparisonDomainCell
name={row.worksmobileDomainName}
id={row.worksmobileDomainId}
/>
</TableCell>
)}
{isColumnVisible("worksmobile") && (
<TableCell>
<div className="space-y-1">
<div>{formatWorksmobilePersonName(row) || "-"}</div>
<div className="text-xs text-muted-foreground">
{row.worksmobileEmail ?? ""}
</div>
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.worksmobileParentName
: row.worksmobilePrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? []
: formatWorksmobileOrgDetails(row)
}
/>
</TableCell>
</TableCell>
)}
{isColumnVisible("worksmobileOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.worksmobileParentName
: row.worksmobilePrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? []
: formatWorksmobileOrgDetails(row)
}
/>
</TableCell>
)}
{isColumnVisible("manage") && (
<TableCell className="whitespace-nowrap">
{row.resourceType === "USER" && (
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
disabled={
!canOpenWorksmobilePasswordManage(
row,
passwordManageTenantId,
)
}
onClick={() => openPasswordManage(row)}
>
<KeyRound size={16} />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>