forked from baron/baron-sso
feat: improve Worksmobile tenant sync handling
This commit is contained in:
@@ -48,6 +48,7 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
@@ -56,7 +57,6 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -70,6 +70,7 @@ function renderLayout(entry = "/users") {
|
||||
path="tenants/:tenantId"
|
||||
element={<div>Tenant outlet</div>}
|
||||
/>
|
||||
<Route path="worksmobile" element={<div>Worksmobile outlet</div>} />
|
||||
<Route path="login" element={<div>Login outlet</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -99,7 +100,28 @@ describe("admin AppLayout", () => {
|
||||
expect(screen.getByText("Admin Control")).toBeInTheDocument();
|
||||
expect(screen.getByText("Users outlet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
||||
expect(screen.queryByText("User Projection")).not.toBeInTheDocument();
|
||||
const navigation = screen.getByRole("navigation");
|
||||
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
|
||||
link.textContent?.trim(),
|
||||
);
|
||||
expect(navLabels).toEqual([
|
||||
"Overview",
|
||||
"Tenants",
|
||||
"Worksmobile",
|
||||
"Users",
|
||||
"Data Integrity",
|
||||
"Auth Guard",
|
||||
"API Keys",
|
||||
"Audit Logs",
|
||||
]);
|
||||
const worksmobileIcon = screen.getByTestId("worksmobile-nav-icon");
|
||||
expect(worksmobileIcon.tagName.toLowerCase()).toBe("svg");
|
||||
expect(worksmobileIcon).toHaveAttribute("fill", "none");
|
||||
expect(worksmobileIcon.querySelectorAll("path")).toHaveLength(4);
|
||||
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||
|
||||
@@ -2,13 +2,11 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
ShieldCheck,
|
||||
ShieldHalf,
|
||||
@@ -32,7 +30,7 @@ import {
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
} from "../../../../common/shell";
|
||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
import { t } from "../../lib/i18n";
|
||||
@@ -61,6 +59,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
to: "/auth",
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.api_keys",
|
||||
labelFallback: "API Keys",
|
||||
@@ -73,12 +77,6 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
||||
to: "/audit-logs",
|
||||
icon: NotebookTabs,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
to: "/auth",
|
||||
icon: KeyRound,
|
||||
},
|
||||
];
|
||||
|
||||
type SessionStatusProps = {
|
||||
@@ -123,6 +121,38 @@ function SessionStatusText(props: SessionStatusProps) {
|
||||
return <>{sessionStatus.text}</>;
|
||||
}
|
||||
|
||||
function LineWorksNavIcon({ size = 18 }: { size?: number | string }) {
|
||||
const iconSize = typeof size === "number" ? size : Number.parseFloat(size);
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-testid="worksmobile-nav-icon"
|
||||
width={Number.isFinite(iconSize) ? iconSize : size}
|
||||
height={Number.isFinite(iconSize) ? iconSize : size}
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
className="shrink-0 text-current"
|
||||
>
|
||||
<path
|
||||
d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
@@ -178,11 +208,10 @@ function AppLayout() {
|
||||
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
|
||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
{ includeInternal: true },
|
||||
);
|
||||
|
||||
const showWorksmobile = canAccessWorksmobile({
|
||||
...profile,
|
||||
role: effectiveRole ?? profile?.role,
|
||||
});
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (isTest) return true;
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
@@ -196,20 +225,15 @@ function AppLayout() {
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
filteredItems.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
if (showWorksmobile) {
|
||||
filteredItems.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(4, 0, {
|
||||
labelKey: "ui.admin.nav.user_projection",
|
||||
labelFallback: "User Projection",
|
||||
to: "/system/projections/users",
|
||||
icon: Database,
|
||||
});
|
||||
filteredItems.splice(5, 0, {
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
to: "/system/data-integrity",
|
||||
@@ -231,26 +255,14 @@ function AppLayout() {
|
||||
icon: Building2,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(
|
||||
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
|
||||
0,
|
||||
{
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||
filteredItems.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
if (showWorksmobile) {
|
||||
filteredItems.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filteredItems;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function canShowWorksmobileEntry(tenant?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
parentId?: string | null;
|
||||
}) {
|
||||
return tenant?.slug === "hanmac-family" && !tenant.parentId;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
60
adminfront/src/features/tenants/routes/worksmobileAccess.ts
Normal file
60
adminfront/src/features/tenants/routes/worksmobileAccess.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,6 +73,7 @@ function buildUserTenantPreviewRows(
|
||||
emailDomain: user.tenantImport?.emailDomain ?? "",
|
||||
visibility: "public",
|
||||
orgUnitType: "node",
|
||||
worksmobileSync: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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([
|
||||
"사원",
|
||||
"대리",
|
||||
"과장",
|
||||
"차장",
|
||||
"부장",
|
||||
"이사",
|
||||
"상무",
|
||||
"전무",
|
||||
"부사장",
|
||||
"사장",
|
||||
"회장",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -818,6 +818,10 @@ export type WorksmobileCredentialBatchFailure = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorksmobilePendingJobDeleteResult = {
|
||||
deletedCount: number;
|
||||
};
|
||||
|
||||
export type WorksmobileComparisonItem = {
|
||||
resourceType: string;
|
||||
baronId?: string;
|
||||
@@ -978,6 +982,13 @@ export async function deleteWorksmobileCredentialBatchPasswords(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteWorksmobilePendingJobs(tenantId: string) {
|
||||
const { data } = await apiClient.delete<WorksmobilePendingJobDeleteResult>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/jobs/pending`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
|
||||
|
||||
Reference in New Issue
Block a user