1
0
forked from baron/baron-sso

adminfront 태넌트 화면 기능 누락 복구

This commit is contained in:
2026-05-21 10:29:15 +09:00
parent 5bb1c5871c
commit 8dfe6fed82
3 changed files with 326 additions and 9 deletions

View File

@@ -763,7 +763,7 @@ function TenantListPage() {
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
"테넌트 이름, 슬러그, UUID 검색...",
)}
className="h-9 pl-9"
value={search}
@@ -771,6 +771,64 @@ function TenantListPage() {
/>
</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"]}>
<input
ref={fileInputRef}

View File

@@ -120,8 +120,8 @@ test.describe("Tenants Management", () => {
items: [
{
id: "company-1",
name: "Hanmac",
slug: "hanmac",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
@@ -162,7 +162,7 @@ test.describe("Tenants Management", () => {
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
.fill("team-1");
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
@@ -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 ({
page,
}) => {
@@ -565,6 +641,13 @@ test.describe("Tenants Management", () => {
let exportUrl = "";
let importRequested = false;
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) => {
const url = route.request().url();
@@ -627,18 +710,34 @@ test.describe("Tenants Management", () => {
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
// Open Data Management dropdown for export check
await page.getByTestId("tenant-data-mgmt-btn").click();
await openDataManagementMenu();
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();
const download = page.waitForEvent("download");
await page.getByTestId("tenant-export-menu-item").click();
await download;
await page.getByTestId("tenant-export-menu-item").dispatchEvent("click");
const exportDownload = await download;
expect(exportRequested).toBe(true);
expect(exportDownload.suggestedFilename()).toBe("tenants.csv");
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)
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",

View File

@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { expect, test } from "@playwright/test";
test.describe("Worksmobile tenant management", () => {
@@ -558,4 +559,163 @@ test.describe("Worksmobile tenant management", () => {
immutableRow.getByRole("button", { name: /비밀번호 관리/ }),
).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");
});
});