1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/issue-917-sub-email-support

This commit is contained in:
2026-05-29 13:23:06 +09:00
186 changed files with 11084 additions and 2370 deletions

View File

@@ -1,5 +1,5 @@
import { createBrowserRouter } from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LanguageSelector from "./LanguageSelector";

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
import { t } from "../../lib/i18n";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];

View File

@@ -22,13 +22,13 @@ import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
AppSidebar,
type ShellSidebarNavItem,
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
type ShellSidebarNavItem,
type ShellTranslator,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
@@ -310,13 +310,16 @@ function AppLayout() {
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
window.removeEventListener(
LOCALE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
window.removeEventListener(
DEV_ROLE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
};
}, [isDevelopmentRuntime]);
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -429,7 +432,6 @@ function AppLayout() {
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isDevelopmentRuntime,
isSessionExpiryEnabled,
]);
@@ -668,7 +670,10 @@ function AppLayout() {
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.shell.session.auto_extend", "세션 만료 관리")}
{t(
"ui.shell.session.auto_extend",
"세션 만료 관리",
)}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled ? (
@@ -677,7 +682,10 @@ function AppLayout() {
t={t}
/>
) : (
t("ui.shell.session.disabled", "세션 만료 비활성화")
t(
"ui.shell.session.disabled",
"세션 만료 비활성화",
)
)}
</p>
</div>

View File

@@ -44,4 +44,4 @@ const AvatarFallback = React.forwardRef<
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
export { Avatar, AvatarFallback, AvatarImage };

View File

@@ -50,9 +50,9 @@ function CardFooter({
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};

View File

@@ -144,18 +144,20 @@ const DialogClose = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
DialogClose.displayName = "DialogClose";
const DialogOverlay = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, onMouseDown, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogOverlay");
return (
<div
<button
type="button"
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 border-0 bg-black/80 p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
data-state="open"
aria-label="Close dialog"
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
if (event.target === event.currentTarget) {
setOpen(false);
@@ -273,13 +275,13 @@ DialogDescription.displayName = "DialogDescription";
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -183,18 +183,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
};

View File

@@ -146,13 +146,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectGroup,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -92,11 +92,11 @@ TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -84,4 +84,4 @@ const TabsContent = React.forwardRef<
});
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };
export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@@ -31,6 +31,8 @@ describe("AuthPage", () => {
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Check permission" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Check permission" }),
).toBeInTheDocument();
});
});

View File

