@podosoft/podokit 0.13.0 → 0.14.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 (66) hide show
  1. package/dist/add.d.ts +27 -1
  2. package/dist/add.js +3 -1
  3. package/dist/index.js +6 -3
  4. package/dist/lib.d.ts +1 -1
  5. package/dist/lockfile.d.ts +4 -1
  6. package/dist/lockfile.js +4 -3
  7. package/dist/module-migrations.d.ts +25 -0
  8. package/dist/module-migrations.js +233 -0
  9. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
  10. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
  11. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
  12. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
  15. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
  16. package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +155 -2
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +13 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +13 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
  35. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +64 -1
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +16 -0
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +4 -7
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +4 -1
  40. package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
  41. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +5 -1
  42. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
  43. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
  44. package/dist/templates/modules/auth/module.manifest.json +1 -1
  45. package/dist/templates/todo/Dockerfile.dev +3 -1
  46. package/dist/templates/todo/apps/api/package.json +1 -1
  47. package/dist/templates/todo/apps/web/package.json +1 -1
  48. package/dist/templates/todo/compose.dev.yaml +6 -0
  49. package/dist/update.d.ts +8 -1
  50. package/dist/update.js +68 -12
  51. package/package.json +1 -1
  52. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
  53. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
  54. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
  55. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
  56. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
  57. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
  58. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
  59. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
  60. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
  61. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
  62. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +0 -0
  63. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +0 -0
  64. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
  65. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
  66. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +0 -0
@@ -1,51 +1,6 @@
1
- import { redirect } from "@sveltejs/kit";
2
1
  import type { LayoutServerLoad } from "./$types";
3
- import type { Capabilities } from "@podosoft/podokit-api-client";
4
- import { requireBackendAvailable } from "$lib/server/guards";
2
+ import { loadProtectedLayout } from "$lib/server/protected-layout";
5
3
 
6
4
  export const load: LayoutServerLoad = async ({ locals, fetch }) => {
7
- requireBackendAvailable(locals);
8
-
9
- let capabilities: Capabilities = {
10
- twoFactor: false,
11
- providers: [],
12
- deleteAccount: false,
13
- auditLog: false,
14
- emailVerification: false,
15
- signupApprovalRequired: false,
16
- passwordBreachCheck: false,
17
- magicLink: false,
18
- emailOtp: false,
19
- username: false,
20
- multiSession: false,
21
- phoneNumber: false,
22
- apiKey: false,
23
- passkey: false,
24
- organization: false,
25
- oidcProvider: false,
26
- roles: ["admin", "user"],
27
- };
28
- try {
29
- const res = await fetch("/api/account/capabilities");
30
- if (res.ok) capabilities = (await res.json()) as Capabilities;
31
- } catch {
32
- /* keep defaults */
33
- }
34
-
35
- // Require-two-factor policy: a signed-in user who hasn't enrolled is sent to the
36
- // enrolment page until they do (the API's TwoFactorRequiredGuard is the real
37
- // boundary; this is the UX). /setup-2fa lives outside this layout, so no loop.
38
- const user = locals.user as (App.Locals["user"] & { twoFactorEnabled?: boolean }) | null;
39
- let mustEnrol = false;
40
- if (user && !user.twoFactorEnabled) {
41
- try {
42
- const res = await fetch("/api/account/require-2fa");
43
- mustEnrol = res.ok && ((await res.json()) as { require2fa?: boolean }).require2fa === true;
44
- } catch {
45
- /* don't block the app if the policy check fails */
46
- }
47
- }
48
- if (mustEnrol) redirect(302, "/setup-2fa");
49
-
50
- return { user: locals.user, impersonating: !!locals.session?.impersonatedBy, capabilities };
5
+ return loadProtectedLayout({ locals, fetch });
51
6
  };
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { page } from "$app/state";
3
+ import AccountMenu from "$lib/components/account-menu.svelte";
2
4
  import { Button } from "$lib/components/ui/button";
3
5
  import LanguageSwitch from "$lib/components/language-switch.svelte";
4
6
  import ThemeToggle from "$lib/components/theme-toggle.svelte";
@@ -6,12 +8,14 @@
6
8
  import { getI18n } from "$lib/i18n";
7
9
 
8
10
  const i18n = getI18n();
11
+ const user = $derived(page.data.user ?? null);
9
12
  </script>
10
13
 
