1
0
forked from baron/baron-sso

feat: improve Worksmobile tenant sync handling

This commit is contained in:
2026-06-02 18:05:36 +09:00
parent d6d39ca300
commit d32ca69eee
58 changed files with 4035 additions and 1400 deletions

View File

@@ -6,6 +6,9 @@ import {
fetchDataIntegrityReport,
fetchMe,
fetchOrphanUserLoginIDs,
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage";
@@ -60,6 +63,24 @@ vi.mock("../../lib/adminApi", () => ({
],
total: 1,
})),
fetchUserProjectionStatus: vi.fn(async () => ({
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
})),
reconcileUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:01:00Z",
})),
resetUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:02:00Z",
})),
deleteOrphanUserLoginIDs: vi.fn(async () => ({
deletedCount: 1,
deleted: [
@@ -95,6 +116,7 @@ describe("DataIntegrityPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
@@ -102,6 +124,12 @@ describe("DataIntegrityPage", () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "사용자 동기화" }),
).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
@@ -113,6 +141,28 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("renders user projection sync inside data integrity", async () => {
renderPage();
fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" }));
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await waitFor(() => {
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
await waitFor(() => {
expect(resetUserProjection).toHaveBeenCalledTimes(1);
});
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();

View File

@@ -19,6 +19,7 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
@@ -187,6 +188,14 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
}
}
function pageTabClassName(active: boolean) {
return `relative px-6 py-3 text-sm font-medium transition-colors ${
active
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`;
}
function OrphanLoginIDTable({
items,
selectedIds,
@@ -284,6 +293,9 @@ function OrphanLoginIDTable({
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
"integrity",
);
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
@@ -360,210 +372,243 @@ function DataIntegrityContent() {
</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
</header>
<div className="space-y-4 pb-6">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
{activeTab === "integrity" ? (
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
) : null}
</header>
<div
className="flex border-b border-border"
role="tablist"
aria-label="데이터 정합성 탭"
>
<button
type="button"
role="tab"
aria-selected={activeTab === "integrity"}
className={pageTabClassName(activeTab === "integrity")}
onClick={() => setActiveTab("integrity")}
>
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "projection"}
className={pageTabClassName(activeTab === "projection")}
onClick={() => setActiveTab("projection")}
>
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
</button>
</div>
{activeTab === "integrity" ? (
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
</div>
) : (
<div className="animate-in fade-in duration-500">
<UserProjectionContent embedded />
</div>
)}
</main>
);
}

View File

@@ -55,7 +55,11 @@ function ProjectionStatusBadge({
);
}
function UserProjectionContent() {
export function UserProjectionContent({
embedded = false,
}: {
embedded?: boolean;
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["user-projection-status"],
@@ -94,50 +98,55 @@ function UserProjectionContent() {
const actionResult = reconcileMutation.data ?? resetMutation.data;
const actionError = reconcileMutation.error ?? resetMutation.error;
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Users size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t(
"ui.admin.user_projection.title",
"User Projection Management",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.subtitle",
"Review and sync the Kratos user read model.",
)}
</p>
</div>
const header = (
<header
className={
embedded
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
}
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Users size={20} />
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
</Button>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.user_projection.title", "User Projection Management")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.subtitle",
"Review and sync the Kratos user read model.",
)}
</p>
</div>
</header>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
</Button>
</div>
</header>
);
const body = (
<>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
@@ -243,6 +252,22 @@ function UserProjectionContent() {
</div>
) : null}
</section>
</>
);
if (embedded) {
return (
<div className="space-y-4 pb-6">
{header}
{body}
</div>
);
}
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{header}
{body}
</main>
);
}

View File

@@ -33,6 +33,8 @@ type ParentTenantSelectorProps = {
orgChartPickerLabel?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
controlTestId?: string;
};
export function ParentTenantSelector({
@@ -49,6 +51,8 @@ export function ParentTenantSelector({
orgChartPickerLabel,
localPickerLabel,
localTenantFilter,
compact = false,
controlTestId,
}: ParentTenantSelectorProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false);
@@ -81,19 +85,37 @@ export function ParentTenantSelector({
}, [excludeTenantId, onChange, pickerOpen]);
return (
<div className="space-y-2">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
<div className={compact ? "space-y-1" : "space-y-2"}>
<div
className={
compact
? "flex min-h-5 flex-wrap items-center justify-between gap-2"
: "flex min-h-8 flex-wrap items-center justify-between gap-2"
}
>
<Label className="text-sm font-semibold">{label}</Label>
{labelAction}
</div>
<input id={id} name={id} type="hidden" value={value} readOnly />
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
<div
data-testid={controlTestId}
className={
compact
? "flex h-10 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2"
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"
}
>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Button
type="button"
variant="outline"
size="sm"
className={compact ? "h-8 shrink-0 px-2" : undefined}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
(compact ? undefined : selectedTenant?.name) ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
</DialogTrigger>
@@ -185,14 +207,23 @@ export function ParentTenantSelector({
)}
{selectedTenant ? (
<>
<span className="text-xs text-muted-foreground">
{selectedTenant.slug} · {selectedTenant.type}
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
title={`${selectedTenant.name} · ${selectedTenant.slug} · ${selectedTenant.type}`}
>
{compact
? `${selectedTenant.name} · ${selectedTenant.slug}`
: `${selectedTenant.slug} · ${selectedTenant.type}`}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
onClick={() => onChange("")}
aria-label={noneLabel}
>
@@ -200,7 +231,15 @@ export function ParentTenantSelector({
</Button>
</>
) : (
<span className="text-xs text-muted-foreground">{noneLabel}</span>
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
>
{noneLabel}
</span>
)}
{contextLabel && (
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">

View File

@@ -4,6 +4,7 @@ import { Building2, Sparkles } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Card,
CardContent,
@@ -46,6 +47,7 @@ function TenantCreatePage() {
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState<string[]>([]);
@@ -109,7 +111,11 @@ function TenantCreatePage() {
status,
domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
? mergeTenantOrgConfig(undefined, {
orgUnitType,
visibility,
worksmobileExcluded,
})
: undefined,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
@@ -284,6 +290,27 @@ function TenantCreatePage() {
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="flex min-h-9 items-center gap-2 rounded-md border border-input px-3 py-2"
>
<Checkbox
id="worksmobileExcluded"
checked={worksmobileExcluded}
onCheckedChange={(checked) =>
setWorksmobileExcluded(checked === true)
}
/>
<Label
htmlFor="worksmobileExcluded"
className="cursor-pointer text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"WORKS 연동 제외",
)}
</Label>
</div>
</>
)}
</div>

View File

@@ -1,7 +0,0 @@
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
describe("TenantDetailPage Worksmobile entry visibility", () => {
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
expect(
canShowWorksmobileEntry({
id: "hanmac-family-id",
slug: "hanmac-family",
parentId: undefined,
}),
).toBe(true);
expect(
canShowWorksmobileEntry({
id: "hanmac-child-id",
slug: "hanmac-family",
parentId: "root-id",
}),
).toBe(false);
expect(
canShowWorksmobileEntry({
id: "other-id",
slug: "other",
parentId: undefined,
}),
).toBe(false);
});
});

View File

@@ -5,7 +5,6 @@ import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
@@ -26,8 +25,6 @@ function TenantDetailPage() {
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema =
profileRole === "super_admin" || profileRole === "tenant_admin";
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
const isWorksmobileTab = location.pathname.includes("/worksmobile");
@@ -125,18 +122,6 @@ function TenantDetailPage() {
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
{showWorksmobileEntry && (
<Link
to={`/tenants/${tenantId}/worksmobile`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isWorksmobileTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -29,7 +29,6 @@ function renderTenantDetailPage() {
<Routes>
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
<Route index element={<div>profile</div>} />
<Route path="worksmobile" element={<div>worksmobile</div>} />
</Route>
</Routes>
</MemoryRouter>
@@ -42,16 +41,11 @@ describe("TenantDetailPage Worksmobile navigation", () => {
vi.clearAllMocks();
});
it("opens Worksmobile management in the current admin route", async () => {
it("does not render Worksmobile as a tenant detail tab", async () => {
renderTenantDetailPage();
const link = await screen.findByRole("link", { name: /Worksmobile/i });
await screen.findByText("프로필");
expect(link).toHaveAttribute(
"href",
"/tenants/hanmac-family-id/worksmobile",
);
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
expect(screen.queryByRole("link", { name: /Worksmobile/i })).toBeNull();
});
});

View File

@@ -116,7 +116,7 @@ import {
} from "./tenantListView";
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n";
const tenantPageSize = 500;
const _tenantVirtualizationThreshold = 250;
const _tenantEstimatedRowHeight = 73;

View File

@@ -30,8 +30,8 @@ import {
type ServerDomainConflict,
} from "../utils/domainTags";
import {
getOrgUnitTypeOptionsForTenantType,
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
@@ -70,6 +70,7 @@ export function TenantProfilePage() {
const [orgUnitType, setOrgUnitType] = useState("");
const [tenantVisibility, setTenantVisibility] =
useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
useEffect(() => {
if (tenantQuery.data) {
@@ -84,6 +85,7 @@ export function TenantProfilePage() {
setParentId(tenantQuery.data.parentId ?? "");
setOrgUnitType(orgConfig.orgUnitType);
setTenantVisibility(orgConfig.visibility);
setWorksmobileExcluded(orgConfig.worksmobileExcluded);
}
}, [tenantQuery.data]);
@@ -101,6 +103,7 @@ export function TenantProfilePage() {
orgConfigCandidate,
])
: false;
const orgUnitTypeOptions = getOrgUnitTypeOptionsForTenantType(type);
const updateMutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) => {
@@ -109,6 +112,7 @@ export function TenantProfilePage() {
? mergeTenantOrgConfig(baseConfig, {
orgUnitType,
visibility: tenantVisibility,
worksmobileExcluded,
})
: removeTenantOrgConfig(baseConfig);
@@ -226,78 +230,46 @@ export function TenantProfilePage() {
return (
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
<Card className="mt-4 bg-[var(--color-panel)]">
<CardHeader className="px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 px-5 pb-4">
{loadError && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{loadError}
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="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)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div
data-testid="tenant-parent-org-config-layout"
className="grid gap-4 md:grid-cols-4"
data-testid="tenant-profile-primary-row"
className="grid gap-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.8fr)_minmax(320px,1.4fr)]"
>
<div
data-testid="tenant-parent-picker-slot"
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
>
<div data-testid="tenant-name-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
id="parentId"
label={t(
@@ -308,18 +280,61 @@ export function TenantProfilePage() {
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
helpText={t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
excludeTenantId={tenantId}
compact
controlTestId="tenant-parent-picker-control"
/>
</div>
</div>
<div
data-testid="tenant-profile-config-row"
className="grid gap-3 lg:grid-cols-[minmax(190px,1fr)_minmax(150px,0.8fr)_minmax(150px,0.8fr)_minmax(190px,0.9fr)]"
>
<div data-testid="tenant-type-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
data-testid="tenant-type-select"
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)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
{canEditOrgConfig && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
@@ -328,19 +343,20 @@ export function TenantProfilePage() {
)}
</Label>
<select
data-testid="tenant-org-unit-type-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={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
{orgUnitTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-2">
<div data-testid="tenant-visibility-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
@@ -360,68 +376,92 @@ export function TenantProfilePage() {
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동",
)}
</Label>
<select
id="worksmobileExcluded"
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={worksmobileExcluded ? "excluded" : "enabled"}
onChange={(event) =>
setWorksmobileExcluded(event.target.value === "excluded")
}
>
<option value="enabled">
{t(
"ui.admin.tenants.profile.worksmobile_enabled",
"연동",
)}
</option>
<option value="excluded">
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"제외",
)}
</option>
</select>
</div>
</>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
{t(
"ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
<div className="grid gap-3 lg:grid-cols-[minmax(260px,0.9fr)_minmax(360px,1.4fr)_auto]">
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</div>
{errorMsg && (
@@ -432,7 +472,7 @@ export function TenantProfilePage() {
</CardContent>
</Card>
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<Button
variant="outline"
onClick={handleDelete}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
canAccessWorksmobile,
HANMAC_FAMILY_TENANT_ID,
} from "./worksmobileAccess";
describe("worksmobile access", () => {
it("allows super admins", () => {
expect(canAccessWorksmobile({ role: "super_admin" })).toBe(true);
});
it("allows hanmac-family tenant managers", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ id: HANMAC_FAMILY_TENANT_ID }],
}),
).toBe(true);
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ slug: "hanmac-family" }],
}),
).toBe(true);
});
it("rejects admins that do not manage hanmac-family", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ slug: "other-company" }],
}),
).toBe(false);
expect(
canAccessWorksmobile({
role: "user",
tenantId: HANMAC_FAMILY_TENANT_ID,
tenantSlug: "hanmac-family",
}),
).toBe(false);
expect(canAccessWorksmobile({ role: "user" })).toBe(false);
});
it("rejects admins that only manage Worksmobile-excluded hanmac-family tenants", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [
{
slug: "hanmac-family",
config: { worksmobileExcluded: true },
},
],
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,60 @@
import { isSuperAdminRole } from "../../../lib/roles";
export const HANMAC_FAMILY_TENANT_ID = "038326b6-954a-48a7-a85f-efd83f62b82a";
export const HANMAC_FAMILY_TENANT_SLUG = "hanmac-family";
export type WorksmobileAccessProfile = {
role?: string;
tenantId?: string;
tenantSlug?: string;
tenant?: {
id?: string;
slug?: string;
config?: Record<string, unknown>;
};
manageableTenants?: Array<{
id?: string;
slug?: string;
config?: Record<string, unknown>;
}>;
};
export function isWorksmobileExcludedConfig(
config?: Record<string, unknown>,
) {
const rawValue = config?.worksmobileExcluded;
return (
rawValue === true || String(rawValue ?? "").trim().toLowerCase() === "true"
);
}
function isProfileTenantWorksmobileExcluded(
profile?: WorksmobileAccessProfile | null,
) {
if (isWorksmobileExcludedConfig(profile?.tenant?.config)) {
return true;
}
return (profile?.manageableTenants ?? []).some((tenant) => {
const isCurrentTenant =
(profile?.tenantId && tenant.id === profile.tenantId) ||
(profile?.tenantSlug && tenant.slug === profile.tenantSlug);
return isCurrentTenant && isWorksmobileExcludedConfig(tenant.config);
});
}
export function canAccessWorksmobile(
profile?: WorksmobileAccessProfile | null,
) {
if (isSuperAdminRole(profile?.role)) {
return true;
}
if (isProfileTenantWorksmobileExcluded(profile)) {
return false;
}
return (profile?.manageableTenants ?? []).some(
(tenant) =>
!isWorksmobileExcludedConfig(tenant.config) &&
(tenant.id === HANMAC_FAMILY_TENANT_ID ||
tenant.slug === HANMAC_FAMILY_TENANT_SLUG),
);
}

View File

@@ -4,6 +4,7 @@ import {
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "./orgConfig";
@@ -49,17 +50,69 @@ describe("tenant org config", () => {
it("reads and writes tenant visibility and org unit type", () => {
expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({ orgUnitType: "팀", visibility: "private" });
).toEqual({
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: false,
});
expect(
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
).toEqual({ orgUnitType: "센터", visibility: "internal" });
).toEqual({
orgUnitType: "센터",
visibility: "internal",
worksmobileExcluded: false,
});
expect(
mergeTenantOrgConfig(
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
{ orgUnitType: "", visibility: "internal" },
{
orgUnitType: "",
visibility: "internal",
worksmobileExcluded: false,
},
),
).toEqual({ userSchema: [], visibility: "internal" });
).toEqual({
userSchema: [],
visibility: "internal",
worksmobileExcluded: false,
});
});
it("reads, writes, and removes the Worksmobile exclusion flag", () => {
expect(
readTenantOrgConfig({ worksmobileExcluded: true }),
).toMatchObject({
worksmobileExcluded: true,
});
expect(
readTenantOrgConfig({ worksmobileExcluded: "true" }),
).toMatchObject({
worksmobileExcluded: true,
});
expect(
mergeTenantOrgConfig(
{ userSchema: [], worksmobileExcluded: false },
{
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
},
),
).toEqual({
userSchema: [],
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
});
expect(
removeTenantOrgConfig({
userSchema: [],
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
}),
).toEqual({ userSchema: [] });
});
it("includes task-force and executive-direct org unit types", () => {

View File

@@ -14,6 +14,13 @@ export const ORG_UNIT_TYPE_OPTIONS = [
"임원직속",
] as const;
export const USER_GROUP_ORG_UNIT_TYPE_OPTIONS = [
"팀",
"TF",
"TF팀",
"셀",
] as const;
export const TENANT_VISIBILITY_OPTIONS = [
{ label: "공개", value: "public" },
{ label: "내부", value: "internal" },
@@ -26,6 +33,7 @@ export type TenantVisibility =
export type TenantOrgConfig = {
orgUnitType: string;
visibility: TenantVisibility;
worksmobileExcluded: boolean;
};
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
@@ -55,17 +63,29 @@ export function shouldAllowHanmacOrgConfig(
return false;
}
export function getOrgUnitTypeOptionsForTenantType(type: string) {
return type === "USER_GROUP"
? USER_GROUP_ORG_UNIT_TYPE_OPTIONS
: ORG_UNIT_TYPE_OPTIONS;
}
export function readTenantOrgConfig(
config: Record<string, unknown> | undefined,
): TenantOrgConfig {
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
const rawOrgUnitType = String(config?.orgUnitType ?? "");
const rawWorksmobileExcluded = config?.worksmobileExcluded;
return {
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
? (rawVisibility as TenantVisibility)
: "public",
worksmobileExcluded:
rawWorksmobileExcluded === true ||
String(rawWorksmobileExcluded ?? "")
.trim()
.toLowerCase() === "true",
};
}
@@ -76,6 +96,7 @@ export function mergeTenantOrgConfig(
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
const merged = { ...rest };
merged.visibility = next.visibility;
merged.worksmobileExcluded = next.worksmobileExcluded;
if (next.orgUnitType) {
merged.orgUnitType = next.orgUnitType;
@@ -90,6 +111,7 @@ export function removeTenantOrgConfig(
const {
orgUnitType: _orgUnitType,
visibility: _visibility,
worksmobileExcluded: _worksmobileExcluded,
...rest
} = config ?? {};
return rest;

View File

@@ -66,7 +66,7 @@ describe("tenantCsvImport", () => {
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터\n",
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터,no\n",
);
expect(rows).toEqual([
@@ -82,6 +82,7 @@ describe("tenantCsvImport", () => {
emailDomain: "hanmac-tech.example.com",
visibility: "internal",
orgUnitType: "센터",
worksmobileSync: "no",
},
]);
});
@@ -111,7 +112,7 @@ describe("tenantCsvImport", () => {
it("serializes selected matches by filling tenant_id before upload", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀\n",
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no\n",
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
@@ -119,10 +120,10 @@ describe("tenantCsvImport", () => {
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
);
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀",
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no",
);
});
@@ -253,10 +254,10 @@ ${exportedTenantId},Tenant With UUID,COMPANY,,,tenant-with-uuid,Memo,tenant-with
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,,,,yes",
);
});

View File

@@ -12,6 +12,7 @@ export type TenantCSVRow = {
emailDomain: string;
visibility: string;
orgUnitType: string;
worksmobileSync: string;
};
export type TenantCSVParseOptions = {
@@ -80,6 +81,7 @@ const importHeaders = [
"email_domain",
"visibility",
"org_unit_type",
"worksmobile_sync",
];
const headerAliases: Record<string, TenantCSVSourceKey> = {
@@ -116,6 +118,11 @@ const headerAliases: Record<string, TenantCSVSourceKey> = {
organization_type: "orgUnitType",
orgtype: "orgUnitType",
org_type: "orgUnitType",
worksmobile: "worksmobileSync",
worksmobilesync: "worksmobileSync",
worksmobile_sync: "worksmobileSync",
works_sync: "worksmobileSync",
works: "worksmobileSync",
};
export function parseTenantCSV(
@@ -175,6 +182,7 @@ export function parseTenantCSV(
emailDomain: value("emailDomain"),
visibility: value("visibility"),
orgUnitType: value("orgUnitType"),
worksmobileSync: normalizeWorksmobileSync(value("worksmobileSync")),
};
});
}
@@ -305,6 +313,7 @@ export function serializeTenantImportCSV(
preview.row.emailDomain,
preview.row.visibility,
preview.row.orgUnitType,
preview.row.worksmobileSync || "yes",
]);
}
return `${lines.map(formatCSVRecord).join("\n")}\n`;
@@ -528,6 +537,30 @@ function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
function normalizeWorksmobileSync(value: string) {
const normalized = value.trim().toLowerCase();
if (
[
"no",
"n",
"false",
"0",
"off",
"none",
"excluded",
"exclude",
"not_sync",
"not-synced",
"미연동",
"연동안함",
"제외",
].includes(normalized)
) {
return "no";
}
return "yes";
}
function slugFromMailingList(value: string) {
if (!value) return "";
return normalizeTenantSlug(value.split("@")[0] ?? value);

View File

@@ -52,6 +52,7 @@ import { isSuperAdminRole } from "../../lib/roles";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
@@ -896,63 +897,73 @@ function UserCreatePage() {
data-testid={`appointment-tenant-owner-line-${index}`}
>
<Label> </Label>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
disabled={isResolvingTenant}
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName || "테넌트 선택"}
</Button>
{appointment.tenantSlug && (
<span className="text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
<div
className="flex items-center justify-between gap-3"
data-testid={`appointment-tenant-owner-controls-${index}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
type="button"
variant="outline"
className="min-w-0 max-w-full"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
aria-label={t(
disabled={isResolvingTenant}
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName || "테넌트 선택"}
</span>
</Button>
{appointment.tenantSlug && (
<span className="truncate text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
</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(
</label>
<label className="flex items-center gap-2 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",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</label>
</div>
</div>
</div>
@@ -964,15 +975,26 @@ function UserCreatePage() {
<Label htmlFor={`appointment-grade-${index}`}>
</Label>
<Input
<select
id={`appointment-grade-${index}`}
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={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
grade: event.target.value || "",
})
}
/>
>
<option value=""></option>
{getTenantGradeOptions(
appointment,
tenants,
).map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor={`appointment-job-title-${index}`}>

View File

@@ -79,6 +79,7 @@ import { generateSecurePassword } from "../../lib/utils";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyTenant,
isHanmacFamilyUser,
type OrgChartTenantSelection,
@@ -1445,67 +1446,78 @@ function UserDetailPage() {
"소속 테넌트",
)}
</Label>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
disabled={isResolvingTenant}
>
<Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName ||
t(
"ui.admin.users.detail.form.pick_tenant",
"테넌트 선택",
)}
</Button>
{appointment.tenantSlug && (
<span className="text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
<div
className="flex items-center justify-between gap-3"
data-testid={`detail-appointment-tenant-owner-controls-${index}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
type="button"
variant="outline"
className="min-w-0 max-w-full"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
disabled={appointment.isPrimary === true}
aria-label={t(
disabled={isResolvingTenant}
data-testid={`detail-appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName ||
t(
"ui.admin.users.detail.form.pick_tenant",
"테넌트 선택",
)}
</span>
</Button>
{appointment.tenantSlug && (
<span className="truncate text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
})
}
disabled={appointment.isPrimary === true}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
</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(
</label>
<label className="flex items-center gap-2 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",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</label>
</div>
</div>
</div>
@@ -1522,15 +1534,26 @@ function UserDetailPage() {
"직급",
)}
</Label>
<Input
<select
id={`detail-appointment-grade-${index}`}
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={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
grade: event.target.value || "",
})
}
/>
>
<option value=""></option>
{getTenantGradeOptions(
appointment,
tenants,
).map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label

View File

@@ -73,6 +73,7 @@ function buildUserTenantPreviewRows(
emailDomain: user.tenantImport?.emailDomain ?? "",
visibility: "public",
orgUnitType: "node",
worksmobileSync: "yes",
});
});

View File

@@ -4,6 +4,7 @@ import {
buildAuthenticatedOrgChartUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
@@ -114,6 +115,22 @@ describe("orgChartPicker", () => {
type: "COMPANY",
parentId: undefined,
},
{
id: "internal-id",
slug: "internal",
name: "Internal",
type: "COMPANY",
parentId: undefined,
config: { visibility: "internal" },
},
{
id: "private-id",
slug: "private",
name: "Private",
type: "COMPANY",
parentId: undefined,
visibility: "private",
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
@@ -249,4 +266,54 @@ describe("orgChartPicker", () => {
),
).toBe(false);
});
it("returns GPDTDC rank options for GPDTDC subtree and general Hanmac ranks otherwise", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "gpdtdc-id",
slug: "gpdtdc",
name: "총괄기획&기술개발센터",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "gpdtdc-team-id",
slug: "gpdtdc-team",
name: "연구팀",
type: "USER_GROUP",
parentId: "gpdtdc-id",
},
{
id: "hanmac-id",
slug: "hanmac",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
];
expect(
getTenantGradeOptions({ tenantId: "gpdtdc-team-id" }, tenants),
).toEqual(["연구원", "선임", "책임", "수석", "부사장", "사장"]);
expect(getTenantGradeOptions({ tenantSlug: "hanmac" }, tenants)).toEqual([
"사원",
"대리",
"과장",
"차장",
"부장",
"이사",
"상무",
"전무",
"부사장",
"사장",
"회장",
]);
});
});

View File

@@ -12,6 +12,8 @@ export type TenantFilterTarget = {
parentId?: string | null;
name?: string;
tenantName?: string;
visibility?: string;
config?: Record<string, unknown>;
};
export type HanmacFamilyUserTarget = {
@@ -43,6 +45,29 @@ type OrgChartLoginOptions = {
returnTo?: string;
};
export const GPDTDC_GRADE_OPTIONS = [
"연구원",
"선임",
"책임",
"수석",
"부사장",
"사장",
] as const;
export const HANMAC_FAMILY_GRADE_OPTIONS = [
"사원",
"대리",
"과장",
"차장",
"부장",
"이사",
"상무",
"전무",
"부사장",
"사장",
"회장",
] as const;
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
@@ -56,6 +81,73 @@ function isSystemTenant(tenant: TenantFilterTarget) {
);
}
function resolveTenantTarget<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
if (!target) return undefined;
const tenantID = target.id ?? target.tenantId ?? "";
const tenantSlug = target.slug ?? target.tenantSlug ?? "";
return (
tenants.find((tenant) => tenantID && tenant.id === tenantID) ??
tenants.find(
(tenant) =>
tenantSlug &&
tenant.slug?.trim().toLowerCase() === tenantSlug.trim().toLowerCase(),
) ??
target
);
}
function isGPDTDCTenant<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
const tenant = resolveTenantTarget(target, tenants);
if (!tenant) return false;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
let current: TenantFilterTarget | undefined = tenant;
const visited = new Set<string>();
while (current) {
const slug = current.slug?.trim().toLowerCase();
if (slug === "gpdtdc") {
return true;
}
const parentId = current.parentId ?? "";
if (!parentId || visited.has(parentId)) {
return false;
}
visited.add(parentId);
current = tenantById.get(parentId);
}
return false;
}
export function getTenantGradeOptions<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
return isGPDTDCTenant(target, tenants)
? [...GPDTDC_GRADE_OPTIONS]
: [...HANMAC_FAMILY_GRADE_OPTIONS];
}
function isPublicRepresentativeTenant(tenant: TenantFilterTarget) {
const visibility = String(
tenant.visibility ?? tenant.config?.visibility ?? "public",
)
.trim()
.toLowerCase();
return visibility !== "internal" && visibility !== "private";
}
function isInTenantSubtree<T extends TenantFilterTarget>(
tenant: T,
rootTenantId: string,
@@ -187,6 +279,7 @@ export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
isPublicRepresentativeTenant(tenant) &&
!isInTenantSubtree(tenant, rootTenantId, tenantById),
);
}