@podosoft/podokit 0.12.4 → 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 (101) 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/session-idle-timeout.svelte +109 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +21 -1
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +21 -1
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +8 -1
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +73 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
  43. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
  44. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
  47. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +70 -7
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +18 -2
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -1
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +3 -3
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -9
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +36 -3
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
  69. package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
  70. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  71. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
  72. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
  73. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
  74. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +9 -2
  75. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
  76. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
  77. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
  78. package/dist/templates/modules/auth/module.manifest.json +5 -4
  79. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  80. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  81. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  82. package/dist/templates/todo/Dockerfile.dev +3 -1
  83. package/dist/templates/todo/apps/api/package.json +1 -1
  84. package/dist/templates/todo/apps/web/package.json +1 -1
  85. package/dist/templates/todo/compose.dev.yaml +6 -0
  86. package/dist/update.d.ts +8 -1
  87. package/dist/update.js +68 -12
  88. package/package.json +1 -1
  89. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
  90. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
  91. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
  92. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
  93. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
  94. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
  95. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
  96. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
  97. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
  98. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
  99. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
  100. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
  101. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +0 -0
@@ -4,7 +4,7 @@ import { clearMailpit, mailpitReachable, waitForLink, waitForOtp } from "../help
4
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
  const origin = { origin: base };
6
6
 
