@podosoft/podokit 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/lockfile.js +2 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
- package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +18 -10
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
- package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
- package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
- package/dist/templates/todo/tests/playwright.config.ts +6 -4
- package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
- package/dist/update.js +17 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,9 @@ podo update --apply # apply it: clean updates are written, your edits are 3-way
|
|
|
101
101
|
|
|
102
102
|
Files you own (routes, your components, shadcn UI) are never touched. See the
|
|
103
103
|
[updating guide](https://github.com/podosoft-dev/podokit/blob/main/docs/updating.md).
|
|
104
|
+
An exact path handed over with `podo eject` remains owned even if a module also
|
|
105
|
+
declares it managed; if you later move or delete that file, update reports the
|
|
106
|
+
missing owned path instead of recreating it.
|
|
104
107
|
|
|
105
108
|
## What you get (`fullstack-nest-svelte`)
|
|
106
109
|
|
package/dist/lockfile.js
CHANGED
|
@@ -66,6 +66,8 @@ exports.DEFAULT_OWNED_GLOBS = [
|
|
|
66
66
|
".env.docker",
|
|
67
67
|
"infra/traefik/**",
|
|
68
68
|
".podokit/dev.json",
|
|
69
|
+
// Optional application-owned Playwright device/browser projects.
|
|
70
|
+
"tests/playwright.projects.cjs",
|
|
69
71
|
];
|
|
70
72
|
const TEXT_EXT = /\.(json|md|ts|tsx|js|mjs|cjs|css|html|yml|yaml|txt|env|svelte|gitignore|example)$/i;
|
|
71
73
|
function podokitDir(projectRoot) {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
<html lang="en" class="h-full">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
+
<!-- Add viewport-fit=cover only when the application-owned layout also
|
|
6
|
+
handles env(safe-area-inset-*) padding for edge-to-edge content. -->
|
|
5
7
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
8
|
<title>{{projectName}}</title>
|
|
7
9
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
import { loadPlaywrightProjects } from "./playwright.extensions";
|
|
2
3
|
|
|
3
4
|
// The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
|
|
4
5
|
// the API). Start it first (see docs/testing.md) — locally via the dev harness,
|
|
5
6
|
// in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
|
|
6
7
|
// and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
|
|
7
8
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
9
|
+
const coreProjects = [
|
|
10
|
+
{ name: "api", testMatch: /.*\.api\.spec\.ts/ },
|
|
11
|
+
{ name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
|
|
12
|
+
];
|
|
8
13
|
|
|
9
14
|
export default defineConfig({
|
|
10
15
|
testDir: ".",
|
|
@@ -18,8 +23,5 @@ export default defineConfig({
|
|
|
18
23
|
trace: "on-first-retry",
|
|
19
24
|
screenshot: "only-on-failure",
|
|
20
25
|
},
|
|
21
|
-
projects: [
|
|
22
|
-
{ name: "api", testMatch: /.*\.api\.spec\.ts/ },
|
|
23
|
-
{ name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
|
|
24
|
-
],
|
|
26
|
+
projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
|
|
25
27
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { TestConfig } from "@playwright/test";
|
|
5
|
+
|
|
6
|
+
type Projects = NonNullable<TestConfig["projects"]>;
|
|
7
|
+
|
|
8
|
+
const requireFromTests = createRequire(__filename);
|
|
9
|
+
|
|
10
|
+
function projectName(project: unknown): string | null {
|
|
11
|
+
if (typeof project !== "object" || project === null || !("name" in project)) return null;
|
|
12
|
+
return typeof project.name === "string" && project.name.length > 0 ? project.name : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Merge application-owned Playwright projects into PodoKit's managed config.
|
|
17
|
+
* The optional CommonJS file stays owned so applications can add devices and
|
|
18
|
+
* browsers without creating a 3-way merge hotspot in playwright.config.ts.
|
|
19
|
+
*/
|
|
20
|
+
export function loadPlaywrightProjects(coreProjects: Projects): Projects {
|
|
21
|
+
const extensionPath = join(__dirname, "playwright.projects.cjs");
|
|
22
|
+
if (!existsSync(extensionPath)) return [];
|
|
23
|
+
|
|
24
|
+
const loaded = requireFromTests(extensionPath) as unknown;
|
|
25
|
+
if (!Array.isArray(loaded)) {
|
|
26
|
+
throw new Error("tests/playwright.projects.cjs must export an array of Playwright projects.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const names = new Set(
|
|
30
|
+
coreProjects.map((project) => projectName(project)).filter((name): name is string => name !== null),
|
|
31
|
+
);
|
|
32
|
+
for (const project of loaded) {
|
|
33
|
+
const name = projectName(project);
|
|
34
|
+
if (!name) {
|
|
35
|
+
throw new Error("Every project in tests/playwright.projects.cjs must have a non-empty name.");
|
|
36
|
+
}
|
|
37
|
+
if (names.has(name)) {
|
|
38
|
+
throw new Error(`Duplicate Playwright project name: ${name}`);
|
|
39
|
+
}
|
|
40
|
+
names.add(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return loaded as Projects;
|
|
44
|
+
}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte
CHANGED
|
@@ -7,12 +7,30 @@
|
|
|
7
7
|
import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
|
|
8
8
|
import LogOutIcon from "@lucide/svelte/icons/log-out";
|
|
9
9
|
import UserIcon from "@lucide/svelte/icons/user";
|
|
10
|
+
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
|
10
11
|
import { api } from "$lib/api";
|
|
11
12
|
import { getI18n } from "$lib/i18n";
|
|
13
|
+
import { cn } from "$lib/utils";
|
|
12
14
|
import type { SessionUser } from "../../app.d.ts";
|
|
13
15
|
import UserAvatar from "./user-avatar.svelte";
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
type MenuSide = "top" | "right" | "bottom" | "left";
|
|
18
|
+
type MenuAlign = "start" | "center" | "end";
|
|
19
|
+
type Props = {
|
|
20
|
+
user: SessionUser | null;
|
|
21
|
+
variant?: "avatar" | "identity";
|
|
22
|
+
side?: MenuSide;
|
|
23
|
+
align?: MenuAlign;
|
|
24
|
+
class?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
user,
|
|
29
|
+
variant = "avatar",
|
|
30
|
+
side = "bottom",
|
|
31
|
+
align = "end",
|
|
32
|
+
class: className,
|
|
33
|
+
}: Props = $props();
|
|
16
34
|
const i18n = getI18n();
|
|
17
35
|
const loginHref = $derived(withAuthRedirect("/login", currentPath(page.url)));
|
|
18
36
|
|
|
@@ -27,12 +45,25 @@
|
|
|
27
45
|
<DropdownMenu.Root>
|
|
28
46
|
<DropdownMenu.Trigger
|
|
29
47
|
data-testid="account-menu"
|
|
30
|
-
class=
|
|
48
|
+
class={cn(
|
|
49
|
+
"hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-3",
|
|
50
|
+
variant === "avatar"
|
|
51
|
+
? "inline-flex size-8 items-center justify-center rounded-full"
|
|
52
|
+
: "flex w-full items-center gap-2 rounded-md p-2 text-left",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
31
55
|
aria-label={i18n.t.nav.account}
|
|
32
56
|
>
|
|
33
57
|
<UserAvatar {user} class="size-8" />
|
|
58
|
+
{#if variant === "identity"}
|
|
59
|
+
<div class="min-w-0 flex-1 text-sm leading-tight">
|
|
60
|
+
<span class="block truncate font-medium">{user.name}</span>
|
|
61
|
+
<span class="text-muted-foreground block truncate text-xs">{user.email}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<ChevronsUpDownIcon class="size-4 shrink-0" />
|
|
64
|
+
{/if}
|
|
34
65
|
</DropdownMenu.Trigger>
|
|
35
|
-
<DropdownMenu.Content align
|
|
66
|
+
<DropdownMenu.Content {align} {side} class="w-56">
|
|
36
67
|
<DropdownMenu.Label>
|
|
37
68
|
<span class="block truncate font-medium">{user.name}</span>
|
|
38
69
|
<span class="text-muted-foreground block truncate text-xs font-normal">{user.email}</span>
|
|
@@ -47,5 +78,5 @@
|
|
|
47
78
|
</DropdownMenu.Content>
|
|
48
79
|
</DropdownMenu.Root>
|
|
49
80
|
{:else}
|
|
50
|
-
<Button data-testid="sign-in-link" href={loginHref} variant="ghost">{i18n.t.auth.signIn}</Button>
|
|
81
|
+
<Button data-testid="sign-in-link" href={loginHref} variant="ghost" class={className}>{i18n.t.auth.signIn}</Button>
|
|
51
82
|
{/if}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
+
import { cn } from "$lib/utils";
|
|
3
|
+
|
|
4
|
+
export type DataTableBreakpoint = "sm" | "md" | "lg" | "xl";
|
|
2
5
|
export type DataTableColumn<Row> = {
|
|
3
6
|
key: string;
|
|
4
7
|
label: string;
|
|
5
8
|
sortable?: boolean;
|
|
6
9
|
class?: string;
|
|
10
|
+
hideBelow?: DataTableBreakpoint;
|
|
7
11
|
// Value used for client-side sorting (defaults to row[key]). Use for nested
|
|
8
12
|
// or derived fields, e.g. (s) => s.user.email.
|
|
9
13
|
value?: (row: Row) => string | number | boolean | Date | null | undefined;
|
|
10
14
|
};
|
|
11
15
|
export type SortState = { key: string; dir: "asc" | "desc" };
|
|
16
|
+
export type DataTableRowContext = {
|
|
17
|
+
cellClass: (columnKey: string) => string | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const HIDE_BELOW_CLASS: Record<DataTableBreakpoint, string> = {
|
|
21
|
+
sm: "max-sm:hidden",
|
|
22
|
+
md: "max-md:hidden",
|
|
23
|
+
lg: "max-lg:hidden",
|
|
24
|
+
xl: "max-xl:hidden",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function getDataTableColumnClass<Row>(
|
|
28
|
+
column: DataTableColumn<Row>,
|
|
29
|
+
): string | undefined {
|
|
30
|
+
return cn(column.class, column.hideBelow ? HIDE_BELOW_CLASS[column.hideBelow] : undefined);
|
|
31
|
+
}
|
|
12
32
|
|
|
13
33
|
// Shared page size for all admin tables — sized so a desktop viewport shows a
|
|
14
34
|
// full list at a glance (rather than the old 5-row pages). Change it here to
|
|
@@ -33,9 +53,10 @@
|
|
|
33
53
|
type Props = {
|
|
34
54
|
columns: DataTableColumn<Row>[];
|
|
35
55
|
rows: Row[];
|
|
36
|
-
row: Snippet<[Row]>; // renders the <Table.Cell>s for one row
|
|
56
|
+
row: Snippet<[Row, DataTableRowContext]>; // renders the <Table.Cell>s for one row
|
|
37
57
|
getKey?: (row: Row, index: number) => string | number;
|
|
38
58
|
empty?: string;
|
|
59
|
+
ariaLabel?: string;
|
|
39
60
|
sort?: SortState | null;
|
|
40
61
|
manualSort?: boolean;
|
|
41
62
|
page?: number;
|
|
@@ -51,6 +72,7 @@
|
|
|
51
72
|
row,
|
|
52
73
|
getKey = (_r, i) => i,
|
|
53
74
|
empty = "No data.",
|
|
75
|
+
ariaLabel,
|
|
54
76
|
sort = $bindable(null),
|
|
55
77
|
manualSort = false,
|
|
56
78
|
page = $bindable(1),
|
|
@@ -71,6 +93,13 @@
|
|
|
71
93
|
onChange?.({ sort, page });
|
|
72
94
|
}
|
|
73
95
|
|
|
96
|
+
function cellClass(columnKey: string): string | undefined {
|
|
97
|
+
const column = columns.find((candidate) => candidate.key === columnKey);
|
|
98
|
+
return column ? getDataTableColumnClass(column) : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rowContext: DataTableRowContext = { cellClass };
|
|
102
|
+
|
|
74
103
|
function cmp(a: unknown, b: unknown): number {
|
|
75
104
|
if (a == null && b == null) return 0;
|
|
76
105
|
if (a == null) return -1;
|
|
@@ -95,12 +124,12 @@
|
|
|
95
124
|
</script>
|
|
96
125
|
|
|
97
126
|
<div class="rounded-md border">
|
|
98
|
-
<Table.Root>
|
|
127
|
+
<Table.Root aria-label={ariaLabel}>
|
|
99
128
|
<Table.Header>
|
|
100
129
|
<Table.Row>
|
|
101
130
|
{#each columns as col (col.key)}
|
|
102
131
|
<Table.Head
|
|
103
|
-
class={col
|
|
132
|
+
class={getDataTableColumnClass(col)}
|
|
104
133
|
aria-sort={sort?.key === col.key ? (sort.dir === "asc" ? "ascending" : "descending") : undefined}
|
|
105
134
|
>
|
|
106
135
|
{#if col.sortable}
|
|
@@ -121,7 +150,7 @@
|
|
|
121
150
|
</Table.Header>
|
|
122
151
|
<Table.Body>
|
|
123
152
|
{#each view as r, i (getKey(r, i))}
|
|
124
|
-
<Table.Row>{@render row(r)}</Table.Row>
|
|
153
|
+
<Table.Row>{@render row(r, rowContext)}</Table.Row>
|
|
125
154
|
{:else}
|
|
126
155
|
<Table.Row>
|
|
127
156
|
<Table.Cell colspan={columns.length} class="text-muted-foreground py-8 text-center">{empty}</Table.Cell>
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { toast } from "svelte-sonner";
|
|
17
17
|
import { api } from "$lib/api";
|
|
18
18
|
import { getI18n, fmt, formatDateTime } from "$lib/i18n";
|
|
19
|
+
import { cn } from "$lib/utils";
|
|
19
20
|
import type { PageData } from "./$types";
|
|
20
21
|
|
|
21
22
|
let { data }: { data: PageData } = $props();
|
|
@@ -58,9 +59,15 @@
|
|
|
58
59
|
const columns = $derived<DataTableColumn<Row>[]>([
|
|
59
60
|
{ key: "name", label: i18n.t.users.name, sortable: true },
|
|
60
61
|
{ key: "email", label: i18n.t.users.email, sortable: true },
|
|
61
|
-
{ key: "role", label: i18n.t.users.role, sortable: true },
|
|
62
|
-
{ key: "status", label: i18n.t.users.status },
|
|
63
|
-
{
|
|
62
|
+
{ key: "role", label: i18n.t.users.role, sortable: true, hideBelow: "md" },
|
|
63
|
+
{ key: "status", label: i18n.t.users.status, hideBelow: "lg" },
|
|
64
|
+
{
|
|
65
|
+
key: "createdAt",
|
|
66
|
+
label: i18n.t.users.joined,
|
|
67
|
+
sortable: true,
|
|
68
|
+
hideBelow: "lg",
|
|
69
|
+
value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0),
|
|
70
|
+
},
|
|
64
71
|
{ key: "actions", label: "", class: "w-10" },
|
|
65
72
|
]);
|
|
66
73
|
const filters = $derived<ToolbarFilter[]>([
|
|
@@ -380,13 +387,14 @@
|
|
|
380
387
|
bind:sort
|
|
381
388
|
bind:page
|
|
382
389
|
perPage={PAGE_SIZE}
|
|
390
|
+
ariaLabel={i18n.t.users.title}
|
|
383
391
|
label={fmt(i18n.t.users.total, { count: filtered.length })}
|
|
384
392
|
>
|
|
385
|
-
{#snippet row(user)}
|
|
386
|
-
<Table.Cell class="font-medium">{user.name}</Table.Cell>
|
|
387
|
-
<Table.Cell class="text-muted-foreground">{user.email}</Table.Cell>
|
|
388
|
-
<Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
|
|
389
|
-
<Table.Cell>
|
|
393
|
+
{#snippet row(user, { cellClass })}
|
|
394
|
+
<Table.Cell class={cn(cellClass("name"), "font-medium")}>{user.name}</Table.Cell>
|
|
395
|
+
<Table.Cell class={cn(cellClass("email"), "text-muted-foreground")}>{user.email}</Table.Cell>
|
|
396
|
+
<Table.Cell class={cellClass("role")}><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
|
|
397
|
+
<Table.Cell class={cellClass("status")}>
|
|
390
398
|
<div class="flex flex-wrap gap-1">
|
|
391
399
|
{#if user.signupApproved === false}
|
|
392
400
|
<Badge variant="secondary">{i18n.t.users.pendingApproval}</Badge>
|
|
@@ -400,8 +408,8 @@
|
|
|
400
408
|
{/if}
|
|
401
409
|
</div>
|
|
402
410
|
</Table.Cell>
|
|
403
|
-
<Table.Cell class="text-muted-foreground">{user.createdAt ? formatDateTime(user.createdAt) : "—"}</Table.Cell>
|
|
404
|
-
<Table.Cell>
|
|
411
|
+
<Table.Cell class={cn(cellClass("createdAt"), "text-muted-foreground")}>{user.createdAt ? formatDateTime(user.createdAt) : "—"}</Table.Cell>
|
|
412
|
+
<Table.Cell class={cellClass("actions")}>
|
|
405
413
|
<DropdownMenu.Root>
|
|
406
414
|
<DropdownMenu.Trigger>
|
|
407
415
|
{#snippet child({ props })}
|
package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md
CHANGED
|
@@ -13,19 +13,20 @@ put pagination outside the table.
|
|
|
13
13
|
```svelte
|
|
14
14
|
<script lang="ts">
|
|
15
15
|
import DataTable from "$lib/components/data-table.svelte";
|
|
16
|
+
import { cn } from "$lib/utils";
|
|
16
17
|
const columns = [
|
|
17
18
|
{ key: "name", label: "Name", sortable: true },
|
|
18
19
|
{ key: "email", label: "Email", sortable: true },
|
|
19
|
-
{ key: "role", label: "Role", sortable: true
|
|
20
|
+
{ key: "role", label: "Role", sortable: true, hideBelow: "md" },
|
|
20
21
|
];
|
|
21
22
|
let { rows } = $props(); // Svelte 5 runes only
|
|
22
23
|
</script>
|
|
23
24
|
|
|
24
|
-
<DataTable {columns} {rows} label="{rows.length} users">
|
|
25
|
-
{#snippet row(item)}
|
|
26
|
-
<Table.Cell>{item.name}</Table.Cell>
|
|
27
|
-
<Table.Cell>{item.email}</Table.Cell>
|
|
28
|
-
<Table.Cell>{item.role}</Table.Cell>
|
|
25
|
+
<DataTable {columns} {rows} ariaLabel="Users" label="{rows.length} users">
|
|
26
|
+
{#snippet row(item, { cellClass })}
|
|
27
|
+
<Table.Cell class={cellClass("name")}>{item.name}</Table.Cell>
|
|
28
|
+
<Table.Cell class={cellClass("email")}>{item.email}</Table.Cell>
|
|
29
|
+
<Table.Cell class={cn(cellClass("role"), "text-muted-foreground")}>{item.role}</Table.Cell>
|
|
29
30
|
{/snippet}
|
|
30
31
|
</DataTable>
|
|
31
32
|
```
|
|
@@ -34,6 +35,10 @@ put pagination outside the table.
|
|
|
34
35
|
- **Server data** (huge lists): use `manualSort` + `manualPagination` + `total`
|
|
35
36
|
+ `onChange` (re-fetch with `sortBy`/`sortDirection`/`page`).
|
|
36
37
|
- Nested/derived sort keys: give the column a `value` accessor.
|
|
38
|
+
- Responsive columns: set `hideBelow` to `sm`, `md`, `lg`, or `xl`, then apply
|
|
39
|
+
`cellClass(columnKey)` to the matching `Table.Cell`. Never repeat the
|
|
40
|
+
responsive class by hand; the shared class keeps headers and cells aligned.
|
|
41
|
+
- Give each table an `ariaLabel` and scope Playwright locators to that table.
|
|
37
42
|
- No footer needed: `perPage={0}`.
|
|
38
43
|
- **Search & filters**: put the shared `table-toolbar.svelte` (`TableToolbar`)
|
|
39
44
|
above the DataTable. Filters + search apply together on the Search button/Enter
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -13,7 +13,7 @@ async function signedIn(
|
|
|
13
13
|
return ctx;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
test("audit log records auth/admin actions", async ({ playwright }) => {
|
|
16
|
+
test("audit log records auth/admin actions", async ({ playwright, disposableUsers }) => {
|
|
17
17
|
const admin = await signedIn(playwright, ADMIN);
|
|
18
18
|
const probe = await admin.get("/api/audit-logs");
|
|
19
19
|
test.skip(probe.status() === 404, "audit-log module not installed");
|
|
@@ -24,7 +24,7 @@ test("audit log records auth/admin actions", async ({ playwright }) => {
|
|
|
24
24
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "Audit", role: "user" },
|
|
25
25
|
});
|
|
26
26
|
expect(created.ok()).toBeTruthy();
|
|
27
|
-
const userId =
|
|
27
|
+
const userId = await disposableUsers.trackResponse(created);
|
|
28
28
|
|
|
29
29
|
const res = await admin.get("/api/audit-logs");
|
|
30
30
|
expect(res.ok()).toBeTruthy();
|
|
@@ -34,6 +34,7 @@ test("audit log records auth/admin actions", async ({ playwright }) => {
|
|
|
34
34
|
expect(entry?.actorEmail, "audit entry records the acting admin").toBe(ADMIN.email);
|
|
35
35
|
|
|
36
36
|
await admin.post("/api/auth/admin/remove-user", { data: { userId } });
|
|
37
|
+
disposableUsers.forget(userId);
|
|
37
38
|
await admin.dispose();
|
|
38
39
|
});
|
|
39
40
|
|
|
@@ -47,11 +48,14 @@ test("the audit log is admin-only", async ({ playwright }) => {
|
|
|
47
48
|
await user.dispose();
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
test("sending a verification email is audited", async ({ playwright }) => {
|
|
51
|
+
test("sending a verification email is audited", async ({ playwright, disposableUsers }) => {
|
|
51
52
|
const admin = await signedIn(playwright, ADMIN);
|
|
52
53
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
53
54
|
const email = `verif-audit-${Date.now()}@example.com`;
|
|
54
|
-
await admin.post("/api/auth/admin/create-user", {
|
|
55
|
+
const created = await admin.post("/api/auth/admin/create-user", {
|
|
56
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "VA", role: "user" },
|
|
57
|
+
});
|
|
58
|
+
await disposableUsers.trackResponse(created);
|
|
55
59
|
await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
|
|
56
60
|
const entries = (await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>;
|
|
57
61
|
expect(entries.some((e) => e.action === "auth.verification_sent"), "verification send should be audited").toBeTruthy();
|
|
@@ -60,7 +64,7 @@ test("sending a verification email is audited", async ({ playwright }) => {
|
|
|
60
64
|
|
|
61
65
|
// Audit logging is a DB-backed on/off toggle (auth_config `server`, applied live).
|
|
62
66
|
// Runs after the recording tests and restores the flag so nothing else is affected.
|
|
63
|
-
test("audit logging honours the DB on/off toggle @smoke", async ({ playwright }) => {
|
|
67
|
+
test("audit logging honours the DB on/off toggle @smoke", async ({ playwright, disposableUsers }) => {
|
|
64
68
|
const admin = await signedIn(playwright, ADMIN);
|
|
65
69
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
66
70
|
const auditLog = async () => (await (await admin.get("/api/account/capabilities")).json()).auditLog as boolean;
|
|
@@ -68,8 +72,12 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
|
|
|
68
72
|
((await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>).some(
|
|
69
73
|
(e) => e.action === "user.create" && e.targetLabel === label,
|
|
70
74
|
);
|
|
71
|
-
const createUser = async (email: string) =>
|
|
72
|
-
|
|
75
|
+
const createUser = async (email: string): Promise<string> => {
|
|
76
|
+
const response = await admin.post("/api/auth/admin/create-user", {
|
|
77
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "T", role: "user" },
|
|
78
|
+
});
|
|
79
|
+
return disposableUsers.trackResponse(response);
|
|
80
|
+
};
|
|
73
81
|
|
|
74
82
|
try {
|
|
75
83
|
// OFF → the action is not recorded. Wait past the config-store cache TTL so the
|
|
@@ -81,6 +89,7 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
|
|
|
81
89
|
const offId = await createUser(offEmail);
|
|
82
90
|
expect(await recorded(offEmail), "no audit entry while disabled").toBe(false);
|
|
83
91
|
await admin.post("/api/auth/admin/remove-user", { data: { userId: offId } });
|
|
92
|
+
disposableUsers.forget(offId);
|
|
84
93
|
|
|
85
94
|
// ON → recording resumes.
|
|
86
95
|
await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
|
|
@@ -90,6 +99,7 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
|
|
|
90
99
|
const onId = await createUser(onEmail);
|
|
91
100
|
expect(await recorded(onEmail), "audit entry recorded once re-enabled").toBe(true);
|
|
92
101
|
await admin.post("/api/auth/admin/remove-user", { data: { userId: onId } });
|
|
102
|
+
disposableUsers.forget(onId);
|
|
93
103
|
} finally {
|
|
94
104
|
await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
|
|
95
105
|
await admin.dispose();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -13,7 +13,7 @@ async function signedIn(
|
|
|
13
13
|
return ctx;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
test("admin can create a user, set its password, and remove it", async ({ playwright }) => {
|
|
16
|
+
test("admin can create a user, set its password, and remove it", async ({ playwright, disposableUsers }) => {
|
|
17
17
|
const admin = await signedIn(playwright, ADMIN);
|
|
18
18
|
const email = `api-crud-${Date.now()}@example.com`;
|
|
19
19
|
|
|
@@ -21,7 +21,7 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
|
|
|
21
21
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "API CRUD", role: "user" },
|
|
22
22
|
});
|
|
23
23
|
expect(created.ok()).toBeTruthy();
|
|
24
|
-
const id =
|
|
24
|
+
const id = await disposableUsers.trackResponse(created);
|
|
25
25
|
expect(id).toBeTruthy();
|
|
26
26
|
|
|
27
27
|
const setPw = await admin.post("/api/auth/admin/set-user-password", {
|
|
@@ -37,6 +37,7 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
|
|
|
37
37
|
|
|
38
38
|
const removed = await admin.post("/api/auth/admin/remove-user", { data: { userId: id } });
|
|
39
39
|
expect(removed.ok()).toBeTruthy();
|
|
40
|
+
disposableUsers.forget(id);
|
|
40
41
|
await admin.dispose();
|
|
41
42
|
});
|
|
42
43
|
|
|
@@ -49,13 +50,13 @@ test("a normal user cannot create users (403)", async ({ playwright }) => {
|
|
|
49
50
|
await user.dispose();
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
test("admin can list and revoke a user's sessions", async ({ playwright }) => {
|
|
53
|
+
test("admin can list and revoke a user's sessions", async ({ playwright, disposableUsers }) => {
|
|
53
54
|
const admin = await signedIn(playwright, ADMIN);
|
|
54
55
|
const email = `api-sess-${Date.now()}@example.com`;
|
|
55
56
|
const created = await admin.post("/api/auth/admin/create-user", {
|
|
56
57
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "API Sess", role: "user" },
|
|
57
58
|
});
|
|
58
|
-
const userId =
|
|
59
|
+
const userId = await disposableUsers.trackResponse(created);
|
|
59
60
|
|
|
60
61
|
// the user signs in -> creates a session
|
|
61
62
|
const theirs = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -69,14 +70,18 @@ test("admin can list and revoke a user's sessions", async ({ playwright }) => {
|
|
|
69
70
|
const revoked = await admin.post("/api/auth/admin/revoke-user-sessions", { data: { userId } });
|
|
70
71
|
expect(revoked.ok()).toBeTruthy();
|
|
71
72
|
await admin.post("/api/auth/admin/remove-user", { data: { userId } });
|
|
73
|
+
disposableUsers.forget(userId);
|
|
72
74
|
await admin.dispose();
|
|
73
75
|
});
|
|
74
76
|
|
|
75
|
-
test("revokeOtherSessions keeps the current session valid", async ({ playwright }) => {
|
|
77
|
+
test("revokeOtherSessions keeps the current session valid", async ({ playwright, disposableUsers }) => {
|
|
76
78
|
// Use a throwaway account so we don't revoke the shared USER's ui storageState.
|
|
77
79
|
const admin = await signedIn(playwright, ADMIN);
|
|
78
80
|
const email = `api-revoke-others-${Date.now()}@example.com`;
|
|
79
|
-
await admin.post("/api/auth/admin/create-user", {
|
|
81
|
+
const created = await admin.post("/api/auth/admin/create-user", {
|
|
82
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "RO", role: "user" },
|
|
83
|
+
});
|
|
84
|
+
await disposableUsers.trackResponse(created);
|
|
80
85
|
await admin.dispose();
|
|
81
86
|
|
|
82
87
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -109,13 +114,13 @@ test("account capabilities reports optional-feature flags", async ({ playwright
|
|
|
109
114
|
await ctx.dispose();
|
|
110
115
|
});
|
|
111
116
|
|
|
112
|
-
test("admin can update a user and impersonate them", async ({ playwright }) => {
|
|
117
|
+
test("admin can update a user and impersonate them", async ({ playwright, disposableUsers }) => {
|
|
113
118
|
const admin = await signedIn(playwright, ADMIN);
|
|
114
119
|
const email = `api-edit-${Date.now()}@example.com`;
|
|
115
120
|
const created = await admin.post("/api/auth/admin/create-user", {
|
|
116
121
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "Edit Me", role: "user" },
|
|
117
122
|
});
|
|
118
|
-
const userId =
|
|
123
|
+
const userId = await disposableUsers.trackResponse(created);
|
|
119
124
|
|
|
120
125
|
const updated = await admin.post("/api/auth/admin/update-user", { data: { userId, data: { name: "Updated" } } });
|
|
121
126
|
expect(updated.ok()).toBeTruthy();
|
|
@@ -131,5 +136,6 @@ test("admin can update a user and impersonate them", async ({ playwright }) => {
|
|
|
131
136
|
await imp.dispose();
|
|
132
137
|
|
|
133
138
|
await admin.post("/api/auth/admin/remove-user", { data: { userId } });
|
|
139
|
+
disposableUsers.forget(userId);
|
|
134
140
|
await admin.dispose();
|
|
135
141
|
});
|
|
@@ -3,4 +3,5 @@ export const ADMIN = { name: "Admin", email: "admin@example.com", password: "Pod
|
|
|
3
3
|
export const USER = { name: "Normal User", email: "user@example.com", password: "Podokit3e-Str0ng!pw" };
|
|
4
4
|
export const adminState = "playwright/.auth/admin.json";
|
|
5
5
|
export const userState = "playwright/.auth/user.json";
|
|
6
|
+
export const userBaselineState = "playwright/.auth/user-baseline.json";
|
|
6
7
|
export const anonState = { cookies: [], origins: [] } as const;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
expect,
|
|
3
|
+
test as base,
|
|
4
|
+
type APIResponse,
|
|
5
|
+
} from "@playwright/test";
|
|
6
|
+
|
|
7
|
+
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const origin = { origin: baseURL };
|
|
9
|
+
|
|
10
|
+
type CreateUserInput = {
|
|
11
|
+
email: string;
|
|
12
|
+
password?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
role?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DisposableUsers = {
|
|
18
|
+
create(input: CreateUserInput): Promise<string>;
|
|
19
|
+
trackResponse(response: APIResponse): Promise<string>;
|
|
20
|
+
track(userId: string): void;
|
|
21
|
+
forget(userId: string): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function readCreatedUserId(response: APIResponse): Promise<string> {
|
|
25
|
+
expect(response.ok()).toBeTruthy();
|
|
26
|
+
const body = (await response.json()) as { user?: { id?: unknown } };
|
|
27
|
+
expect(typeof body.user?.id).toBe("string");
|
|
28
|
+
return body.user?.id as string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
32
|
+
disposableUsers: async ({ context }, use) => {
|
|
33
|
+
// Reuse the browser context's storageState-backed request client. Creating
|
|
34
|
+
// a second admin login here can revoke the browser session in applications
|
|
35
|
+
// that enforce a single concurrent session.
|
|
36
|
+
const request = context.request;
|
|
37
|
+
const userIds = new Set<string>();
|
|
38
|
+
|
|
39
|
+
const disposableUsers: DisposableUsers = {
|
|
40
|
+
async create(input): Promise<string> {
|
|
41
|
+
const response = await request.post("/api/auth/admin/create-user", {
|
|
42
|
+
headers: origin,
|
|
43
|
+
data: {
|
|
44
|
+
password: "Podokit3e-Str0ng!pw",
|
|
45
|
+
name: "Throwaway",
|
|
46
|
+
role: "user",
|
|
47
|
+
...input,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const userId = await readCreatedUserId(response);
|
|
51
|
+
userIds.add(userId);
|
|
52
|
+
return userId;
|
|
53
|
+
},
|
|
54
|
+
async trackResponse(response): Promise<string> {
|
|
55
|
+
const userId = await readCreatedUserId(response);
|
|
56
|
+
userIds.add(userId);
|
|
57
|
+
return userId;
|
|
58
|
+
},
|
|
59
|
+
track(userId): void {
|
|
60
|
+
userIds.add(userId);
|
|
61
|
+
},
|
|
62
|
+
forget(userId): void {
|
|
63
|
+
userIds.delete(userId);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await use(disposableUsers);
|
|
69
|
+
} finally {
|
|
70
|
+
for (const userId of userIds) {
|
|
71
|
+
const response = await request.post("/api/auth/admin/remove-user", {
|
|
72
|
+
headers: origin,
|
|
73
|
+
data: { userId },
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok() && response.status() !== 404) {
|
|
76
|
+
throw new Error(`Failed to remove disposable user ${userId}: HTTP ${response.status()}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export { expect };
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
import { loadPlaywrightProjects } from "./playwright.extensions";
|
|
2
3
|
|
|
3
4
|
// admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
|
|
4
5
|
// (storageState) which the `ui` project reuses. Serial + single worker because
|
|
5
6
|
// tests share one backend/DB. Runs against a live stack on E2E_BASE_URL.
|
|
6
7
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const coreProjects = [
|
|
9
|
+
{ name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
|
|
10
|
+
{ name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
|
|
11
|
+
{ name: "cleanup", testMatch: /.*\.teardown\.ts/ },
|
|
12
|
+
{
|
|
13
|
+
name: "ui",
|
|
14
|
+
testMatch: /.*\.ui\.spec\.ts/,
|
|
15
|
+
dependencies: ["setup"],
|
|
16
|
+
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/admin.json" },
|
|
17
|
+
},
|
|
18
|
+
];
|
|
7
19
|
|
|
8
20
|
export default defineConfig({
|
|
9
21
|
testDir: ".",
|
|
@@ -13,14 +25,5 @@ export default defineConfig({
|
|
|
13
25
|
workers: 1,
|
|
14
26
|
reporter: [["list"], ["html", { open: "never" }]],
|
|
15
27
|
use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
|
|
16
|
-
projects: [
|
|
17
|
-
{ name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
|
|
18
|
-
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
|
19
|
-
{
|
|
20
|
-
name: "ui",
|
|
21
|
-
testMatch: /.*\.ui\.spec\.ts/,
|
|
22
|
-
dependencies: ["setup"],
|
|
23
|
-
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/admin.json" },
|
|
24
|
-
},
|
|
25
|
-
],
|
|
28
|
+
projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
|
|
26
29
|
});
|
|
@@ -1,10 +1,46 @@
|
|
|
1
1
|
import { test as setup, expect } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ADMIN,
|
|
6
|
+
USER,
|
|
7
|
+
adminState,
|
|
8
|
+
userState,
|
|
9
|
+
userBaselineState,
|
|
10
|
+
type Account,
|
|
11
|
+
} from "./helpers/accounts";
|
|
3
12
|
|
|
4
13
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
14
|
+
mkdirSync(dirname(adminState), { recursive: true });
|
|
15
|
+
|
|
16
|
+
type StorageState = Awaited<
|
|
17
|
+
ReturnType<import("@playwright/test").APIRequestContext["storageState"]>
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
function withTestLocale(state: StorageState): StorageState {
|
|
21
|
+
const url = new URL(base);
|
|
22
|
+
return {
|
|
23
|
+
...state,
|
|
24
|
+
cookies: [
|
|
25
|
+
...state.cookies.filter((cookie) => cookie.name !== "locale"),
|
|
26
|
+
{
|
|
27
|
+
name: "locale",
|
|
28
|
+
value: "en",
|
|
29
|
+
domain: url.hostname,
|
|
30
|
+
path: "/",
|
|
31
|
+
expires: -1,
|
|
32
|
+
httpOnly: false,
|
|
33
|
+
secure: url.protocol === "https:",
|
|
34
|
+
sameSite: "Lax",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
5
39
|
|
|
6
40
|
// Seed a session via the API (reliable, no UI hydration races) and save its
|
|
7
|
-
// cookies as storageState for the browser `ui` project to reuse.
|
|
41
|
+
// cookies as storageState for the browser `ui` project to reuse. Pin the suite
|
|
42
|
+
// locale because UI locators use the generated English catalog and must not
|
|
43
|
+
// inherit an application's configured default language.
|
|
8
44
|
async function seedSession(
|
|
9
45
|
playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
|
|
10
46
|
account: Account,
|
|
@@ -26,7 +62,7 @@ async function seedSession(
|
|
|
26
62
|
if (!Array.isArray(sessions) || sessions.length <= 1) break;
|
|
27
63
|
expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
|
|
28
64
|
}
|
|
29
|
-
await ctx.storageState(
|
|
65
|
+
writeFileSync(path, `${JSON.stringify(withTestLocale(await ctx.storageState()), null, 2)}\n`);
|
|
30
66
|
await ctx.dispose();
|
|
31
67
|
}
|
|
32
68
|
|
|
@@ -48,3 +84,33 @@ setup("enable optional features", async ({ playwright }) => {
|
|
|
48
84
|
setup("seed user session", async ({ playwright }) => {
|
|
49
85
|
await seedSession(playwright, USER, userState);
|
|
50
86
|
});
|
|
87
|
+
|
|
88
|
+
setup("capture the user cleanup baseline", async ({ playwright }) => {
|
|
89
|
+
const ctx = await playwright.request.newContext({
|
|
90
|
+
baseURL: base,
|
|
91
|
+
extraHTTPHeaders: { origin: base },
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
const signIn = await ctx.post("/api/auth/sign-in/email", {
|
|
95
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
96
|
+
});
|
|
97
|
+
expect(signIn.ok(), "sign in before capturing the user baseline").toBeTruthy();
|
|
98
|
+
const ids: string[] = [];
|
|
99
|
+
const limit = 100;
|
|
100
|
+
for (let offset = 0; ; offset += limit) {
|
|
101
|
+
const response = await ctx.get(`/api/auth/admin/list-users?limit=${limit}&offset=${offset}`);
|
|
102
|
+
expect(response.ok(), "list users before the suite").toBeTruthy();
|
|
103
|
+
const body = (await response.json()) as { users?: Array<{ id?: unknown }> };
|
|
104
|
+
const page = body.users ?? [];
|
|
105
|
+
ids.push(
|
|
106
|
+
...page
|
|
107
|
+
.map((user) => user.id)
|
|
108
|
+
.filter((id): id is string => typeof id === "string"),
|
|
109
|
+
);
|
|
110
|
+
if (page.length < limit) break;
|
|
111
|
+
}
|
|
112
|
+
writeFileSync(userBaselineState, `${JSON.stringify(ids, null, 2)}\n`);
|
|
113
|
+
} finally {
|
|
114
|
+
await ctx.dispose();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { expect, test as cleanup } from "@playwright/test";
|
|
3
|
+
import { ADMIN, userBaselineState } from "./helpers/accounts";
|
|
4
|
+
|
|
5
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
|
+
|
|
7
|
+
type ListedUser = {
|
|
8
|
+
id: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
cleanup("remove users created by the suite", async ({ playwright }) => {
|
|
12
|
+
if (!existsSync(userBaselineState)) return;
|
|
13
|
+
const baseline = new Set(JSON.parse(readFileSync(userBaselineState, "utf8")) as string[]);
|
|
14
|
+
const ctx = await playwright.request.newContext({
|
|
15
|
+
baseURL: base,
|
|
16
|
+
extraHTTPHeaders: { origin: base },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const signIn = await ctx.post("/api/auth/sign-in/email", {
|
|
21
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
22
|
+
});
|
|
23
|
+
expect(signIn.ok(), "sign in before cleaning users").toBeTruthy();
|
|
24
|
+
|
|
25
|
+
const users: ListedUser[] = [];
|
|
26
|
+
const limit = 100;
|
|
27
|
+
for (let offset = 0; ; offset += limit) {
|
|
28
|
+
const response = await ctx.get(`/api/auth/admin/list-users?limit=${limit}&offset=${offset}`);
|
|
29
|
+
expect(response.ok(), "list users during cleanup").toBeTruthy();
|
|
30
|
+
const body = (await response.json()) as { users?: ListedUser[] };
|
|
31
|
+
const page = body.users ?? [];
|
|
32
|
+
users.push(...page);
|
|
33
|
+
if (page.length < limit) break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const user of users) {
|
|
37
|
+
if (baseline.has(user.id)) continue;
|
|
38
|
+
const response = await ctx.post("/api/auth/admin/remove-user", {
|
|
39
|
+
data: { userId: user.id },
|
|
40
|
+
});
|
|
41
|
+
expect(response.ok(), `remove disposable user ${user.id}`).toBeTruthy();
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
await ctx.dispose();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -30,3 +30,16 @@ test("audit table sorts client-side", async ({ page }) => {
|
|
|
30
30
|
await page.getByRole("button", { name: "When" }).click(); // default desc -> asc
|
|
31
31
|
await expect(page.getByRole("columnheader", { name: /When/ })).toHaveAttribute("aria-sort", "ascending");
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
test("users table keeps responsive headers and cells aligned", async ({ page }) => {
|
|
35
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
36
|
+
await ready(page, "/admin/users");
|
|
37
|
+
const table = page.getByRole("table", { name: "Users" });
|
|
38
|
+
const visibleHeaders = table.locator("thead th:visible");
|
|
39
|
+
const visibleCells = table.locator("tbody tr").first().locator("td:visible");
|
|
40
|
+
|
|
41
|
+
await expect(table.getByRole("columnheader", { name: "Role" })).toBeHidden();
|
|
42
|
+
await expect(table.getByRole("columnheader", { name: "Status" })).toBeHidden();
|
|
43
|
+
await expect(table.getByRole("columnheader", { name: "Joined" })).toBeHidden();
|
|
44
|
+
expect(await visibleHeaders.count()).toBe(await visibleCells.count());
|
|
45
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
|
|
4
4
|
|
|
@@ -17,14 +17,11 @@ test("verification badges stay hidden when the feature is off", async ({ page })
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
// Admin can trigger a password-reset email for a user (not verification-gated).
|
|
20
|
-
test("admin sends a password reset email to a user", async ({ page }) => {
|
|
20
|
+
test("admin sends a password reset email to a user", async ({ page, disposableUsers }) => {
|
|
21
21
|
test.skip(!(await mailpitReachable()), "Mailpit not available");
|
|
22
22
|
await ready(page, "/admin/users");
|
|
23
23
|
const email = `admin-reset-${Date.now()}@example.com`;
|
|
24
|
-
await
|
|
25
|
-
headers: { origin: base },
|
|
26
|
-
data: { email, password: "Podokit3e-Str0ng!pw", name: "AR", role: "user" },
|
|
27
|
-
});
|
|
24
|
+
await disposableUsers.create({ email, name: "AR" });
|
|
28
25
|
await clearMailpit();
|
|
29
26
|
await page.locator("#toolbar-search").fill(email);
|
|
30
27
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type Page } from "@playwright/test";
|
|
2
|
+
import { expect, test, type DisposableUsers } from "../helpers/disposable-users";
|
|
2
3
|
import { ready } from "../helpers/hydration";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
|
|
6
7
|
// Seed a throwaway user via the API (admin cookies come from storageState) so
|
|
7
8
|
// the mutating UI tests don't touch the shared admin/user seed accounts.
|
|
8
|
-
async function seedUser(
|
|
9
|
-
|
|
10
|
-
headers: { origin: base },
|
|
11
|
-
data: { email, password: "Podokit3e-Str0ng!pw", name: "Throwaway", role: "user" },
|
|
12
|
-
});
|
|
13
|
-
expect(res.ok()).toBeTruthy();
|
|
9
|
+
async function seedUser(disposableUsers: DisposableUsers, email: string): Promise<string> {
|
|
10
|
+
return disposableUsers.create({ email });
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
// Open the Manage two-pane modal for a seeded user.
|
|
@@ -21,7 +18,7 @@ async function openManage(page: Page, email: string): Promise<void> {
|
|
|
21
18
|
await page.getByRole("menuitem", { name: "Manage" }).click();
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
test("admin can create a user via the dialog @smoke", async ({ page }) => {
|
|
21
|
+
test("admin can create a user via the dialog @smoke", async ({ page, disposableUsers }) => {
|
|
25
22
|
await ready(page, "/admin/users");
|
|
26
23
|
const email = `ui-create-${Date.now()}@example.com`;
|
|
27
24
|
await page.getByRole("button", { name: "Add user" }).click();
|
|
@@ -30,7 +27,13 @@ test("admin can create a user via the dialog @smoke", async ({ page }) => {
|
|
|
30
27
|
await dialog.getByLabel("Email").fill(email);
|
|
31
28
|
await dialog.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
|
|
32
29
|
await dialog.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
|
|
30
|
+
const created = page.waitForResponse(
|
|
31
|
+
(response) =>
|
|
32
|
+
response.url().endsWith("/api/auth/admin/create-user") &&
|
|
33
|
+
response.request().method() === "POST",
|
|
34
|
+
);
|
|
33
35
|
await dialog.getByRole("button", { name: "Create" }).click();
|
|
36
|
+
await disposableUsers.trackResponse(await created);
|
|
34
37
|
// Wait for the create to settle (dialog closes + list reloads) before searching,
|
|
35
38
|
// otherwise the search input can be refilled by the post-create reload.
|
|
36
39
|
await expect(page.getByRole("dialog")).toBeHidden();
|
|
@@ -51,10 +54,10 @@ test("create rejects mismatched passwords", async ({ page }) => {
|
|
|
51
54
|
await expect(dialog.getByText("Passwords do not match")).toBeVisible();
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
test("manage: edit profile name", async ({ page }) => {
|
|
57
|
+
test("manage: edit profile name", async ({ page, disposableUsers }) => {
|
|
55
58
|
await ready(page, "/admin/users");
|
|
56
59
|
const email = `ui-edit-${Date.now()}@example.com`;
|
|
57
|
-
await seedUser(
|
|
60
|
+
await seedUser(disposableUsers, email);
|
|
58
61
|
await openManage(page, email);
|
|
59
62
|
const dialog = page.getByRole("dialog");
|
|
60
63
|
await dialog.getByLabel("Name").fill("Edited Name");
|
|
@@ -62,10 +65,10 @@ test("manage: edit profile name", async ({ page }) => {
|
|
|
62
65
|
await expect(page.getByText("User updated")).toBeVisible();
|
|
63
66
|
});
|
|
64
67
|
|
|
65
|
-
test("manage: set a password (and reject mismatch)", async ({ page }) => {
|
|
68
|
+
test("manage: set a password (and reject mismatch)", async ({ page, disposableUsers }) => {
|
|
66
69
|
await ready(page, "/admin/users");
|
|
67
70
|
const email = `ui-setpw-${Date.now()}@example.com`;
|
|
68
|
-
await seedUser(
|
|
71
|
+
await seedUser(disposableUsers, email);
|
|
69
72
|
await openManage(page, email);
|
|
70
73
|
const dialog = page.getByRole("dialog");
|
|
71
74
|
await dialog.getByRole("button", { name: "Security" }).click();
|
|
@@ -78,10 +81,10 @@ test("manage: set a password (and reject mismatch)", async ({ page }) => {
|
|
|
78
81
|
await expect(page.getByText("Password updated")).toBeVisible();
|
|
79
82
|
});
|
|
80
83
|
|
|
81
|
-
test("manage: ban a user with a reason", async ({ page }) => {
|
|
84
|
+
test("manage: ban a user with a reason", async ({ page, disposableUsers }) => {
|
|
82
85
|
await ready(page, "/admin/users");
|
|
83
86
|
const email = `ui-ban-${Date.now()}@example.com`;
|
|
84
|
-
await seedUser(
|
|
87
|
+
await seedUser(disposableUsers, email);
|
|
85
88
|
await openManage(page, email);
|
|
86
89
|
const dialog = page.getByRole("dialog");
|
|
87
90
|
await dialog.getByRole("button", { name: "Security" }).click();
|
|
@@ -90,10 +93,10 @@ test("manage: ban a user with a reason", async ({ page }) => {
|
|
|
90
93
|
await expect(page.getByText("User banned", { exact: true })).toBeVisible();
|
|
91
94
|
});
|
|
92
95
|
|
|
93
|
-
test("manage: revoke a user's session", async ({ page, playwright }) => {
|
|
96
|
+
test("manage: revoke a user's session", async ({ page, playwright, disposableUsers }) => {
|
|
94
97
|
await ready(page, "/admin/users");
|
|
95
98
|
const email = `ui-sess-${Date.now()}@example.com`;
|
|
96
|
-
await seedUser(
|
|
99
|
+
await seedUser(disposableUsers, email);
|
|
97
100
|
// create a session for the user in an isolated context (don't touch the admin cookie)
|
|
98
101
|
const uctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
|
|
99
102
|
expect((await uctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok()).toBeTruthy();
|
|
@@ -105,39 +108,42 @@ test("manage: revoke a user's session", async ({ page, playwright }) => {
|
|
|
105
108
|
await expect(page.getByText("Session revoked")).toBeVisible();
|
|
106
109
|
});
|
|
107
110
|
|
|
108
|
-
test("manage: delete a user from the danger zone", async ({ page }) => {
|
|
111
|
+
test("manage: delete a user from the danger zone", async ({ page, disposableUsers }) => {
|
|
109
112
|
await ready(page, "/admin/users");
|
|
110
113
|
const email = `ui-delete-${Date.now()}@example.com`;
|
|
111
|
-
await seedUser(
|
|
114
|
+
const userId = await seedUser(disposableUsers, email);
|
|
112
115
|
await openManage(page, email);
|
|
113
116
|
const dialog = page.getByRole("dialog");
|
|
114
117
|
await dialog.getByRole("button", { name: "Danger zone" }).click();
|
|
115
118
|
await dialog.getByRole("button", { name: "Delete" }).click(); // arm
|
|
116
119
|
await dialog.getByRole("button", { name: "Delete" }).click(); // confirm
|
|
117
120
|
await expect(page.getByText("User deleted")).toBeVisible();
|
|
121
|
+
disposableUsers.forget(userId);
|
|
118
122
|
await page.locator("#toolbar-search").fill(email);
|
|
119
123
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
120
124
|
await expect(page.getByRole("cell", { name: email })).toHaveCount(0);
|
|
121
125
|
});
|
|
122
126
|
|
|
123
|
-
test("users list shows pagination beyond one page", async ({ page }) => {
|
|
127
|
+
test("users list shows pagination beyond one page", async ({ page, disposableUsers }) => {
|
|
124
128
|
await ready(page, "/admin/users");
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
const prefix = `pg-${Date.now()}`;
|
|
130
|
+
for (let i = 0; i < 16; i += 1) {
|
|
131
|
+
await disposableUsers.create({
|
|
132
|
+
email: `${prefix}-${i}@example.com`,
|
|
133
|
+
name: `Page user ${i}`,
|
|
129
134
|
});
|
|
130
135
|
}
|
|
131
|
-
await page.
|
|
132
|
-
|
|
136
|
+
await page.locator("#toolbar-search").fill(prefix);
|
|
137
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
138
|
+
const nav = page.getByRole("navigation", { name: "pagination" }).first();
|
|
133
139
|
await expect(nav).toBeVisible();
|
|
134
|
-
await expect(nav.getByRole("button", { name: "Page 2" })).toBeVisible();
|
|
140
|
+
await expect(nav.getByRole("button", { name: "Page 2", exact: true }).first()).toBeVisible();
|
|
135
141
|
});
|
|
136
142
|
|
|
137
|
-
test("manage modal closes from its footer", async ({ page }) => {
|
|
143
|
+
test("manage modal closes from its footer", async ({ page, disposableUsers }) => {
|
|
138
144
|
await ready(page, "/admin/users");
|
|
139
145
|
const email = `ui-close-${Date.now()}@example.com`;
|
|
140
|
-
await seedUser(
|
|
146
|
+
await seedUser(disposableUsers, email);
|
|
141
147
|
await openManage(page, email);
|
|
142
148
|
// footer Close (a visible button, distinct from the header X) dismisses the modal
|
|
143
149
|
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click();
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
|
|
6
|
-
test("admin can create a user with a custom role", async ({ page }) => {
|
|
6
|
+
test("admin can create a user with a custom role", async ({ page, disposableUsers }) => {
|
|
7
7
|
await ready(page, "/admin/users");
|
|
8
8
|
await page.getByRole("button", { name: "Add user" }).click();
|
|
9
9
|
const role = page.locator("#c-role");
|
|
@@ -16,7 +16,13 @@ test("admin can create a user with a custom role", async ({ page }) => {
|
|
|
16
16
|
// pick the Moderator role from the select
|
|
17
17
|
await role.click();
|
|
18
18
|
await page.getByRole("option", { name: "Moderator" }).click();
|
|
19
|
+
const created = page.waitForResponse(
|
|
20
|
+
(response) =>
|
|
21
|
+
response.url().endsWith("/api/auth/admin/create-user") &&
|
|
22
|
+
response.request().method() === "POST",
|
|
23
|
+
);
|
|
19
24
|
await page.getByRole("button", { name: "Create", exact: true }).click();
|
|
25
|
+
await disposableUsers.trackResponse(await created);
|
|
20
26
|
await expect(page.getByText("User created")).toBeVisible();
|
|
21
27
|
// it appears in the list, filterable by the Moderator role
|
|
22
28
|
await page.locator("#toolbar-search").fill(email);
|
|
@@ -52,43 +58,27 @@ test("row menu exposes admin actions", async ({ page }) => {
|
|
|
52
58
|
await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
|
|
53
59
|
});
|
|
54
60
|
|
|
55
|
-
test("admin can approve a pending registration from the user list", async ({ page }) => {
|
|
61
|
+
test("admin can approve a pending registration from the user list", async ({ page, disposableUsers }) => {
|
|
56
62
|
const email = `pending-ui-${Date.now()}@example.com`;
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
const userId = await disposableUsers.create({ email, name: "Pending UI" });
|
|
64
|
+
expect(
|
|
65
|
+
(await page.request.post("/api/auth/admin/update-user", {
|
|
66
|
+
headers: { origin: base },
|
|
67
|
+
data: { userId, data: { signupApproved: false } },
|
|
68
|
+
})).ok(),
|
|
69
|
+
).toBeTruthy();
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
await expect(row.getByText("Pending approval")).toBeVisible();
|
|
78
|
-
await row.getByRole("button").click();
|
|
79
|
-
const response = page.waitForResponse(
|
|
80
|
-
(res) => res.url().endsWith("/api/auth/admin/update-user") && res.request().method() === "POST",
|
|
81
|
-
);
|
|
82
|
-
await page.getByRole("menuitem", { name: "Approve sign-up" }).click();
|
|
83
|
-
expect((await response).ok()).toBeTruthy();
|
|
84
|
-
await expect(page.getByText("Sign-up approved")).toBeVisible();
|
|
85
|
-
await expect(row.getByText("Active")).toBeVisible();
|
|
86
|
-
} finally {
|
|
87
|
-
if (userId) {
|
|
88
|
-
await page.request.post("/api/auth/admin/remove-user", {
|
|
89
|
-
headers: { origin: base },
|
|
90
|
-
data: { userId },
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
71
|
+
await ready(page, "/admin/users");
|
|
72
|
+
await page.locator("#toolbar-search").fill(email);
|
|
73
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
74
|
+
const row = page.getByRole("row", { name: new RegExp(email) });
|
|
75
|
+
await expect(row.getByText("Pending approval")).toBeVisible();
|
|
76
|
+
await row.getByRole("button").click();
|
|
77
|
+
const response = page.waitForResponse(
|
|
78
|
+
(res) => res.url().endsWith("/api/auth/admin/update-user") && res.request().method() === "POST",
|
|
79
|
+
);
|
|
80
|
+
await page.getByRole("menuitem", { name: "Approve sign-up" }).click();
|
|
81
|
+
expect((await response).ok()).toBeTruthy();
|
|
82
|
+
await expect(page.getByText("Sign-up approved")).toBeVisible();
|
|
83
|
+
await expect(row.getByText("Active")).toBeVisible();
|
|
94
84
|
});
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"file": "AGENTS.md",
|
|
120
120
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
121
121
|
"optional": true,
|
|
122
|
-
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `$lib/server/guards` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
|
|
122
|
+
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `$lib/server/guards` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"file": "apps/api/src/app.module.ts",
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
import { loadPlaywrightProjects } from "./playwright.extensions";
|
|
2
3
|
|
|
3
4
|
// The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
|
|
4
5
|
// the API). Start it first (see docs/testing.md) — locally via the dev harness,
|
|
5
6
|
// in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
|
|
6
7
|
// and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
|
|
7
8
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
9
|
+
const coreProjects = [
|
|
10
|
+
{ name: "api", testMatch: /.*\.api\.spec\.ts/ },
|
|
11
|
+
{ name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
|
|
12
|
+
];
|
|
8
13
|
|
|
9
14
|
export default defineConfig({
|
|
10
15
|
testDir: ".",
|
|
@@ -18,8 +23,5 @@ export default defineConfig({
|
|
|
18
23
|
trace: "on-first-retry",
|
|
19
24
|
screenshot: "only-on-failure",
|
|
20
25
|
},
|
|
21
|
-
projects: [
|
|
22
|
-
{ name: "api", testMatch: /.*\.api\.spec\.ts/ },
|
|
23
|
-
{ name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
|
|
24
|
-
],
|
|
26
|
+
projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
|
|
25
27
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { TestConfig } from "@playwright/test";
|
|
5
|
+
|
|
6
|
+
type Projects = NonNullable<TestConfig["projects"]>;
|
|
7
|
+
|
|
8
|
+
const requireFromTests = createRequire(__filename);
|
|
9
|
+
|
|
10
|
+
function projectName(project: unknown): string | null {
|
|
11
|
+
if (typeof project !== "object" || project === null || !("name" in project)) return null;
|
|
12
|
+
return typeof project.name === "string" && project.name.length > 0 ? project.name : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Merge application-owned Playwright projects into PodoKit's managed config.
|
|
17
|
+
* The optional CommonJS file stays owned so applications can add devices and
|
|
18
|
+
* browsers without creating a 3-way merge hotspot in playwright.config.ts.
|
|
19
|
+
*/
|
|
20
|
+
export function loadPlaywrightProjects(coreProjects: Projects): Projects {
|
|
21
|
+
const extensionPath = join(__dirname, "playwright.projects.cjs");
|
|
22
|
+
if (!existsSync(extensionPath)) return [];
|
|
23
|
+
|
|
24
|
+
const loaded = requireFromTests(extensionPath) as unknown;
|
|
25
|
+
if (!Array.isArray(loaded)) {
|
|
26
|
+
throw new Error("tests/playwright.projects.cjs must export an array of Playwright projects.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const names = new Set(
|
|
30
|
+
coreProjects.map((project) => projectName(project)).filter((name): name is string => name !== null),
|
|
31
|
+
);
|
|
32
|
+
for (const project of loaded) {
|
|
33
|
+
const name = projectName(project);
|
|
34
|
+
if (!name) {
|
|
35
|
+
throw new Error("Every project in tests/playwright.projects.cjs must have a non-empty name.");
|
|
36
|
+
}
|
|
37
|
+
if (names.has(name)) {
|
|
38
|
+
throw new Error(`Duplicate Playwright project name: ${name}`);
|
|
39
|
+
}
|
|
40
|
+
names.add(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return loaded as Projects;
|
|
44
|
+
}
|
package/dist/update.js
CHANGED
|
@@ -91,20 +91,24 @@ function planUpdate(projectRoot, templatesDir) {
|
|
|
91
91
|
const locked = lock.files[path];
|
|
92
92
|
const newText = treeText(newTree, path);
|
|
93
93
|
const disk = diskContent(projectRoot, path);
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
: locked?.tier ??
|
|
103
|
-
(newText !== null
|
|
104
|
-
? (0, lockfile_1.classifyTier)(path, newText, ownedGlobs, managedOverrides)
|
|
105
|
-
: "managed");
|
|
94
|
+
// Use the same ownership precedence as lockfile classification. In
|
|
95
|
+
// particular, an exact path recorded by `podo eject` must keep winning over
|
|
96
|
+
// a module's managed override, while managed overrides may still reclaim a
|
|
97
|
+
// path covered only by a broad owned glob.
|
|
98
|
+
const controlledByTarget = managedOverrides.some((glob) => (0, lockfile_1.matchGlob)(path, glob)) ||
|
|
99
|
+
ownedGlobs.some((glob) => (0, lockfile_1.matchGlob)(path, glob));
|
|
100
|
+
const classified = (0, lockfile_1.classifyTier)(path, newText ?? disk ?? "", ownedGlobs, managedOverrides);
|
|
101
|
+
const tier = controlledByTarget ? classified : locked?.tier ?? classified;
|
|
106
102
|
if (tier === "owned") {
|
|
107
|
-
|
|
103
|
+
const explicitlyOwned = ownedGlobs.some((glob) => !glob.includes("*") && (0, lockfile_1.matchGlob)(path, glob));
|
|
104
|
+
changes.push({
|
|
105
|
+
path,
|
|
106
|
+
tier,
|
|
107
|
+
action: "skip",
|
|
108
|
+
note: explicitlyOwned && disk === null && newText !== null
|
|
109
|
+
? "explicitly owned — missing or relocated; not restored"
|
|
110
|
+
: "owned — never modified",
|
|
111
|
+
});
|
|
108
112
|
continue;
|
|
109
113
|
}
|
|
110
114
|
// File removed upstream.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|