@podosoft/podokit 0.9.0 → 0.10.1

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 (87) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/README.md +8 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +12 -8
  14. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +2 -2
  15. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +12 -8
  16. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  18. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +3 -1
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +4 -2
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  21. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +4 -0
  22. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  23. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +2 -0
  24. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  25. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  26. package/dist/templates/fullstack-nest-svelte/package.json +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +11 -1
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +32 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -717
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +17 -9
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +64 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +42 -13
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -3
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +14 -7
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +12 -4
  57. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  58. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  59. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +2 -2
  60. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -0
  61. package/dist/templates/modules/auth/module.manifest.json +5 -1
  62. package/dist/templates/modules/bullmq/files/infra/docker/worker.compose.example.yml +2 -1
  63. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  64. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  65. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  66. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  67. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +38 -3
  68. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  69. package/dist/templates/modules/rate-limit/module.manifest.json +6 -3
  70. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  71. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  72. package/dist/templates/todo/apps/api/Dockerfile +12 -8
  73. package/dist/templates/todo/apps/api/src/health/health.controller.ts +2 -2
  74. package/dist/templates/todo/apps/web/Dockerfile +12 -8
  75. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  76. package/dist/templates/todo/apps/web/src/lib/server/api.ts +3 -1
  77. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +4 -2
  78. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  79. package/dist/templates/todo/compose.dev.yaml +4 -0
  80. package/dist/templates/todo/dot-gitignore +6 -0
  81. package/dist/templates/todo/infra/k3s/configmap.yaml +2 -0
  82. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  83. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  84. package/dist/templates/todo/package.json +1 -1
  85. package/dist/update.js +74 -8
  86. package/package.json +1 -1
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
@@ -7,8 +7,10 @@
7
7
  import { Button } from "$lib/components/ui/button";
8
8
  import * as Card from "$lib/components/ui/card";
9
9
  import * as Dialog from "$lib/components/ui/dialog";
10
+ import * as Select from "$lib/components/ui/select";
10
11
  import * as Tabs from "$lib/components/ui/tabs";
11
12
  import GeneralSettings from "./general-settings.svelte";
13
+ import AppearanceCard from "$lib/components/settings/appearance-card.svelte";
12
14
  import { moduleSettingsSections } from "$lib/admin/registry.svelte";
13
15
  import { toast } from "svelte-sonner";
14
16
  import { api } from "$lib/api";
@@ -237,6 +239,7 @@
237
239
  <Tabs.Root value="general">
238
240
  <Tabs.List>
239
241
  <Tabs.Trigger value="general">{i18n.t.general.tab}</Tabs.Trigger>
242
+ <Tabs.Trigger value="appearance">{i18n.t.general.appearanceTitle}</Tabs.Trigger>
240
243
  <Tabs.Trigger value="auth">{i18n.t.settings.tab}</Tabs.Trigger>
