forked from baron/baron-sso
adminfront 태넌트 화면 기능 누락 복구
This commit is contained in:
@@ -763,7 +763,7 @@ function TenantListPage() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.tenants.list.search_placeholder",
|
"ui.admin.tenants.list.search_placeholder",
|
||||||
"테넌트 이름 또는 슬러그 검색...",
|
"테넌트 이름, 슬러그, UUID 검색...",
|
||||||
)}
|
)}
|
||||||
className="h-9 pl-9"
|
className="h-9 pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -771,6 +771,64 @@ function TenantListPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex rounded-md border bg-background p-0.5"
|
||||||
|
data-testid="tenant-view-mode-toggle"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
aria-pressed={viewMode === "tree"}
|
||||||
|
onClick={() => setViewMode("tree")}
|
||||||
|
data-testid="tenant-view-tree-btn"
|
||||||
|
>
|
||||||
|
<Network size={14} />
|
||||||
|
{t("ui.admin.tenants.view.tree", "트리")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={viewMode === "table" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
aria-pressed={viewMode === "table"}
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
data-testid="tenant-view-table-btn"
|
||||||
|
>
|
||||||
|
<List size={14} />
|
||||||
|
{t("ui.admin.tenants.view.table", "평면")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={scopeTenantId ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-9 gap-2"
|
||||||
|
onClick={() => setScopePickerOpen(true)}
|
||||||
|
data-testid="tenant-scope-picker-btn"
|
||||||
|
>
|
||||||
|
<Network size={16} />
|
||||||
|
{selectedScopeTenant
|
||||||
|
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||||
|
name: selectedScopeTenant.name,
|
||||||
|
})
|
||||||
|
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||||
|
</Button>
|
||||||
|
{scopeTenantId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setScopeTenantId("")}
|
||||||
|
data-testid="tenant-scope-clear-btn"
|
||||||
|
>
|
||||||
|
{t("ui.common.clear", "초기화")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<RoleGuard roles={["super_admin"]}>
|
<RoleGuard roles={["super_admin"]}>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ test.describe("Tenants Management", () => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "company-1",
|
id: "company-1",
|
||||||
name: "Hanmac",
|
name: "Acme",
|
||||||
slug: "hanmac",
|
slug: "acme",
|
||||||
status: "active",
|
status: "active",
|
||||||
type: "COMPANY",
|
type: "COMPANY",
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
@@ -162,7 +162,7 @@ test.describe("Tenants Management", () => {
|
|||||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||||
.fill("team-1");
|
.fill("team-1");
|
||||||
await expect(page.locator("table")).toContainText("Platform");
|
await expect(page.locator("table")).toContainText("Platform");
|
||||||
await expect(page.locator("table")).toContainText("Hanmac");
|
await expect(page.locator("table")).toContainText("Acme");
|
||||||
|
|
||||||
await page.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i).fill("");
|
await page.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i).fill("");
|
||||||
await page
|
await page
|
||||||
@@ -176,6 +176,82 @@ test.describe("Tenants Management", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("switches tree and flat views, searches UUID, and selects descendants", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1100, height: 760 });
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "company-1",
|
||||||
|
name: "Acme",
|
||||||
|
slug: "acme",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
memberCount: 0,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dept-1",
|
||||||
|
name: "Planning",
|
||||||
|
slug: "planning",
|
||||||
|
status: "active",
|
||||||
|
type: "ORGANIZATION",
|
||||||
|
parentId: "company-1",
|
||||||
|
memberCount: 0,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team-1",
|
||||||
|
name: "Platform",
|
||||||
|
slug: "platform",
|
||||||
|
status: "active",
|
||||||
|
type: "USER_GROUP",
|
||||||
|
parentId: "dept-1",
|
||||||
|
memberCount: 0,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("tenant-view-tree-btn")).toBeVisible();
|
||||||
|
await page.getByTestId("tenant-view-table-btn").click();
|
||||||
|
await expect(page.getByTestId("tenant-view-table-btn")).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
|
||||||
|
await expect(page.locator("table")).toContainText("Platform");
|
||||||
|
await expect(page.locator("table")).not.toContainText("Acme");
|
||||||
|
|
||||||
|
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
|
||||||
|
await page
|
||||||
|
.locator("tbody tr")
|
||||||
|
.filter({ hasText: "Acme" })
|
||||||
|
.getByRole("checkbox")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("tenant-bulk-action-bar")).toContainText(
|
||||||
|
"3개 선택됨",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("should virtualize large tenant lists and load next pages automatically", async ({
|
test("should virtualize large tenant lists and load next pages automatically", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -565,6 +641,13 @@ test.describe("Tenants Management", () => {
|
|||||||
let exportUrl = "";
|
let exportUrl = "";
|
||||||
let importRequested = false;
|
let importRequested = false;
|
||||||
let importBody = "";
|
let importBody = "";
|
||||||
|
const openDataManagementMenu = async () => {
|
||||||
|
const exportMenuItem = page.getByTestId("tenant-export-menu-item");
|
||||||
|
if (!(await exportMenuItem.isVisible().catch(() => false))) {
|
||||||
|
await page.getByTestId("tenant-data-mgmt-btn").click();
|
||||||
|
}
|
||||||
|
await expect(exportMenuItem).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||||
const url = route.request().url();
|
const url = route.request().url();
|
||||||
@@ -627,18 +710,34 @@ test.describe("Tenants Management", () => {
|
|||||||
|
|
||||||
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
|
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
|
||||||
|
|
||||||
// Open Data Management dropdown for export check
|
await openDataManagementMenu();
|
||||||
await page.getByTestId("tenant-data-mgmt-btn").click();
|
|
||||||
await expect(page.getByTestId("tenant-template-menu-item")).toBeVisible();
|
await expect(page.getByTestId("tenant-template-menu-item")).toBeVisible();
|
||||||
await expect(page.getByTestId("tenant-export-menu-item")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("tenant-import-menu-item")).toBeVisible();
|
await expect(page.getByTestId("tenant-import-menu-item")).toBeVisible();
|
||||||
|
|
||||||
const download = page.waitForEvent("download");
|
const download = page.waitForEvent("download");
|
||||||
await page.getByTestId("tenant-export-menu-item").click();
|
await page.getByTestId("tenant-export-menu-item").dispatchEvent("click");
|
||||||
await download;
|
const exportDownload = await download;
|
||||||
expect(exportRequested).toBe(true);
|
expect(exportRequested).toBe(true);
|
||||||
|
expect(exportDownload.suggestedFilename()).toBe("tenants.csv");
|
||||||
expect(exportUrl).toContain("includeIds=false");
|
expect(exportUrl).toContain("includeIds=false");
|
||||||
|
|
||||||
|
await openDataManagementMenu();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("tenant-export-with-ids-menu-item"),
|
||||||
|
).toBeVisible();
|
||||||
|
const exportWithIdsDownload = page.waitForEvent("download");
|
||||||
|
await page
|
||||||
|
.getByTestId("tenant-export-with-ids-menu-item")
|
||||||
|
.dispatchEvent("click");
|
||||||
|
await exportWithIdsDownload;
|
||||||
|
expect(exportUrl).toContain("includeIds=true");
|
||||||
|
|
||||||
|
await openDataManagementMenu();
|
||||||
|
const templateDownload = page.waitForEvent("download");
|
||||||
|
await page.getByTestId("tenant-template-menu-item").dispatchEvent("click");
|
||||||
|
const template = await templateDownload;
|
||||||
|
expect(template.suggestedFilename()).toBe("tenant-import-template.csv");
|
||||||
|
|
||||||
// Upload directly via setInputFiles (Playwright supports hidden inputs)
|
// Upload directly via setInputFiles (Playwright supports hidden inputs)
|
||||||
await page.getByTestId("tenant-import-input").setInputFiles({
|
await page.getByTestId("tenant-import-input").setInputFiles({
|
||||||
name: "tenants.csv",
|
name: "tenants.csv",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe("Worksmobile tenant management", () => {
|
test.describe("Worksmobile tenant management", () => {
|
||||||
@@ -558,4 +559,163 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
immutableRow.getByRole("button", { name: /비밀번호 관리/ }),
|
immutableRow.getByRole("button", { name: /비밀번호 관리/ }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("downloads initial password CSV and enqueues WORKS admin jobs", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
const headers = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const method = route.request().method();
|
||||||
|
|
||||||
|
if (url.pathname.endsWith("/user/me")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { id: "admin-user", name: "Admin", role: "super_admin" },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
|
||||||
|
method === "GET"
|
||||||
|
) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
tenant: {
|
||||||
|
id: "hanmac-family-id",
|
||||||
|
name: "한맥 가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
tokenConfigured: true,
|
||||||
|
adminTenantId: "works-tenant-1",
|
||||||
|
},
|
||||||
|
recentJobs: [
|
||||||
|
{
|
||||||
|
id: "job-retry",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: "user-failed",
|
||||||
|
action: "sync",
|
||||||
|
status: "failed",
|
||||||
|
retryCount: 1,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
|
||||||
|
) &&
|
||||||
|
method === "GET"
|
||||||
|
) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { users: [], groups: [] },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/hanmac-family-id/worksmobile/initial-passwords.csv",
|
||||||
|
) &&
|
||||||
|
method === "GET"
|
||||||
|
) {
|
||||||
|
requests.push("download-passwords");
|
||||||
|
return route.fulfill({
|
||||||
|
body: "email,password\nuser@example.com,Secret123!\n",
|
||||||
|
contentType: "text/csv",
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"Content-Disposition":
|
||||||
|
'attachment; filename="worksmobile-passwords.csv"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/hanmac-family-id/worksmobile/backfill/dry-run",
|
||||||
|
) &&
|
||||||
|
method === "POST"
|
||||||
|
) {
|
||||||
|
requests.push("dry-run");
|
||||||
|
return route.fulfill({ json: { id: "job-dry-run" }, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/hanmac-family-id/worksmobile/orgunits/org-1/sync",
|
||||||
|
) &&
|
||||||
|
method === "POST"
|
||||||
|
) {
|
||||||
|
requests.push("org-sync");
|
||||||
|
return route.fulfill({ json: { id: "job-org-sync" }, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/hanmac-family-id/worksmobile/users/user-1/sync",
|
||||||
|
) &&
|
||||||
|
method === "POST"
|
||||||
|
) {
|
||||||
|
requests.push("user-sync");
|
||||||
|
return route.fulfill({ json: { id: "job-user-sync" }, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/hanmac-family-id/worksmobile/jobs/job-retry/retry",
|
||||||
|
) &&
|
||||||
|
method === "POST"
|
||||||
|
) {
|
||||||
|
requests.push("retry");
|
||||||
|
return route.fulfill({ json: { id: "job-retry-next" }, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants/hanmac-family-id/worksmobile");
|
||||||
|
await expect(page.getByText("Worksmobile 연동")).toBeVisible();
|
||||||
|
|
||||||
|
const download = page.waitForEvent("download");
|
||||||
|
await page.getByRole("button", { name: "초기 비밀번호 CSV" }).click();
|
||||||
|
const passwordCsv = await download;
|
||||||
|
expect(passwordCsv.suggestedFilename()).toBe("worksmobile-passwords.csv");
|
||||||
|
const passwordCsvPath = await passwordCsv.path();
|
||||||
|
expect(passwordCsvPath).toBeTruthy();
|
||||||
|
expect(await readFile(passwordCsvPath ?? "", "utf8")).toContain(
|
||||||
|
"user@example.com,Secret123!",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Backfill Dry-run" }).click();
|
||||||
|
await expect.poll(() => requests).toContain("dry-run");
|
||||||
|
|
||||||
|
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
|
||||||
|
await page.getByRole("button", { name: "조직 Sync" }).click();
|
||||||
|
await expect.poll(() => requests).toContain("org-sync");
|
||||||
|
|
||||||
|
await page.getByPlaceholder("Kratos user UUID").fill("user-1");
|
||||||
|
await page.getByRole("button", { name: "구성원 Sync" }).click();
|
||||||
|
await expect.poll(() => requests).toContain("user-sync");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("row", { name: /USER:user-failed/ })
|
||||||
|
.getByRole("button")
|
||||||
|
.click();
|
||||||
|
await expect.poll(() => requests).toContain("retry");
|
||||||
|
expect(requests).toContain("download-passwords");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user