11
14
  <div class="flex min-h-svh flex-col">
12
15
  <header class="flex items-center justify-end gap-2 p-4">
13
16
  <LanguageSwitch />
14
17
  <ThemeToggle />
18
+ <AccountMenu {user} />
15
19
  </header>
16
20
  <main class="flex flex-1 flex-col items-center justify-center gap-6 px-4 text-center">
17
21
  <div class="flex flex-col gap-3">
@@ -0,0 +1,96 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ADMIN } from "../helpers/accounts";
3
+
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+ const origin = { origin: base };
6
+ const PNG = Buffer.from(
7
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
8
+ "base64",
9
+ );
10
+ const TOO_WIDE_PNG = Buffer.from(
11
+ "iVBORw0KGgoAAAANSUhEUgAACAEAAAABAQMAAACfGLePAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCScoTk3I6QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozOTo0MCswMDowMCgv/7YAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzk6NDArMDA6MDBZckcKAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM5OjQwKzAwOjAwDmdm1QAAAAxJREFUGNNjYBjpAAABAgABRf+HpwAAAABJRU5ErkJggg==",
12
+ "base64",
13
+ );
14
+
15
+ async function adminContext(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
16
+ const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
17
+ await context.post("/api/auth/sign-in/email", {
18
+ data: { email: ADMIN.email, password: ADMIN.password },
19
+ });
20
+ return context;
21
+ }
22
+
23
+ test("profile image upload requires authentication", async ({ playwright }) => {
24
+ const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
25
+ const response = await anonymous.post("/api/account/profile-image", {
26
+ multipart: { file: { name: "avatar.png", mimeType: "image/png", buffer: PNG } },
27
+ });
28
+ expect(response.status()).toBe(401);
29
+ await anonymous.dispose();
30
+ });
31
+
32
+ test("profile image upload, replacement, public read, and removal", async ({ playwright }) => {
33
+ const admin = await adminContext(playwright);
34
+ await admin.delete("/api/account/profile-image");
35
+
36
+ const first = await admin.post("/api/account/profile-image", {
37
+ multipart: { file: { name: "avatar.png", mimeType: "image/png", buffer: PNG } },
38
+ });
39
+ expect(first.status()).toBe(201);
40
+ const firstBody = await first.json() as { image: string };
41
+ expect(firstBody.image).toMatch(/^\/api\/profile-images\/[0-9a-f-]+\.png$/);
42
+
43
+ const sessionAfterUpload = await (await admin.get("/api/auth/get-session")).json() as {
44
+ user: { image?: string | null };
45
+ };
46
+ expect(sessionAfterUpload.user.image).toBe(firstBody.image);
47
+ const firstImage = await admin.get(firstBody.image);
48
+ expect(firstImage.ok()).toBeTruthy();
49
+ expect(firstImage.headers()["content-type"]).toContain("image/png");
50
+ expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
51
+
52
+ const replacement = await admin.post("/api/account/profile-image", {
53
+ multipart: { file: { name: "replacement.png", mimeType: "image/png", buffer: PNG } },
54
+ });
55
+ expect(replacement.status()).toBe(201);
56
+ const replacementBody = await replacement.json() as { image: string };
57
+ expect(replacementBody.image).not.toBe(firstBody.image);
58
+ expect((await admin.get(firstBody.image)).status()).toBe(404);
59
+
60
+ const removed = await admin.delete("/api/account/profile-image");
61
+ expect(await removed.json()).toEqual({ image: null });
62
+ expect((await admin.get(replacementBody.image)).status()).toBe(404);
63
+ const sessionAfterRemoval = await (await admin.get("/api/auth/get-session")).json() as {
64
+ user: { image?: string | null };
65
+ };
66
+ expect(sessionAfterRemoval.user.image).toBeNull();
67
+ await admin.dispose();
68
+ });
69
+
70
+ test("profile image upload returns stable validation errors", async ({ playwright }) => {
71
+ const admin = await adminContext(playwright);
72
+ const invalidType = await admin.post("/api/account/profile-image", {
73
+ multipart: { file: { name: "avatar.png", mimeType: "image/png", buffer: Buffer.from("not an image") } },
74
+ });
75
+ expect(invalidType.status()).toBe(400);
76
+ expect((await invalidType.json()).error.code).toBe("PROFILE_IMAGE_TYPE_INVALID");
77
+
78
+ const tooWide = await admin.post("/api/account/profile-image", {
79
+ multipart: { file: { name: "wide.png", mimeType: "image/png", buffer: TOO_WIDE_PNG } },
80
+ });
81
+ expect(tooWide.status()).toBe(400);
82
+ expect((await tooWide.json()).error.code).toBe("PROFILE_IMAGE_DIMENSIONS_INVALID");
83
+
84
+ const tooLarge = await admin.post("/api/account/profile-image", {
85
+ multipart: {
86
+ file: {
87
+ name: "large.png",
88
+ mimeType: "image/png",
89
+ buffer: Buffer.alloc(2 * 1024 * 1024 + 1),
90
+ },
91
+ },
92
+ });
93
+ expect(tooLarge.status()).toBe(413);
94
+ expect((await tooLarge.json()).error.code).toBe("PROFILE_IMAGE_TOO_LARGE");
95
+ await admin.dispose();
96
+ });
@@ -2,6 +2,15 @@ import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
  import { ADMIN } from "../helpers/accounts";
