forked from baron/baron-sso
테넌트 목록 및 조직 계층 구조 개선
This commit is contained in:
@@ -19,7 +19,7 @@ describe("Label Component", () => {
|
||||
<>
|
||||
<Label htmlFor="test-input">Label Text</Label>
|
||||
<input id="test-input" />
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
const label = screen.getByText("Label Text");
|
||||
expect(label).toHaveAttribute("for", "test-input");
|
||||
|
||||
87
adminfront/src/components/ui/tabs.tsx
Normal file
87
adminfront/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const TabsContext = React.createContext<{
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}>({});
|
||||
|
||||
const Tabs = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
>(({ className, value, onValueChange, ...props }, ref) => {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||
<div ref={ref} className={cn("w-full", className)} {...props} />
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
});
|
||||
Tabs.displayName = "Tabs";
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const { value: activeValue, onValueChange } = React.useContext(TabsContext);
|
||||
const isSelected = activeValue === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
data-state={isSelected ? "active" : "inactive"}
|
||||
onClick={() => onValueChange?.(value)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { value: string }
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const { value: activeValue } = React.useContext(TabsContext);
|
||||
const isSelected = activeValue === value;
|
||||
|
||||
if (!isSelected) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tabpanel"
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -29,8 +29,8 @@ function TenantCreatePage() {
|
||||
const [domains, setDomains] = useState("");
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
|
||||
@@ -27,118 +27,13 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type TenantNode = TenantSummary & { children: TenantNode[] };
|
||||
|
||||
function buildTenantTree(tenants: TenantSummary[]): TenantNode[] {
|
||||
const tenantMap = new Map<string, TenantNode>();
|
||||
const rootTenants: TenantNode[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
tenantMap.set(tenant.id, { ...tenant, children: [] });
|
||||
}
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const node = tenantMap.get(tenant.id);
|
||||
if (!node) continue;
|
||||
|
||||
if (tenant.parentId) {
|
||||
const parent = tenantMap.get(tenant.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
rootTenants.push(node); // Orphaned
|
||||
}
|
||||
} else {
|
||||
rootTenants.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return rootTenants;
|
||||
}
|
||||
|
||||
const TenantRow: React.FC<{
|
||||
tenant: TenantNode;
|
||||
level: number;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
isDeleting: boolean;
|
||||
}> = ({ tenant, level, onDelete, isDeleting }) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{level > 0 && (
|
||||
<CornerDownRight size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-semibold">{tenant.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{tenant.type || "PERSONAL"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{tenant.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(tenant.id, tenant.name)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{tenant.children.map((child) => (
|
||||
<TenantRow
|
||||
key={child.id}
|
||||
tenant={child}
|
||||
level={level + 1}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function TenantListPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree
|
||||
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
||||
onSuccess: () => {
|
||||
@@ -153,7 +48,7 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : [];
|
||||
const tenants = query.data?.items ?? [];
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
if (
|
||||
@@ -182,12 +77,12 @@ function TenantListPage() {
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.tenants.title", "테넌트 목록")}
|
||||
{t("ui.admin.tenants.title", "테넌트 레지스트리")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.tenants.subtitle",
|
||||
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
|
||||
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -213,7 +108,7 @@ function TenantListPage() {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant registry")}
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||
@@ -247,6 +142,9 @@ function TenantListPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
@@ -258,15 +156,15 @@ function TenantListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenantTree.length === 0 && (
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -276,14 +174,63 @@ function TenantListPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenantTree.map((tenant) => (
|
||||
<TenantRow
|
||||
key={tenant.id}
|
||||
tenant={tenant}
|
||||
level={0}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ export type TenantSummary = {
|
||||
domains?: string[];
|
||||
parentId?: string;
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number; // Added member count
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -55,6 +56,7 @@ export type TenantUpdateRequest = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
slug?: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
domains?: string[];
|
||||
@@ -380,9 +382,14 @@ export type UserUpdateRequest = {
|
||||
jobTitle?: string;
|
||||
};
|
||||
|
||||
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
search?: string,
|
||||
companyCode?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search },
|
||||
params: { limit, offset, search, companyCode },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ describe("i18n utility", () => {
|
||||
});
|
||||
|
||||
it("replaces variables in template", () => {
|
||||
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe("Hello World");
|
||||
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe(
|
||||
"Hello World",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects locale in localStorage", () => {
|
||||
@@ -27,7 +29,7 @@ describe("i18n utility", () => {
|
||||
});
|
||||
|
||||
it("defaults to ko if no locale set and browser language is ko", () => {
|
||||
vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR');
|
||||
vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
|
||||
expect(t("ui.common.save", "저장")).toBe("저장");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,8 @@ description = "Description"
|
||||
delete_confirm = "Delete Tenant \\\"{{name}}\\\"?"
|
||||
empty = "Empty"
|
||||
fetch_error = "Fetch Error"
|
||||
not_found = "Tenant not found."
|
||||
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
@@ -760,10 +762,26 @@ type_boolean = "Boolean"
|
||||
type_number = "Number"
|
||||
type_text = "Text"
|
||||
|
||||
[ui.admin.tenants.detail]
|
||||
breadcrumb_list = "Tenant List"
|
||||
header_subtitle = "Update tenant information or manage integration settings."
|
||||
loading = "Loading tenant information..."
|
||||
tab_admins = "Admin Settings"
|
||||
tab_federation = "External Integration"
|
||||
tab_organization = "Sub-tenant Management"
|
||||
tab_profile = "Profile"
|
||||
tab_schema = "User Schema"
|
||||
title = "Tenant Details"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = "Add"
|
||||
add = "Add Sub-tenant"
|
||||
add_existing = "Add Existing Tenant"
|
||||
add_dialog_title = "Add Sub-tenant"
|
||||
add_dialog_desc = "Search existing tenants to add as sub-tenants."
|
||||
search_placeholder = "Search name or slug..."
|
||||
no_candidates = "No available tenants found."
|
||||
manage = "Manage"
|
||||
title = "Sub-tenants ({{count}})"
|
||||
title = "Sub-tenant Management ({{count}})"
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = "ACTION"
|
||||
|
||||
@@ -149,6 +149,8 @@ delete_success = "테넌트가 삭제되었습니다."
|
||||
empty = "아직 등록된 테넌트가 없습니다."
|
||||
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
||||
missing_id = "테넌트 ID가 없습니다."
|
||||
not_found = "테넌트를 찾을 수 없습니다."
|
||||
remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?'
|
||||
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
@@ -792,7 +794,7 @@ header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리
|
||||
loading = "테넌트 정보를 불러오는 중..."
|
||||
tab_admins = "관리자 설정"
|
||||
tab_federation = "외부 연동"
|
||||
tab_organization = "조직 관리"
|
||||
tab_organization = "하위 테넌트 관리"
|
||||
tab_profile = "프로필"
|
||||
tab_schema = "사용자 스키마"
|
||||
title = "테넌트 상세"
|
||||
@@ -866,8 +868,13 @@ type_text = "텍스트 (Text)"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = "하위 테넌트 추가"
|
||||
add_existing = "기존 테넌트 추가"
|
||||
add_dialog_title = "하위 테넌트 추가"
|
||||
add_dialog_desc = "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다."
|
||||
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
|
||||
no_candidates = "추가 가능한 테넌트가 없습니다."
|
||||
manage = "관리"
|
||||
title = "Sub-tenants ({{count}})"
|
||||
title = "하위 테넌트 관리 ({{count}})"
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = "ACTION"
|
||||
|
||||
@@ -1,79 +1,84 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test.describe("Authentication", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock OIDC configuration
|
||||
await page.route('**/oidc/.well-known/openid-configuration', async route => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
issuer: "http://localhost:5000/oidc",
|
||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||
token_endpoint: "http://localhost:5000/oidc/token",
|
||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||
response_types_supported: ["code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"]
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
"**/oidc/.well-known/openid-configuration",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
issuer: "http://localhost:5000/oidc",
|
||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||
token_endpoint: "http://localhost:5000/oidc/token",
|
||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||
response_types_supported: ["code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect unauthorized users to login page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
test("should redirect unauthorized users to login page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Should be redirected to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator('h1')).toContainText('Baron SSO');
|
||||
await expect(page.locator("h1")).toContainText("Baron SSO");
|
||||
});
|
||||
|
||||
test('should allow access to dashboard when authenticated', async ({ page }) => {
|
||||
test("should allow access to dashboard when authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: 'fake-token',
|
||||
token_type: 'Bearer',
|
||||
profile: {
|
||||
sub: 'admin-user',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com'
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for the auth loading to finish
|
||||
await expect(page.locator('.animate-spin')).not.toBeVisible();
|
||||
|
||||
await expect(page.locator(".animate-spin")).not.toBeVisible();
|
||||
|
||||
// Should be on the dashboard/overview
|
||||
await expect(page.locator('aside')).toBeVisible();
|
||||
await expect(page.locator('h1')).toContainText('Admin Control');
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
await expect(page.locator("h1")).toContainText("Admin Control");
|
||||
});
|
||||
|
||||
test('should logout and redirect to login page', async ({ page }) => {
|
||||
test("should logout and redirect to login page", async ({ page }) => {
|
||||
// Start authenticated
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: 'fake-token',
|
||||
token_type: 'Bearer',
|
||||
profile: { sub: 'admin-user', name: 'Admin' },
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: { sub: "admin-user", name: "Admin" },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside')).toBeVisible();
|
||||
await page.goto("/");
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
|
||||
// Mock window.confirm
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
|
||||
// Click logout button (label: ui.admin.nav.logout)
|
||||
await page.click('button:has-text("Logout"), button:has-text("로그아웃")');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe('Tenants Management', () => {
|
||||
test.describe("Tenants Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Authenticate
|
||||
await page.addInitScript(() => {
|
||||
@@ -8,68 +8,90 @@ test.describe('Tenants Management', () => {
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: 'fake-token',
|
||||
token_type: 'Bearer',
|
||||
profile: { sub: 'admin-user', name: 'Admin User', email: 'admin@example.com' },
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
// Mock OIDC config to avoid redirects
|
||||
await page.route('**/oidc/.well-known/openid-configuration', async route => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
await page.route(
|
||||
"**/oidc/.well-known/openid-configuration",
|
||||
async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('should list tenants', async ({ page }) => {
|
||||
await page.route('**/api/v1/admin/tenants*', async route => {
|
||||
test("should list tenants", async ({ page }) => {
|
||||
await page.route("**/api/v1/admin/tenants*", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{ id: '1', name: 'Tenant A', slug: 'tenant-a', status: 'active', type: 'COMPANY', updatedAt: new Date().toISOString() },
|
||||
{
|
||||
id: "1",
|
||||
name: "Tenant A",
|
||||
slug: "tenant-a",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/tenants');
|
||||
await expect(page.locator('h2')).toContainText('테넌트 목록');
|
||||
await expect(page.locator('table')).toContainText('Tenant A');
|
||||
await page.goto("/tenants");
|
||||
await expect(page.locator("h2")).toContainText("테넌트 목록");
|
||||
await expect(page.locator("table")).toContainText("Tenant A");
|
||||
});
|
||||
|
||||
test('should create a new tenant', async ({ page }) => {
|
||||
test("should create a new tenant", async ({ page }) => {
|
||||
// Mock GET for list (empty) and for parents
|
||||
await page.route('**/api/v1/admin/tenants*', async route => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 } });
|
||||
} else if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
json: { id: '2', name: 'New Tenant', slug: 'new-tenant', status: 'active', type: 'COMPANY' }
|
||||
await page.route("**/api/v1/admin/tenants*", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||
});
|
||||
} else if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "2",
|
||||
name: "New Tenant",
|
||||
slug: "new-tenant",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/tenants/new');
|
||||
|
||||
await page.fill('input >> nth=0', 'New Tenant');
|
||||
await page.fill('input >> nth=1', 'new-tenant');
|
||||
await page.fill('textarea', 'Description');
|
||||
|
||||
await page.goto("/tenants/new");
|
||||
|
||||
await page.fill("input >> nth=0", "New Tenant");
|
||||
await page.fill("input >> nth=1", "new-tenant");
|
||||
await page.fill("textarea", "Description");
|
||||
|
||||
await page.click('button:has-text("생성")');
|
||||
|
||||
await expect(page).toHaveURL(/\/tenants$/);
|
||||
});
|
||||
|
||||
test('should show validation error on empty name', async ({ page }) => {
|
||||
await page.goto('/tenants/new');
|
||||
test("should show validation error on empty name", async ({ page }) => {
|
||||
await page.goto("/tenants/new");
|
||||
const submitBtn = page.locator('button:has-text("생성")');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await page.fill('input >> nth=0', 'Valid Name');
|
||||
|
||||
await page.fill("input >> nth=0", "Valid Name");
|
||||
await expect(submitBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user