@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.
Files changed (86) hide show
  1. package/README.md +3 -0
  2. package/dist/add.d.ts +27 -1
  3. package/dist/add.js +3 -1
  4. package/dist/index.js +6 -3
  5. package/dist/lib.d.ts +1 -1
  6. package/dist/lockfile.d.ts +4 -1
  7. package/dist/lockfile.js +6 -3
  8. package/dist/module-migrations.d.ts +25 -0
  9. package/dist/module-migrations.js +233 -0
  10. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
  11. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
  12. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
  16. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
  17. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
  18. package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
  19. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -0
  20. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
  21. package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +155 -2
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +13 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +13 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +18 -10
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
  43. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
  44. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
  46. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
  49. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
  50. package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +64 -1
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +16 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +4 -7
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +4 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
  59. package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
  60. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +5 -1
  61. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
  62. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
  63. package/dist/templates/modules/auth/module.manifest.json +1 -1
  64. package/dist/templates/todo/Dockerfile.dev +3 -1
  65. package/dist/templates/todo/apps/api/package.json +1 -1
  66. package/dist/templates/todo/apps/web/package.json +1 -1
  67. package/dist/templates/todo/compose.dev.yaml +6 -0
  68. package/dist/templates/todo/tests/playwright.config.ts +6 -4
  69. package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
  70. package/dist/update.d.ts +8 -1
  71. package/dist/update.js +85 -25
  72. package/package.json +1 -1
  73. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
  74. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
  75. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
  76. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
  77. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
  78. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
  79. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
  80. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
  81. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
  82. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
  83. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +0 -0
  84. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +0 -0
  85. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
  86. /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 "@playwright/test";
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 page.request.post("/api/auth/admin/create-user", {
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-only nav is hidden and admin routes are forbidden", async ({ page }) => {
27
- await page.goto("/admin");
28
- await expect(page.getByRole("link", { name: "Users" })).toHaveCount(0);
29
- await expect(page.getByRole("link", { name: "Sessions" })).toHaveCount(0);
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
- await page.getByRole("button", { name: "Configure" }).click();
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 { expect, test, type Page } from "@playwright/test";
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(page: Page, email: string): Promise<void> {
9
- const res = await page.request.post("/api/auth/admin/create-user", {
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(page, email);
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(page, email);
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(page, email);
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(page, email);
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(page, email);
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
- for (let i = 0; i < 6; i += 1) {
126
- await page.request.post("/api/auth/admin/create-user", {
127
- headers: { origin: base },
128
- data: { email: `pg-${Date.now()}-${i}@example.com`, password: "Podokit3e-Str0ng!pw", name: `Pg${i}`, role: "user" },
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.reload();
132
- const nav = page.getByRole("navigation", { name: "pagination" });
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(page, email);
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 "@playwright/test";
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 created = await page.request.post("/api/auth/admin/create-user", {
58
- headers: { origin: base },
59
- data: { email, password: "Podokit3e-Str0ng!pw", name: "Pending UI", role: "user" },
60
- });
61
- expect(created.ok()).toBeTruthy();
62
- const userId = ((await created.json()).user?.id ?? "") as string;
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
- try {
65
- expect(userId).toBeTruthy();
66
- expect(
67
- (await page.request.post("/api/auth/admin/update-user", {
68
- headers: { origin: base },
69
- data: { userId, data: { signupApproved: false } },
70
- })).ok(),
71
- ).toBeTruthy();
72
-
73
- await ready(page, "/admin/users");
74
- await page.locator("#toolbar-search").fill(email);
75
- await page.getByRole("button", { name: "Search", exact: true }).click();
76
- const row = page.getByRole("row", { name: new RegExp(email) });
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 dashboard UI (SvelteKit + shadcn-svelte): login/signup/password-reset, a sidebar shell, and user + session management via the better-auth admin plugin. Requires auth.",
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/(app)/admin/account/+page.server.ts",
23
- "apps/web/src/routes/(app)/admin/audit/+page.server.ts",
24
- "apps/web/src/routes/(app)/admin/organizations/+page.server.ts",
25
- "apps/web/src/routes/(app)/admin/sessions/+page.server.ts",
26
- "apps/web/src/routes/(app)/admin/settings/+page.server.ts",
27
- "apps/web/src/routes/(app)/admin/users/+page.server.ts",
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: { enabled: config.allowDelete },
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
+ }
@@ -8,7 +8,7 @@
8
8
  ],
9
9
  "dependencies": {
10
10
  "@podosoft/podokit-auth": "^0.3.0",
11
- "@podosoft/podokit-contracts": "^0.3.0",
11
+ "@podosoft/podokit-contracts": "^0.4.0",
12
12
  "better-auth": "^1.6.23",
13
13
  "@thallesp/nestjs-better-auth": "^2.7.0",
14
14
  "pg": "^8.12.0",
@@ -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
- RUN npm install
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.3.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",
@@ -13,7 +13,7 @@
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
15
15
  "@lucide/svelte": "^1.23.0",
16
- "@podosoft/podokit-api-client": "^0.7.0",
16
+ "@podosoft/podokit-api-client": "^0.8.0",
17
17
  "bits-ui": "^2.18.1",
18
18
  "clsx": "^2.1.1",
19
19
  "mode-watcher": "^1.1.0",
@@ -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