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

@@ -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),
);
}