forked from baron/baron-sso
feat: improve Worksmobile tenant sync handling
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user