@podosoft/podokit 0.13.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/add.d.ts +27 -1
- package/dist/add.js +3 -1
- package/dist/index.js +6 -3
- package/dist/lib.d.ts +1 -1
- package/dist/lockfile.d.ts +4 -1
- package/dist/lockfile.js +6 -3
- package/dist/module-migrations.d.ts +25 -0
- package/dist/module-migrations.js +233 -0
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -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/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
- 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/account-page.svelte +155 -2
- 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/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +18 -10
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
- 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/profile-image.api.spec.ts +96 -0
- 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/account.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +16 -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/guards.ui.spec.ts +4 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +4 -1
- 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 +74 -11
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +5 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -1
- package/dist/templates/todo/Dockerfile.dev +3 -1
- package/dist/templates/todo/apps/api/package.json +1 -1
- package/dist/templates/todo/apps/web/package.json +1 -1
- package/dist/templates/todo/compose.dev.yaml +6 -0
- package/dist/templates/todo/tests/playwright.config.ts +6 -4
- package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
- package/dist/update.d.ts +8 -1
- package/dist/update.js +85 -25
- package/package.json +1 -1
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
|
@@ -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();
|
|
@@ -23,13 +23,10 @@ test.describe("unauthenticated", () => {
|
|
|
23
23
|
|
|
24
24
|
test.describe("normal user (non-admin)", () => {
|
|
25
25
|
test.use({ storageState: userState });
|
|
26
|
-
test("admin
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
expect((await page.goto("/admin/users"))?.status()).toBe(403);
|
|
31
|
-
expect((await page.goto("/admin/sessions"))?.status()).toBe(403);
|
|
32
|
-
expect((await page.goto("/admin/settings"))?.status()).toBe(403);
|
|
26
|
+
test("admin route group is forbidden to non-admin users @smoke", async ({ page }) => {
|
|
27
|
+
for (const route of ["/admin", "/admin/users", "/admin/sessions", "/admin/settings"]) {
|
|
28
|
+
expect((await page.goto(route))?.status()).toBe(403);
|
|
29
|
+
}
|
|
33
30
|
});
|
|
34
31
|
|
|
35
32
|
test("account is available without the admin shell", async ({ page }) => {
|
|
@@ -9,7 +9,10 @@ test("settings: add and remove a social login provider", async ({ page }) => {
|
|
|
9
9
|
const openConfigure = async (): Promise<void> => {
|
|
10
10
|
await page.waitForLoadState("networkidle");
|
|
11
11
|
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
12
|
-
|
|
12
|
+
const socialCard = page
|
|
13
|
+
.getByText("Social login", { exact: true })
|
|
14
|
+
.locator('xpath=ancestor::div[@data-slot="card"]');
|
|
15
|
+
await socialCard.getByRole("button", { name: "Configure" }).click();
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
await ready(page, "/admin/settings");
|
|
@@ -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
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
+
"manifestVersion": 2,
|
|
2
3
|
"name": "admin-dashboard",
|
|
3
|
-
"description": "Admin
|
|
4
|
+
"description": "Admin console UI (SvelteKit + shadcn-svelte): the `(admin)` route group and `admin-sidebar.svelte` are reserved for `/admin/*`; put protected product pages in `(app)` or their own guarded layout. Requires auth.",
|
|
4
5
|
"requires": [
|
|
5
6
|
"auth",
|
|
6
7
|
"object-storage-s3"
|
|
@@ -10,21 +11,22 @@
|
|
|
10
11
|
"qrcode": "^1.5.4"
|
|
11
12
|
},
|
|
12
13
|
"devDependencies": {
|
|
13
|
-
"@types/qrcode": "^1.5.5"
|
|
14
|
-
"@types/multer": "^1.4.12"
|
|
14
|
+
"@types/qrcode": "^1.5.5"
|
|
15
15
|
},
|
|
16
16
|
"ownedGlobs": [
|
|
17
17
|
"apps/web/src/lib/i18n/locales/**",
|
|
18
18
|
"apps/web/src/lib/i18n/catalogs/app/**"
|
|
19
19
|
],
|
|
20
20
|
"managedOverrides": [
|
|
21
|
+
"apps/web/src/lib/components/ui/input/input.svelte",
|
|
21
22
|
"apps/web/src/routes/(app)/+layout.server.ts",
|
|
22
|
-
"apps/web/src/routes/(
|
|
23
|
-
"apps/web/src/routes/(
|
|
24
|
-
"apps/web/src/routes/(
|
|
25
|
-
"apps/web/src/routes/(
|
|
26
|
-
"apps/web/src/routes/(
|
|
27
|
-
"apps/web/src/routes/(
|
|
23
|
+
"apps/web/src/routes/(admin)/+layout.server.ts",
|
|
24
|
+
"apps/web/src/routes/(admin)/admin/account/+page.server.ts",
|
|
25
|
+
"apps/web/src/routes/(admin)/admin/audit/+page.server.ts",
|
|
26
|
+
"apps/web/src/routes/(admin)/admin/organizations/+page.server.ts",
|
|
27
|
+
"apps/web/src/routes/(admin)/admin/sessions/+page.server.ts",
|
|
28
|
+
"apps/web/src/routes/(admin)/admin/settings/+page.server.ts",
|
|
29
|
+
"apps/web/src/routes/(admin)/admin/users/+page.server.ts",
|
|
28
30
|
"apps/web/src/routes/(auth)/login/+page.server.ts",
|
|
29
31
|
"apps/web/src/routes/(auth)/signup/+page.server.ts",
|
|
30
32
|
"apps/web/src/routes/account/+page.server.ts",
|
|
@@ -32,6 +34,12 @@
|
|
|
32
34
|
],
|
|
33
35
|
"packageOverlays": {
|
|
34
36
|
"api": {
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"image-size": "^2.0.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/multer": "^1.4.12"
|
|
42
|
+
},
|
|
35
43
|
"scripts": {
|
|
36
44
|
"admin:bootstrap": "node scripts/bootstrap-admin.mjs"
|
|
37
45
|
}
|
|
@@ -41,7 +49,52 @@
|
|
|
41
49
|
"# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
|
|
42
50
|
"ADMIN_EMAILS=admin@example.com"
|
|
43
51
|
],
|
|
52
|
+
"migrations": [
|
|
53
|
+
{
|
|
54
|
+
"id": "admin-route-group",
|
|
55
|
+
"moves": [
|
|
56
|
+
{
|
|
57
|
+
"from": "apps/web/src/routes/(app)/+layout.svelte",
|
|
58
|
+
"to": "apps/web/src/routes/(admin)/+layout.svelte"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"from": "apps/web/src/routes/(app)/admin",
|
|
62
|
+
"to": "apps/web/src/routes/(admin)/admin"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"from": "apps/web/src/lib/components/app-sidebar.svelte",
|
|
66
|
+
"to": "apps/web/src/lib/components/admin-sidebar.svelte"
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"replacements": [
|
|
70
|
+
{
|
|
71
|
+
"path": "apps/web/src/routes/(admin)/+layout.svelte",
|
|
72
|
+
"from": "AppSidebar",
|
|
73
|
+
"to": "AdminSidebar",
|
|
74
|
+
"expected": 2
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"path": "apps/web/src/routes/(admin)/+layout.svelte",
|
|
78
|
+
"from": "$lib/components/app-sidebar.svelte",
|
|
79
|
+
"to": "$lib/components/admin-sidebar.svelte",
|
|
80
|
+
"expected": 1
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
],
|
|
44
85
|
"inject": [
|
|
86
|
+
{
|
|
87
|
+
"file": "apps/web/src/routes/+page.svelte",
|
|
88
|
+
"marker": "// podokit:end:landing-imports",
|
|
89
|
+
"optional": true,
|
|
90
|
+
"text": "import { page } from \"$app/state\";\n import AccountMenu from \"$lib/components/account-menu.svelte\";\n import LanguageSwitch from \"$lib/components/language-switch.svelte\";\n import ThemeToggle from \"$lib/components/theme-toggle.svelte\";"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"file": "apps/web/src/routes/+page.svelte",
|
|
94
|
+
"marker": "<!-- podokit:end:landing-actions -->",
|
|
95
|
+
"optional": true,
|
|
96
|
+
"text": "<header class=\"flex items-center justify-end gap-2 p-4\">\n <LanguageSwitch />\n <ThemeToggle />\n <AccountMenu user={page.data.user ?? null} />\n</header>"
|
|
97
|
+
},
|
|
45
98
|
{
|
|
46
99
|
"file": "apps/api/src/auth/auth.ts",
|
|
47
100
|
"marker": "// podokit:end:auth-imports",
|
|
@@ -66,24 +119,34 @@
|
|
|
66
119
|
"file": "AGENTS.md",
|
|
67
120
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
68
121
|
"optional": true,
|
|
69
|
-
"text": "### admin-dashboard\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- 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"
|
|
70
123
|
},
|
|
71
124
|
{
|
|
72
125
|
"file": "apps/api/src/app.module.ts",
|
|
73
126
|
"marker": "// podokit:end:imports",
|
|
74
127
|
"text": "import { SiteSettingsModule } from \"./site-settings/site-settings.module\";"
|
|
75
128
|
},
|
|
129
|
+
{
|
|
130
|
+
"file": "apps/api/src/app.module.ts",
|
|
131
|
+
"marker": "// podokit:end:imports",
|
|
132
|
+
"text": "import { ProfileImageModule } from \"./profile-image/profile-image.module\";"
|
|
133
|
+
},
|
|
76
134
|
{
|
|
77
135
|
"file": "apps/api/src/app.module.ts",
|
|
78
136
|
"marker": "// podokit:end:module-imports",
|
|
79
137
|
"text": "SiteSettingsModule,"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"file": "apps/api/src/app.module.ts",
|
|
141
|
+
"marker": "// podokit:end:module-imports",
|
|
142
|
+
"text": "ProfileImageModule,"
|
|
80
143
|
}
|
|
81
144
|
],
|
|
82
145
|
"instructions": [
|
|
83
146
|
"Set ADMIN_EMAILS in .env (comma-separated). After building and migrating, inject ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD for one command only, then run npm run admin:bootstrap -w <app>-api. The command creates a verified, approved admin or safely verifies an existing one.",
|
|
84
147
|
"Re-run the auth migration to add the admin and sign-up approval columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
|
|
85
148
|
"Remove ADMIN_BOOTSTRAP_PASSWORD from the environment after bootstrap. Keep ADMIN_EMAILS so matching future sign-ups are promoted consistently.",
|
|
86
|
-
"Admin pages: /admin/users (list, approve pending registrations, ban, set role, revoke sessions). Signed-in users manage their profile and security at /account; /admin/account remains available in the admin shell. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
|
|
149
|
+
"Admin pages: /admin/users (list, approve pending registrations, ban, set role, revoke sessions). Signed-in users manage their profile, profile image, and security at /account; /admin/account remains available in the admin shell. Profile images accept PNG, JPEG, and WebP up to 2 MB and 2048 by 2048 pixels. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
|
|
87
150
|
"Password reset emails go through the mailer module (installed automatically with auth); in dev without SMTP they are logged to the API console. Configure SMTP in admin settings or via SMTP_* env for production."
|
|
88
151
|
]
|
|
89
152
|
}
|
|
@@ -11,6 +11,7 @@ import { apiKey } from "@better-auth/api-key";
|
|
|
11
11
|
import { passkey } from "@better-auth/passkey";
|
|
12
12
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
13
13
|
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
|
|
14
|
+
import { runUserDeletedHandlers } from "./user-delete-handlers";
|
|
14
15
|
// podokit:begin:auth-imports
|
|
15
16
|
// podokit:end:auth-imports
|
|
16
17
|
|
|
@@ -259,7 +260,10 @@ export function buildAuth(config: AuthConfig) {
|
|
|
259
260
|
},
|
|
260
261
|
},
|
|
261
262
|
// Self-service account deletion — admin-toggleable (auth_config), applied live.
|
|
262
|
-
deleteUser: {
|
|
263
|
+
deleteUser: {
|
|
264
|
+
enabled: config.allowDelete,
|
|
265
|
+
afterDelete: runUserDeletedHandlers,
|
|
266
|
+
},
|
|
263
267
|
},
|
|
264
268
|
databaseHooks,
|
|
265
269
|
// podokit:begin:auth-options
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
registerUserDeletedHandler,
|
|
4
|
+
runUserDeletedHandlers,
|
|
5
|
+
type UserDeletedHandler,
|
|
6
|
+
} from "./user-delete-handlers";
|
|
7
|
+
|
|
8
|
+
describe("user deletion handlers", () => {
|
|
9
|
+
const unregister: Array<() => void> = [];
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
for (const remove of unregister.splice(0)) remove();
|
|
13
|
+
jest.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function register(handler: UserDeletedHandler): void {
|
|
17
|
+
unregister.push(registerUserDeletedHandler(handler));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
it("runs registered module cleanup with the deleted user", async () => {
|
|
21
|
+
const handler: UserDeletedHandler = jest.fn(async () => undefined);
|
|
22
|
+
register(handler);
|
|
23
|
+
const user = { id: "user-1", image: "/api/profile-images/avatar.png" };
|
|
24
|
+
|
|
25
|
+
await runUserDeletedHandlers(user);
|
|
26
|
+
|
|
27
|
+
expect(handler).toHaveBeenCalledWith(user);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not fail account deletion when one cleanup rejects", async () => {
|
|
31
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
32
|
+
register(async () => {
|
|
33
|
+
throw new Error("storage unavailable");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await expect(runUserDeletedHandlers({ id: "user-1" })).resolves.toBeUndefined();
|
|
37
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
38
|
+
"Run user deletion cleanup failed",
|
|
39
|
+
expect.objectContaining({ userId: "user-1", error: "storage unavailable" }),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface DeletedUser {
|
|
2
|
+
id: string;
|
|
3
|
+
image?: string | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type UserDeletedHandler = (user: DeletedUser) => Promise<void>;
|
|
7
|
+
|
|
8
|
+
const handlers = new Set<UserDeletedHandler>();
|
|
9
|
+
|
|
10
|
+
/** Register module-owned cleanup that should run after better-auth removes a user. */
|
|
11
|
+
export function registerUserDeletedHandler(handler: UserDeletedHandler): () => void {
|
|
12
|
+
handlers.add(handler);
|
|
13
|
+
return () => handlers.delete(handler);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Run every cleanup without turning a completed account deletion into an error. */
|
|
17
|
+
export async function runUserDeletedHandlers(user: DeletedUser): Promise<void> {
|
|
18
|
+
const results = await Promise.allSettled([...handlers].map((handler) => handler(user)));
|
|
19
|
+
for (const result of results) {
|
|
20
|
+
if (result.status === "rejected") {
|
|
21
|
+
console.error("Run user deletion cleanup failed", {
|
|
22
|
+
userId: user.id,
|
|
23
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -17,7 +17,9 @@ WORKDIR /app
|
|
|
17
17
|
COPY package.json ./
|
|
18
18
|
COPY apps/api/package.json ./apps/api/package.json
|
|
19
19
|
COPY apps/web/package.json ./apps/web/package.json
|
|
20
|
-
|
|
20
|
+
ARG PODOKIT_NPM_REGISTRY=""
|
|
21
|
+
RUN if [ -n "$PODOKIT_NPM_REGISTRY" ]; then npm config set registry "$PODOKIT_NPM_REGISTRY"; fi \
|
|
22
|
+
&& npm install
|
|
21
23
|
|
|
22
24
|
# Baked source (kept in sync during development by `docker compose watch`).
|
|
23
25
|
COPY . .
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"migration:generate": "npm run typeorm -- migration:generate"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@podosoft/podokit-contracts": "^0.
|
|
17
|
+
"@podosoft/podokit-contracts": "^0.4.0",
|
|
18
18
|
"@nestjs/common": "^11.1.27",
|
|
19
19
|
"@nestjs/config": "^4.0.4",
|
|
20
20
|
"@nestjs/core": "^11.1.27",
|
|
@@ -41,6 +41,8 @@ services:
|
|
|
41
41
|
build:
|
|
42
42
|
context: .
|
|
43
43
|
dockerfile: Dockerfile.dev
|
|
44
|
+
args:
|
|
45
|
+
PODOKIT_NPM_REGISTRY: ${PODOKIT_NPM_REGISTRY:-}
|
|
44
46
|
command: npm run dev -w {{projectName}}-web -- --host 0.0.0.0
|
|
45
47
|
working_dir: /app
|
|
46
48
|
env_file: .env.docker
|
|
@@ -64,6 +66,8 @@ services:
|
|
|
64
66
|
build:
|
|
65
67
|
context: .
|
|
66
68
|
dockerfile: Dockerfile.dev
|
|
69
|
+
args:
|
|
70
|
+
PODOKIT_NPM_REGISTRY: ${PODOKIT_NPM_REGISTRY:-}
|
|
67
71
|
init: true
|
|
68
72
|
command: npm run dev -w {{projectName}}-api
|
|
69
73
|
working_dir: /app
|
|
@@ -87,6 +91,8 @@ services:
|
|
|
87
91
|
build:
|
|
88
92
|
context: .
|
|
89
93
|
dockerfile: Dockerfile.dev
|
|
94
|
+
args:
|
|
95
|
+
PODOKIT_NPM_REGISTRY: ${PODOKIT_NPM_REGISTRY:-}
|
|
90
96
|
init: true
|
|
91
97
|
command: npm run dev:worker -w {{projectName}}-api
|
|
92
98
|
working_dir: /app
|