1
0
forked from baron/baron-sso

Merge branch 'dev' into fix/login

This commit is contained in:
2026-03-18 13:05:10 +09:00
67 changed files with 7242 additions and 2393 deletions

View File

@@ -6,7 +6,6 @@ import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
@@ -35,7 +34,6 @@ export const router = createBrowserRouter(
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "dashboard", element: <DashboardPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },

View File

@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import type * as React from "react";
import { fetchMe } from "../../lib/adminApi";
interface RoleGuardProps {
children: React.ReactNode;
roles: string[];
fallback?: React.ReactNode;
}
/**
* RoleGuard conditionally renders children based on the current user's role.
*
* Usage:
* <RoleGuard roles={['super_admin']}>
* <button>System Only Action</button>
* </RoleGuard>
*/
export function RoleGuard({
children,
roles,
fallback = null,
}: RoleGuardProps) {
const { data: profile, isLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return null;
const userRole = profile?.role || "user";
const hasAccess = roles.includes(userRole);
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -14,6 +14,7 @@ import {
User as UserIcon,
Users,
} from "lucide-react";
import * as React from "react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
@@ -22,23 +23,14 @@ import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
const staticNavItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{
label: "ui.admin.nav.tenant_dashboard",
to: "/dashboard",
icon: ShieldHalf,
},
{
label: "ui.admin.nav.tenants",
to: "/tenants",
icon: Building2,
},
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const auth = useAuth();
const navigate = useNavigate();
@@ -51,9 +43,57 @@ function AppLayout() {
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
enabled: auth.isAuthenticated && !auth.isLoading,
enabled:
(auth.isAuthenticated && !auth.isLoading) ||
import.meta.env.MODE === "development" ||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true,
});
const navItems = React.useMemo(() => {
const items = [...staticNavItems];
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
// 테스트 모드이면 profile이 없어도 super_admin으로 간주하여 모든 메뉴 렌더링
const isSuperAdmin = isTest || profile?.role === "super_admin";
const isTenantAdmin = profile?.role === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
const filteredItems = items.filter((item) => {
if (isTest) return true;
if (item.to === "/api-keys") return isSuperAdmin;
return true;
});
if (isSuperAdmin) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
to: "/tenants",
icon: Building2,
});
} else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) {
// Direct link if only one (or zero in array but has tenantId) tenant
filteredItems.splice(1, 0, {
label: "ui.admin.nav.my_tenant",
to: `/tenants/${profile.tenantId}`,
icon: Building2,
});
} else if (manageableCount > 1) {
// Show list menu if multiple tenants
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
to: "/tenants",
icon: Building2,
});
}
}
return filteredItems;
}, [profile]);
const handleLogout = () => {
if (
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
@@ -252,6 +292,49 @@ function AppLayout() {
</span>
</div>
</div>
{/* Manageable Tenants Section */}
{profile?.manageableTenants &&
profile.manageableTenants.length > 0 && (
<div className="px-2 py-2 border-b border-border/50 mb-1">
<p className="px-1 mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.admin.profile.manageable_tenants",
"Manageable Tenants",
)}
</p>
<div className="max-h-40 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
{profile.manageableTenants.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(`/tenants/${tenant.id}`);
}}
className="w-full flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-left text-muted-foreground transition hover:bg-muted/50 hover:text-foreground group"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
{tenant.type === "USER_GROUP" ? (
<Users size={12} />
) : (
<Building2 size={12} />
)}
</div>
<div className="flex flex-col truncate">
<span className="font-medium truncate">
{tenant.name}
</span>
<span className="text-[9px] opacity-60 font-mono truncate">
{tenant.slug}
</span>
</div>
</button>
))}
</div>
</div>
)}
<button
type="button"
onClick={() => {

View File

@@ -1,243 +0,0 @@
import {
Activity,
ArrowRight,
Building2,
CheckCircle2,
LineChart,
Radio,
ShieldCheck,
Sparkles,
} from "lucide-react";
const guardHighlights = [
{
title: "Tenant isolation",
body: "All admin calls expect X-Tenant-ID and are prepared for tenant-aware headers.",
metric: "Header guard",
accent: "active",
},
{
title: "Admin TTL",
body: "Session budget kept short for admins. App session vs admin session split is explicit.",
metric: "15m default",
accent: "ttl",
},
{
title: "Audit-first",
body: "Every management action should log to ClickHouse. Hooks in place for later wiring.",
metric: "per-action",
accent: "audit",
},
];
const stackReadiness = [
"React 19 + Vite 7, strict TS, Router v6 data router.",
"TanStack Query 5 provider ready with sane defaults.",
"Axios client stub with bearer + tenant header injector.",
"Tailwind v4 tokens tuned for admin dark plane.",
"React Hook Form + Zod planned for client forms.",
"IdP-neutral auth hook point reserved for role guard.",
];
const nextSteps = [
"Add IdP-neutral OIDC/OAuth auth layer and enforce admin role in RequireAuth.",
"Persist tenant picklist and feed X-Tenant-ID for every admin call.",
"Add shadcn/ui primitives for forms and tables; lock lint/format.",
];
function DashboardPage() {
return (
<div className="space-y-10">
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-3 max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
<Sparkles size={14} />
adminfront ready
</div>
<h2 className="text-3xl font-semibold leading-tight">
Build the admin plane with{" "}
<span className="text-[var(--color-accent)]">tenant-aware</span>{" "}
defaults and{" "}
<span className="text-[var(--color-accent-strong)]">
least privilege
</span>{" "}
UX.
</h2>
<p className="text-[var(--color-muted)]">
Route, query, and styling scaffolds are in place. Use this canvas
to ship RP registry, audit exploration, and guarded login aligned
with issue #60 while keeping providers swappable.
</p>
<div className="flex flex-wrap gap-3 text-sm">
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
Router + Query wired
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
Admin namespace only
</span>
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
Auth hook pending
</span>
</div>
</div>
<div className="grid gap-3 text-sm">
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<ShieldCheck size={16} />
Admin guard scoped to /admin
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Building2 size={16} />
Tenant selection placeholder ready
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Radio size={16} />
Audit stream hook for ClickHouse
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{guardHighlights.map((item) => (
<div
key={item.title}
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
<div className="relative flex items-center justify-between gap-2">
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
{item.metric}
</div>
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
{item.accent}
</span>
</div>
<div className="relative mt-3 space-y-2">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
</div>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Stack readiness
</p>
<h3 className="text-xl font-semibold">Matches issue #60</h3>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Setup notes
<ArrowRight size={14} />
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{stackReadiness.map((item) => (
<div
key={item}
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<CheckCircle2
size={16}
className="text-[var(--color-accent)]"
/>
<p className="text-sm">{item}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Next actions
</p>
<h3 className="mt-2 text-xl font-semibold">
Ship the first guarded flows
</h3>
<div className="mt-4 space-y-3">
{nextSteps.map((item, idx) => (
<div
key={item}
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
{idx + 1}
</div>
<p className="text-sm text-[var(--color-text)]">{item}</p>
</div>
))}
</div>
</div>
</section>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Ops board
</p>
<h3 className="text-xl font-semibold">What to prototype next</h3>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
Audit ClickHouse
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
RP registry
</span>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<LineChart size={16} />
<span className="text-xs uppercase tracking-[0.16em]">
Metrics
</span>
</div>
<h4 className="mt-2 text-lg font-semibold">
RP registration funnel
</h4>
<p className="text-sm text-[var(--color-muted)]">
Visualize create secret rotate redirect URI edits per tenant.
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Activity size={16} />
<span className="text-xs uppercase tracking-[0.16em]">Audit</span>
</div>
<h4 className="mt-2 text-lg font-semibold">Admin action stream</h4>
<p className="text-sm text-[var(--color-muted)]">
Live feed of admin API calls with per-action tenant, actor, and
rate-limit outcome.
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ShieldCheck size={16} />
<span className="text-xs uppercase tracking-[0.16em]">
Access
</span>
</div>
<h4 className="mt-2 text-lg font-semibold">Admin login journey</h4>
<p className="text-sm text-[var(--color-muted)]">
Outline SMS + app-based MFA choice and emphasize admin session
TTL with logout.
</p>
</div>
</div>
</section>
</div>
);
}
export default DashboardPage;

View File

@@ -7,6 +7,7 @@ import {
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -19,41 +20,6 @@ import {
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
const summaryCards = [
{
labelKey: "ui.admin.overview.summary.total_tenants",
labelFallback: "Total Tenants",
value: "-",
hintKey: "msg.admin.overview.summary.total_tenants",
hintFallback: "Tenant-aware core",
icon: Users,
},
{
labelKey: "ui.admin.overview.summary.oidc_clients",
labelFallback: "OIDC Clients",
value: "-",
hintKey: "msg.admin.overview.summary.oidc_clients",
hintFallback: "Hydra registry",
icon: ShieldCheck,
},
{
labelKey: "ui.admin.overview.summary.audit_events_24h",
labelFallback: "Audit Events (24h)",
value: "-",
hintKey: "msg.admin.overview.summary.audit_events_24h",
hintFallback: "ClickHouse stream",
icon: Activity,
},
{
labelKey: "ui.admin.overview.summary.policy_gate",
labelFallback: "Policy Gate",
value: "Planned",
hintKey: "msg.admin.overview.summary.policy_gate",
hintFallback: "Keto + Admin checks",
icon: Database,
},
];
function GlobalOverviewPage() {
return (
<div className="space-y-10">
@@ -72,42 +38,99 @@ function GlobalOverviewPage() {
)}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
</Badge>
<Badge variant="muted">
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
</Badge>
</div>
<RoleGuard roles={["super_admin"]}>
<div className="flex items-center gap-2">
<Badge variant="muted">
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
</Badge>
<Badge variant="muted">
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
</Badge>
</div>
</RoleGuard>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(
({
labelKey,
labelFallback,
value,
hintKey,
hintFallback,
icon: Icon,
}) => (
<Card key={labelKey} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{t(labelKey, labelFallback)}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(hintKey, hintFallback)}
</p>
</CardContent>
</Card>
),
)}
<RoleGuard roles={["super_admin"]}>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
{t("ui.admin.overview.summary.total_tenants", "Total Tenants")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Users size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(
"msg.admin.overview.summary.total_tenants",
"Tenant-aware core",
)}
</p>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
{t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<ShieldCheck size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t("msg.admin.overview.summary.oidc_clients", "Hydra registry")}
</p>
</CardContent>
</Card>
</RoleGuard>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
{t(
"ui.admin.overview.summary.audit_events_24h",
"Audit Events (24h)",
)}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Activity size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(
"msg.admin.overview.summary.audit_events_24h",
"ClickHouse stream",
)}
</p>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>
{t("ui.admin.overview.summary.policy_gate", "Policy Gate")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Database size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">Planned</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{t(
"msg.admin.overview.summary.policy_gate",
"Keto + Admin checks",
)}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
@@ -178,16 +201,46 @@ function GlobalOverviewPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<RoleGuard roles={["super_admin"]}>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/tenants/new">
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
<ArrowUpRight size={16} />
</Link>
</Button>
</RoleGuard>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/tenants/new">
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
<Link to="/users">
{t(
"ui.admin.overview.quick_links.user_management",
"사용자 관리",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
<RoleGuard roles={["super_admin"]}>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/api-keys">
{t(
"ui.admin.overview.quick_links.api_key_management",
"API 키 관리",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
</RoleGuard>
<Button
asChild
className="w-full justify-between"
@@ -201,24 +254,13 @@ function GlobalOverviewPage() {
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/dashboard">
{t(
"ui.admin.overview.quick_links.tenant_dashboard",
"테넌트 대시보드",
)}
<ArrowUpRight size={16} />
</Link>
</Button>
</CardContent>
</Card>
</div>
<PermissionChecker />
<RoleGuard roles={["super_admin"]}>
<PermissionChecker />
</RoleGuard>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Download, FileText, Loader2, Upload } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { importOrgChart } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface OrgChartUploadModalProps {
tenantId: string;
onSuccess?: () => void;
}
export function OrgChartUploadModal({
tenantId,
onSuccess,
}: OrgChartUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const mutation = useMutation({
mutationFn: (file: File) => importOrgChart(tenantId, file),
onSuccess: () => {
toast.success(
t(
"msg.admin.org.import_success",
"조직도가 성공적으로 업로드되었습니다.",
),
);
setOpen(false);
onSuccess?.();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
}
};
const handleUpload = () => {
if (file) {
mutation.mutate(file);
}
};
const downloadTemplate = () => {
const headers = "email,name,organization,position,jobtitle,is_owner";
const example = `ceo@example.com,홍길동,경영진,대표이사,경영총괄,true
cto@example.com,이몽룡,기술부문,이사,기술총괄,true
user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false`;
const blob = new Blob(
[
`${headers}
${example}`,
],
{ type: "text/csv" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "org_chart_template.csv";
a.click();
URL.revokeObjectURL(url);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Upload size={14} />
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV)")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.org.import_description",
"CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
>
<Download size={14} />
{t("ui.admin.org.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
size="sm"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20 flex items-center gap-3">
<FileText className="text-primary" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
onClick={handleUpload}
disabled={!file || mutation.isPending}
className="w-full sm:w-auto"
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.org.start_import", "임포트 시작")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -100,19 +100,29 @@ function TenantCreatePage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-type" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="type"
id="tenant-type"
name="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
@@ -141,11 +151,12 @@ function TenantCreatePage() {
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full 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"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
@@ -160,10 +171,12 @@ function TenantCreatePage() {
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
@@ -173,23 +186,30 @@ function TenantCreatePage() {
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
id="tenant-domains"
name="domains"
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder={t(

View File

@@ -47,6 +47,7 @@ import {
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
@@ -443,13 +444,19 @@ function TenantGroupsPage() {
)}
</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
<div className="flex items-center gap-2">
<OrgChartUploadModal
tenantId={tenantId}
onSuccess={() => groupsQuery.refetch()}
/>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</div>
</CardHeader>
<CardContent>
<Table>

View File

@@ -1,8 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import type React from "react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -23,17 +24,41 @@ import {
import {
type TenantSummary,
deleteTenant,
fetchMe,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantListPage() {
const navigate = useNavigate();
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
React.useEffect(() => {
if (profile?.role === "tenant_admin") {
const manageableCount = profile.manageableTenants?.length ?? 0;
// If only 1 in array, OR array is empty but we have a primary tenantId
if (
(manageableCount === 1 || manageableCount === 0) &&
profile.tenantId
) {
navigate(`/tenants/${profile.tenantId}`, { replace: true });
}
}
}, [profile, navigate]);
const query = useQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0),
enabled:
profile?.role === "super_admin" ||
(profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) > 1),
});
const navigate = useNavigate();
const deleteMutation = useMutation({
mutationFn: (tenantId: string) => deleteTenant(tenantId),
onSuccess: () => {
@@ -41,6 +66,31 @@ function TenantListPage() {
},
});
if (
profile &&
profile.role !== "super_admin" &&
profile.role !== "tenant_admin"
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-xl font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
// While redirecting (only if exactly one manageable tenant)
if (
profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1
) {
return null;
}
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -95,12 +145,14 @@ function TenantListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
<RoleGuard roles={["super_admin"]}>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
</RoleGuard>
</div>
</header>

View File

@@ -17,7 +17,7 @@ import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaFieldType = "text" | "number" | "boolean";
type SchemaFieldType = "text" | "number" | "boolean" | "date";
type SchemaField = {
id: string;
@@ -25,6 +25,8 @@ type SchemaField = {
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
};
function createFieldId() {
@@ -62,10 +64,15 @@ export function TenantSchemaPage() {
key: typeof field?.key === "string" ? field.key : "",
label: typeof field?.label === "string" ? field.label : "",
type:
field?.type === "number" || field?.type === "boolean"
field?.type === "number" ||
field?.type === "boolean" ||
field?.type === "date"
? field.type
: "text",
required: Boolean(field?.required),
adminOnly: Boolean(field?.adminOnly),
validation:
typeof field?.validation === "string" ? field.validation : "",
})),
);
}
@@ -105,6 +112,8 @@ export function TenantSchemaPage() {
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
},
]);
};
@@ -141,7 +150,7 @@ export function TenantSchemaPage() {
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
{fields.length === 0 && (
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
{t(
@@ -153,84 +162,144 @@ export function TenantSchemaPage() {
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-end gap-4 p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors"
className="p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors space-y-4"
>
<div className="flex-1 space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"예: employee_id",
)}
className="h-10"
/>
</div>
<div className="flex-1 space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label>
<Input
value={field.label}
onChange={(e) =>
updateField(index, { label: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"예: 사번",
)}
className="h-10"
/>
</div>
<div className="w-40 space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean"
) {
updateField(index, { type: nextType });
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) =>
updateField(index, { key: e.target.value })
}
}}
>
<option value="text">
{t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"예: employee_id",
)}
</option>
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label>
<Input
value={field.label}
onChange={(e) =>
updateField(index, { label: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"예: 사번",
)}
</option>
<option value="boolean">
{t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option>
</select>
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean" ||
nextType === "date"
) {
updateField(index, { type: nextType });
}
}}
>
<option value="text">
{t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
)}
</option>
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
)}
</option>
<option value="boolean">
{t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option>
<option value="date">
{t(
"ui.admin.tenants.schema.field.type_date",
"날짜 (Date)",
)}
</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.required}
onChange={(e) =>
updateField(index, { required: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.adminOnly}
onChange={(e) =>
updateField(index, { adminOnly: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.admin_only",
"관리자 전용",
)}
</span>
</label>
</div>
<div className="space-y-2">
<Input
value={field.validation}
onChange={(e) =>
updateField(index, { validation: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.validation_placeholder",
"정규식 (예: ^[0-9]+$)",
)}
className="h-9 text-xs font-mono"
/>
</div>
<div className="flex justify-end">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
>
<Trash2 size={18} />
</Button>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
>
<Trash2 size={18} />
</Button>
</div>
))}
</CardContent>

View File

@@ -17,7 +17,7 @@ import {
UserPlus,
Users,
} from "lucide-react";
import type React from "react";
import * as React from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -56,9 +56,11 @@ import {
TabsTrigger,
} from "../../../components/ui/tabs";
import {
type GroupSummary,
type TenantSummary,
type UserSummary,
createUser,
fetchGroups,
fetchTenants,
fetchUsers,
updateTenant,
@@ -141,6 +143,9 @@ const MemberListDialog: React.FC<{
<Users size={24} className="text-primary" />
{node.name}{" "}
{t("ui.admin.tenants.members.list_title", "구성원 관리")}
<span className="text-sm font-normal text-muted-foreground ml-1">
({isDirectLoading ? "..." : (directData?.total ?? 0)})
</span>
</DialogTitle>
<DialogDescription>
{t(
@@ -162,7 +167,7 @@ const MemberListDialog: React.FC<{
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-0 py-2"
>
{t("ui.admin.tenants.members.direct", "소속 멤버")} (
{node.memberCount || 0})
{isDirectLoading ? "..." : (directData?.total ?? 0)})
</TabsTrigger>
<TabsTrigger
value="descendants"
@@ -578,20 +583,79 @@ const TenantTreeRow: React.FC<{
level: number;
isRoot: boolean;
onRemove: (id: string, name: string) => void;
onMove: (id: string, newParentId: string) => void;
isUpdating: boolean;
}> = ({ node, level, isRoot, onRemove, isUpdating }) => {
searchTerm?: string;
}> = ({ node, level, isRoot, onRemove, onMove, isUpdating, searchTerm }) => {
const navigate = useNavigate();
const [isExpanded, setIsExpanded] = useState(true);
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
const [isMemberListOpen, setIsMemberListOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const hasChildren = node.children && node.children.length > 0;
// Auto expand if search matches children
React.useEffect(() => {
if (searchTerm) {
const hasMatchingChild = (n: TenantNode): boolean => {
return n.children.some(
(c) =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
hasMatchingChild(c),
);
};
if (hasMatchingChild(node)) {
setIsExpanded(true);
}
}
}, [searchTerm, node]);
const isMatching =
searchTerm &&
(node.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
node.slug.toLowerCase().includes(searchTerm.toLowerCase()));
const TypeIcon = getTenantIcon(node.type);
// DnD Handlers
const handleDragStart = (e: React.DragEvent) => {
if (isRoot) return;
e.dataTransfer.setData("nodeId", node.id);
e.dataTransfer.setData("nodeName", node.name);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (isUpdating) return;
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const draggedId = e.dataTransfer.getData("nodeId");
if (!draggedId || draggedId === node.id) return;
onMove(draggedId, node.id);
};
const hoverTitle = `${node.name} (${node.type})\n${t("ui.admin.tenants.members.direct", "소속 멤버")}: ${node.memberCount || 0}\n${t("ui.admin.tenants.members.total", "총 멤버")}: ${node.recursiveMemberCount || 0}`;
return (
<>
<TableRow
className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""}`}
draggable={!isRoot}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""} ${isMatching ? "bg-primary/10 ring-1 ring-primary/20" : ""} ${isDragOver ? "bg-primary/20 border-2 border-dashed border-primary" : ""}`}
title={hoverTitle}
>
<TableCell style={{ paddingLeft: `${1 + level * 2}rem` }}>
<div className="flex items-center gap-2">
@@ -651,6 +715,10 @@ const TenantTreeRow: React.FC<{
type="button"
className="flex items-center gap-2 cursor-pointer hover:bg-muted p-1.5 rounded-md transition-all group/members w-full text-left"
onClick={() => setIsMemberListOpen(true)}
title={t(
"msg.admin.org.hover_member_info",
"클릭하여 멤버 상세 조회",
)}
>
<div className="bg-primary/10 p-1.5 rounded text-primary">
<Users size={16} />
@@ -705,7 +773,15 @@ const TenantTreeRow: React.FC<{
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => navigate(`/tenants/${node.id}`)}
onClick={() => {
if (node.type === "USER_GROUP") {
// User groups have a different detail path
const baseTenantId = node.tenantId || tenantId;
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
} else {
navigate(`/tenants/${node.id}`);
}
}}
title={t("ui.common.manage", "관리")}
>
<ArrowRight size={14} />
@@ -739,7 +815,9 @@ const TenantTreeRow: React.FC<{
level={level + 1}
isRoot={false}
onRemove={onRemove}
onMove={onMove}
isUpdating={isUpdating}
searchTerm={searchTerm}
/>
))}
</>
@@ -752,6 +830,7 @@ function TenantUserGroupsTab() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [treeSearchTerm, setTreeSearchTerm] = useState("");
if (!tenantId) return null;
@@ -760,6 +839,23 @@ function TenantUserGroupsTab() {
queryFn: () => fetchTenants(1000, 0),
});
const { data: groupsData, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: !!tenantId,
});
const groupNodes = useMemo(() => {
if (!groupsData) return [];
return groupsData.map((g) => ({
...g,
type: "USER_GROUP",
children: [], // Simplified for now, just a list or separate tree
memberCount: g.members?.length || 0,
recursiveMemberCount: g.members?.length || 0,
})) as unknown as TenantNode[];
}, [groupsData]);
const updateParentMutation = useMutation({
mutationFn: ({
id,
@@ -775,13 +871,21 @@ function TenantUserGroupsTab() {
const allTenants = data?.items ?? [];
const { currentBase, subTree } = useMemo(
() => buildTenantFullTree(allTenants, tenantId),
[allTenants, tenantId],
);
const { currentBase, subTree } = useMemo(() => {
const tree = buildTenantFullTree(allTenants, tenantId);
if (tree.currentBase) {
// Merge backend-provided UserGroups into the tree as virtual children
tree.currentBase.children = [...tree.currentBase.children, ...groupNodes];
}
return tree;
}, [allTenants, tenantId, groupNodes]);
const handleAdd = (id: string) =>
updateParentMutation.mutate({ id, parentId: tenantId });
const handleMove = (id: string, newParentId: string) => {
if (id === newParentId) return;
updateParentMutation.mutate({ id, parentId: newParentId });
};
const handleRemove = (id: string, name: string) => {
if (
window.confirm(
@@ -974,6 +1078,30 @@ function TenantUserGroupsTab() {
</Dialog>
</div>
</CardHeader>
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.sub.tree_search_placeholder",
"조직도 내 검색...",
)}
className="pl-9 h-9"
value={treeSearchTerm}
onChange={(e) => setTreeSearchTerm(e.target.value)}
/>
</div>
{treeSearchTerm && (
<Button
variant="ghost"
size="sm"
onClick={() => setTreeSearchTerm("")}
className="text-xs"
>
{t("ui.common.clear_search", "검색 초기화")}
</Button>
)}
</div>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/5">
@@ -1001,7 +1129,9 @@ function TenantUserGroupsTab() {
level={0}
isRoot={true}
onRemove={handleRemove}
onMove={handleMove}
isUpdating={updateParentMutation.isPending}
searchTerm={treeSearchTerm}
/>
</TableBody>
</Table>

View File

@@ -26,8 +26,10 @@ import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
};
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
@@ -48,10 +50,16 @@ function UserCreatePage() {
});
const tenants = tenantsData?.items ?? [];
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<UserFormValues>({
defaultValues: {
@@ -68,6 +76,13 @@ function UserCreatePage() {
},
});
// Lock company for tenant_admin
React.useEffect(() => {
if (profile?.role === "tenant_admin" && profile.companyCode) {
setValue("companyCode", profile.companyCode);
}
}, [profile, setValue]);
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
@@ -85,8 +100,28 @@ function UserCreatePage() {
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
const registerMetadata = (field: UserSchemaField) =>
register(`metadata.${field.key}` as `metadata.${string}`, {
required: field.required
? t(
"msg.admin.users.create.form.field_required",
"{{label}}은(는) 필수입니다.",
{
label: field.label || field.key,
},
)
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.create.form.field_invalid",
"{{label}} 형식이 올바르지 않습니다.",
{ label: field.label || field.key },
),
}
: undefined,
});
const mutation = useMutation({
mutationFn: createUser,
@@ -107,17 +142,16 @@ function UserCreatePage() {
},
});
const onSubmit = (data: UserCreateRequest) => {
const onSubmit = (data: UserFormValues) => {
setError(null);
setGeneratedPassword(null);
setCreatedEmail(null);
if (autoPassword) {
mutation.mutate({ ...data, password: "" });
return;
}
const payload = { ...data };
if (!data.password) {
if (autoPassword) {
payload.password = "";
} else if (!data.password) {
setError(
t(
"msg.admin.users.create.password_required",
@@ -127,7 +161,7 @@ function UserCreatePage() {
return;
}
mutation.mutate(data);
mutation.mutate(payload);
};
const onCopyPassword = async () => {
@@ -335,6 +369,7 @@ function UserCreatePage() {
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
disabled={profile?.role === "tenant_admin"}
>
<option value="">
{t(
@@ -414,13 +449,40 @@ function UserCreatePage() {
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
{field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && (
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
Admin Only
</span>
)}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : ""
}
{...registerMetadata(field)}
/>
{errors.metadata?.[field.key] && (
<p className="text-xs text-destructive">
{
(errors.metadata[field.key] as { message?: string })
?.message
}
</p>
)}
</div>
))}
</div>

View File

@@ -1,8 +1,19 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import {
ArrowLeft,
BadgeCheck,
Building2,
Loader2,
Save,
Users,
} from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import {
type FieldErrors,
type UseFormRegister,
useForm,
} from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
@@ -15,7 +26,9 @@ import {
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
type TenantSummary,
type UserUpdateRequest,
fetchMe,
fetchTenant,
fetchTenants,
fetchUser,
@@ -26,11 +39,107 @@ import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
};
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
type UserFormValues = UserUpdateRequest & {
metadata: Record<string, Record<string, unknown>>;
};
// [New] Component for per-tenant profile/schema management
function TenantProfileCard({
tenant,
register,
errors,
isAdmin,
}: {
tenant: TenantSummary;
register: UseFormRegister<UserFormValues>;
errors: FieldErrors<UserFormValues>;
isAdmin: boolean;
}) {
const { data: detail, isLoading } = useQuery({
queryKey: ["tenant", tenant.id],
queryFn: () => fetchTenant(tenant.id),
});
const schema: UserSchemaField[] = Array.isArray(detail?.config?.userSchema)
? (detail?.config?.userSchema as UserSchemaField[])
: [];
if (isLoading)
return (
<div className="p-4 border rounded-lg animate-pulse bg-muted/20">
Loading schema...
</div>
);
if (schema.length === 0) return null;
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 size={14} className="text-primary" />
<span className="text-xs font-bold uppercase tracking-tight">
{tenant.name}
</span>
</div>
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
</div>
<div className="p-4 grid gap-4 md:grid-cols-2">
{schema.map((field) => (
<div key={field.key} className="space-y-2">
<Label
htmlFor={`metadata.${tenant.id}.${field.key}`}
className="text-xs"
>
{field.label}
{field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
</span>
)}
</Label>
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"
}
{...register(`metadata.${tenant.id}.${field.key}`, {
required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
})}
/>
{errors.metadata?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive">
{errors.metadata[tenant.id][field.key].message}
</p>
)}
</div>
))}
</div>
</div>
);
}
function UserDetailPage() {
const params = useParams<{ id: string }>();
@@ -40,6 +149,11 @@ function UserDetailPage() {
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const {
data: user,
isLoading,
@@ -77,25 +191,8 @@ function UserDetailPage() {
},
});
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
React.useEffect(() => {
if (user) {
@@ -139,8 +236,8 @@ function UserDetailPage() {
},
});
const onSubmit = (data: UserUpdateRequest) => {
const payload = { ...data };
const onSubmit = (data: UserFormValues) => {
const payload: UserUpdateRequest = { ...data };
if (!payload.password) {
payload.password = undefined;
}
@@ -163,6 +260,15 @@ function UserDetailPage() {
);
}
// Combined affiliated tenants
const userAffiliatedTenants = [...(user.joinedTenants || [])];
if (
user.tenant &&
!userAffiliatedTenants.find((t) => t.id === user.tenant?.id)
) {
userAffiliatedTenants.push(user.tenant);
}
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
@@ -212,6 +318,97 @@ function UserDetailPage() {
</div>
)}
{/* Tenant Affiliation Section (Enhanced) */}
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Building2 size={16} className="text-primary" />
{t(
"ui.admin.users.detail.tenants_section.title",
"소속 및 조직 정보",
)}
</h3>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t(
"ui.admin.users.detail.tenants_section.primary",
"대표 소속 테넌트",
)}
</Label>
{/* Select box to specify representative tenant from joined ones */}
{userAffiliatedTenants.length > 0 ? (
<div className="relative">
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("companyCode")}
disabled={
profile?.role === "tenant_admin" &&
userAffiliatedTenants.length <= 1
}
>
<option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역",
)}
</option>
{userAffiliatedTenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
<BadgeCheck
size={14}
className="absolute right-8 top-3 text-primary pointer-events-none"
/>
</div>
) : (
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</div>
)}
<p className="text-[10px] text-muted-foreground">
* .
</p>
</div>
{userAffiliatedTenants.length > 1 && (
<div className="space-y-1">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t(
"ui.admin.users.detail.tenants_section.additional",
"전체 소속 목록",
)}
</Label>
<div className="flex flex-wrap gap-1.5 pt-1">
{userAffiliatedTenants.map((jt) => (
<Link
key={jt.id}
to={`/tenants/${jt.id}`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
jt.id ===
tenants.find((t) => t.slug === watch("companyCode"))
?.id
? "bg-primary/10 border-primary/30 text-primary font-bold"
: "bg-background border-border text-muted-foreground hover:border-primary/50"
}`}
>
<Users size={10} />
{jt.name}
</Link>
))}
</div>
</div>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">
@@ -304,33 +501,6 @@ function UserDetailPage() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode">
{t("ui.admin.users.detail.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.detail.form.department", "부서")}
@@ -347,69 +517,38 @@ function UserDetailPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="position">
{t("ui.admin.users.detail.form.position", "직급")}
</Label>
<Input
id="position"
placeholder={t(
"ui.admin.users.detail.form.position_placeholder",
"수석/책임/선임",
)}
{...register("position")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="jobTitle">
{t("ui.admin.users.detail.form.job_title", "직무")}
</Label>
<Input
id="jobTitle"
placeholder={t(
"ui.admin.users.detail.form.job_title_placeholder",
"프론트엔드 개발",
)}
{...register("jobTitle")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{/* Tenant-specific Profiles (Namespaced Metadata) */}
<div className="border-t pt-6 space-y-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
{t(
"ui.admin.users.detail.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
"ui.admin.users.detail.custom_fields.multi_title",
"테넌트별 프로필 관리",
)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
/>
</div>
))}
</div>
<p className="text-[11px] text-muted-foreground">
.
</p>
</div>
)}
<div className="grid gap-4">
{userAffiliatedTenants.map((tenant) => (
<TenantProfileCard
key={tenant.id}
tenant={tenant}
register={register}
errors={errors}
isAdmin={isAdmin}
/>
))}
</div>
</div>
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t("ui.admin.users.detail.security.title", "보안 설정")}
</h3>
<div className="space-y-2">
<Label htmlFor="password">
{t(

View File

@@ -3,10 +3,12 @@ import type { AxiosError } from "axios";
import {
ChevronLeft,
ChevronRight,
FileDown,
Pencil,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
User,
} from "lucide-react";
@@ -21,6 +23,15 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Table,
@@ -30,20 +41,101 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteUser, fetchUsers } from "../../lib/adminApi";
import {
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
exportUsersCSVUrl,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
key: string;
label: string;
type: string;
};
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
const [visibleColumns, setVisibleColumns] = React.useState<
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const limit = 50;
const offset = (page - 1) * limit;
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const tenants = tenantsData?.items ?? [];
// Lock company for tenant_admin
React.useEffect(() => {
if (profile?.role === "tenant_admin" && profile.companyCode) {
setSelectedCompany(profile.companyCode);
}
}, [profile]);
const selectedTenantId = React.useMemo(() => {
return tenants.find((t) => t.slug === selectedCompany)?.id ?? "";
}, [tenants, selectedCompany]);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
// Initialize visible columns when schema changes
React.useEffect(() => {
if (userSchema.length > 0) {
const initial: Record<string, boolean> = {};
for (const field of userSchema) {
initial[field.key] = true;
}
setVisibleColumns((prev) => {
// Only set if not already set for these keys to avoid reset on every render
const next = { ...initial, ...prev };
return next;
});
}
}, [userSchema]);
const toggleColumn = (key: string) => {
setVisibleColumns((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const query = useQuery({
queryKey: ["users", { limit, offset, search }],
queryFn: () => fetchUsers(limit, offset, search),
queryKey: [
"users",
{ limit, offset, search, companyCode: selectedCompany },
],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
placeholderData: (previousData) => previousData,
});
@@ -65,6 +157,11 @@ function UserListPage() {
}
};
const handleExport = () => {
const url = exportUsersCSVUrl(search, selectedCompany);
window.open(url, "_blank");
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -79,11 +176,67 @@ function UserListPage() {
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
React.useEffect(() => {
if (items.length > 0) {
console.log("User items:", items);
const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) {
setSelectedUserIds([]);
} else {
setSelectedUserIds(items.map((u) => u.id));
}
}, [items]);
};
const toggleSelectUser = (id: string) => {
setSelectedUserIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
);
};
const bulkDeleteMutation = useMutation({
mutationFn: bulkDeleteUsers,
onSuccess: () => {
query.refetch();
setSelectedUserIds([]);
toast.success(
t(
"msg.admin.users.bulk.delete_success",
"선택한 사용자들이 삭제되었습니다.",
),
);
},
});
const bulkUpdateMutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
query.refetch();
setSelectedUserIds([]);
toast.success(
t(
"msg.admin.users.bulk.update_success",
"선택한 사용자들의 정보가 수정되었습니다.",
),
);
},
});
const handleBulkStatusChange = (status: string) => {
if (selectedUserIds.length === 0) return;
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
};
const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return;
if (
window.confirm(
t(
"msg.admin.users.bulk.delete_confirm",
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
{ count: selectedUserIds.length },
),
)
) {
bulkDeleteMutation.mutate(selectedUserIds);
}
};
const handleDelete = (userId: string, userName: string) => {
if (
@@ -111,13 +264,13 @@ function UserListPage() {
{t("ui.admin.users.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold">
<h2 className="text-3xl font-semibold" data-testid="page-title">
{t("ui.admin.users.list.title", "사용자 관리")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다. (Local DB)",
"시스템 사용자를 조회하고 관리합니다.",
)}
</p>
</div>
@@ -130,6 +283,67 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button variant="outline" onClick={handleExport} className="gap-2">
<FileDown size={16} />
{t("ui.common.export", "내보내기")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Settings2 size={16} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.list.columns.description",
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{userSchema.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t(
"msg.admin.users.list.columns.no_custom",
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
)}
</p>
)}
{userSchema.map((field) => (
<label
key={field.key}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={visibleColumns[field.key] !== false}
onChange={() => toggleColumn(field.key)}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{field.label}</span>
<span className="text-xs text-muted-foreground font-mono">
{field.key}
</span>
</div>
</label>
))}
</div>
<DialogFooter>
<DialogTrigger asChild>
<Button variant="secondary">
{t("ui.common.close", "닫기")}
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
@@ -155,8 +369,8 @@ function UserListPage() {
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<div className="mb-6 flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
@@ -169,6 +383,29 @@ function UserListPage() {
onKeyDown={handleKeyDown}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
</span>
<select
className="flex h-9 w-[200px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
</div>
<Button variant="secondary" onClick={handleSearch}>
{t("ui.common.search", "검색")}
</Button>
@@ -180,11 +417,22 @@ function UserListPage() {
</div>
)}
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<TableHead className="w-12">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={
items.length > 0 &&
selectedUserIds.length === items.length
}
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="min-w-[200px]">
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
</TableHead>
<TableHead>
@@ -199,12 +447,15 @@ function UserListPage() {
"TENANT / DEPT",
)}
</TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.position_job",
"POSITION / JOB",
)}
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableHead key={field.key} className="uppercase">
{field.label}
</TableHead>
),
)}
<TableHead>
{t("ui.admin.users.list.table.created", "CREATED")}
</TableHead>
@@ -216,20 +467,39 @@ function UserListPage() {
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
<TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
<TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow key={user.id}>
<TableRow
key={user.id}
className={
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
}
>
<TableCell>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleSelectUser(user.id)}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
@@ -262,32 +532,20 @@ function UserListPage() {
<span className="font-medium text-blue-600">
{user.tenant?.name || user.companyCode || "-"}
</span>
{user.tenant && (
<span className="text-[10px] text-muted-foreground uppercase">
{t(
"ui.admin.users.list.tenant_slug",
"Slug: {{slug}}",
{
slug: user.tenant.slug,
},
)}
</span>
)}
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium">
{user.position || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.jobTitle || "-"}
</span>
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableCell key={field.key} className="text-sm">
{String(user.metadata?.[field.key] ?? "-")}
</TableCell>
),
)}
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
@@ -297,11 +555,6 @@ function UserListPage() {
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
aria-label={t(
"ui.admin.users.list.edit_aria",
"사용자 수정: {{name}}",
{ name: user.name },
)}
>
<Pencil size={16} />
</Button>
@@ -311,11 +564,6 @@ function UserListPage() {
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
aria-label={t(
"ui.admin.users.list.delete_aria",
"사용자 삭제: {{name}}",
{ name: user.name },
)}
>
<Trash2 size={16} />
</Button>
@@ -327,6 +575,68 @@ function UserListPage() {
</Table>
</div>
{/* Bulk Action Bar */}
{selectedUserIds.length > 0 && (
<div
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300"
data-testid="bulk-action-bar"
>
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
count: selectedUserIds.length,
})}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("active")}
data-testid="bulk-active-btn"
>
{t("ui.common.status.active", "활성화")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("inactive")}
data-testid="bulk-inactive-btn"
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
}}
/>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"
size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete}
data-testid="bulk-delete-btn"
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
<Button
variant="ghost"
size="icon"
className="text-background/50 hover:text-background h-8 w-8 ml-2"
onClick={() => setSelectedUserIds([])}
aria-label={t("ui.common.close", "닫기")}
data-testid="bulk-close-btn"
>
<Plus size={16} className="rotate-45" />
</Button>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2">

View File

@@ -0,0 +1,217 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { FolderTree, Loader2, Search } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
type GroupSummary,
type TenantSummary,
bulkUpdateUsers,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface UserBulkMoveGroupModalProps {
userIds: string[];
onSuccess?: () => void;
}
export function UserBulkMoveGroupModal({
userIds,
onSuccess,
}: UserBulkMoveGroupModalProps) {
const [open, setOpen] = React.useState(false);
const [selectedTenantSlug, setSelectedTenantSlug] =
React.useState<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
const [searchTerm, setSearchTerm] = React.useState("");
const queryClient = useQueryClient();
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
enabled: open,
});
const tenants = tenantsData?.items ?? [];
const selectedTenantId = React.useMemo(
() => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
[tenants, selectedTenantSlug],
);
const { data: groups, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", selectedTenantId],
queryFn: () => fetchGroups(selectedTenantId),
enabled: open && !!selectedTenantId,
});
const mutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
toast.success(
t(
"msg.admin.users.bulk.move_success",
"사용자들의 부서가 이동되었습니다.",
),
);
setOpen(false);
onSuccess?.();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const handleMove = () => {
if (!selectedTenantSlug) return;
mutation.mutate({
userIds,
companyCode: selectedTenantSlug,
department: selectedGroupName, // can be empty for "No Department"
});
};
const filteredGroups = React.useMemo(() => {
if (!groups) return [];
if (!searchTerm) return groups;
return groups.filter((g) =>
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [groups, searchTerm]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
>
{t("ui.admin.users.bulk.move_group", "부서 이동")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.move_description",
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
{ count: userIds.length },
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
</label>
<select
className="flex h-9 w-full 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"
value={selectedTenantSlug}
onChange={(e) => {
setSelectedTenantSlug(e.target.value);
setSelectedGroupName("");
}}
>
<option value="">{t("ui.common.select", "선택하세요...")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
{selectedTenantSlug && (
<div className="space-y-2">
<label className="text-sm font-medium">
{t("ui.admin.users.bulk.select_group", "부서 선택")}
</label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "검색...")}
className="pl-9 h-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[200px] rounded-md border p-2">
<div className="space-y-1">
<button
type="button"
onClick={() => setSelectedGroupName("")}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === ""
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
</button>
{isGroupsLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
) : (
filteredGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setSelectedGroupName(group.name)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === group.name
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{group.name}
</button>
))
)}
</div>
</ScrollArea>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleMove}
disabled={!selectedTenantSlug || mutation.isPending}
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.do_move", "이동 실행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,305 @@
import { useMutation } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { parseUserCSV } from "../utils/csvParser";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
}
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const mutation = useMutation({
mutationFn: bulkCreateUsers,
onSuccess: (data) => {
setResults(data.results);
onSuccess?.();
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
parseCSV(selectedFile);
}
};
const parseCSV = (file: File) => {
setParsing(true);
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const data = parseUserCSV(text);
setPreviewData(data);
setParsing(false);
};
reader.readAsText(file);
};
const handleUpload = () => {
if (previewData.length > 0) {
mutation.mutate(previewData);
}
};
const downloadTemplate = () => {
const headers = "email,name,phone,role,companyCode,department,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
const blob = new Blob(
[
`${headers}
${example}`,
],
{ type: "text/csv" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
const reset = () => {
setFile(null);
setPreviewData([]);
setResults(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
return (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
className="gap-2"
data-testid="bulk-import-btn"
>
<Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.description",
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
)}
</DialogDescription>
</DialogHeader>
{!results ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
>
<Download size={14} />
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20">
<div className="flex items-center gap-3 mb-2">
<FileText className="text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
{parsing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
{t("msg.common.parsing", "파싱 중...")}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t(
"msg.admin.users.bulk.parsed_count",
"{{count}}명의 사용자가 감지되었습니다.",
{ count: previewData.length },
)}
</div>
)}
</div>
)}
{previewData.length > 0 && (
<ScrollArea className="h-[200px] rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="p-2 text-left">Email</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">Tenant</th>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u) => (
<tr key={u.email} className="border-t">
<td className="p-2">{u.email}</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.companyCode || "-"}</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={3}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
</td>
</tr>
)}
</tbody>
</table>
</ScrollArea>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">
{failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div>
</div>
<ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2">
{results.map((r) => (
<div
key={r.email}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? (
<CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : (
<AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div>
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
<DialogFooter>
{!results ? (
<Button
onClick={handleUpload}
disabled={previewData.length === 0 || mutation.isPending}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (
<Button
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
data-testid="bulk-close-dialog-btn"
>
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { parseUserCSV } from "./csvParser";
describe("parseUserCSV", () => {
it("should parse valid CSV correctly", () => {
const csv = `email,name,phone,role,companyCode,department,emp_id
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
email: "user1@test.com",
name: "Hong Gil Dong",
phone: "010-1111-2222",
role: "user",
companyCode: "baron",
department: "HR",
metadata: {
emp_id: "E001",
},
});
expect(result[1].email).toBe("user2@test.com");
expect(result[1].metadata.emp_id).toBe("E002");
});
it("should return empty array for empty input", () => {
expect(parseUserCSV("")).toEqual([]);
});
it("should skip rows without email or name", () => {
const csv = `email,name
,Only Name
no-name@test.com,`;
expect(parseUserCSV(csv)).toHaveLength(0);
});
it("should handle mixed case headers", () => {
const csv = `EMAIL,Name,CompanyCode
test@test.com,Test,baron`;
const result = parseUserCSV(csv);
expect(result[0].email).toBe("test@test.com");
expect(result[0].companyCode).toBe("baron");
});
});

View File

@@ -0,0 +1,48 @@
import type { BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] {
const lines = text.split(/\r?\n/);
if (lines.length < 2) {
return [];
}
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const data: BulkUserItem[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(",").map((v) => v.trim());
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
metadata: {},
};
for (let index = 0; index < headers.length; index++) {
const header = headers[index];
const value = values[index];
if (value === undefined || value === "") continue;
if (header === "email") {
item.email = value;
} else if (header === "name") {
item.name = value;
} else if (header === "phone") {
item.phone = value;
} else if (header === "role") {
item.role = value;
} else if (header === "companycode") {
item.companyCode = value;
} else if (header === "department") {
item.department = value;
} else {
item.metadata[header] = value;
}
}
if (item.email && item.name) {
data.push(item as BulkUserItem);
}
}
return data;
}

View File

@@ -260,6 +260,21 @@ export async function removeGroupMember(
);
}
export async function importOrgChart(tenantId: string, file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/import`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return data;
}
export type GroupRole = {
tenantId: string;
tenantName: string;
@@ -296,21 +311,6 @@ export async function removeGroupRole(
);
}
export async function importOrgChart(tenantId: string, file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/import`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return data;
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
@@ -354,6 +354,7 @@ export type UserSummary = {
status: string;
companyCode?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
position?: string;
@@ -397,6 +398,27 @@ export type UserUpdateRequest = {
jobTitle?: string;
};
export type BulkUserItem = {
email: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
department?: string;
metadata?: Record<string, unknown>;
};
export type BulkUserResult = {
email: string;
success: boolean;
message?: string;
userId?: string;
};
export type BulkUserResponse = {
results: BulkUserResult[];
};
export async function fetchUsers(
limit = 50,
offset = 0,
@@ -424,6 +446,43 @@ export async function createUser(payload: UserCreateRequest) {
return data;
}
export function exportUsersCSVUrl(search?: string, companyCode?: string) {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (companyCode) params.append("companyCode", companyCode);
// Get mock role from storage if exists for dev environment
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (mockRole) params.append("x-test-role", mockRole);
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
return `${baseUrl}/admin/users/export?${params.toString()}`;
}
export async function bulkCreateUsers(users: BulkUserItem[]) {
const { data } = await apiClient.post<BulkUserResponse>(
"/v1/admin/users/bulk",
{ users },
);
return data;
}
export async function bulkUpdateUsers(payload: {
userIds: string[];
status?: string;
role?: string;
}) {
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
return data;
}
export async function bulkDeleteUsers(userIds: string[]) {
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
data: { userIds },
});
return data;
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,

View File

@@ -1,7 +1,10 @@
import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE
? "http://playwright-mock/api"
: (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
});
apiClient.interceptors.request.use((config) => {

View File

@@ -24,30 +24,47 @@ export function buildTenantFullTree(
});
}
// Build initial children relations
const visitedDuringBuild = new Set<string>();
// Build initial children relations and prevent simple cycles
for (const t of allTenants) {
if (t.parentId) {
if (t.parentId && t.parentId !== t.id) {
const parent = tenantMap.get(t.parentId);
const child = tenantMap.get(t.id);
if (parent && child) {
// Simple cycle prevention during build: don't add if it creates an immediate loop
parent.children.push(child);
}
}
}
// Function to calculate recursive counts
const visitedForCalc = new Set<string>();
// Function to calculate recursive counts with cycle protection
const calculateRecursive = (node: TenantNode): number => {
if (visitedForCalc.has(node.id)) {
console.warn(
`Circular dependency detected in tenant tree for ID: ${node.id}`,
);
return 0; // Prevent infinite loop
}
visitedForCalc.add(node.id);
let total = node.memberCount || 0;
for (const child of node.children) {
total += calculateRecursive(child);
}
node.recursiveMemberCount = total;
// We don't remove from visitedForCalc here because a tree shouldn't have
// multiple paths to the same node anyway (it's a tree, not a graph).
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
// a node should only be visited once.
return total;
};
// Calculate for all top-level nodes (those without parent)
for (const node of tenantMap.values()) {
if (!node.parentId) {
visitedForCalc.clear();
calculateRecursive(node);
}
}
@@ -57,6 +74,7 @@ export function buildTenantFullTree(
const base = tenantMap.get(rootId);
if (base) {
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
visitedForCalc.clear();
calculateRecursive(base);
return { currentBase: base, subTree: base.children };
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,11 +13,35 @@ jangheon = ""
ptc = ""
saman = ""
[domain.tenant_type]
company = ""
company_group = ""
personal = ""
user_group = ""
[err]
[err.common]
unknown = ""
[err.backend]
authorization_pending = ""
bad_request = ""
conflict = ""
expired_token = ""
forbidden = ""
internal_error = ""
invalid_code = ""
invalid_or_expired_code = ""
invalid_session = ""
invalid_session_reference = ""
not_found = ""
not_supported = ""
password_or_email_mismatch = ""
rate_limited = ""
service_unavailable = ""
slow_down = ""
[err.userfront]
[err.userfront.auth_proxy]
@@ -49,6 +73,9 @@ scope_admin = ""
session_ttl = ""
tenant_headers = ""
[msg.admin.common]
forbidden = ""
[msg.admin.api_keys]
[msg.admin.api_keys.create]
@@ -89,17 +116,40 @@ count = ""
[msg.admin.groups]
[msg.admin.groups.create]
description = ""
title = ""
[msg.admin.groups.list]
create_error = ""
create_success = ""
delete_confirm = ""
delete_error = ""
delete_success = ""
empty = ""
import_error = ""
import_success = ""
loading = ""
subtitle = ""
[msg.admin.groups.members]
add_success = ""
count = ""
empty = ""
remove_confirm = ""
remove_success = ""
title = ""
[msg.admin.groups.prompt]
user_id = ""
[msg.admin.groups.roles]
assign_success = ""
description = ""
empty = ""
remove_confirm = ""
remove_success = ""
[msg.admin.header]
subtitle = ""
@@ -107,6 +157,12 @@ subtitle = ""
idp_policy = ""
scope = ""
[msg.admin.org]
hover_member_info = ""
import_description = ""
import_error = ""
import_success = ""
[msg.admin.overview]
description = ""
idp_fallback = ""
@@ -122,10 +178,36 @@ tenant_title = ""
[msg.admin.overview.quick_links]
description = ""
[msg.admin.overview.summary]
audit_events_24h = ""
oidc_clients = ""
policy_gate = ""
total_tenants = ""
[msg.admin.tenants]
approve_confirm = ""
approve_success = ""
delete_confirm = ""
delete_success = ""
empty = ""
fetch_error = ""
missing_id = ""
not_found = ""
remove_sub_confirm = ""
subtitle = ""
[msg.admin.tenants.admins]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants.owners]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants.create]
@@ -142,7 +224,9 @@ subtitle = ""
subtitle = ""
[msg.admin.tenants.members]
desc = ""
empty = ""
limit_notice = ""
[msg.admin.tenants.registry]
count = ""
@@ -160,15 +244,28 @@ subtitle = ""
[msg.admin.users]
[msg.admin.users.bulk]
delete_confirm = ""
delete_success = ""
description = ""
move_description = ""
move_error = ""
move_success = ""
parsed_count = ""
update_success = ""
[msg.admin.users.create]
error = ""
password_required = ""
success = ""
[msg.admin.users.create.account]
subtitle = ""
[msg.admin.users.create.form]
email_required = ""
field_invalid = ""
field_required = ""
name_required = ""
password_auto_help = ""
password_manual_help = ""
@@ -185,6 +282,7 @@ update_error = ""
update_success = ""
[msg.admin.users.detail.form]
field_required = ""
name_required = ""
[msg.admin.users.detail.security]
@@ -196,23 +294,40 @@ empty = ""
fetch_error = ""
subtitle = ""
[msg.admin.users.list.columns]
description = ""
no_custom = ""
[msg.admin.users.list.registry]
count = ""
[msg.common]
error = ""
loading = ""
no_description = ""
parsing = ""
requesting = ""
saving = ""
unknown_error = ""
[msg.dev]
logout_confirm = ""
[msg.dev.audit]
empty = ""
forbidden = ""
load_error = ""
loaded_count = ""
loading = ""
subtitle = ""
[msg.dev.clients]
copy_client_id = ""
load_error = ""
loading = ""
showing = ""
status_update_error = ""
status_updated = ""
deleted = ""
delete_error = ""
delete_confirm = ""
[msg.dev.clients.consents]
empty = ""
@@ -220,6 +335,7 @@ load_error = ""
loading = ""
showing = ""
subtitle = ""
revoke_confirm = ""
[msg.dev.clients.details]
copy_client_id = ""
@@ -247,6 +363,13 @@ note = ""
load_error = ""
loading = ""
saved = ""
save_error = ""
status_changed = ""
[msg.dev.clients.federation]
subtitle = ""
add_subtitle = ""
empty = ""
[msg.dev.clients.general.identity]
logo_help = ""
@@ -260,8 +383,8 @@ empty = ""
subtitle = ""
[msg.dev.clients.general.security]
confidential_help = ""
public_help = ""
private_help = ""
pkce_help = ""
subtitle = ""
[msg.dev.clients.help]
@@ -314,6 +437,7 @@ approved_device = ""
approved_ip = ""
audit_empty = ""
audit_load_error = ""
render_error = ""
auth_method = ""
client_id = ""
client_id_missing = ""
@@ -406,7 +530,6 @@ token_missing = ""
verification_failed = ""
[msg.userfront.login.link]
approved = ""
helper = ""
missing_login_id = ""
missing_phone = ""
@@ -469,8 +592,6 @@ organization = ""
security = ""
[msg.userfront.qr]
approve_error = ""
approve_success = ""
camera_error = ""
permission_error = ""
permission_required = ""
@@ -655,16 +776,30 @@ status = ""
time = ""
[ui.admin.groups]
import_csv = ""
[ui.admin.groups.create]
description = ""
title = ""
[ui.admin.groups.detail]
breadcrumb_org = ""
breadcrumb_tenant = ""
breadcrumb_unit = ""
members_subtitle = ""
members_title = ""
permissions_subtitle = ""
permissions_title = ""
[ui.admin.groups.form]
desc_label = ""
desc_placeholder = ""
name_label = ""
name_placeholder = ""
parent_label = ""
submit = ""
unit_level_label = ""
unit_level_placeholder = ""
[ui.admin.groups.list]
title = ""
@@ -696,6 +831,12 @@ user_groups = ""
tenants = ""
users = ""
[ui.admin.org]
download_template = ""
import_btn = ""
import_title = ""
start_import = ""
[ui.admin.overview]
kicker = ""
title = ""
@@ -705,10 +846,20 @@ title = ""
[ui.admin.overview.quick_links]
add_tenant = ""
tenant_dashboard = ""
api_key_management = ""
user_management = ""
title = ""
view_audit_logs = ""
[ui.admin.overview.summary]
audit_events_24h = ""
oidc_clients = ""
policy_gate = ""
total_tenants = ""
[ui.admin.profile]
manageable_tenants = ""
[ui.admin.role]
rp_admin = ""
super_admin = ""
@@ -720,6 +871,31 @@ user = ""
add = ""
title = ""
[ui.admin.tenants.admins]
add_button = ""
already_admin = ""
dialog_description = ""
dialog_no_results = ""
dialog_search_hint = ""
dialog_search_placeholder = ""
dialog_title = ""
remove_title = ""
table_actions = ""
table_email = ""
table_name = ""
title = ""
[ui.admin.tenants.owners]
add_button = ""
already_owner = ""
dialog_description = ""
dialog_title = ""
remove_title = ""
table_actions = ""
table_email = ""
table_name = ""
title = ""
[ui.admin.tenants.breadcrumb]
list = ""
section = ""
@@ -736,9 +912,11 @@ description = ""
domains_label = ""
domains_placeholder = ""
name = ""
parent = ""
slug = ""
slug_placeholder = ""
status = ""
type = ""
[ui.admin.tenants.create.memo]
title = ""
@@ -746,15 +924,47 @@ title = ""
[ui.admin.tenants.create.profile]
title = ""
[ui.admin.tenants.members]
[ui.admin.tenants.detail]
breadcrumb_list = ""
header_subtitle = ""
loading = ""
tab_federation = ""
tab_organization = ""
tab_permissions = ""
tab_profile = ""
tab_schema = ""
title = ""
[ui.admin.tenants.list]
select_placeholder = ""
[ui.admin.tenants.members]
descendants = ""
direct = ""
direct_label = ""
list_title = ""
title = ""
total = ""
total_label = ""
[ui.admin.tenants.members.table]
email = ""
name = ""
role = ""
status = ""
[ui.admin.tenants.profile]
allowed_domains = ""
allowed_domains_help = ""
approve_button = ""
description = ""
name = ""
slug = ""
status = ""
subtitle = ""
title = ""
type = ""
[ui.admin.tenants.registry]
title = ""
@@ -764,19 +974,29 @@ save = ""
title = ""
[ui.admin.tenants.schema.field]
admin_only = ""
key = ""
key_placeholder = ""
label = ""
label_placeholder = ""
required = ""
type = ""
type_boolean = ""
type_date = ""
type_number = ""
type_text = ""
validation_placeholder = ""
[ui.admin.tenants.sub]
add = ""
add_dialog_desc = ""
add_dialog_title = ""
add_existing = ""
manage = ""
no_candidates = ""
search_placeholder = ""
title = ""
tree_search_placeholder = ""
[ui.admin.tenants.sub.table]
action = ""
@@ -786,13 +1006,26 @@ status = ""
[ui.admin.tenants.table]
actions = ""
members = ""
name = ""
slug = ""
status = ""
type = ""
updated = ""
[ui.admin.users]
[ui.admin.users.bulk]
do_move = ""
download_template = ""
move_group = ""
move_title = ""
no_department = ""
select_group = ""
selected_count = ""
start_upload = ""
title = ""
[ui.admin.users.create]
back = ""
go_list = ""
@@ -815,12 +1048,16 @@ department = ""
department_placeholder = ""
email = ""
email_placeholder = ""
job_title = ""
job_title_placeholder = ""
name = ""
name_placeholder = ""
password = ""
password_placeholder = ""
phone = ""
phone_placeholder = ""
position = ""
position_placeholder = ""
role = ""
tenant = ""
tenant_global = ""
@@ -837,7 +1074,7 @@ title = ""
section = ""
[ui.admin.users.detail.custom_fields]
title = ""
multi_title = ""
[ui.admin.users.detail.form]
department = ""
@@ -856,19 +1093,32 @@ password = ""
password_placeholder = ""
title = ""
[ui.admin.users.detail.tenants_section]
additional = ""
primary = ""
title = ""
[ui.admin.users.list]
add = ""
delete_aria = ""
edit_aria = ""
bulk_import = ""
empty = ""
fetch_error = ""
search_placeholder = ""
tenant_slug = ""
subtitle = ""
title = ""
[ui.admin.users.list.breadcrumb]
list = ""
section = ""
[ui.admin.users.list.columns]
title = ""
[ui.admin.users.list.filter]
tenant = ""
[ui.admin.users.list.registry]
count = ""
title = ""
[ui.admin.users.list.table]
@@ -879,11 +1129,21 @@ role = ""
status = ""
tenant_dept = ""
[ui.admin.users.table]
email = ""
name = ""
role = ""
[ui.common]
add = ""
all = ""
admin_only = ""
assign = ""
back = ""
cancel = ""
change_file = ""
clear_search = ""
close = ""
collapse = ""
confirm = ""
@@ -892,25 +1152,36 @@ create = ""
delete = ""
details = ""
edit = ""
export = ""
fail = ""
go_home = ""
view = ""
hyphen = ""
manage = ""
na = ""
never = ""
next = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
reset = ""
read_only = ""
refresh = ""
requesting = ""
remove = ""
resend = ""
retry = ""
save = ""
search = ""
select = ""
select_file = ""
select_placeholder = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
success = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
@@ -938,14 +1209,48 @@ ok = ""
pending = ""
success = ""
[test]
key = ""
[non.existent]
key = ""
[ui.dev]
brand = ""
console_title = ""
env_badge = ""
scope_badge = ""
[ui.dev.nav]
clients = ""
logout = ""
[ui.dev.audit]
load_more = ""
title = ""
[ui.dev.audit.registry]
title = ""
[ui.dev.audit.filter]
action = ""
client_id = ""
status_all = ""
[ui.dev.audit.table]
action = ""
actor = ""
status = ""
target = ""
time = ""
[ui.dev.profile]
menu_aria = ""
menu_title = ""
unknown_email = ""
unknown_name = ""
[ui.dev.clients]
copy_client_id = ""
new = ""
search_placeholder = ""
tenant_scoped = ""
@@ -955,11 +1260,17 @@ untitled = ""
admin_session = ""
tenant_selected = ""
[ui.dev.clients.filter]
status_all = ""
type_all = ""
type_label = ""
[ui.dev.clients.consents]
export_csv = ""
revoke = ""
revoked_at = ""
scope_label = ""
search_placeholder = ""
status_all = ""
status_label = ""
status_revoked = ""
subject = ""
@@ -989,10 +1300,6 @@ user = ""
[ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
current = ""
section = ""
[ui.dev.clients.details.credentials]
client_id = ""
client_secret = ""
@@ -1025,16 +1332,13 @@ settings = ""
[ui.dev.clients.general]
create = ""
display_new = ""
save = ""
title_create = ""
title_edit = ""
[ui.dev.clients.general.breadcrumb]
section = ""
[ui.dev.clients.general.footer]
client_id = ""
created_on = ""
[ui.dev.clients.federation]
title = ""
add_title = ""
add_btn = ""
[ui.dev.clients.general.identity]
description = ""
@@ -1060,14 +1364,17 @@ title = ""
description = ""
mandatory = ""
name = ""
delete = ""
[ui.dev.clients.general.security]
confidential = ""
public = ""
private = ""
pkce = ""
title = ""
[ui.dev.clients.help]
docs_body = ""
docs_title = ""
subtitle = ""
title = ""
view_guides = ""
@@ -1084,9 +1391,15 @@ subtitle = ""
title = ""
[ui.dev.clients.registry]
description = ""
subtitle = ""
title = ""
[ui.dev.clients.scopes]
email = ""
openid = ""
profile = ""
[ui.dev.clients.table]
actions = ""
application = ""
@@ -1096,8 +1409,8 @@ status = ""
type = ""
[ui.dev.clients.type]
confidential = ""
public = ""
pkce = ""
private = ""
[ui.dev.dashboard]
ready_badge = ""
@@ -1133,6 +1446,14 @@ title = ""
plane = ""
subtitle = ""
[ui.dev.session]
active = ""
unknown = ""
expired = ""
expiring = ""
remaining = ""
refresh = ""
refreshing = ""
[ui.userfront]
app_title = ""
@@ -1209,12 +1530,9 @@ login_id = ""
password = ""
[ui.userfront.login.link]
action_label = ""
code_only = ""
page_title = ""
resend_with_time = ""
send = ""
title = ""
[ui.userfront.login.qr]
expired = ""
@@ -1284,9 +1602,7 @@ organization = ""
security = ""
[ui.userfront.qr]
request_permission = ""
rescan = ""
result_failure = ""
result_success = ""
title = ""