7
- test("email one-time code signs a user in @smoke", async ({ playwright }) => {
7
+ test("email one-time code signs a user in", async ({ playwright }) => {
8
8
  test.skip(!(await mailpitReachable()), "Mailpit not available");
9
9
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
10
10
  const caps = await (await ctx.get("/api/account/capabilities")).json();
@@ -22,7 +22,7 @@ test("email one-time code signs a user in @smoke", async ({ playwright }) => {
22
22
  await ctx.dispose();
23
23
  });
24
24
 
25
- test("organization invitation can be sent and accepted @smoke", async ({ playwright }) => {
25
+ test("organization invitation can be sent and accepted", async ({ playwright }) => {
26
26
  const owner = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
27
27
  const caps = await (await owner.get("/api/account/capabilities")).json();
28
28
  test.skip(!caps?.organization, "organizations not enabled");
@@ -49,7 +49,7 @@ test("organization invitation can be sent and accepted @smoke", async ({ playwri
49
49
  await owner.dispose();
50
50
  });
51
51
 
52
- test("magic link signs a user in @smoke", async ({ playwright }) => {
52
+ test("magic link signs a user in", async ({ playwright }) => {
53
53
  test.skip(!(await mailpitReachable()), "Mailpit not available");
54
54
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
55
55
  const caps = await (await ctx.get("/api/account/capabilities")).json();
@@ -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
+ });
@@ -11,7 +11,7 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
11
11
  return ctx;
12
12
  }
13
13
 
14
- test("theme settings: valid preset/radius/accent round-trip @smoke", async ({ playwright }) => {
14
+ test("theme settings: valid preset/radius/accent round-trip", async ({ playwright }) => {
15
15
  const admin = await adminCtx(playwright);
16
16
  const put = await admin.put("/api/site/settings", {
17
17
  data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
@@ -29,7 +29,7 @@ test("theme settings: valid preset/radius/accent round-trip @smoke", async ({ pl
29
29
  await anon.dispose();
30
30
  });
31
31
 
32
- test("theme settings: valid per-token overrides accepted @smoke", async ({ playwright }) => {
32
+ test("theme settings: valid per-token overrides accepted", async ({ playwright }) => {
33
33
  const admin = await adminCtx(playwright);
34
34
  const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
35
35
  const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
@@ -22,7 +22,7 @@ async function loginFresh(page: import("@playwright/test").Page, playwright: imp
22
22
  await expect(page).toHaveURL(/\/admin\/account/);
23
23
  }
24
24
 
25
- test("account: register and verify a phone number @smoke", async ({ page, playwright }) => {
25
+ test("account: register and verify a phone number", async ({ page, playwright }) => {
26
26
  test.skip(!(await smsSinkReachable()), "sms sink unreachable");
27
27
  await loginFresh(page, playwright, `acc-ph-${Date.now()}@example.com`);
28
28
  const phoneInput = page.locator("#phone");
@@ -38,14 +38,14 @@ test("account: register and verify a phone number @smoke", async ({ page, playwr
38
38
  await expect(page.getByText("Phone verified")).toBeVisible();
39
39
  });
40
40
 
41
- test("account: change email address @smoke", async ({ page, playwright }) => {
41
+ test("account: change email address", async ({ page, playwright }) => {
42
42
  await loginFresh(page, playwright, `acc-em-${Date.now()}@example.com`);
43
43
  await page.locator("#email").fill(`changed-${Date.now()}@example.com`);
44
44
  await page.getByRole("button", { name: "Change email" }).click();
45
45
  await expect(page.getByText(/Email address updated|approve the change/)).toBeVisible();
46
46
  });
47
47
 
48
- test("account: delete my own account @smoke", async ({ page, playwright }) => {
48
+ test("account: delete my own account", async ({ page, playwright }) => {
49
49
  const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
50
50
  await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: PW } });
51
51
  try {
@@ -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,7 +23,61 @@ 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
 
17
- test("account exposes a change-email action when the address is edited @smoke", async ({ page }) => {
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
+
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");
20
83
  await expect(email).toHaveValue("admin@example.com");
@@ -27,14 +90,14 @@ test("account exposes a change-email action when the address is edited @smoke",
27
90
  await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
28
91
  });
29
92
 
30
- test("account shows a username field when enabled @smoke", async ({ page }) => {
93
+ test("account shows a username field when enabled", async ({ page }) => {
31
94
  await ready(page, "/admin/account");
32
95
  const username = page.getByLabel("Username", { exact: true });
33
96
  test.skip((await username.count()) === 0, "username not enabled");
34
97
  await expect(username).toBeVisible();
35
98
  });
36
99
 
37
- test("account can create and revoke an API key when enabled @smoke", async ({ page }) => {
100
+ test("account can create and revoke an API key when enabled", async ({ page }) => {
38
101
  await ready(page, "/admin/account");
39
102
  const nav = page.getByRole("button", { name: "API keys" });
40
103
  test.skip((await nav.count()) === 0, "api keys not enabled");
@@ -52,7 +115,7 @@ test("account can create and revoke an API key when enabled @smoke", async ({ pa
52
115
  await expect(page.getByText("Key revoked")).toBeVisible();
53
116
  });
54
117
 
55
- test("account can register and remove a passkey when enabled @smoke", async ({ page }) => {
118
+ test("account can register and remove a passkey when enabled", async ({ page }) => {
56
119
  await ready(page, "/admin/account");
57
120
  await page.getByRole("button", { name: "Security" }).click();
58
121
  const addBtn = page.getByRole("button", { name: "Add passkey" });
@@ -74,7 +137,7 @@ test("account can register and remove a passkey when enabled @smoke", async ({ p
74
137
  await expect(page.getByText("Passkey removed")).toBeVisible();
75
138
  });
76
139
 
77
- test("account shows a phone field when enabled @smoke", async ({ page }) => {
140
+ test("account shows a phone field when enabled", async ({ page }) => {
78
141
  await ready(page, "/admin/account");
79
142
  const phone = page.getByLabel("Phone number", { exact: true });
80
143
  test.skip((await phone.count()) === 0, "phone number not enabled");
@@ -106,12 +169,12 @@ 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
  });
113
176
 
114
- test("two-factor setup shows a scannable QR code when enabled @smoke", async ({ page }) => {
177
+ test("two-factor setup shows a scannable QR code when enabled", async ({ page }) => {
115
178
  await ready(page, "/admin/account");
116
179
  await page.getByRole("button", { name: "Security" }).click();
117
180
  const heading = page.getByText("Two-factor authentication");
@@ -12,7 +12,7 @@ async function openAppearance(page: import("@playwright/test").Page): Promise<vo
12
12
  }).toPass();
13
13
  }
14
14
 
15
- test("appearance: has its own tab with theme controls @smoke", async ({ page }) => {
15
+ test("appearance: has its own tab with theme controls", async ({ page }) => {
16
16
  await openAppearance(page);
17
17
  await expect(page.getByRole("heading", { name: "Featured themes" })).toBeVisible();
18
18
  await expect(page.getByRole("button", { name: "Default", exact: true })).toBeVisible();
@@ -10,7 +10,23 @@ 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("sidebar navigates to sessions and account", async ({ page }) => {
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
+
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();
16
32
  await expect(page).toHaveURL(/\/admin\/sessions/);
@@ -19,7 +35,7 @@ test("sidebar navigates to sessions and account", async ({ page }) => {
19
35
  await expect(page.getByRole("link", { name: "Account" })).toHaveCount(0);
20
36
  await page.getByRole("button", { name: /admin@example.com/ }).click();
21
37
  await page.getByRole("menuitem", { name: "Account" }).click();
22
- await expect(page).toHaveURL(/\/admin\/account/);
38
+ await expect(page).toHaveURL((url) => url.pathname === "/account");
23
39
  await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
24
40
  await expect(page.getByLabel("Name", { exact: true })).toBeVisible(); // profile section (exact: "Username" also contains "Name")
25
41
  });
@@ -3,7 +3,7 @@ import { ready } from "../helpers/hydration";
3
3
 
4
4
  // Sorting via the shared DataTable. Users is server-sorted (manual), audit is
5
5
  // client-sorted — both drive the same clickable headers + aria-sort.
6
- test("users table sorts by a column header (server) @smoke", async ({ page }) => {
6
+ test("users table sorts by a column header (server)", async ({ page }) => {
7
7
  await ready(page, "/admin/users");
8
8
  const header = page.getByRole("columnheader", { name: /Name/ });
9
9
  // Click the sort button *inside* the header — a bare name match collides with
@@ -9,7 +9,7 @@ const origin = { origin: base };
9
9
  // The reset flow is for signed-out users — run without the seeded session.
10
10
  test.use({ storageState: anonState });
11
11
 
12
- test("forgot password: request, open the emailed link, set a new password, sign in @smoke", async ({ page, playwright }) => {
12
+ test("forgot password: request, open the emailed link, set a new password, sign in", async ({ page, playwright }) => {
13
13
  test.skip(!(await mailpitReachable()), "Mailpit not available");
14
14
  const email = `ui-reset-${Date.now()}@example.com`;
15
15
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
@@ -43,7 +43,7 @@ test("general: empty locale displays the application default", async ({ page })
43
43
  }
44
44
  });
45
45
 
46
- test("general: footer text and links render on public pages @smoke", async ({ page, browser }) => {
46
+ test("general: footer text and links render on public pages", async ({ page, browser }) => {
47
47
  await ready(page, "/admin/settings");
48
48
  const origin = new URL(page.url()).origin;
49
49
  const footer = page.getByLabel("Footer text");
@@ -78,7 +78,7 @@ test("general: footer text and links render on public pages @smoke", async ({ pa
78
78
  }
79
79
  });
80
80
 
81
- test("general: maintenance mode holds non-admins on the maintenance page @smoke", async ({ page, browser }) => {
81
+ test("general: maintenance mode holds non-admins on the maintenance page", async ({ page, browser }) => {
82
82
  await ready(page, "/admin/settings");
83
83
  const origin = new URL(page.url()).origin;
84
84
  await page.getByRole("switch", { name: "Maintenance mode" }).click();
@@ -102,7 +102,7 @@ test("general: maintenance mode holds non-admins on the maintenance page @smoke"
102
102
  }
103
103
  });
104
104
 
105
- test("general: closing sign-up redirects /signup and hides the link @smoke", async ({ page, browser }) => {
105
+ test("general: closing sign-up redirects /signup and hides the link", async ({ page, browser }) => {
106
106
  await ready(page, "/admin/settings");
107
107
  const origin = new URL(page.url()).origin;
108
108
  await page.getByRole("switch", { name: "Allow public sign-up" }).click();
@@ -10,7 +10,7 @@ test.describe("unauthenticated", () => {
10
10
  }
11
11
  });
12
12
 
13
- test("private and authentication routes prevent search indexing @smoke", async ({ request }) => {
13
+ test("private and authentication routes prevent search indexing", async ({ request }) => {
14
14
  for (const route of ["/admin", "/account", "/login", "/api/account/me"]) {
15
15
  const response = await request.get(route, { maxRedirects: 0 });
16
16
  expect(response.headers()["x-robots-tag"]).toBe("noindex, nofollow");
@@ -23,16 +23,13 @@ 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
- test("account is available without the admin shell @smoke", async ({ page }) => {
32
+ test("account is available without the admin shell", async ({ page }) => {
36
33
  await page.goto("/account");
37
34
  await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
38
35
  await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
@@ -4,7 +4,7 @@ import { USER } from "../helpers/accounts";
4
4
 
5
5
  // admin storageState (project default)
6
6
 
7
- test("admin can impersonate a user and stop @smoke", async ({ page }) => {
7
+ test("admin can impersonate a user and stop", async ({ page }) => {
8
8
  await ready(page, "/admin/users");
9
9
  await page.locator("#toolbar-search").fill(USER.email);
10
10
  await page.getByRole("button", { name: "Search", exact: true }).click();
@@ -8,7 +8,7 @@ test.use({ storageState: anonState });
8
8
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
9
9
  const origin = { origin: base };
10
10
 
11
- test("login: magic link shows a sent confirmation @smoke", async ({ page, playwright }) => {
11
+ test("login: magic link shows a sent confirmation", async ({ page, playwright }) => {
12
12
  const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
13
13
  const caps = await (await api.get("/api/account/capabilities")).json();
14
14
  await api.dispose();
@@ -20,7 +20,7 @@ test("login: magic link shows a sent confirmation @smoke", async ({ page, playwr
20
20
  await expect(page.getByTestId("magic-link-sent")).toBeVisible();
21
21
  });
22
22
 
23
- test("login: an emailed one-time code signs a user in @smoke", async ({ page, playwright }) => {
23
+ test("login: an emailed one-time code signs a user in", async ({ page, playwright }) => {
24
24
  const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
25
25
  const caps = await (await api.get("/api/account/capabilities")).json();
26
26
  test.skip(!caps?.emailOtp, "email otp disabled");
@@ -10,7 +10,7 @@ const REDIRECT = "http://localhost:9999/callback";
10
10
  // (with PKCE) that validates the client and hands off to the consent page. The
11
11
  // back-channel token exchange is driven by an OIDC client SDK / real RP — see
12
12
  // docs/modules.md for the manual round-trip checklist.
13
- test("OIDC provider: discovery, JWKS, client registration, authorize handoff @smoke", async ({ page, baseURL }) => {
13
+ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", async ({ page, baseURL }) => {
14
14
  const headers = { origin: baseURL ?? "" };
15
15
  await page.request.put("/api/account/settings", { data: { oidcProvider: true }, headers });
16
16
  await expect
@@ -19,7 +19,7 @@ test("admin can create an organization @smoke", async ({ page }) => {
19
19
  await expect(page.getByText("Organization deleted").first()).toBeVisible();
20
20
  });
21
21
 
22
- test("create an org with a non-ascii name, a parent, and a manager @smoke", async ({ page }) => {
22
+ test("create an org with a non-ascii name, a parent, and a manager", async ({ page }) => {
23
23
  await ready(page, "/admin/organizations");
24
24
  const newBtn = page.getByRole("button", { name: "New organization" });
25
25
  test.skip((await newBtn.count()) === 0, "organizations not enabled");
@@ -54,7 +54,7 @@ test("create an org with a non-ascii name, a parent, and a manager @smoke", asyn
54
54
  await expect(page.getByText("Organization deleted").first()).toBeVisible();
55
55
  });
56
56
 
57
- test("organization manage: remove a member, invite one, and cancel the invite @smoke", async ({ page }) => {
57
+ test("organization manage: remove a member, invite one, and cancel the invite", async ({ page }) => {
58
58
  await ready(page, "/admin/organizations");
59
59
  const newBtn = page.getByRole("button", { name: "New organization" });
60
60
  test.skip((await newBtn.count()) === 0, "organizations not enabled");
@@ -4,7 +4,7 @@ import { ready } from "../helpers/hydration";
4
4
  // Full passkey round trip on a throwaway account — never touches the shared admin.
5
5
  test.use({ storageState: { cookies: [], origins: [] } });
6
6
 
7
- test("register a passkey and sign in with it @smoke", async ({ page, baseURL }) => {
7
+ test("register a passkey and sign in with it", async ({ page, baseURL }) => {
8
8
  const origin = { origin: baseURL ?? "" };
9
9
  // A virtual authenticator answers both the register (create) and sign-in (get)
10
10
  // WebAuthn ceremonies without hardware.
@@ -2,12 +2,12 @@ import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
4
  // admin storageState (project default) — sessions is an admin-only view
5
- test("sessions table shows the expires column @smoke", async ({ page }) => {
5
+ test("sessions table shows the expires column", async ({ page }) => {
6
6
  await ready(page, "/admin/sessions");
7
7
  await expect(page.getByRole("columnheader", { name: "Expires" })).toBeVisible();
8
8
  });
9
9
 
10
- test("admin sees user sessions across users @smoke", async ({ page }) => {
10
+ test("admin sees user sessions across users", async ({ page }) => {
11
11
  await ready(page, "/admin/sessions");
12
12
  await expect(page.getByRole("heading", { name: "User sessions" })).toBeVisible();
13
13
  // Search by exact address — robust to pagination as sessions accumulate (the
@@ -3,13 +3,16 @@ import { ready } from "../helpers/hydration";
3
3
 
4
4
  // admin storageState (project default)
5
5
 
6
- test("settings: add and remove a social login provider @smoke", async ({ page }) => {
6
+ test("settings: add and remove a social login provider", async ({ page }) => {
7
7
  const dialog = page.getByRole("dialog");
8
8
  const addBtn = dialog.getByRole("button", { name: "Add provider" });
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");
@@ -47,7 +50,7 @@ test("settings: add and remove a social login provider @smoke", async ({ page })
47
50
  }
48
51
  });
49
52
 
50
- test("settings: open and save the SMTP dialog @smoke", async ({ page }) => {
53
+ test("settings: open and save the SMTP dialog", async ({ page }) => {
51
54
  await ready(page, "/admin/settings");
52
55
  await page.getByRole("tab", { name: "Authentication" }).click();
53
56
  await page.getByRole("button", { name: "SMTP settings" }).click();
@@ -66,3 +69,33 @@ test("settings: open and save the SMTP dialog @smoke", async ({ page }) => {
66
69
  .catch(() => undefined);
67
70
  }
68
71
  });
72
+
73
+ test("settings: configure automatic logout", async ({ page }) => {
74
+ await page.request.put("/api/account/auth-config", {
75
+ data: { server: { sessionIdleTimeoutMinutes: null } },
76
+ });
77
+ await ready(page, "/admin/settings");
78
+ await page.getByRole("tab", { name: "Authentication" }).click();
79
+
80
+ const toggle = page.getByRole("switch", { name: "Automatic logout" });
81
+ await expect(toggle).not.toBeChecked();
82
+ await toggle.click();
83
+ await expect(toggle).toBeChecked();
84
+
85
+ const card = page
86
+ .getByText("Automatic logout", { exact: true })
87
+ .locator('xpath=ancestor::div[@data-slot="card"]');
88
+ await card.getByRole("button", { name: "Configure" }).click();
89
+ const dialog = page.getByRole("dialog");
90
+ await dialog.getByLabel("Idle time (minutes)").fill("45");
91
+ await dialog.getByRole("button", { name: "Save", exact: true }).click();
92
+ await expect(page.getByText("45 min", { exact: true })).toBeVisible();
93
+
94
+ const saved = await (await page.request.get("/api/account/auth-config")).json();
95
+ expect(saved.server.sessionIdleTimeoutMinutes).toBe(45);
96
+ const caps = await (await page.request.get("/api/account/capabilities")).json();
97
+ expect(caps.sessionIdleTimeoutMinutes).toBe(45);
98
+
99
+ await toggle.click();
100
+ await expect(toggle).not.toBeChecked();
101
+ });