4
4
 
5
+ const PNG = Buffer.from(
6
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
7
+ "base64",
8
+ );
9
+ const TOO_WIDE_PNG = Buffer.from(
10
+ "iVBORw0KGgoAAAANSUhEUgAACAEAAAABAQMAAACfGLePAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCScoTk3I6QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozOTo0MCswMDowMCgv/7YAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzk6NDArMDA6MDBZckcKAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM5OjQwKzAwOjAwDmdm1QAAAAxJREFUGNNjYBjpAAABAgABRf+HpwAAAABJRU5ErkJggg==",
11
+ "base64",
12
+ );
13
+
5
14
  // admin storageState (project default)
6
15
  test("account opens on the profile section @smoke", async ({ page }) => {
7
16
  await ready(page, "/admin/account");
@@ -14,6 +23,60 @@ test("account opens on the profile section @smoke", async ({ page }) => {
14
23
  await expect(page.getByRole("button", { name: "Save changes" })).toBeEnabled();
15
24
  });
16
25
 
26
+ test("protected app routes do not inherit the admin shell @smoke", async ({ page }) => {
27
+ await ready(page, "/accept-invitation/not-a-real-invitation");
28
+ await expect(page.getByText("Organization invitation", { exact: true })).toBeVisible();
29
+ await expect(page.getByTestId("sidebar-brand")).toHaveCount(0);
30
+
31
+ await ready(page, "/admin");
32
+ await expect(page.getByTestId("sidebar-brand")).toBeVisible();
33
+ });
34
+
35
+ test("account displays profile image limits and updates every avatar", async ({ page }) => {
36
+ await ready(page, "/account");
37
+ await page.request.delete("/api/account/profile-image");
38
+ await ready(page, "/account");
39
+ await expect(page.getByTestId("profile-image-hint")).toContainText(/PNG, JPEG, (?:or )?WebP/);
40
+ await expect(page.getByTestId("profile-image-hint")).toContainText(/2 ?MB/);
41
+ await expect(page.getByTestId("profile-image-hint")).toContainText(/2048 × 2048 ?px/);
42
+
43
+ await page.locator("#profile-image").setInputFiles({
44
+ name: "avatar.png",
45
+ mimeType: "image/png",
46
+ buffer: PNG,
47
+ });
48
+ await expect(page.getByText(/Profile image updated|프로필 이미지를 변경했습니다/)).toBeVisible();
49
+ await expect(page.getByTestId("profile-image-preview").locator("img")).toBeVisible();
50
+
51
+ await ready(page, "/");
52
+ await expect(page.getByTestId("account-menu").locator("img")).toBeVisible();
53
+ await ready(page, "/admin");
54
+ await expect(page.getByTestId("sidebar-user-menu").locator("img")).toBeVisible();
55
+
56
+ await ready(page, "/account");
57
+ await page.getByRole("button", { name: /Remove image|이미지 제거/ }).click();
58
+ await expect(page.getByText(/Profile image removed|프로필 이미지를 제거했습니다/)).toBeVisible();
59
+ await expect(page.getByTestId("profile-image-preview").locator("img")).toHaveCount(0);
60
+ await expect(page.getByRole("button", { name: /Upload image|이미지 업로드/ })).toBeVisible();
61
+ });
62
+
63
+ test("account rejects invalid profile images before upload", async ({ page }) => {
64
+ await ready(page, "/admin/account");
65
+ await page.locator("#profile-image").setInputFiles({
66
+ name: "avatar.txt",
67
+ mimeType: "text/plain",
68
+ buffer: Buffer.from("not an image"),
69
+ });
70
+ await expect(page.getByText(/Choose a valid PNG, JPEG, or WebP image|올바른 PNG, JPEG 또는 WebP 이미지를 선택해 주세요/)).toBeVisible();
71
+
72
+ await page.locator("#profile-image").setInputFiles({
73
+ name: "wide.png",
74
+ mimeType: "image/png",
75
+ buffer: TOO_WIDE_PNG,
76
+ });
77
+ await expect(page.getByText(/The image must not exceed 2048 × 2048 px|이미지는 2048 × 2048px를 초과할 수 없습니다/)).toBeVisible();
78
+ });
79
+
17
80
  test("account exposes a change-email action when the address is edited", async ({ page }) => {
18
81
  await ready(page, "/admin/account");
19
82
  const email = page.getByLabel("Email");
@@ -106,7 +169,7 @@ test("account security and sessions sub-navigation", async ({ page }) => {
106
169
 
107
170
  test("account nav shows the core sections", async ({ page }) => {
108
171
  await ready(page, "/admin/account");
109
- await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
172
+ await expect(page.getByRole("button", { name: "Profile", exact: true })).toBeVisible();
110
173
  await expect(page.getByRole("button", { name: "Security" })).toBeVisible();
111
174
  await expect(page.getByRole("button", { name: "Sessions" })).toBeVisible();
112
175
  });
@@ -10,6 +10,22 @@ test("overview shows the signed-in admin @smoke", async ({ page }) => {
10
10
  await expect(page.getByRole("main").getByText(ADMIN.email)).toBeVisible();
11
11
  });
12
12
 
13
+ test("landing shows language, theme, and signed-in account actions", async ({ page }) => {
14
+ await ready(page, "/");
15
+ const language = page.getByRole("button", { name: /^(Language|언어)$/ });
16
+ const theme = page.getByRole("button", { name: /^(Toggle theme|테마 전환)$/ });
17
+ await expect(language).toBeVisible();
18
+ await expect(language.locator("svg")).toBeVisible();
19
+ await expect(theme).toBeVisible();
20
+ await expect(theme.locator("svg:visible")).toBeVisible();
21
+ await page.getByTestId("account-menu").click();
22
+ const label = page.getByRole("menu").locator('[data-slot="dropdown-menu-label"]');
23
+ await expect(label.getByText(ADMIN.name, { exact: true })).toBeVisible();
24
+ await expect(label.getByText(ADMIN.email, { exact: true })).toBeVisible();
25
+ await expect(page.getByRole("menuitem", { name: "Account" })).toBeVisible();
26
+ await expect(page.getByRole("menuitem", { name: "Sign out" })).toBeVisible();
27
+ });
28
+
13
29
  test("sidebar navigates to sessions and account @smoke", async ({ page }) => {
14
30
  await ready(page, "/admin");
15
31
  await page.getByRole("link", { name: "Sessions" }).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,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- 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"
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
package/dist/update.d.ts CHANGED
@@ -5,9 +5,11 @@ import { type Tier } from "./lockfile";
5
5
  * working copy, classifying each file. Applying the plan (writing files, 3-way
6
6
  * merging) is a later step (see ADR-0009).
7
7
  */
8
- export type Action = "update" | "merge" | "conflict" | "add" | "remove" | "skip" | "up-to-date";
8
+ export type Action = "update" | "merge" | "conflict" | "add" | "move" | "remove" | "skip" | "up-to-date";
9
9
  export interface FileChange {
10
10
  path: string;
11
+ /** Previous path when this change is an update migration move. */
12
+ fromPath?: string;
11
13
  tier: Tier;
12
14
  action: Action;
13
15
  /** Why the action was chosen (for the report). */
@@ -33,6 +35,11 @@ export interface ApplyOptions {
33
35
  }
34
36
  export interface ApplyResult {
35
37
  written: string[];
38
+ /** Files relocated by module update migrations. */
39
+ moved: {
40
+ from: string;
41
+ to: string;
42
+ }[];
36
43
  removed: string[];
37
44
  /** Files 3-way merged cleanly. */
38
45
  merged: string[];