241
244
  {#each moduleSettingsSections as s (s.value)}
242
245
  <Tabs.Trigger value={s.value}>{s.label(i18n.t)}</Tabs.Trigger>
@@ -247,6 +250,10 @@
247
250
  <GeneralSettings />
248
251
  </Tabs.Content>
249
252
 
253
+ <Tabs.Content value="appearance" class="mt-6">
254
+ <AppearanceCard />
255
+ </Tabs.Content>
256
+
250
257
  <Tabs.Content value="auth" class="mt-6 flex flex-col gap-6">
251
258
  {#each sections as section (section.title)}
252
259
  <section class="flex flex-col gap-3">
@@ -306,15 +313,16 @@
306
313
  {#if socialForm.adding}
307
314
  <div class="flex flex-col gap-1">
308
315
  <Label for="social-provider">{i18n.t.settings.provider}</Label>
309
- <select
310
- id="social-provider"
311
- bind:value={socialForm.id}
312
- class="border-input bg-background ring-offset-background focus-visible:ring-ring h-9 rounded-md border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
313
- >
314
- {#each addable as p (p.id)}
315
- <option value={p.id}>{p.label}</option>
316
- {/each}
317
- </select>
316
+ <Select.Root type="single" bind:value={socialForm.id}>
317
+ <Select.Trigger id="social-provider" class="w-full">
318
+ {addable.find((p) => p.id === socialForm.id)?.label ?? i18n.t.settings.provider}
319
+ </Select.Trigger>
320
+ <Select.Content>
321
+ {#each addable as p (p.id)}
322
+ <Select.Item value={p.id} label={p.label}>{p.label}</Select.Item>
323
+ {/each}
324
+ </Select.Content>
325
+ </Select.Root>
318
326
  </div>
319
327
  {/if}
320
328
  <label class="flex items-center justify-between gap-3 text-sm font-medium">{i18n.t.settings.smtpEnabled}<Switch aria-label={i18n.t.settings.smtpEnabled} bind:checked={socialForm.enabled} /></label>
@@ -19,7 +19,6 @@
19
19
  description: "",
20
20
  supportEmail: "",
21
21
  footerText: "",
22
- brandColor: "",
23
22
  termsUrl: "",
24
23
  privacyUrl: "",
25
24
  locale: "",
@@ -35,7 +34,6 @@
35
34
  description: s.description ?? "",
36
35
  supportEmail: s.supportEmail ?? "",
37
36
  footerText: s.footerText ?? "",
38
- brandColor: s.brandColor ?? "",
39
37
  termsUrl: s.termsUrl ?? "",
40
38
  privacyUrl: s.privacyUrl ?? "",
41
39
  locale: s.locale ?? "",
@@ -127,11 +125,6 @@
127
125
  </div>
128
126
  </div>
129
127
  </div>
130
-
131
- <div class="flex flex-col gap-1.5">
132
- <Label for="brand-color">{t.brandColor}</Label>
133
- <Input id="brand-color" bind:value={form.brandColor} placeholder="#6d28d9" />
134
- </div>
135
128
  </Card.Content>
136
129
  </Card.Root>
137
130
 
@@ -0,0 +1,4 @@
1
+ import { loadAccountData } from "$lib/account-data.server";
2
+ import type { PageServerLoad } from "./$types";
3
+
4
+ export const load: PageServerLoad = ({ locals, fetch }) => loadAccountData(locals, fetch);
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import AccountPage from "$lib/components/account-page.svelte";
3
+ import LanguageSwitch from "$lib/components/language-switch.svelte";
4
+ import SiteFooter from "$lib/components/site-footer.svelte";
5
+ import ThemeToggle from "$lib/components/theme-toggle.svelte";
6
+ import { Button } from "$lib/components/ui/button";
7
+ import { Toaster } from "$lib/components/ui/sonner";
8
+ import { getI18n } from "$lib/i18n";
9
+ import type { Capabilities } from "@podosoft/podokit-api-client";
10
+ import type { SessionUser } from "../../app.d.ts";
11
+
12
+ let {
13
+ data,
14
+ }: {
15
+ data: { user: SessionUser; currentSessionId: string | null; capabilities: Capabilities };
16
+ } = $props();
17
+ const i18n = getI18n();
18
+ </script>
19
+
20
+ <div class="flex min-h-svh flex-col">
21
+ <header class="flex h-14 items-center gap-2 border-b px-4">
22
+ <Button href="/" variant="ghost">{i18n.t.nav.home}</Button>
23
+ <div class="flex-1"></div>
24
+ {#if data.user.role === "admin"}
25
+ <Button href="/admin" variant="outline">{i18n.t.common.appName}</Button>
26
+ {/if}
27
+ <LanguageSwitch />
28
+ <ThemeToggle />
29
+ </header>
30
+ <main class="mx-auto w-full max-w-6xl flex-1 p-4 sm:p-6">
31
+ <AccountPage {data} />
32
+ </main>
33
+ <SiteFooter />
34
+ </div>
35
+ <Toaster />
@@ -0,0 +1,56 @@
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
+
7
+ // Fresh admin context so these never rotate the seeded admin session.
8
+ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
9
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
10
+ await ctx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
11
+ return ctx;
12
+ }
13
+
14
+ test("theme settings: valid preset/radius/accent round-trip @smoke", async ({ playwright }) => {
15
+ const admin = await adminCtx(playwright);
16
+ const put = await admin.put("/api/site/settings", {
17
+ data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
18
+ });
19
+ expect(put.ok()).toBeTruthy();
20
+
21
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
22
+ const settings = await (await anon.get("/api/site/settings")).json();
23
+ expect(settings.themePreset).toBe("slate");
24
+ expect(settings.themeRadius).toBe("0.75");
25
+ expect(settings.brandColor).toBe("#2563eb");
26
+
27
+ // restore
28
+ await admin.put("/api/site/settings", { data: { themePreset: "", themeRadius: "", brandColor: "" } });
29
+ await anon.dispose();
30
+ });
31
+
32
+ test("theme settings: valid per-token overrides accepted @smoke", async ({ playwright }) => {
33
+ const admin = await adminCtx(playwright);
34
+ const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
35
+ const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
36
+ expect(put.ok()).toBeTruthy();
37
+ await admin.put("/api/site/settings", { data: { themeOverrides: "" } }); // restore
38
+ });
39
+
40
+ test("theme settings: invalid values are rejected (no CSS injection)", async ({ playwright }) => {
41
+ const admin = await adminCtx(playwright);
42
+ const bad = [
43
+ { brandColor: "red; } body{display:none" },
44
+ { brandColor: "notacolor" },
45
+ { themeRadius: "9" },
46
+ { themeRadius: "-1" },
47
+ { themePreset: "bad preset!" },
48
+ { themeOverrides: "{not json" },
49
+ { themeOverrides: JSON.stringify({ light: { primary: "nothex" } }) },
50
+ { themeOverrides: JSON.stringify({ light: { bogusToken: "#ffffff" } }) },
51
+ { unknownKey: "x" },
52
+ ];
53
+ for (const data of bad) {
54
+ expect((await admin.put("/api/site/settings", { data })).status(), JSON.stringify(data)).toBe(400);
55
+ }
56
+ });
@@ -16,6 +16,16 @@ async function seedSession(
16
16
  data: { email: account.email, password: account.password },
17
17
  });
18
18
  expect(res.ok(), `sign-in ${account.email}`).toBeTruthy();
19
+ // Keep repeated local runs from filling better-auth's 100-session response
20
+ // before the newly created current session can appear in account tests. The
21
+ // endpoint clears at most one response page, so drain several pages when a
22
+ // long-lived local database has accumulated them.
23
+ for (let attempt = 0; attempt < 10; attempt += 1) {
24
+ const sessionsResponse = await ctx.get("/api/auth/list-sessions");
25
+ const sessions = sessionsResponse.ok() ? (await sessionsResponse.json()) as unknown : [];
26
+ if (!Array.isArray(sessions) || sessions.length <= 1) break;
27
+ expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
28
+ }
19
29
  await ctx.storageState({ path });
20
30
  await ctx.dispose();
21
31
  }
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
+ import { ADMIN } from "../helpers/accounts";
3
4
 
4
5
  // admin storageState (project default)
5
6
  test("account opens on the profile section @smoke", async ({ page }) => {
@@ -9,7 +10,7 @@ test("account opens on the profile section @smoke", async ({ page }) => {
9
10
  await expect(page.getByLabel("Email")).toHaveValue("admin@example.com");
10
11
  // save is enabled only once the name changes
11
12
  await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
12
- await page.getByLabel("Name", { exact: true }).fill("Admin Renamed");
13
+ await page.getByLabel("Name", { exact: true }).fill(`Admin Preview ${Date.now()}`);
13
14
  await expect(page.getByRole("button", { name: "Save changes" })).toBeEnabled();
14
15
  });
15
16
 
@@ -82,7 +83,13 @@ test("account shows a phone field when enabled @smoke", async ({ page }) => {
82
83
 
83
84
  test("admin can update their profile name", async ({ page }) => {
84
85
  await ready(page, "/admin/account");
85
- await page.getByLabel("Name", { exact: true }).fill("Admin Renamed");
86
+ const name = page.getByLabel("Name", { exact: true });
87
+ await name.fill(`Admin Updated ${Date.now()}`);
88
+ await page.getByRole("button", { name: "Save changes" }).click();
89
+ await expect(page.getByText("Profile updated")).toBeVisible();
90
+ await ready(page, "/admin/account");
91
+ const refreshedName = page.getByLabel("Name", { exact: true });
92
+ await refreshedName.fill(ADMIN.name);
86
93
  await page.getByRole("button", { name: "Save changes" }).click();
87
94
  await expect(page.getByText("Profile updated")).toBeVisible();
88
95
  });
@@ -0,0 +1,64 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // admin storageState (project default). The Appearance controls live in their
5
+ // own "Appearance" settings tab.
6
+ async function openAppearance(page: import("@playwright/test").Page): Promise<void> {
7
+ await ready(page, "/admin/settings");
8
+ const tab = page.getByRole("tab", { name: "Appearance" });
9
+ await expect(async () => {
10
+ await tab.click();
11
+ await expect(tab).toHaveAttribute("data-state", "active");
12
+ }).toPass();
13
+ }
14
+
15
+ test("appearance: has its own tab with theme controls @smoke", async ({ page }) => {
16
+ await openAppearance(page);
17
+ await expect(page.getByRole("heading", { name: "Featured themes" })).toBeVisible();
18
+ await expect(page.getByRole("button", { name: "Default", exact: true })).toBeVisible();
19
+ await expect(page.getByRole("button", { name: "Violet" })).toBeVisible();
20
+ await expect(page.getByRole("button", { name: "Zinc" })).toBeHidden();
21
+ await expect(page.getByLabel("Brand color").first()).toBeVisible();
22
+ await expect(page.getByText("Corner style", { exact: true })).toBeVisible();
23
+ await expect(page.getByText("Preview", { exact: true })).toBeVisible();
24
+ });
25
+
26
+ test("appearance: keeps the full preset catalog behind a disclosure", async ({ page }) => {
27
+ await openAppearance(page);
28
+ await page.getByRole("button", { name: "Show more themes" }).click();
29
+ await expect(page.getByRole("button", { name: "Zinc" })).toBeVisible();
30
+ await expect(page.getByRole("button", { name: "GitHub" })).toBeVisible();
31
+ await page.getByRole("button", { name: "Show fewer themes" }).click();
32
+ await expect(page.getByRole("button", { name: "Zinc" })).toBeHidden();
33
+ });
34
+
35
+ test("appearance: preview light/dark toggle switches modes", async ({ page }) => {
36
+ await openAppearance(page);
37
+ await expect(page.getByRole("button", { name: "Light" })).toBeVisible();
38
+ await expect(page.getByRole("button", { name: "Dark" })).toBeVisible();
39
+ await page.getByRole("button", { name: "Dark" }).click();
40
+ });
41
+
42
+ test("appearance: advanced reveals per-token pickers", async ({ page }) => {
43
+ await openAppearance(page);
44
+ await page.getByRole("button", { name: "Fine-tune colors" }).click();
45
+ await expect(page.getByRole("button", { name: "Clear color adjustments" })).toBeVisible();
46
+ await expect(page.getByLabel("Primary light")).toBeVisible();
47
+ await page.getByRole("button", { name: "Dark", exact: true }).last().click();
48
+ await expect(page.getByLabel("Primary dark")).toBeVisible();
49
+ });
50
+
51
+ test("appearance: pick a preset, save, apply live, then restore @smoke", async ({ page }) => {
52
+ await openAppearance(page);
53
+ await page.getByRole("button", { name: "Slate" }).click();
54
+ await page.getByRole("button", { name: "Medium" }).click();
55
+ await page.getByRole("button", { name: "Save changes" }).click();
56
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
57
+ // Applied globally via the scoped override stylesheet.
58
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBeGreaterThan(0);
59
+
60
+ // "Restore defaults" reverts everything in one click and persists.
61
+ await page.getByRole("button", { name: "Restore defaults" }).click();
62
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
63
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBe(0);
64
+ });
@@ -8,29 +8,58 @@ import { ready } from "../helpers/hydration";
8
8
  // toggle-then-restore pattern safe for the rest of the suite; restores run in a
9
9
  // `finally` so a mid-test failure still leaves the shared site settings clean.
10
10
 
11
- async function saveGeneral(page: Page): Promise<void> {
12
- await page.getByRole("button", { name: "Save changes" }).click();
13
- await expect(page.getByText("General settings saved.")).toBeVisible();
11
+ async function saveGeneral(
12
+ page: Page,
13
+ prepare?: () => Promise<void>,
14
+ expected?: Record<string, string>,
15
+ ): Promise<void> {
16
+ const button = page.getByRole("button", { name: "Save changes" });
17
+ await expect(async () => {
18
+ await prepare?.();
19
+ const saved = page.waitForResponse(
20
+ (response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
21
+ { timeout: 3_000 },
22
+ );
23
+ await button.click();
24
+ const response = await saved;
25
+ expect(response.ok()).toBeTruthy();
26
+ if (expected) expect(await response.json()).toMatchObject(expected);
27
+ }).toPass({ timeout: 10_000 });
28
+ await expect(page.getByText("General settings saved.").last()).toBeVisible();
14
29
  }
15
30
 
16
- test("general: footer text and links render on public pages @smoke", async ({ page }) => {
31
+ test("general: footer text and links render on public pages @smoke", async ({ page, browser }) => {
17
32
  await ready(page, "/admin/settings");
33
+ const origin = new URL(page.url()).origin;
18
34
  const footer = page.getByLabel("Footer text");
19
35
  const support = page.getByLabel("Support email");
20
36
  const origFooter = await footer.inputValue();
21
37
  const origSupport = await support.inputValue();
22
- await footer.fill("© 2026 PodoKit Test");
23
- await support.fill("help@example.com");
24
- await saveGeneral(page);
38
+ await saveGeneral(
39
+ page,
40
+ async () => {
41
+ await footer.fill("© 2026 PodoKit Test");
42
+ await support.fill("help@example.com");
43
+ },
44
+ { footerText: "© 2026 PodoKit Test", supportEmail: "help@example.com" },
45
+ );
25
46
  try {
26
- await page.goto("/");
27
- await expect(page.getByText("© 2026 PodoKit Test")).toBeVisible();
28
- await expect(page.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
47
+ const ctx = await browser.newContext({ storageState: anonState });
48
+ const anon = await ctx.newPage();
49
+ await anon.goto(`${origin}/login`);
50
+ await expect(anon.getByText("© 2026 PodoKit Test")).toBeVisible();
51
+ await expect(anon.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
52
+ await ctx.close();
29
53
  } finally {
30
54
  await ready(page, "/admin/settings");
31
- await page.getByLabel("Footer text").fill(origFooter);
32
- await page.getByLabel("Support email").fill(origSupport);
33
- await saveGeneral(page);
55
+ await saveGeneral(
56
+ page,
57
+ async () => {
58
+ await page.getByLabel("Footer text").fill(origFooter);
59
+ await page.getByLabel("Support email").fill(origSupport);
60
+ },
61
+ { footerText: origFooter, supportEmail: origSupport },
62
+ );
34
63
  }
35
64
  });
36
65
 
@@ -4,8 +4,20 @@ import { anonState, userState } from "../helpers/accounts";
4
4
  test.describe("unauthenticated", () => {
5
5
  test.use({ storageState: anonState });
6
6
  test("protected route redirects to login @smoke", async ({ page }) => {
7
- await page.goto("/admin");
8
- await expect(page).toHaveURL(/\/login\?redirect=/);
7
+ for (const route of ["/admin", "/account"]) {
8
+ await page.goto(route);
9
+ await expect(page).toHaveURL(/\/login\?redirect=/);
10
+ }
11
+ });
12
+
13
+ test("private and authentication routes prevent search indexing @smoke", async ({ request }) => {
14
+ for (const route of ["/admin", "/account", "/login", "/api/account/me"]) {
15
+ const response = await request.get(route, { maxRedirects: 0 });
16
+ expect(response.headers()["x-robots-tag"]).toBe("noindex, nofollow");
17
+ }
18
+
19
+ const landing = await request.get("/");
20
+ expect(landing.headers()["x-robots-tag"]).toBeUndefined();
9
21
  });
10
22
  });
11
23
 
@@ -19,4 +31,11 @@ test.describe("normal user (non-admin)", () => {
19
31
  expect((await page.goto("/admin/sessions"))?.status()).toBe(403);
20
32
  expect((await page.goto("/admin/settings"))?.status()).toBe(403);
21
33
  });
34
+
35
+ test("account is available without the admin shell @smoke", async ({ page }) => {
36
+ await page.goto("/account");
37
+ await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
38
+ await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
39
+ await expect(page.getByRole("link", { name: "Overview" })).toHaveCount(0);
40
+ });
22
41
  });
@@ -18,7 +18,7 @@ test("register a passkey and sign in with it @smoke", async ({ page, baseURL })
18
18
  const email = `pk-${Date.now()}@example.com`;
19
19
  const pw = "Podokit3e-Str0ng!pw";
20
20
  const signup = await page.context().request.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "PK" }, headers: origin });
21
- expect(signup.ok()).toBeTruthy();
21
+ expect(signup.ok(), await signup.text()).toBeTruthy();
22
22
 
23
23
  await ready(page, "/admin/account");
24
24
  await page.getByRole("button", { name: "Security" }).click();
@@ -22,9 +22,11 @@ test("settings: add and remove a social login provider @smoke", async ({ page })
22
22
  }).toPass({ timeout: 25000 });
23
23
 
24
24
  await addBtn.click();
25
- const select = dialog.locator("#social-provider");
26
- await expect(select.locator("option").first()).toBeAttached();
27
- const providerId = await select.inputValue(); // first addable provider (not hard-coded)
25
+ const provider = dialog.locator("#social-provider");
26
+ await expect(provider).toBeVisible();
27
+ const callback = await dialog.locator("code").textContent();
28
+ const providerId = callback?.trim().split("/").pop() ?? ""; // first addable provider (not hard-coded)
29
+ expect(providerId).not.toBe("");
28
30
  try {
29
31
  await dialog.locator("#social-id").fill("test-client-id");
30
32
  await dialog.locator("#social-secret").fill("test-secret");
@@ -1,8 +1,17 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type Page } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
4
  // admin storageState (project default)
5
5
 
6
+ async function saveGeneral(page: Page): Promise<void> {
7
+ const saved = page.waitForResponse(
8
+ (response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
9
+ );
10
+ await page.getByRole("button", { name: "Save changes" }).click();
11
+ expect((await saved).ok()).toBeTruthy();
12
+ await expect(page.getByText("General settings saved.").last()).toBeVisible();
13
+ }
14
+
6
15
  test("settings has General and Authentication tabs @smoke", async ({ page }) => {
7
16
  await ready(page, "/admin/settings");
8
17
  await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
@@ -23,14 +32,12 @@ test("general settings: edit the site name, save, and apply live @smoke", async
23
32
  const name = page.getByLabel("Site name");
24
33
  const original = await name.inputValue();
25
34
  await name.fill("PodoKit Test Site");
26
- await page.getByRole("button", { name: "Save changes" }).click();
27
- await expect(page.getByText("General settings saved.")).toBeVisible();
35
+ await saveGeneral(page);
28
36
  // the browser tab title reflects the new name live (no reload)
29
37
  await expect(page).toHaveTitle("PodoKit Test Site");
30
38
  // restore so other specs start clean
31
39
  await name.fill(original);
32
- await page.getByRole("button", { name: "Save changes" }).click();
33
- await expect(page.getByText("General settings saved.")).toBeVisible();
40
+ await saveGeneral(page);
34
41
  });
35
42
 
36
43
  test("settings lists the auth features under the Authentication tab @smoke", async ({ page }) => {
@@ -49,8 +56,8 @@ test("sidebar: back-to-home link returns to the landing page @smoke", async ({ p
49
56
  // the home link lives in the sidebar footer, above the user menu
50
57
  await page.getByRole("link", { name: "Back to home" }).click();
51
58
  await expect(page).toHaveURL(/\/$/);
52
- // the landing page shows the "Open admin" entry point
53
- await expect(page.getByRole("link", { name: "Open admin" })).toBeVisible();
59
+ // Landing routes are app-owned, so only verify the shared page shell.
60
+ await expect(page.locator("main").first()).toBeVisible();
54
61
  });
55
62
 
56
63
  test("settings is reachable from the sidebar @smoke", async ({ page }) => {
@@ -1,4 +1,4 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type Page } from "@playwright/test";
2
2
  import { anonState } from "../helpers/accounts";
3
3
  import { totpCode } from "../helpers/totp";
4
4
 
@@ -7,6 +7,14 @@ test.use({ storageState: anonState });
7
7
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
8
  const origin = { origin: base };
9
9
 
10
+ async function openHydratedLogin(page: Page, redirect?: string): Promise<void> {
11
+ const query = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
12
+ await page.goto(`/login${query}`);
13
+ // The form is server-rendered before Svelte attaches its submit handler. Wait
14
+ // for the client bundle so a fast test click cannot trigger a native reload.
15
+ await page.waitForLoadState("networkidle");
16
+ }
17
+
10
18
  // A disposable account (never the shared admin/user) so enabling 2FA here can't
11
19
  // invalidate the seeded sessions the other auth specs rely on.
12
20
  test("two-factor: sign in with a backup code from the login page @smoke", async ({ page, playwright }) => {
@@ -23,7 +31,7 @@ test("two-factor: sign in with a backup code from the login page @smoke", async
23
31
  await api.dispose();
24
32
 
25
33
  // Password sign-in hands off to the second-factor step (no session yet).
26
- await page.goto(`/login?redirect=${encodeURIComponent("/admin/account")}`);
34
+ await openHydratedLogin(page, "/admin/account");
27
35
  await page.getByLabel("Email").fill(email);
28
36
  await page.getByLabel("Password").fill(pw);
29
37
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
@@ -66,7 +74,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page @smoke", a
66
74
  await probe.dispose();
67
75
 
68
76
  // Signing in through the UI lands on the mandatory enrolment page.
69
- await page.goto("/login");
77
+ await openHydratedLogin(page);
70
78
  await page.getByLabel("Email").fill(email);
71
79
  await page.getByLabel("Password").fill(pw);
72
80
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
@@ -111,7 +119,7 @@ test("account: regenerate backup codes shows a fresh set @smoke", async ({ page,
111
119
  await api.dispose();
112
120
 
113
121
  // Sign in (backup-code path) straight to the account page.
114
- await page.goto(`/login?redirect=${encodeURIComponent("/admin/account")}`);
122
+ await openHydratedLogin(page, "/admin/account");
115
123
  await page.getByLabel("Email").fill(email);
116
124
  await page.getByLabel("Password").fill(pw);
117
125
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
@@ -59,7 +59,7 @@
59
59
  "Set ADMIN_EMAILS in .env (comma-separated). Those emails become admins on sign-up.",
60
60
  "Re-run the auth migration to add the admin columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
61
61
  "Start the app (npm run dev), open /signup, register an ADMIN_EMAILS address, then sign in.",
62
- "Admin pages: /admin/users (list, ban, set role, revoke sessions). Everyone: /admin/sessions, /admin/account.",
62
+ "Admin pages: /admin/users (list, 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.",
63
63
  "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."
64
64
  ]
65
65
  }
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ // api-key auth is machine-to-machine: clients call the backend API directly, not
4
+ // through the browser web proxy (which strips custom headers by design). So this
5
+ // test targets the backend URL (`E2E_API_URL`) and skips when it isn't provided.
6
+ const apiBase = process.env.E2E_API_URL;
7
+ const apiOrigin = { origin: apiBase ?? "" };
8
+
9
+ // The static service key(s) come from the `API_KEYS` env var; the module ships
10
+ // `dev-key-please-change` as the default in .env.example.
11
+ const KEY = process.env.E2E_API_KEY ?? "dev-key-please-change";
12
+
13
+ test("api-key auth: a valid key passes, missing/invalid are 401 @smoke", async ({ request }) => {
14
+ test.skip(!apiBase, "E2E_API_URL (backend base) not set");
15
+ const ok = await request.get(`${apiBase}/machine/ping`, { headers: { ...apiOrigin, "x-api-key": KEY } });
16
+ test.skip(ok.status() === 404, "api-key-auth not installed");
17
+ expect(ok.status()).toBe(200);
18
+ expect(await ok.json()).toMatchObject({ ok: true, via: "api-key" });
19
+
20
+ const missing = await request.get(`${apiBase}/machine/ping`, { headers: apiOrigin });
21
+ expect(missing.status()).toBe(401);
22
+
23
+ const invalid = await request.get(`${apiBase}/machine/ping`, { headers: { ...apiOrigin, "x-api-key": "wrong-key" } });
24
+ expect(invalid.status()).toBe(401);
25
+ });
@@ -1,4 +1,4 @@
1
- import { Controller, Get } from "@nestjs/common";
1
+ import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
2
2
  import { InjectDataSource } from "@nestjs/typeorm";
3
3
  import { DataSource } from "typeorm";
4
4
  import { Public } from "@thallesp/nestjs-better-auth";
@@ -20,7 +20,7 @@ export class HealthController {
20
20
  await this.dataSource.query("SELECT 1");
21
21
  return { status: "ready", db: "up" };
22
22
  } catch {
23
- return { status: "degraded", db: "down" };
23
+ throw new ServiceUnavailableException({ status: "degraded", db: "down" });
24
24
  }
25
25
  }
26
26
  }
@@ -0,0 +1,22 @@
1
+ import { getMigrations } from "better-auth/db/migration";
2
+ import { auth } from "./auth/auth";
3
+ import { pool } from "./auth/db";
4
+ import dataSource from "./database/data-source";
5
+
6
+ async function runMigrations(): Promise<void> {
7
+ const authMigrations = await getMigrations(auth.options);
8
+ await authMigrations.runMigrations();
9
+
10
+ await dataSource.initialize();
11
+ await dataSource.runMigrations();
12
+ }
13
+
14
+ runMigrations()
15
+ .catch((error: unknown) => {
16
+ console.error("Run database migrations failed", error);
17
+ process.exitCode = 1;
18
+ })
19
+ .finally(async (): Promise<void> => {
20
+ if (dataSource.isInitialized) await dataSource.destroy();
21
+ await pool.end();
22
+ });
@@ -16,6 +16,9 @@
16
16
  "devDependencies": {
17
17
  "@types/pg": "^8.11.10"
18
18
  },
19
+ "scripts": {
20
+ "migrate:all": "node dist/migrate"
21
+ },
19
22
  "env": [
20
23
  "# \u2500\u2500 Auth (better-auth) \u2014 the only required secrets \u2500\u2500",
21
24
  "# BETTER_AUTH_SECRET also derives the key that encrypts config secrets stored in the",
@@ -101,13 +104,14 @@
101
104
  "file": "AGENTS.md",
102
105
  "marker": "<!-- podokit:end:agents-modules -->",
103
106
  "optional": true,
104
- "text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, and the server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Create the auth tables: `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`.\n"
107
+ "text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, and the server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Development setup: create auth tables with `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`, then run TypeORM migrations. Production images can run both from compiled output with `npm run migrate:all`.\n"
105
108
  }
106
109
  ],
107
110
  "instructions": [
108
111
  "Set BETTER_AUTH_SECRET in .env (>= 32 chars). This also derives the key that encrypts config secrets stored in the DB, so keep it stable and secret.",
109
112
  "Create the auth tables: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
110
113
  "Create the app tables (auth_config, app_setting): npm run migration:run",
114
+ "After building a production image, run all Better Auth and TypeORM migrations: npm run migrate:all",
111
115
  "Sign up: curl -c cookies.txt -XPOST localhost:5002/api/auth/sign-up/email -H 'content-type: application/json' -d '{\"email\":\"a@example.com\",\"password\":\"password123\",\"name\":\"A\"}'",
112
116
  "Call a protected route with the session: curl -b cookies.txt localhost:5002/account/me",
113
117
  "Everything is protected by default; mark public routes with @Public(). Configure OAuth (Google/GitHub), SMTP, and server-enforced toggles on the admin Settings page \u2014 stored encrypted in the DB and applied live (no restart). Environment variables (GOOGLE_CLIENT_*, SMTP_*, AUTH_*) still work as an optional fallback/override."