@@ -42,7 +42,9 @@ describe("LoginPage", () => {
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
renderLoginPage("/login?returnTo=%2F");
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
@@ -61,7 +63,9 @@ describe("LoginPage", () => {
});
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).toHaveBeenCalledWith({
state: {

View File

@@ -48,10 +48,7 @@ function PermissionChecker() {
<Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.title",
"ReBAC permission checker",
)}
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
</CardTitle>
<CardDescription>
{t(
@@ -92,7 +89,9 @@ function PermissionChecker() {
</select>
</div>
<div className="space-y-2">
<Label>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
<Label>
{t("ui.admin.auth_guard.checker.relation", "Relation")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.relation_placeholder",
@@ -103,7 +102,9 @@ function PermissionChecker() {
/>
</div>
<div className="space-y-2">
<Label>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
<Label>
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.object_id_placeholder",
@@ -115,10 +116,7 @@ function PermissionChecker() {
</div>
<div className="space-y-2">
<Label>
{t(
"ui.admin.auth_guard.checker.subject",
"Subject (User:ID)",
)}
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
</Label>
<Input
placeholder={t(
@@ -155,10 +153,7 @@ function PermissionChecker() {
<>
<CheckCircle2 size={48} />
<div className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.allowed",
"Access ALLOWED",
)}
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
</div>
<p className="text-center text-sm opacity-80">
{t(
@@ -171,10 +166,7 @@ function PermissionChecker() {
<>
<XCircle size={48} />
<div className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.denied",
"Access DENIED",
)}
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
</div>
<p className="text-center text-sm opacity-80">
{t(

View File

@@ -175,16 +175,16 @@ describe("DataIntegrityPage", () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(
await screen.findByText("Data Integrity Check"),
).toBeInTheDocument();
expect(await screen.findByText("Data Integrity Check")).toBeInTheDocument();
expect(
await screen.findByText(
"Review integrity status and inspect checks across the admin data model.",
),
).toBeInTheDocument();
expect(await screen.findByText("Tenant integrity")).toBeInTheDocument();
expect(await screen.findByText("Duplicate tenant slug")).toBeInTheDocument();
expect(
await screen.findByText("Duplicate tenant slug"),
).toBeInTheDocument();
expect(
await screen.findByText(
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",

View File

@@ -12,10 +12,10 @@ import { Button } from "../../components/ui/button";
import {
type DataIntegrityCheck,
type DataIntegrityStatus,
type OrphanUserLoginID,
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchOrphanUserLoginIDs,
type OrphanUserLoginID,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";

View File

@@ -17,13 +17,13 @@ import {
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
type DataIntegrityStatus,
type RPUsageDailyMetric,
type RPUsagePeriod,
type TenantSummary,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchAllTenants,
fetchDataIntegrityReport,
type RPUsageDailyMetric,
type RPUsagePeriod,
type TenantSummary,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
@@ -203,10 +203,7 @@ function IntegrityOverviewSummary() {
<AlertTriangle size={18} className="text-amber-600" />
)}
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.summary.title",
"정합성 최종 검증",
)}
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
@@ -466,7 +463,7 @@ function GlobalOverviewPage() {
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const periodControls = (
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
<fieldset className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", t("ui.common.chart.period.day", "일")],
["week", t("ui.common.chart.period.week", "주")],
@@ -486,7 +483,7 @@ function GlobalOverviewPage() {
{label}
</button>
))}
</div>
</fieldset>
);
const chartFilters = (
<div>

View File

@@ -61,17 +61,13 @@ describe("UserProjectionPage", () => {
it("renders projection status for super_admin", async () => {
renderPage();
expect(
await screen.findByText("사용자 동기화 관리"),
).toBeInTheDocument();
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
expect(
await screen.findByText(
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
),
).toBeInTheDocument();
expect(
await screen.findByText("Kratos 사용자 동기화"),
).toBeInTheDocument();
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
@@ -100,9 +96,7 @@ describe("UserProjectionPage", () => {
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(
screen.queryByText("사용자 동기화 관리"),
).not.toBeInTheDocument();
expect(screen.queryByText("사용자 동기화 관리")).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
});

View File

@@ -133,10 +133,7 @@ function UserProjectionContent() {
disabled={isWorking}
>
<RotateCcw size={16} />
{t(
"ui.admin.user_projection.actions.reset",
"Reset and rebuild",
)}
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
</Button>
</div>
</header>
@@ -230,10 +227,7 @@ function UserProjectionContent() {
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.updated_at",
"Updated at",
)}
{t("ui.admin.user_projection.summary.updated_at", "Updated at")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.updatedAt)}
@@ -258,22 +252,19 @@ export default function UserProjectionPage() {
<RoleGuard
roles={["super_admin"]}
fallback={
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold">
{t(
"ui.admin.user_projection.forbidden.title",
"Access denied",
)}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.forbidden.description",
"This screen is only available to super_admin users.",
)}
</p>
</section>
</main>
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold">
{t("ui.admin.user_projection.forbidden.title", "Access denied")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.forbidden.description",
"This screen is only available to super_admin users.",
)}
</p>
</section>
</main>
}
>
<UserProjectionContent />

View File

@@ -7,8 +7,8 @@ import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTrigger,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Label } from "../../../components/ui/label";
import type { TenantSummary } from "../../../lib/adminApi";

View File

@@ -41,7 +41,6 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type TenantAdmin,
addTenantAdmin,
addTenantOwner,
fetchTenantAdmins,
@@ -49,6 +48,7 @@ import {
fetchUsers,
removeTenantAdmin,
removeTenantOwner,
type TenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -69,15 +69,14 @@ export function TenantAdminsAndOwnersTab() {
const auth = useAuth();
const navigate = useNavigate();
const currentUserId = auth.user?.profile.sub;
const { tenantId } = useParams<{ tenantId: string }>();
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
if (!tenantId) return null;
const ownersQuery = useQuery({
queryKey: ["tenant-owners", tenantId],
queryFn: () => fetchTenantOwners(tenantId),
@@ -339,6 +338,8 @@ export function TenantAdminsAndOwnersTab() {
}
};
if (!tenantId) return null;
const serverOwners = ownersQuery.data || [];
const serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);

View File

@@ -19,15 +19,15 @@ import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
type ServerDomainConflict,
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import {
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
shouldAllowHanmacOrgConfig,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
mergeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig";
type AdminFrontTestHooks = {

View File

@@ -53,13 +53,13 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
addGroupMember,
createGroup,
deleteGroup,
fetchGroups,
fetchTenant,
fetchUsers,
type GroupSummary,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";

View File

@@ -27,6 +27,7 @@ import {
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { PageHeader } from "../../../../../common/core/components/page";
import {
SortableTableHead,
sortableTableHeadBaseClassName,
@@ -38,7 +39,6 @@ import {
sortItems,
toggleSort,
} from "../../../../../common/core/utils";
import { PageHeader } from "../../../../../common/core/components/page";
import {
commonStickyTableHeaderClass,
commonTableShellClass,
@@ -92,18 +92,18 @@ import {
import { toast } from "../../../components/ui/use-toast";
import type { UserProfileResponse } from "../../../lib/adminApi";
import {
type TenantSummary,
deleteTenant,
deleteTenantsBulk,
exportTenantsCSV,
fetchMe,
fetchTenants,
importTenantsCSV,
type TenantSummary,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
@@ -112,20 +112,20 @@ import {
} from "../../users/orgChartPicker";
import { isSeedTenant } from "../utils/protectedTenants";
import {
type TenantImportPreviewRow,
type TenantImportResolution,
buildTenantImportParentOptionGroups,
buildTenantImportPreview,
inferTenantImportRootParentSlug,
parseTenantCSV,
serializeTenantImportCSV,
type TenantImportPreviewRow,
type TenantImportResolution,
} from "../utils/tenantCsvImport";
import {
type TenantViewMode,
type TenantViewRow,
filterTenantsByScope,
getTenantViewRows,
resolveTenantSelectionIds,
type TenantViewMode,
type TenantViewRow,
tenantMatchesListSearch,
} from "./tenantListView";
@@ -453,30 +453,6 @@ function TenantListPage() {
},
});
if (
profile &&
profileRole !== "super_admin" &&
profileRole !== "tenant_admin"
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
if (
profileRole === "tenant_admin" &&
(profile?.manageableTenants?.length ?? 0) <= 1
) {
return null;
}
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -574,6 +550,30 @@ function TenantListPage() {
return () => window.removeEventListener("message", onMessage);
}, [allTenants, scopePickerOpen]);
if (
profile &&
profileRole !== "super_admin" &&
profileRole !== "tenant_admin"
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
if (
profileRole === "tenant_admin" &&
(profile?.manageableTenants?.length ?? 0) <= 1
) {
return null;
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(deletableTenants.map((t) => t.id));

View File

@@ -26,34 +26,30 @@ import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
type ServerDomainConflict,
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import {
ORG_UNIT_TYPE_OPTIONS,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
} from "../utils/orgConfig";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const parentQuery = useQuery({
@@ -197,6 +193,12 @@ export function TenantProfilePage() {
? isSeedTenant(tenantQuery.data)
: false;
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const handleDelete = () => {
if (isProtectedSeedTenant) {
return;

View File

@@ -18,10 +18,10 @@ import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import {
type SchemaField,
createSchemaField,
isSchemaFieldType,
normalizeSchemaField,
type SchemaField,
} from "./tenantSchemaFields";
export function TenantSchemaPage() {

View File

@@ -38,7 +38,6 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type WorksmobileComparisonItem,
downloadWorksmobileInitialPasswordsCSV,
enqueueWorksmobileBackfillDryRun,
enqueueWorksmobileOrgUnitDelete,
@@ -47,13 +46,10 @@ import {
fetchWorksmobileComparison,
fetchWorksmobileOverview,
retryWorksmobileJob,
type WorksmobileComparisonItem,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
type WorksmobileComparisonColumnKey,
type WorksmobileComparisonColumnVisibility,
type WorksmobileComparisonFilter,
type WorksmobileComparisonSummary,
buildWorksmobilePasswordManageUrl,
canOpenWorksmobilePasswordManage,
canSelectWorksmobileRow,
@@ -71,6 +67,10 @@ import {
getWorksmobileSelectedActionIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
summarizeWorksmobileComparison,
type WorksmobileComparisonColumnKey,
type WorksmobileComparisonColumnVisibility,
type WorksmobileComparisonFilter,
type WorksmobileComparisonSummary,
} from "./worksmobileComparison";
export function TenantWorksmobilePage() {
@@ -1196,13 +1196,7 @@ function ComparisonTable({
);
}
function ComparisonDomainCell({
name,
id,
}: {
name?: string;
id?: number;
}) {
function ComparisonDomainCell({ name, id }: { name?: string; id?: number }) {
if (!name && !id) {
return <span className="text-muted-foreground">-</span>;
}

View File

@@ -1,5 +1,5 @@
import type { TenantSummary } from "../../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
export type TenantViewMode = "tree" | "table";
export type TenantViewRow = TenantNode & { depth: number };

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
ORG_UNIT_TYPE_OPTIONS,
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
readTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "./orgConfig";

View File

@@ -150,7 +150,6 @@ describe("tenantCsvImport", () => {
expect(csv).not.toContain("local-tenant-id");
});
it("preserves source tenant_id when a create resolution does not override it", () => {
const exportedTenantId = "11111111-2222-4333-8444-555555555555";
const rows = parseTenantCSV(

View File

@@ -403,7 +403,6 @@ function createTenantImportId() {
.padEnd(12, "0")}`;
}
function isUUIDLikeTenantId(value: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
@@ -596,7 +595,7 @@ function slugify(value: string) {
: "support",
};
let result = value.trim();
const result = value.trim();
// 1. 전체 매칭 확인
if (commonMappings[result]) {

View File

@@ -21,9 +21,9 @@ import {
TableRow,
} from "../../../components/ui/table";
import {
type TenantSummary,
fetchAllTenants,
fetchGroups,
type TenantSummary,
} from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() {

View File

@@ -70,17 +70,17 @@ import {
} from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import {
type TenantSummary,
type UserSummary,
createUser,
exportTenantsCSV,
fetchAllTenants,
fetchUsers,
type TenantSummary,
type UserSummary,
updateTenant,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
// --- Icons & Helpers ---
const getTenantIcon = (type?: string) => {
@@ -482,8 +482,10 @@ function TenantUserGroupsTab() {
mutationFn: ({
id,
parentId,
}: { id: string; parentId: string | undefined }) =>
updateTenant(id, { parentId: parentId || "" }),
}: {
id: string;
parentId: string | undefined;
}) => updateTenant(id, { parentId: parentId || "" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(

View File

@@ -574,9 +574,9 @@ export function UserGroupDetailPage() {
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
groupRoles.map((role) => (
<TableRow
key={`${role.tenantId}-${role.relation}-${idx}`}
key={`${role.tenantId}-${role.relation}`}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>

View File

@@ -40,21 +40,21 @@ import {
TabsTrigger,
} from "../../components/ui/tabs";
import {
type TenantSummary,
type UserAppointment,
type UserCreateRequest,
type UserCreateResponse,
createUser,
fetchAllTenants,
fetchMe,
fetchTenant,
type TenantSummary,
type UserAppointment,
type UserCreateRequest,
type UserCreateResponse,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import {
type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
@@ -111,7 +111,10 @@ function createEmptyAppointment(): AppointmentDraft {
tenantId: "",
tenantName: "",
tenantSlug: "",
isPrimary: false,
isOwner: false,
isAdmin: false,
isManager: false,
grade: "",
jobTitle: "",
position: "",
@@ -347,8 +350,8 @@ function UserCreatePage() {
if (currentIndex === index) {
return { ...appointment, ...patch };
}
if (patch.isOwner === true) {
return { ...appointment, isOwner: false };
if (patch.isPrimary === true) {
return { ...appointment, isPrimary: false };
}
return appointment;
}),
@@ -463,8 +466,10 @@ function UserCreatePage() {
tenantId: appointment.tenantId,
tenantSlug: appointment.tenantSlug,
tenantName: appointment.tenantName,
isPrimary: appointment.isOwner,
isOwner: appointment.isOwner,
isPrimary: appointment.isPrimary === true,
...(appointment.isOwner === true ? { isOwner: true } : {}),
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
...(appointment.isManager === true ? { isManager: true } : {}),
grade: appointment.grade,
jobTitle: appointment.jobTitle,
position: appointment.position,
@@ -480,12 +485,11 @@ function UserCreatePage() {
return;
}
const primary = appointments.find((a) => a.isOwner);
const primary = appointments.find((a) => a.isPrimary);
if (primary) {
metadata.primaryTenantId = primary.tenantId;
metadata.primaryTenantSlug = primary.tenantSlug;
metadata.primaryTenantName = primary.tenantName;
metadata.primaryTenantIsOwner = true;
}
payload.additionalAppointments = appointments;
@@ -916,10 +920,10 @@ function UserCreatePage() {
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isOwner}
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isOwner: checked === true,
isPrimary: checked === true,
})
}
aria-label={t(
@@ -932,6 +936,24 @@ function UserCreatePage() {
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</div>
</div>

View File

@@ -60,10 +60,8 @@ import {
TabsTrigger,
} from "../../components/ui/tabs";
import { toast } from "../../components/ui/use-toast";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import {
type TenantSummary,
type UserAppointment,
type UserUpdateRequest,
deleteUser,
fetchAllTenants,
fetchMe,
@@ -71,18 +69,20 @@ import {
fetchTenant,
fetchUser,
fetchUserRpHistory,
type TenantSummary,
type UserAppointment,
type UserUpdateRequest,
updateUser,
} from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { normalizeAdminRole } from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils";
import {
type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
isHanmacFamilyTenant,
isHanmacFamilyUser,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
@@ -142,6 +142,8 @@ function createEmptyAppointment(): AppointmentDraft {
tenantSlug: "",
isPrimary: false,
isOwner: false,
isAdmin: false,
isManager: false,
grade: "",
jobTitle: "",
position: "",
@@ -579,8 +581,8 @@ function UserDetailPage() {
if (currentIndex === index) {
return { ...appointment, ...patch };
}
if (patch.isOwner === true) {
return { ...appointment, isOwner: false };
if (patch.isPrimary === true) {
return { ...appointment, isPrimary: false };
}
return appointment;
}),
@@ -701,6 +703,9 @@ function UserDetailPage() {
isPrimary:
appointment.isPrimary === true ||
appointment.tenantId === primaryFromMetadata?.id,
isOwner: appointment.isOwner === true,
isAdmin: appointment.isAdmin === true,
isManager: appointment.isManager === true,
draftId: createDraftId(),
}))
: isUserHanmacFamily
@@ -714,6 +719,8 @@ function UserDetailPage() {
isOwner:
metadata.primaryTenantIsOwner === true &&
tenant.id === fallbackAppointment?.id,
isAdmin: false,
isManager: false,
grade: user.grade,
jobTitle: user.jobTitle,
position: user.position,
@@ -727,6 +734,8 @@ function UserDetailPage() {
tenantSlug: fallbackAppointment.slug,
isPrimary: true,
isOwner: metadata.primaryTenantIsOwner === true,
isAdmin: false,
isManager: false,
grade: user.grade,
jobTitle: user.jobTitle,
position: user.position,
@@ -836,23 +845,23 @@ function UserDetailPage() {
tenantId: appointment.tenantId,
tenantSlug: appointment.tenantSlug,
tenantName: appointment.tenantName,
isPrimary: appointment.isOwner,
isOwner: appointment.isOwner,
isPrimary: appointment.isPrimary === true,
...(appointment.isOwner === true ? { isOwner: true } : {}),
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
...(appointment.isManager === true ? { isManager: true } : {}),
grade: appointment.grade,
jobTitle: appointment.jobTitle,
position: appointment.position,
}));
const primary = appointments.find((a) => a.isOwner);
const primary = appointments.find((a) => a.isPrimary);
if (primary) {
payload.tenantSlug = primary.tenantSlug;
payload.primaryTenantId = primary.tenantId;
payload.primaryTenantName = primary.tenantName;
payload.primaryTenantIsOwner = true;
metadata.primaryTenantId = primary.tenantId;
metadata.primaryTenantSlug = primary.tenantSlug;
metadata.primaryTenantName = primary.tenantName;
metadata.primaryTenantIsOwner = true;
} else {
payload.tenantSlug = undefined;
}
@@ -868,12 +877,10 @@ function UserDetailPage() {
primaryTenantId: primary?.tenantId,
primaryTenantName: primary?.tenantName,
primaryTenantSlug: primary?.tenantSlug,
primaryTenantIsOwner: primary?.isOwner ?? false,
};
payload.tenantSlug = primary?.tenantSlug;
payload.primaryTenantId = primary?.tenantId;
payload.primaryTenantName = primary?.tenantName;
payload.primaryTenantIsOwner = primary?.isOwner ?? false;
}
mutation.mutate(payload);
@@ -1362,13 +1369,13 @@ function UserDetailPage() {
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isOwner}
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isOwner: checked === true,
isPrimary: checked === true,
})
}
disabled={appointment.isPrimary}
disabled={appointment.isPrimary === true}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
@@ -1379,6 +1386,24 @@ function UserDetailPage() {
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</div>
</div>

View File

@@ -0,0 +1,192 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import UserListPage from "./UserListPage";
const selectRenderCounter = vi.hoisted(() => ({ count: 0 }));
const users = Array.from({ length: 200 }, (_, index) => ({
id: `user-${index}`,
name: `User ${index}`,
email: `user${index}@example.com`,
phone: `010-${String(index).padStart(4, "0")}-0000`,
role: "user",
status: "active",
tenantSlug: "hanmac",
tenant: { id: "tenant-1", name: "한맥", slug: "hanmac" },
metadata: {},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
}));
const fetchUsersMock = vi.hoisted(() => vi.fn());
const searchRenderBudgetMs =
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200;
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-user",
role: "super_admin",
name: "Admin",
email: "admin@example.com",
})),
fetchAllTenants: vi.fn(async () => ({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
})),
fetchTenant: vi.fn(async () => ({
id: "tenant-1",
name: "한맥",
slug: "hanmac",
config: { userSchema: [] },
})),
fetchUsers: fetchUsersMock,
bulkCreateUsers: vi.fn(),
bulkDeleteUsers: vi.fn(),
bulkUpdateUsers: vi.fn(),
deleteUser: vi.fn(),
exportUsersCSV: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock("../../components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectTrigger: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
selectRenderCounter.count += 1;
return (
<button type="button" {...props}>
{children}
</button>
);
},
SelectValue: () => <span />,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value: _value,
}: {
children: React.ReactNode;
value: string;
}) => <div>{children}</div>,
}));
function renderUserListPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<UserListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
function createDeferred<T>() {
let resolve: (value: T) => void = () => {};
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
describe("UserListPage search rendering", () => {
beforeEach(() => {
selectRenderCounter.count = 0;
fetchUsersMock.mockReset();
fetchUsersMock.mockImplementation(
async (_limit: number, _offset: number, search?: string) => {
const normalizedSearch = search?.trim().toLowerCase();
const items = normalizedSearch
? users.filter((user) =>
`${user.name} ${user.email}`
.toLowerCase()
.includes(normalizedSearch),
)
: users;
return { items, total: items.length };
},
);
});
it("does not rerender user table controls while typing a draft search", async () => {
renderUserListPage();
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
const renderCountBeforeTyping = selectRenderCounter.count;
fireEvent.change(searchInput, { target: { value: "u" } });
expect(searchInput).toHaveValue("u");
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
});
it("keeps rendered row controls below the full 200-user result set", async () => {
renderUserListPage();
await screen.findByText("User 0");
expect(screen.getAllByTestId(/^user-status-select-/).length).toBeLessThan(
200,
);
});
it("renders compact vertically centered user table headers", async () => {
renderUserListPage();
await screen.findByText("User 0");
const nameHeader = screen.getByRole("columnheader", { name: /이름/ });
const content = nameHeader.firstElementChild;
expect(nameHeader).toHaveClass("h-9", "py-1", "align-middle", "text-xs");
expect(content).toHaveClass("flex", "h-full", "items-center");
});
it("centers the initial loading message across the user table", async () => {
const deferred = createDeferred<{ items: typeof users; total: number }>();
fetchUsersMock.mockReturnValueOnce(deferred.promise);
renderUserListPage();
const loadingCell = await screen.findByTestId("user-table-loading-cell");
expect(loadingCell).toHaveClass(
"flex",
"items-center",
"justify-center",
"text-center",
);
expect(loadingCell).toHaveStyle({ gridColumn: "1 / -1" });
deferred.resolve({ items: users, total: users.length });
});
it("renders a 200-user search result update within 200ms after search submit", async () => {
renderUserListPage();
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
const startedAt = performance.now();
fireEvent.change(searchInput, { target: { value: "user 19" } });
fireEvent.keyDown(searchInput, { key: "Enter" });
expect(screen.getByText("User 19")).toBeInTheDocument();
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
});
});

View File

@@ -1,4 +1,10 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
observeElementRect,
type Rect,
useVirtualizer,
type Virtualizer,
} from "@tanstack/react-virtual";
import type { AxiosError } from "axios";
import {
ArrowDown,
@@ -7,7 +13,6 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Users,
Download,
FileDown,
FileSpreadsheet,
@@ -19,13 +24,13 @@ import {
ShieldCheck,
Trash2,
Upload,
Users,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import {
SortableTableHead,
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../common/core/components/sort";
import {
@@ -81,7 +86,6 @@ import {
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
type UserSummary,
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
@@ -90,13 +94,15 @@ import {
fetchMe,
fetchTenant,
fetchUsers,
type TenantSummary,
type UserSummary,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import {
UserBulkUploadModal,
downloadUserTemplate,
UserBulkUploadModal,
} from "./components/UserBulkUploadModal";
import {
normalizeUserStatusValue,
@@ -113,6 +119,23 @@ type UserSchemaField = {
type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 8;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
const userMetadataColumnWidth = 160;
const userCreatedColumnWidth = 150;
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
const userTableHeadClassName =
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
const userTableHeadInteractiveClassName = `${userTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
const userTableHeadContentClassName = "flex h-full items-center gap-1";
const userSortableTableHeadClassName =
"!h-9 !px-3 !py-1 leading-tight whitespace-nowrap";
const userSortableTableHeadContentClassName = "h-full items-center";
const userTableStateCellClassName =
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
const bulkPermissionOptions = [
{
value: "super_admin",
@@ -130,11 +153,124 @@ function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user";
}
function userMatchesSearch(user: UserSummary, search: string) {
const normalizedSearch = search.trim().toLowerCase();
if (!normalizedSearch) {
return true;
}
return (
user.name?.toLowerCase().includes(normalizedSearch) ||
user.email?.toLowerCase().includes(normalizedSearch) ||
user.phone?.toLowerCase().includes(normalizedSearch) ||
user.id?.toLowerCase().includes(normalizedSearch) ||
user.tenantSlug?.toLowerCase().includes(normalizedSearch) ||
user.tenant?.name?.toLowerCase().includes(normalizedSearch) ||
user.department?.toLowerCase().includes(normalizedSearch) ||
false
);
}
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
return {
width: rect.width > 0 ? rect.width : fallbackWidth,
height:
rect.height > 0 ? rect.height : USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
};
}
type UserListSearchControlsProps = {
search: string;
selectedCompany: string;
tenants: TenantSummary[];
profileRole?: string | null;
onSearch: (value: string) => void;
onCompanyChange: (value: string) => void;
};
const UserListSearchControls = React.memo(function UserListSearchControls({
search,
selectedCompany,
tenants,
profileRole,
onSearch,
onCompanyChange,
}: UserListSearchControlsProps) {
const [searchDraft, setSearchDraft] = React.useState(search);
React.useEffect(() => {
setSearchDraft(search);
}, [search]);
const handleSearch = React.useCallback(() => {
onSearch(searchDraft);
}, [onSearch, searchDraft]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
handleSearch();
}
},
[handleSearch],
);
const tenantOptions = React.useMemo(
() =>
tenants.map((tenant) => (
<option key={tenant.id} value={tenant.slug}>
{tenant.name}
</option>
)),
[tenants],
);
return (
<SearchFilterBar
primary={
<>
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="h-9 pl-9"
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(event) => onCompanyChange(event.target.value)}
disabled={profileRole === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenantOptions}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</>
}
/>
);
});
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>
@@ -148,6 +284,7 @@ function UserListPage() {
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
@@ -254,16 +391,15 @@ function UserListPage() {
},
});
const handleSearch = () => {
setSearch(searchDraft);
const handleSearch = React.useCallback((nextSearch: string) => {
setSearch(nextSearch);
setPage(1);
};
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
const handleCompanyChange = React.useCallback((nextCompany: string) => {
setSelectedCompany(nextCompany);
setPage(1);
}, []);
const handleExport = (includeIds = false) => {
exportMutation.mutate(includeIds);
@@ -279,7 +415,14 @@ function UserListPage() {
)
: null;
const rawItems = query.data?.items ?? [];
const serverItems = query.data?.items ?? [];
const rawItems = React.useMemo(() => {
if (!query.isFetching || search.trim() === "") {
return serverItems;
}
return serverItems.filter((user) => userMatchesSearch(user, search));
}, [query.isFetching, search, serverItems]);
const userSortResolvers = React.useMemo<
SortResolverMap<UserSummary, UserSortKey>
>(
@@ -306,8 +449,55 @@ function UserListPage() {
[userSchema],
);
const items = React.useMemo(() => {
if (!sortConfig) {
return rawItems;
}
return sortItems(rawItems, sortConfig, userSortResolvers);
}, [rawItems, sortConfig, userSortResolvers]);
const visibleUserSchemaFields = React.useMemo(
() => userSchema.filter((field) => visibleColumns[field.key] !== false),
[userSchema, visibleColumns],
);
const userTableColumnWidths = React.useMemo(
() => [
...userFixedColumnWidths,
...visibleUserSchemaFields.map(() => userMetadataColumnWidth),
userCreatedColumnWidth,
],
[visibleUserSchemaFields],
);
const userTableGridTemplateColumns = React.useMemo(
() => userTableColumnWidths.map((width) => `${width}px`).join(" "),
[userTableColumnWidths],
);
const userTableMinWidth = React.useMemo(
() => userTableColumnWidths.reduce((sum, width) => sum + width, 0),
[userTableColumnWidths],
);
const observeUserTableElementRect = React.useCallback(
(instance: UserRowVirtualizer, callback: (rect: Rect) => void) =>
observeElementRect(instance, (rect) => {
callback(normalizeUserTableRect(rect, userTableMinWidth));
}),
[userTableMinWidth],
);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => userTableViewportRef.current,
estimateSize: () => USER_ROW_ESTIMATED_HEIGHT,
measureElement: (element) =>
element.getBoundingClientRect().height || USER_ROW_ESTIMATED_HEIGHT,
observeElementRect: observeUserTableElementRect,
overscan: USER_ROW_OVERSCAN,
initialRect: {
width: userTableMinWidth,
height: USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
},
});
const virtualRows = rowVirtualizer.getVirtualItems();
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length;
const requestSort = (key: UserSortKey) => {
setSortConfig((current) => toggleSort(current, key));
@@ -436,52 +626,13 @@ function UserListPage() {
)}
actions={
<>
<SearchFilterBar
primary={
<>
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="h-9 pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">
{t("ui.common.all", "전체 테넌트")}
</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</>
}
<UserListSearchControls
search={search}
selectedCompany={selectedCompany}
tenants={tenants}
profileRole={profile?.role}
onSearch={handleSearch}
onCompanyChange={handleCompanyChange}
/>
<Button
@@ -643,82 +794,92 @@ function UserListPage() {
)}
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<div
ref={userTableViewportRef}
data-testid="user-table-viewport"
className={commonTableViewportClass}
>
<Table style={{ display: "grid", minWidth: userTableMinWidth }}>
<TableHeader className={sortableTableHeaderClassName}>
<TableRow>
<TableHead
className={`${sortableTableHeadBaseClassName} 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}
/>
<TableRow
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
minWidth: userTableMinWidth,
}}
>
<TableHead className={`${userTableHeadClassName} w-12`}>
<div className="flex h-full items-center justify-center">
<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}
/>
</div>
</TableHead>
<TableHead
className="min-w-[120px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[120px]`}
onClick={() => requestSort("name")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.name", "이름")}
{getSortIcon("name")}
</div>
</TableHead>
<TableHead
className="min-w-[180px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[180px]`}
onClick={() => requestSort("email")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.email", "이메일")}
{getSortIcon("email")}
</div>
</TableHead>
<TableHead
className="min-w-[140px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[140px]`}
onClick={() => requestSort("phone")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.phone", "전화번호")}
{getSortIcon("phone")}
</div>
</TableHead>
<TableHead
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[220px]`}
onClick={() => requestSort("id")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.id", "ID")}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("status")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.status", "STATUS")}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("role")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.role", "ROLE")}
{getSortIcon("role")}
</div>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("tenant_dept")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
@@ -727,21 +888,20 @@ function UserListPage() {
</div>
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<SortableTableHead
key={field.key}
className="whitespace-nowrap"
label={field.label}
onSort={requestSort}
sortConfig={sortConfig}
sortKey={field.key}
/>
),
)}
{visibleUserSchemaFields.map((field) => (
<SortableTableHead
key={field.key}
className={userSortableTableHeadClassName}
contentClassName={userSortableTableHeadContentClassName}
label={field.label}
onSort={requestSort}
sortConfig={sortConfig}
sortKey={field.key}
/>
))}
<SortableTableHead
className="whitespace-nowrap"
className={userSortableTableHeadClassName}
contentClassName={userSortableTableHeadContentClassName}
label={t("ui.admin.users.list.table.created", "CREATED")}
onSort={requestSort}
sortConfig={sortConfig}
@@ -749,22 +909,51 @@ function UserListPage() {
/>
</TableRow>
</TableHeader>
<TableBody>
<TableBody
style={
shouldVirtualizeRows
? {
display: "grid",
height: `${rowVirtualizer.getTotalSize()}px`,
minWidth: userTableMinWidth,
position: "relative",
}
: undefined
}
>
{query.isLoading && (
<TableRow>
<TableRow
data-testid="user-table-loading-row"
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
minWidth: userTableMinWidth,
}}
>
<TableCell
colSpan={7 + userSchema.length}
className="h-24 text-center"
colSpan={tableColumnCount}
data-testid="user-table-loading-cell"
className={userTableStateCellClassName}
style={{ gridColumn: "1 / -1" }}
>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableRow
data-testid="user-table-empty-row"
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
minWidth: userTableMinWidth,
}}
>
<TableCell
colSpan={7 + userSchema.length}
className="h-24 text-center"
colSpan={tableColumnCount}
data-testid="user-table-empty-cell"
className={userTableStateCellClassName}
style={{ gridColumn: "1 / -1" }}
>
{t(
"msg.admin.users.list.empty",
@@ -773,145 +962,162 @@ function UserListPage() {
</TableCell>
</TableRow>
)}
{items.map((user) => (
<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 disabled:opacity-30 disabled:cursor-not-allowed"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleSelectUser(user.id)}
disabled={user.id === profile?.id}
title={
user.id === profile?.id
? t(
"msg.admin.users.self_delete_blocked",
"본인 계정은 삭제할 수 없습니다.",
)
: undefined
{shouldVirtualizeRows &&
virtualRows.map((virtualRow) => {
const user = items[virtualRow.index];
if (!user) return null;
return (
<TableRow
key={user.id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={
selectedUserIds.includes(user.id)
? "bg-primary/5"
: ""
}
/>
</TableCell>
<TableCell>
<Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
title={user.name}
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
height: `${virtualRow.size}px`,
minWidth: userTableMinWidth,
position: "absolute",
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
>
{user.name}
</Link>
</TableCell>
<TableCell
className="text-sm text-muted-foreground truncate max-w-[200px]"
title={user.email}
>
{user.email}
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{user.phone || "-"}
</TableCell>
<TableCell
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
data-testid={`user-internal-id-${user.id}`}
>
{user.id}
</TableCell>
<TableCell>
<Select
value={normalizeUserStatusValue(user.status)}
onValueChange={(status) =>
statusMutation.mutate({
userId: user.id,
status,
})
}
disabled={
statusMutation.isPending || user.id === profile?.id
}
>
<SelectTrigger
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
aria-label={t(
"ui.admin.users.list.change_status",
"{{name}} 상태 변경",
{ name: user.name },
)}
data-testid={`user-status-select-${user.id}`}
<TableCell>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleSelectUser(user.id)}
disabled={user.id === profile?.id}
title={
user.id === profile?.id
? t(
"msg.admin.users.self_delete_blocked",
"본인 계정은 삭제할 수 없습니다.",
)
: undefined
}
/>
</TableCell>
<TableCell>
<Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
title={user.name}
>
{user.name}
</Link>
</TableCell>
<TableCell
className="text-sm text-muted-foreground truncate max-w-[200px]"
title={user.email}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={assignableSystemRoleValue(user.role)}
onValueChange={(value) =>
bulkUpdateMutation.mutate({
userIds: [user.id],
role: value,
})
}
disabled={
bulkUpdateMutation.isPending ||
!isSuperAdminRole(profile?.role) ||
user.id === profile?.id
}
>
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
{user.email}
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{user.phone || "-"}
</TableCell>
<TableCell
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
data-testid={`user-internal-id-${user.id}`}
>
{user.id}
</TableCell>
<TableCell>
<Select
value={normalizeUserStatusValue(user.status)}
onValueChange={(status) =>
statusMutation.mutate({
userId: user.id,
status,
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
>
<SelectTrigger
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
aria-label={t(
"ui.admin.users.list.change_status",
"{{name}} 상태 변경",
{ name: user.name },
)}
data-testid={`user-status-select-${user.id}`}
>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<SelectValue />
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={assignableSystemRoleValue(user.role)}
onValueChange={(value) =>
bulkUpdateMutation.mutate({
userIds: [user.id],
role: value,
})
}
disabled={
bulkUpdateMutation.isPending ||
!isSuperAdminRole(profile?.role) ||
user.id === profile?.id
}
>
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
{visibleUserSchemaFields.map((field) => (
<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>
</TableRow>
))}
))}
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>

View File

@@ -16,12 +16,12 @@ import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
type TenantSummary,
type UserSummary,
bulkUpdateUsers,
fetchAllTenants,
fetchGroups,
type GroupSummary,
type TenantSummary,
type UserSummary,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";

View File

@@ -30,17 +30,17 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildTenantImportPreview,
type TenantCSVRow,
type TenantImportPreviewRow,
buildTenantImportPreview,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { parseUserCSV } from "../utils/csvParser";
import {
type HanmacImportEmailPreview,
buildHanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
import {
buildHanmacImportEmailPreview,
type HanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
@@ -551,7 +551,10 @@ export function UserBulkUploadModal({
</thead>
<tbody>
{previewData.slice(0, 10).map((u, index) => (
<tr key={`${u.email}-${index}`} className="border-t">
<tr
key={`${u.email}-${u.tenantSlug ?? ""}-${u.name}`}
className="border-t"
>
<td className="p-2">
<input
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"

View File

@@ -59,9 +59,7 @@ describe("orgChartPicker", () => {
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
includeInternal: false,
}),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
);
).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart");
});
it("parses the first tenant id and name from orgfront confirm messages", () => {

View File

@@ -12,7 +12,9 @@ export const userStatusValues = [
export type UserStatusValue = (typeof userStatusValues)[number];
export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
export function normalizeUserStatusValue(
status?: string | null,
): UserStatusValue {
switch ((status ?? "").trim().toLowerCase()) {
case "active":
return "active";

View File

@@ -206,6 +206,12 @@ function cleanAdditionalAppointment(
...(appointment.isOwner !== undefined
? { isOwner: appointment.isOwner }
: {}),
...(appointment.isAdmin !== undefined
? { isAdmin: appointment.isAdmin }
: {}),
...(appointment.isManager !== undefined
? { isManager: appointment.isManager }
: {}),
...(appointment.department ? { department: appointment.department } : {}),
...(appointment.grade ? { grade: appointment.grade } : {}),
...(appointment.position ? { position: appointment.position } : {}),
@@ -232,9 +238,7 @@ function normalizeHeader(header: string) {
"worksmobile_alias_email",
"worksmobile_alias_emails",
].includes(separatorNormalized) ||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(
compactKorean,
)
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(compactKorean)
) {
return "secondary_emails";
}

View File

@@ -26,7 +26,8 @@
--input: 215 25% 24%;
--ring: 209 79% 52%;
--radius: 0.75rem;
--app-background-image: radial-gradient(
--app-background-image:
radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%

View File

@@ -701,7 +701,9 @@ export type UserAppointment = {
tenantSlug?: string;
tenantName: string;
isPrimary?: boolean;
isOwner: boolean;
isOwner?: boolean;
isAdmin?: boolean;
isManager?: boolean;
jobTitle?: string;
grade?: string;
position?: string;
@@ -713,6 +715,8 @@ export type BulkUserAppointment = {
tenantName?: string;
isPrimary?: boolean;
isOwner?: boolean;
isAdmin?: boolean;
isManager?: boolean;
department?: string;
grade?: string;
position?: string;

View File

@@ -1,8 +1,6 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import {
shouldSuppressDevelopmentSessionRedirect,
} from "../../../common/core/session";
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
import { userManager } from "./auth";
let isRedirectingToLogin = false;

View File

@@ -38,9 +38,11 @@ describe("common cursor pagination fetch", () => {
expect(response.items).toEqual([{ id: "tenant-1" }, { id: "tenant-2" }]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0].toString()).toContain(
"/api/v1/admin/tenants?parentId=parent-1&limit=1&offset=0",
"/api/v1/admin/tenants?parentId=parent-1&limit=1",
);
expect(fetchMock.mock.calls[0][0].toString()).not.toContain("offset=");
expect(fetchMock.mock.calls[1][0].toString()).toContain("cursor=cursor-1");
expect(fetchMock.mock.calls[1][0].toString()).not.toContain("offset=");
expect(fetchMock.mock.calls[0][1]).toMatchObject({
headers: { Authorization: "Bearer token" },
credentials: "same-origin",

View File

@@ -1,5 +1,7 @@
const CLIENT_DEBUG_LOG_ENABLED = new Set(["1", "true", "yes", "y", "on"]).has(
String(import.meta.env.VITE_CLIENT_LOG_DEBUG ?? "").trim().toLowerCase(),
String(import.meta.env.VITE_CLIENT_LOG_DEBUG ?? "")
.trim()
.toLowerCase(),
);
export function debugLog(...args: Parameters<typeof console.debug>) {

View File

@@ -1,4 +1,8 @@
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "../../../common/core/i18n";
import {
DEFAULT_LOCALE,
LOCALE_STORAGE_KEY,
type Locale,
} from "../../../common/core/i18n";
function isLocale(value: string): value is Locale {
return value === "ko" || value === "en";

View File

@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import {
isSuperAdminRole,
normalizeAdminRole,
ROLE_RP_ADMIN,
ROLE_SUPER_ADMIN,
ROLE_TENANT_ADMIN,
ROLE_USER,
isSuperAdminRole,
normalizeAdminRole,
} from "./roles";
describe("admin role helpers", () => {

View File

@@ -3,8 +3,8 @@ import {
readSessionExpiryEnabled,
SESSION_RENEW_THRESHOLD_MS,
shouldAttemptSlidingSessionRenew,
shouldSuppressDevelopmentSessionRedirect,
shouldAttemptUnlimitedSessionRenew,
shouldSuppressDevelopmentSessionRedirect,
writeSessionExpiryEnabled,
} from "./sessionSliding";

View File

@@ -1,9 +1,9 @@
export {
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
readSessionExpiryEnabled,
shouldAttemptSlidingSessionRenew,
shouldSuppressDevelopmentSessionRedirect,
shouldAttemptUnlimitedSessionRenew,
shouldSuppressDevelopmentSessionRedirect,
writeSessionExpiryEnabled,
} from "../../../common/core/session";

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import {
type SortConfig,
compareNullableValues,
type SortConfig,
sortItems,
toggleSort,
} from "../../../common/core/utils";

View File

@@ -3,9 +3,9 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import LocaleRefreshBoundary from "./components/common/LocaleRefreshBoundary";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import LocaleRefreshBoundary from "./components/common/LocaleRefreshBoundary";
import { Toaster } from "./components/ui/toaster";
import { oidcConfig } from "./lib/auth";
import "./index.css";

View File

@@ -74,8 +74,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"user_login_ids.user_id가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다.",
"msg.admin.integrity.check.orphan_user_tenant_memberships.description":
"users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.",
"msg.admin.integrity.recheck.running":
"정합성 검사를 실행 중입니다.",
"msg.admin.integrity.recheck.running": "정합성 검사를 실행 중입니다.",
"msg.admin.integrity.recheck.success": "검사가 완료되었습니다.",
"msg.admin.user_projection.forbidden.description":
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
@@ -103,7 +102,8 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"ui.admin.auth_guard.checker.denied": "Access DENIED",
"ui.admin.auth_guard.checker.denied_description":
"The subject does not have access to the requested resource.",
"ui.admin.integrity.check.duplicate_tenant_slugs.title": "Duplicate tenant slug",
"ui.admin.integrity.check.duplicate_tenant_slugs.title":
"Duplicate tenant slug",
"ui.admin.integrity.section.tenant_integrity": "Tenant integrity",
"ui.admin.integrity.section.user_integrity": "User integrity",
"ui.admin.integrity.title": "Data Integrity Check",
@@ -173,7 +173,8 @@ function format(template: string, vars?: Vars) {
export function createI18nMock() {
return {
t(key: string, fallback?: string, vars?: Vars) {
const locale = window.localStorage.getItem("locale") === "en" ? "en" : "ko";
const locale =
window.localStorage.getItem("locale") === "en" ? "en" : "ko";
const template = translations[locale][key] ?? fallback ?? key;
return format(template, vars);
},