@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.
- package/dist/add.d.ts +27 -1
- package/dist/add.js +3 -1
- package/dist/index.js +6 -3
- package/dist/lib.d.ts +1 -1
- package/dist/lockfile.d.ts +4 -1
- package/dist/lockfile.js +4 -3
- package/dist/module-migrations.d.ts +25 -0
- package/dist/module-migrations.js +233 -0
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +155 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +109 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +21 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +21 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +73 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +70 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +18 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -9
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +36 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
- package/dist/templates/modules/auth/module.manifest.json +5 -4
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/Dockerfile.dev +3 -1
- package/dist/templates/todo/apps/api/package.json +1 -1
- package/dist/templates/todo/apps/web/package.json +1 -1
- package/dist/templates/todo/compose.dev.yaml +6 -0
- package/dist/update.d.ts +8 -1
- package/dist/update.js +68 -12
- package/package.json +1 -1
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +0 -0
|
@@ -3,6 +3,8 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
// admin storageState (project default)
|
|
5
5
|
|
|
6
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
7
|
+
|
|
6
8
|
async function saveGeneral(page: Page): Promise<void> {
|
|
7
9
|
const saved = page.waitForResponse(
|
|
8
10
|
(response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
|
|
@@ -12,7 +14,7 @@ async function saveGeneral(page: Page): Promise<void> {
|
|
|
12
14
|
await expect(page.getByText("General settings saved.").last()).toBeVisible();
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
test("settings has General and Authentication tabs
|
|
17
|
+
test("settings has General and Authentication tabs", async ({ page }) => {
|
|
16
18
|
await ready(page, "/admin/settings");
|
|
17
19
|
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
|
|
18
20
|
await expect(page.getByRole("tab", { name: "General" })).toBeVisible();
|
|
@@ -21,13 +23,13 @@ test("settings has General and Authentication tabs @smoke", async ({ page }) =>
|
|
|
21
23
|
await expect(page.getByLabel("Site name")).toBeVisible();
|
|
22
24
|
});
|
|
23
25
|
|
|
24
|
-
test("general settings: shows the favicon uploader
|
|
26
|
+
test("general settings: shows the favicon uploader", async ({ page }) => {
|
|
25
27
|
await ready(page, "/admin/settings");
|
|
26
28
|
await expect(page.getByText("Browser icon (favicon)")).toBeVisible();
|
|
27
29
|
await expect(page.locator('input[type="file"]')).toBeVisible();
|
|
28
30
|
});
|
|
29
31
|
|
|
30
|
-
test("general settings: edit the site name, save, and apply live
|
|
32
|
+
test("general settings: edit the site name, save, and apply live", async ({ page }) => {
|
|
31
33
|
await ready(page, "/admin/settings");
|
|
32
34
|
const name = page.getByLabel("Site name");
|
|
33
35
|
const original = await name.inputValue();
|
|
@@ -40,7 +42,7 @@ test("general settings: edit the site name, save, and apply live @smoke", async
|
|
|
40
42
|
await saveGeneral(page);
|
|
41
43
|
});
|
|
42
44
|
|
|
43
|
-
test("settings lists the auth features under the Authentication tab
|
|
45
|
+
test("settings lists the auth features under the Authentication tab", async ({ page }) => {
|
|
44
46
|
await ready(page, "/admin/settings");
|
|
45
47
|
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
46
48
|
await expect(page.getByText("Email & password")).toBeVisible();
|
|
@@ -51,7 +53,7 @@ test("settings lists the auth features under the Authentication tab @smoke", asy
|
|
|
51
53
|
await expect(page.getByText("Enabled", { exact: true }).first()).toBeVisible();
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
test("sidebar: back-to-home link returns to the landing page
|
|
56
|
+
test("sidebar: back-to-home link returns to the landing page", async ({ page }) => {
|
|
55
57
|
await ready(page, "/admin/settings");
|
|
56
58
|
// the home link lives in the sidebar footer, above the user menu
|
|
57
59
|
await page.getByRole("link", { name: "Back to home" }).click();
|
|
@@ -60,7 +62,7 @@ test("sidebar: back-to-home link returns to the landing page @smoke", async ({ p
|
|
|
60
62
|
await expect(page.locator("main").first()).toBeVisible();
|
|
61
63
|
});
|
|
62
64
|
|
|
63
|
-
test("settings is reachable from the sidebar
|
|
65
|
+
test("settings is reachable from the sidebar", async ({ page }) => {
|
|
64
66
|
await ready(page, "/admin");
|
|
65
67
|
await page.getByRole("link", { name: "Settings" }).click();
|
|
66
68
|
await expect(page).toHaveURL(/\/admin\/settings/);
|
|
@@ -81,7 +83,7 @@ test("admin can toggle a feature flag and it applies live @smoke", async ({ page
|
|
|
81
83
|
await expect(toggle).toBeChecked();
|
|
82
84
|
});
|
|
83
85
|
|
|
84
|
-
test("settings: the require-two-factor toggle is available
|
|
86
|
+
test("settings: the require-two-factor toggle is available", async ({ page }) => {
|
|
85
87
|
await ready(page, "/admin/settings");
|
|
86
88
|
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
87
89
|
// The policy toggle renders under Authentication; its enforcement is covered by
|
|
@@ -89,4 +91,72 @@ test("settings: the require-two-factor toggle is available @smoke", async ({ pag
|
|
|
89
91
|
// admin session.
|
|
90
92
|
await expect(page.getByRole("switch", { name: "Require two-factor" })).toBeVisible();
|
|
91
93
|
await expect(page.getByRole("switch", { name: "Sign-up approval" })).toBeVisible();
|
|
94
|
+
await expect(page.getByRole("switch", { name: "Automatic logout" })).toBeVisible();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("inactive browser sessions are signed out automatically @smoke", async ({ browser, page: adminPage }) => {
|
|
98
|
+
// Idle sign-out revokes the active server session. Use a dedicated login so
|
|
99
|
+
// this test cannot invalidate the shared admin storageState used by later specs.
|
|
100
|
+
const context = await browser.newContext({ baseURL: base });
|
|
101
|
+
const page = await context.newPage();
|
|
102
|
+
const email = `idle-admin-${Date.now()}@example.com`;
|
|
103
|
+
let signOutRequests = 0;
|
|
104
|
+
let userId = "";
|
|
105
|
+
try {
|
|
106
|
+
const created = await adminPage.request.post("/api/auth/admin/create-user", {
|
|
107
|
+
headers: { origin: base },
|
|
108
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
|
|
109
|
+
});
|
|
110
|
+
expect(created.ok()).toBeTruthy();
|
|
111
|
+
userId = ((await created.json()).user?.id ?? "") as string;
|
|
112
|
+
expect(userId).toBeTruthy();
|
|
113
|
+
|
|
114
|
+
const signIn = await context.request.post("/api/auth/sign-in/email", {
|
|
115
|
+
headers: { origin: base },
|
|
116
|
+
data: { email, password: "Podokit3e-Str0ng!pw" },
|
|
117
|
+
});
|
|
118
|
+
expect(signIn.ok()).toBeTruthy();
|
|
119
|
+
|
|
120
|
+
const configured = await context.request.put("/api/account/auth-config", {
|
|
121
|
+
data: { server: { sessionIdleTimeoutMinutes: 5 } },
|
|
122
|
+
});
|
|
123
|
+
expect(configured.ok()).toBeTruthy();
|
|
124
|
+
await page.route("**/api/auth/sign-out", async (route) => {
|
|
125
|
+
signOutRequests += 1;
|
|
126
|
+
await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
|
|
127
|
+
});
|
|
128
|
+
await page.clock.install();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await ready(page, "/admin");
|
|
132
|
+
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
|
133
|
+
} finally {
|
|
134
|
+
// Keep the loaded page policy at five minutes while restoring the shared
|
|
135
|
+
// DB policy before the isolated session is revoked.
|
|
136
|
+
const restored = await context.request.put("/api/account/auth-config", {
|
|
137
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
138
|
+
});
|
|
139
|
+
expect(restored.ok()).toBeTruthy();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const removed = await adminPage.request.post("/api/auth/admin/remove-user", {
|
|
143
|
+
headers: { origin: base },
|
|
144
|
+
data: { userId },
|
|
145
|
+
});
|
|
146
|
+
expect(removed.ok()).toBeTruthy();
|
|
147
|
+
userId = "";
|
|
148
|
+
|
|
149
|
+
await page.clock.fastForward(5 * 60_000 + 1_000);
|
|
150
|
+
await expect.poll(() => signOutRequests).toBe(1);
|
|
151
|
+
await expect(page).toHaveURL(/\/login\?reason=idle$/);
|
|
152
|
+
await expect(page.getByTestId("session-timeout-message")).toBeVisible();
|
|
153
|
+
} finally {
|
|
154
|
+
if (userId) {
|
|
155
|
+
await adminPage.request.post("/api/auth/admin/remove-user", {
|
|
156
|
+
headers: { origin: base },
|
|
157
|
+
data: { userId },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
await context.close();
|
|
161
|
+
}
|
|
92
162
|
});
|
|
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
|
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
// admin storageState (project default)
|
|
5
|
-
test("collapsing the sidebar hides the brand instead of overflowing
|
|
5
|
+
test("collapsing the sidebar hides the brand instead of overflowing", async ({ page }) => {
|
|
6
6
|
await ready(page, "/admin");
|
|
7
7
|
const brand = page.getByTestId("sidebar-brand");
|
|
8
8
|
const toggle = page.getByRole("button", { name: "Toggle Sidebar" });
|
|
@@ -3,7 +3,7 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
// The shared toolbar: filters + search both commit on the Search button; the
|
|
5
5
|
// search field itself is selectable (Email, Name, ...).
|
|
6
|
-
test("users toolbar searches only when Search is pressed
|
|
6
|
+
test("users toolbar searches only when Search is pressed", async ({ page }) => {
|
|
7
7
|
await ready(page, "/admin/users");
|
|
8
8
|
const main = page.getByRole("main");
|
|
9
9
|
await page.locator("#toolbar-search").fill("admin@example.com");
|
|
@@ -17,7 +17,7 @@ async function openHydratedLogin(page: Page, redirect?: string): Promise<void> {
|
|
|
17
17
|
|
|
18
18
|
// A disposable account (never the shared admin/user) so enabling 2FA here can't
|
|
19
19
|
// invalidate the seeded sessions the other auth specs rely on.
|
|
20
|
-
test("two-factor: sign in with a backup code from the login page
|
|
20
|
+
test("two-factor: sign in with a backup code from the login page", async ({ page, playwright }) => {
|
|
21
21
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
22
22
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
23
23
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -55,7 +55,7 @@ test("two-factor: sign in with a backup code from the login page @smoke", async
|
|
|
55
55
|
await expect(page).toHaveURL(/\/admin\/account/);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
test("require-2fa: a new sign-up is forced through the enrolment page
|
|
58
|
+
test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
|
|
59
59
|
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
60
60
|
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
61
61
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -106,7 +106,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page @smoke", a
|
|
|
106
106
|
}
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
test("account: regenerate backup codes shows a fresh set
|
|
109
|
+
test("account: regenerate backup codes shows a fresh set", async ({ page, playwright }) => {
|
|
110
110
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
111
111
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
112
112
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -51,7 +51,7 @@ test("create rejects mismatched passwords", async ({ page }) => {
|
|
|
51
51
|
await expect(dialog.getByText("Passwords do not match")).toBeVisible();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
test("manage: edit profile name
|
|
54
|
+
test("manage: edit profile name", async ({ page }) => {
|
|
55
55
|
await ready(page, "/admin/users");
|
|
56
56
|
const email = `ui-edit-${Date.now()}@example.com`;
|
|
57
57
|
await seedUser(page, email);
|
|
@@ -3,7 +3,7 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
|
|
6
|
-
test("admin can create a user with a custom role
|
|
6
|
+
test("admin can create a user with a custom role", async ({ page }) => {
|
|
7
7
|
await ready(page, "/admin/users");
|
|
8
8
|
await page.getByRole("button", { name: "Add user" }).click();
|
|
9
9
|
const role = page.locator("#c-role");
|
|
@@ -24,7 +24,7 @@ test("admin can create a user with a custom role @smoke", async ({ page }) => {
|
|
|
24
24
|
await expect(page.getByRole("cell", { name: email })).toBeVisible();
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("admin sees the user list and can search
|
|
27
|
+
test("admin sees the user list and can search", async ({ page }) => {
|
|
28
28
|
await ready(page, "/admin/users");
|
|
29
29
|
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
|
30
30
|
// Search by exact address — robust to pagination as the user count grows.
|
|
@@ -37,7 +37,7 @@ test("admin sees the user list and can search @smoke", async ({ page }) => {
|
|
|
37
37
|
await expect(page.getByRole("cell", { name: "admin@example.com" })).toHaveCount(0);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
test("user list shows the joined column
|
|
40
|
+
test("user list shows the joined column", async ({ page }) => {
|
|
41
41
|
await ready(page, "/admin/users");
|
|
42
42
|
await expect(page.getByRole("columnheader", { name: "Joined" })).toBeVisible();
|
|
43
43
|
});
|
|
@@ -52,7 +52,7 @@ test("row menu exposes admin actions", async ({ page }) => {
|
|
|
52
52
|
await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("admin can approve a pending registration from the user list
|
|
55
|
+
test("admin can approve a pending registration from the user list", async ({ page }) => {
|
|
56
56
|
const email = `pending-ui-${Date.now()}@example.com`;
|
|
57
57
|
const created = await page.request.post("/api/auth/admin/create-user", {
|
|
58
58
|
headers: { origin: base },
|
|
@@ -4,7 +4,7 @@ import { ready } from "../helpers/hydration";
|
|
|
4
4
|
|
|
5
5
|
test.use({ storageState: anonState });
|
|
6
6
|
|
|
7
|
-
test("verify-email renders the resend form with the address prefilled
|
|
7
|
+
test("verify-email renders the resend form with the address prefilled", async ({ page }) => {
|
|
8
8
|
await ready(page, "/verify-email?email=someone%40example.com");
|
|
9
9
|
await expect(page.getByLabel("Email")).toHaveValue("someone@example.com");
|
|
10
10
|
await expect(page.getByRole("button", { name: "Resend verification email" })).toBeVisible();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
+
"manifestVersion": 2,
|
|
2
3
|
"name": "admin-dashboard",
|
|
3
|
-
"description": "Admin
|
|
4
|
+
"description": "Admin console UI (SvelteKit + shadcn-svelte): the `(admin)` route group and `admin-sidebar.svelte` are reserved for `/admin/*`; put protected product pages in `(app)` or their own guarded layout. Requires auth.",
|
|
4
5
|
"requires": [
|
|
5
6
|
"auth",
|
|
6
7
|
"object-storage-s3"
|
|
@@ -10,21 +11,22 @@
|
|
|
10
11
|
"qrcode": "^1.5.4"
|
|
11
12
|
},
|
|
12
13
|
"devDependencies": {
|
|
13
|
-
"@types/qrcode": "^1.5.5"
|
|
14
|
-
"@types/multer": "^1.4.12"
|
|
14
|
+
"@types/qrcode": "^1.5.5"
|
|
15
15
|
},
|
|
16
16
|
"ownedGlobs": [
|
|
17
17
|
"apps/web/src/lib/i18n/locales/**",
|
|
18
18
|
"apps/web/src/lib/i18n/catalogs/app/**"
|
|
19
19
|
],
|
|
20
20
|
"managedOverrides": [
|
|
21
|
+
"apps/web/src/lib/components/ui/input/input.svelte",
|
|
21
22
|
"apps/web/src/routes/(app)/+layout.server.ts",
|
|
22
|
-
"apps/web/src/routes/(
|
|
23
|
-
"apps/web/src/routes/(
|
|
24
|
-
"apps/web/src/routes/(
|
|
25
|
-
"apps/web/src/routes/(
|
|
26
|
-
"apps/web/src/routes/(
|
|
27
|
-
"apps/web/src/routes/(
|
|
23
|
+
"apps/web/src/routes/(admin)/+layout.server.ts",
|
|
24
|
+
"apps/web/src/routes/(admin)/admin/account/+page.server.ts",
|
|
25
|
+
"apps/web/src/routes/(admin)/admin/audit/+page.server.ts",
|
|
26
|
+
"apps/web/src/routes/(admin)/admin/organizations/+page.server.ts",
|
|
27
|
+
"apps/web/src/routes/(admin)/admin/sessions/+page.server.ts",
|
|
28
|
+
"apps/web/src/routes/(admin)/admin/settings/+page.server.ts",
|
|
29
|
+
"apps/web/src/routes/(admin)/admin/users/+page.server.ts",
|
|
28
30
|
"apps/web/src/routes/(auth)/login/+page.server.ts",
|
|
29
31
|
"apps/web/src/routes/(auth)/signup/+page.server.ts",
|
|
30
32
|
"apps/web/src/routes/account/+page.server.ts",
|
|
@@ -32,6 +34,12 @@
|
|
|
32
34
|
],
|
|
33
35
|
"packageOverlays": {
|
|
34
36
|
"api": {
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"image-size": "^2.0.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/multer": "^1.4.12"
|
|
42
|
+
},
|
|
35
43
|
"scripts": {
|
|
36
44
|
"admin:bootstrap": "node scripts/bootstrap-admin.mjs"
|
|
37
45
|
}
|
|
@@ -41,7 +49,52 @@
|
|
|
41
49
|
"# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
|
|
42
50
|
"ADMIN_EMAILS=admin@example.com"
|
|
43
51
|
],
|
|
52
|
+
"migrations": [
|
|
53
|
+
{
|
|
54
|
+
"id": "admin-route-group",
|
|
55
|
+
"moves": [
|
|
56
|
+
{
|
|
57
|
+
"from": "apps/web/src/routes/(app)/+layout.svelte",
|
|
58
|
+
"to": "apps/web/src/routes/(admin)/+layout.svelte"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"from": "apps/web/src/routes/(app)/admin",
|
|
62
|
+
"to": "apps/web/src/routes/(admin)/admin"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"from": "apps/web/src/lib/components/app-sidebar.svelte",
|
|
66
|
+
"to": "apps/web/src/lib/components/admin-sidebar.svelte"
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"replacements": [
|
|
70
|
+
{
|
|
71
|
+
"path": "apps/web/src/routes/(admin)/+layout.svelte",
|
|
72
|
+
"from": "AppSidebar",
|
|
73
|
+
"to": "AdminSidebar",
|
|
74
|
+
"expected": 2
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"path": "apps/web/src/routes/(admin)/+layout.svelte",
|
|
78
|
+
"from": "$lib/components/app-sidebar.svelte",
|
|
79
|
+
"to": "$lib/components/admin-sidebar.svelte",
|
|
80
|
+
"expected": 1
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
],
|
|
44
85
|
"inject": [
|
|
86
|
+
{
|
|
87
|
+
"file": "apps/web/src/routes/+page.svelte",
|
|
88
|
+
"marker": "// podokit:end:landing-imports",
|
|
89
|
+
"optional": true,
|
|
90
|
+
"text": "import { page } from \"$app/state\";\n import AccountMenu from \"$lib/components/account-menu.svelte\";\n import LanguageSwitch from \"$lib/components/language-switch.svelte\";\n import ThemeToggle from \"$lib/components/theme-toggle.svelte\";"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"file": "apps/web/src/routes/+page.svelte",
|
|
94
|
+
"marker": "<!-- podokit:end:landing-actions -->",
|
|
95
|
+
"optional": true,
|
|
96
|
+
"text": "<header class=\"flex items-center justify-end gap-2 p-4\">\n <LanguageSwitch />\n <ThemeToggle />\n <AccountMenu user={page.data.user ?? null} />\n</header>"
|
|
97
|
+
},
|
|
45
98
|
{
|
|
46
99
|
"file": "apps/api/src/auth/auth.ts",
|
|
47
100
|
"marker": "// podokit:end:auth-imports",
|
|
@@ -66,24 +119,34 @@
|
|
|
66
119
|
"file": "AGENTS.md",
|
|
67
120
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
68
121
|
"optional": true,
|
|
69
|
-
"text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `$lib/server/guards` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
|
|
122
|
+
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- 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
|
}
|
|
@@ -6,13 +6,9 @@ import { ApiTags } from "@nestjs/swagger";
|
|
|
6
6
|
import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
|
|
7
7
|
import { ROLE_NAMES } from "../auth/permissions";
|
|
8
8
|
import { auth } from "../auth/auth";
|
|
9
|
-
import {
|
|
10
|
-
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
9
|
+
import { authConfigStore } from "../auth/auth-config-store";
|
|
11
10
|
import type { Capabilities } from "@podosoft/podokit-contracts";
|
|
12
11
|
|
|
13
|
-
// Reads OAuth/server-toggle state (DB-first, env fallback) for capabilities.
|
|
14
|
-
const configStore = createConfigStore(pool);
|
|
15
|
-
|
|
16
12
|
function isAdmin(session: UserSession): boolean {
|
|
17
13
|
const role = session.user?.role;
|
|
18
14
|
const roles = Array.isArray(role) ? role : (role ?? "").split(",").map((r: string) => r.trim());
|
|
@@ -45,7 +41,7 @@ export class AccountController {
|
|
|
45
41
|
const flags = this.settings.flags();
|
|
46
42
|
// OAuth providers + server-enforced toggles are DB-backed (env fallback) and
|
|
47
43
|
// applied live, so read them from the config store rather than process.env.
|
|
48
|
-
const snapshot = await
|
|
44
|
+
const snapshot = await authConfigStore.capabilitiesSnapshot();
|
|
49
45
|
return {
|
|
50
46
|
twoFactor: flags.twoFactor,
|
|
51
47
|
magicLink: flags.magicLink,
|
|
@@ -63,6 +59,7 @@ export class AccountController {
|
|
|
63
59
|
signupApprovalRequired: snapshot.requireSignupApproval,
|
|
64
60
|
passwordBreachCheck: snapshot.passwordBreachCheck,
|
|
65
61
|
auditLog: snapshot.auditLog,
|
|
62
|
+
sessionIdleTimeoutMinutes: snapshot.sessionIdleTimeoutMinutes,
|
|
66
63
|
roles: ROLE_NAMES,
|
|
67
64
|
};
|
|
68
65
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
2
|
+
import { pool } from "./db";
|
|
3
|
+
|
|
4
|
+
// One process-wide store keeps the auth runtime and capabilities endpoint on the
|
|
5
|
+
// same cache snapshot. Admin writes invalidate this store before rebuilding auth.
|
|
6
|
+
export const authConfigStore = createConfigStore(pool);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { auth as bootstrapAuth, buildAuth } from "./auth";
|
|
2
|
-
import {
|
|
3
|
-
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
2
|
+
import { authConfigStore } from "./auth-config-store";
|
|
4
3
|
|
|
5
4
|
// Runtime auth instance that rebuilds when the DB config changes, so admin edits
|
|
6
5
|
// (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
|
|
@@ -12,7 +11,6 @@ import { createConfigStore } from "@podosoft/podokit-auth";
|
|
|
12
11
|
// current instance too. The guard/session behavior is unchanged (session tokens are
|
|
13
12
|
// signed with the stable BETTER_AUTH_SECRET; trustedOrigins is baked into every build).
|
|
14
13
|
|
|
15
|
-
const store = createConfigStore(pool);
|
|
16
14
|
const TTL_MS = 3_000;
|
|
17
15
|
|
|
18
16
|
let current = bootstrapAuth;
|
|
@@ -32,7 +30,7 @@ export async function refreshIfStale(): Promise<void> {
|
|
|
32
30
|
checkedAt = Date.now();
|
|
33
31
|
let latest: string;
|
|
34
32
|
try {
|
|
35
|
-
latest = await
|
|
33
|
+
latest = await authConfigStore.currentVersion();
|
|
36
34
|
} catch {
|
|
37
35
|
return; // DB blip — keep serving the last-good instance
|
|
38
36
|
}
|
|
@@ -40,7 +38,7 @@ export async function refreshIfStale(): Promise<void> {
|
|
|
40
38
|
if (inflight) return inflight;
|
|
41
39
|
inflight = (async () => {
|
|
42
40
|
try {
|
|
43
|
-
const config = await
|
|
41
|
+
const config = await authConfigStore.load();
|
|
44
42
|
current = buildAuth(config);
|
|
45
43
|
currentVersion = config.version;
|
|
46
44
|
} catch (err) {
|
|
@@ -52,6 +50,15 @@ export async function refreshIfStale(): Promise<void> {
|
|
|
52
50
|
return inflight;
|
|
53
51
|
}
|
|
54
52
|
|
|
53
|
+
/** Apply a successful in-process admin write before the response is returned. */
|
|
54
|
+
export async function refreshAuthNow(): Promise<void> {
|
|
55
|
+
authConfigStore.invalidate();
|
|
56
|
+
checkedAt = 0;
|
|
57
|
+
// Force a rebuild even if two writes receive the same millisecond timestamp.
|
|
58
|
+
currentVersion = `stale:${Date.now()}`;
|
|
59
|
+
await refreshIfStale();
|
|
60
|
+
}
|
|
61
|
+
|
|
55
62
|
// Stable handler reference the adapter captures once; redispatches per request.
|
|
56
63
|
const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
|
|
57
64
|
await refreshIfStale();
|
|
@@ -6,11 +6,12 @@ import { sendSms } from "../sms/sms";
|
|
|
6
6
|
import { assertUserCreationAllowed, createFeatureGate, createSignupOpenCheck } from "./feature-gate";
|
|
7
7
|
import { orgAc, orgRoles } from "./org-permissions";
|
|
8
8
|
import { pool } from "./db";
|
|
9
|
-
import { type AuthConfig, envAuthConfig, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
|
|
9
|
+
import { type AuthConfig, envAuthConfig, sessionIdleOptions, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
|
|
10
10
|
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
|
|
|
@@ -176,6 +177,8 @@ export function buildAuth(config: AuthConfig) {
|
|
|
176
177
|
// podokit:begin:auth-hooks
|
|
177
178
|
// podokit:end:auth-hooks
|
|
178
179
|
|
|
180
|
+
const session = sessionIdleOptions(config.sessionIdleTimeoutMinutes);
|
|
181
|
+
|
|
179
182
|
return betterAuth({
|
|
180
183
|
database: pool,
|
|
181
184
|
emailAndPassword: {
|
|
@@ -208,6 +211,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
208
211
|
},
|
|
209
212
|
},
|
|
210
213
|
socialProviders: buildSocial(config),
|
|
214
|
+
...(session ? { session } : {}),
|
|
211
215
|
plugins,
|
|
212
216
|
hooks,
|
|
213
217
|
secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production-min-32-characters",
|
|
@@ -256,7 +260,10 @@ export function buildAuth(config: AuthConfig) {
|
|
|
256
260
|
},
|
|
257
261
|
},
|
|
258
262
|
// Self-service account deletion — admin-toggleable (auth_config), applied live.
|
|
259
|
-
deleteUser: {
|
|
263
|
+
deleteUser: {
|
|
264
|
+
enabled: config.allowDelete,
|
|
265
|
+
afterDelete: runUserDeletedHandlers,
|
|
266
|
+
},
|
|
260
267
|
},
|
|
261
268
|
databaseHooks,
|
|
262
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
|
+
}
|