@podosoft/podokit 0.3.0 → 0.4.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/templates/fullstack-nest-svelte/apps/api/package.json +20 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/tsconfig.json +14 -5
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +3 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/api.ts +5 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +19 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +43 -4
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +8 -3
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/[...path]/+server.ts +12 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/auth/[...all]/+server.ts +12 -0
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +5 -0
- package/dist/templates/fullstack-nest-svelte/package.json +11 -3
- package/dist/templates/fullstack-nest-svelte/tests/README.md +15 -0
- package/dist/templates/fullstack-nest-svelte/tests/api/health.api.spec.ts +14 -0
- package/dist/templates/fullstack-nest-svelte/tests/package.json +14 -0
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +25 -0
- package/dist/templates/fullstack-nest-svelte/tests/ui/smoke.ui.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +59 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/language-switch.svelte +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +44 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +40 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible-content.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible-trigger.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible.svelte +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/index.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/index.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-content.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-item.svelte +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-link.svelte +42 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-next-button.svelte +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-next.svelte +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-prev-button.svelte +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-previous.svelte +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination.svelte +28 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/index.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-close.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-content.svelte +55 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-description.svelte +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-footer.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-header.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-overlay.svelte +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-portal.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-title.svelte +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet.svelte +7 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/constants.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/context.svelte.ts +81 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/index.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-content.svelte +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-footer.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-action.svelte +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-content.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-label.svelte +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-header.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-input.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-inset.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-action.svelte +37 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-button.svelte +102 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-item.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu.svelte +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-provider.svelte +53 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-rail.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-separator.svelte +19 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-trigger.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar.svelte +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/user-avatar.svelte +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/hooks/is-mobile.svelte.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +359 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.svelte +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.server.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +374 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +78 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +406 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/+layout.svelte +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +59 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +51 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +20 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +22 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +48 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +43 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +134 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +16 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +45 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +16 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +129 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +19 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +41 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +59 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +34 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +70 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-log.entity.ts +29 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.controller.ts +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.decorator.ts +23 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +36 -21
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +36 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +10 -4
- package/dist/templates/modules/audit-log/module.manifest.json +30 -7
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +17 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +26 -3
- package/dist/templates/todo/apps/api/package.json +20 -2
- package/dist/templates/todo/apps/api/tsconfig.json +14 -5
- package/dist/templates/todo/apps/web/package.json +3 -2
- package/dist/templates/todo/apps/web/src/lib/api.ts +5 -0
- package/dist/templates/todo/apps/web/src/lib/server/api.ts +19 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +43 -4
- package/dist/templates/todo/apps/web/src/routes/+page.svelte +19 -18
- package/dist/templates/todo/apps/web/src/routes/api/[...path]/+server.ts +12 -0
- package/dist/templates/todo/apps/web/src/routes/api/auth/[...all]/+server.ts +12 -0
- package/dist/templates/todo/dot-gitignore +5 -0
- package/dist/templates/todo/package.json +11 -3
- package/dist/templates/todo/tests/README.md +15 -0
- package/dist/templates/todo/tests/api/health.api.spec.ts +14 -0
- package/dist/templates/todo/tests/package.json +14 -0
- package/dist/templates/todo/tests/playwright.config.ts +25 -0
- package/dist/templates/todo/tests/ui/smoke.ui.spec.ts +8 -0
- package/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/test/health.e2e-spec.ts +0 -23
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/health/+server.ts +0 -12
- package/dist/templates/todo/apps/api/test/health.e2e-spec.ts +0 -23
- package/dist/templates/todo/apps/web/src/routes/api/health/+server.ts +0 -12
- package/dist/templates/todo/apps/web/src/routes/api/todos/+server.ts +0 -24
- package/dist/templates/todo/apps/web/src/routes/api/todos/[id]/+server.ts +0 -24
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { anonState } from "../helpers/accounts";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
4
|
+
|
|
5
|
+
test.use({ storageState: anonState });
|
|
6
|
+
|
|
7
|
+
test("language switch toggles Korean and sets <html lang>", async ({ page }) => {
|
|
8
|
+
await ready(page, "/login");
|
|
9
|
+
await page.getByRole("button", { name: "Language" }).click();
|
|
10
|
+
await page.getByRole("menuitem", { name: /한국어/ }).click();
|
|
11
|
+
await expect(page.locator("html")).toHaveAttribute("lang", "ko");
|
|
12
|
+
await expect(page.getByRole("button", { name: "로그인", exact: true })).toBeVisible();
|
|
13
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { ready } from "../helpers/hydration";
|
|
3
|
+
|
|
4
|
+
// admin storageState (project default) — sessions is an admin-only view
|
|
5
|
+
test("admin sees user sessions across users @smoke", async ({ page }) => {
|
|
6
|
+
await ready(page, "/admin/sessions");
|
|
7
|
+
await expect(page.getByRole("heading", { name: "User sessions" })).toBeVisible();
|
|
8
|
+
// another user's session is listed here (the across-users admin view); scope to
|
|
9
|
+
// the table, not the sidebar, and match the first row.
|
|
10
|
+
await expect(page.getByRole("main").getByText("user@example.com").first()).toBeVisible();
|
|
11
|
+
// row actions are available
|
|
12
|
+
await page.getByRole("row", { name: /user@example.com/ }).first().getByRole("button").click();
|
|
13
|
+
await expect(page.getByRole("menuitem", { name: "Revoke session" })).toBeVisible();
|
|
14
|
+
await expect(page.getByRole("menuitem", { name: "Revoke all for user" })).toBeVisible();
|
|
15
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { ready } from "../helpers/hydration";
|
|
3
|
+
|
|
4
|
+
// admin storageState (project default)
|
|
5
|
+
test("collapsing the sidebar hides the brand instead of overflowing @smoke", async ({ page }) => {
|
|
6
|
+
await ready(page, "/admin");
|
|
7
|
+
const brand = page.getByTestId("sidebar-brand");
|
|
8
|
+
const toggle = page.getByRole("button", { name: "Toggle Sidebar" });
|
|
9
|
+
await expect(brand).toBeVisible();
|
|
10
|
+
|
|
11
|
+
await toggle.click();
|
|
12
|
+
await expect(brand).toBeHidden(); // icon-collapsed: label is hidden, not spilling out
|
|
13
|
+
|
|
14
|
+
await toggle.click();
|
|
15
|
+
await expect(brand).toBeVisible();
|
|
16
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
+
import { ready } from "../helpers/hydration";
|
|
3
|
+
|
|
4
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5173";
|
|
5
|
+
|
|
6
|
+
// Seed a throwaway user via the API (admin cookies come from storageState) so
|
|
7
|
+
// the mutating UI tests don't touch the shared admin/user seed accounts.
|
|
8
|
+
async function seedUser(page: Page, email: string): Promise<void> {
|
|
9
|
+
const res = await page.request.post("/api/auth/admin/create-user", {
|
|
10
|
+
headers: { origin: base },
|
|
11
|
+
data: { email, password: "password123", name: "Throwaway", role: "user" },
|
|
12
|
+
});
|
|
13
|
+
expect(res.ok()).toBeTruthy();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Open the Manage two-pane modal for a seeded user.
|
|
17
|
+
async function openManage(page: Page, email: string): Promise<void> {
|
|
18
|
+
await page.getByPlaceholder("Search by email…").fill(email);
|
|
19
|
+
await page.getByRole("row", { name: new RegExp(email) }).getByRole("button").click();
|
|
20
|
+
await page.getByRole("menuitem", { name: "Manage" }).click();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("admin can create a user via the dialog @smoke", async ({ page }) => {
|
|
24
|
+
await ready(page, "/admin/users");
|
|
25
|
+
const email = `ui-create-${Date.now()}@example.com`;
|
|
26
|
+
await page.getByRole("button", { name: "Add user" }).click();
|
|
27
|
+
const dialog = page.getByRole("dialog");
|
|
28
|
+
await dialog.getByLabel("Name").fill("Created");
|
|
29
|
+
await dialog.getByLabel("Email").fill(email);
|
|
30
|
+
await dialog.getByLabel("Password", { exact: true }).fill("password123");
|
|
31
|
+
await dialog.getByLabel("Confirm password").fill("password123");
|
|
32
|
+
await dialog.getByRole("button", { name: "Create" }).click();
|
|
33
|
+
await page.getByPlaceholder("Search by email…").fill(email);
|
|
34
|
+
await expect(page.getByRole("cell", { name: email })).toBeVisible();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("create rejects mismatched passwords", async ({ page }) => {
|
|
38
|
+
await ready(page, "/admin/users");
|
|
39
|
+
await page.getByRole("button", { name: "Add user" }).click();
|
|
40
|
+
const dialog = page.getByRole("dialog");
|
|
41
|
+
await dialog.getByLabel("Name").fill("Mismatch");
|
|
42
|
+
await dialog.getByLabel("Email").fill(`ui-mismatch-${Date.now()}@example.com`);
|
|
43
|
+
await dialog.getByLabel("Password", { exact: true }).fill("password123");
|
|
44
|
+
await dialog.getByLabel("Confirm password").fill("different999");
|
|
45
|
+
await dialog.getByRole("button", { name: "Create" }).click();
|
|
46
|
+
await expect(dialog.getByText("Passwords do not match")).toBeVisible();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("manage: edit profile name @smoke", async ({ page }) => {
|
|
50
|
+
await ready(page, "/admin/users");
|
|
51
|
+
const email = `ui-edit-${Date.now()}@example.com`;
|
|
52
|
+
await seedUser(page, email);
|
|
53
|
+
await openManage(page, email);
|
|
54
|
+
const dialog = page.getByRole("dialog");
|
|
55
|
+
await dialog.getByLabel("Name").fill("Edited Name");
|
|
56
|
+
await dialog.getByRole("button", { name: "Save" }).click();
|
|
57
|
+
await expect(page.getByText("User updated")).toBeVisible();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("manage: set a password (and reject mismatch)", async ({ page }) => {
|
|
61
|
+
await ready(page, "/admin/users");
|
|
62
|
+
const email = `ui-setpw-${Date.now()}@example.com`;
|
|
63
|
+
await seedUser(page, email);
|
|
64
|
+
await openManage(page, email);
|
|
65
|
+
const dialog = page.getByRole("dialog");
|
|
66
|
+
await dialog.getByRole("button", { name: "Security" }).click();
|
|
67
|
+
await dialog.getByLabel("New password", { exact: true }).fill("newpass1234");
|
|
68
|
+
await dialog.getByLabel("Confirm new password").fill("different999");
|
|
69
|
+
await dialog.getByRole("button", { name: "Set password" }).click();
|
|
70
|
+
await expect(dialog.getByText("Passwords do not match")).toBeVisible();
|
|
71
|
+
await dialog.getByLabel("Confirm new password").fill("newpass1234");
|
|
72
|
+
await dialog.getByRole("button", { name: "Set password" }).click();
|
|
73
|
+
await expect(page.getByText("Password updated")).toBeVisible();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("manage: ban a user with a reason", async ({ page }) => {
|
|
77
|
+
await ready(page, "/admin/users");
|
|
78
|
+
const email = `ui-ban-${Date.now()}@example.com`;
|
|
79
|
+
await seedUser(page, email);
|
|
80
|
+
await openManage(page, email);
|
|
81
|
+
const dialog = page.getByRole("dialog");
|
|
82
|
+
await dialog.getByRole("button", { name: "Security" }).click();
|
|
83
|
+
await dialog.getByLabel("Reason (optional)").fill("policy violation");
|
|
84
|
+
await dialog.getByRole("button", { name: "Ban", exact: true }).click();
|
|
85
|
+
await expect(page.getByText("User banned", { exact: true })).toBeVisible();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("manage: revoke a user's session", async ({ page, playwright }) => {
|
|
89
|
+
await ready(page, "/admin/users");
|
|
90
|
+
const email = `ui-sess-${Date.now()}@example.com`;
|
|
91
|
+
await seedUser(page, email);
|
|
92
|
+
// create a session for the user in an isolated context (don't touch the admin cookie)
|
|
93
|
+
const uctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
|
|
94
|
+
expect((await uctx.post("/api/auth/sign-in/email", { data: { email, password: "password123" } })).ok()).toBeTruthy();
|
|
95
|
+
await uctx.dispose();
|
|
96
|
+
await openManage(page, email);
|
|
97
|
+
const dialog = page.getByRole("dialog");
|
|
98
|
+
await dialog.getByRole("button", { name: "Sessions" }).click();
|
|
99
|
+
await dialog.getByRole("button", { name: "Revoke", exact: true }).first().click();
|
|
100
|
+
await expect(page.getByText("Session revoked")).toBeVisible();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("manage: delete a user from the danger zone", async ({ page }) => {
|
|
104
|
+
await ready(page, "/admin/users");
|
|
105
|
+
const email = `ui-delete-${Date.now()}@example.com`;
|
|
106
|
+
await seedUser(page, email);
|
|
107
|
+
await openManage(page, email);
|
|
108
|
+
const dialog = page.getByRole("dialog");
|
|
109
|
+
await dialog.getByRole("button", { name: "Danger zone" }).click();
|
|
110
|
+
await dialog.getByRole("button", { name: "Delete" }).click(); // arm
|
|
111
|
+
await dialog.getByRole("button", { name: "Delete" }).click(); // confirm
|
|
112
|
+
await expect(page.getByText("User deleted")).toBeVisible();
|
|
113
|
+
await page.getByPlaceholder("Search by email…").fill(email);
|
|
114
|
+
await expect(page.getByRole("cell", { name: email })).toHaveCount(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("users list shows pagination beyond one page", async ({ page }) => {
|
|
118
|
+
await ready(page, "/admin/users");
|
|
119
|
+
for (let i = 0; i < 6; i += 1) {
|
|
120
|
+
await page.request.post("/api/auth/admin/create-user", {
|
|
121
|
+
headers: { origin: base },
|
|
122
|
+
data: { email: `pg-${Date.now()}-${i}@example.com`, password: "password123", name: `Pg${i}`, role: "user" },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
await page.reload();
|
|
126
|
+
const nav = page.getByRole("navigation", { name: "pagination" });
|
|
127
|
+
await expect(nav).toBeVisible();
|
|
128
|
+
await expect(nav.getByRole("button", { name: "Page 2" })).toBeVisible();
|
|
129
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test("admin sees the user list and can search @smoke", async ({ page }) => {
|
|
4
|
+
await page.goto("/admin/users");
|
|
5
|
+
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
|
6
|
+
await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
7
|
+
await page.getByPlaceholder("Search by email…").fill("user@");
|
|
8
|
+
await expect(page.getByRole("cell", { name: "user@example.com" })).toBeVisible();
|
|
9
|
+
await expect(page.getByRole("cell", { name: "admin@example.com" })).toHaveCount(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("row menu exposes admin actions", async ({ page }) => {
|
|
13
|
+
await page.goto("/admin/users");
|
|
14
|
+
await page.getByPlaceholder("Search by email…").fill("user@example.com");
|
|
15
|
+
const row = page.getByRole("row", { name: /user@example.com/ });
|
|
16
|
+
await row.getByRole("button").click();
|
|
17
|
+
await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
|
|
18
|
+
await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
|
|
19
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "admin-dashboard",
|
|
3
|
+
"description": "Admin dashboard UI (SvelteKit + shadcn-svelte): login/signup/password-reset, a sidebar shell, and user + session management via the better-auth admin plugin. Requires auth.",
|
|
4
|
+
"requires": [
|
|
5
|
+
"auth"
|
|
6
|
+
],
|
|
7
|
+
"targetApp": "web",
|
|
8
|
+
"env": [
|
|
9
|
+
"# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
|
|
10
|
+
"ADMIN_EMAILS=admin@example.com"
|
|
11
|
+
],
|
|
12
|
+
"inject": [
|
|
13
|
+
{
|
|
14
|
+
"file": "apps/api/src/auth/auth.ts",
|
|
15
|
+
"marker": "// podokit:auth-imports",
|
|
16
|
+
"text": "import { admin } from \"better-auth/plugins\";"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"file": "apps/api/src/auth/auth.ts",
|
|
20
|
+
"marker": "// podokit:auth-plugins",
|
|
21
|
+
"text": "plugins.push(admin({ defaultRole: \"user\", adminRoles: [\"admin\"] }));"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"file": "apps/api/src/auth/auth.ts",
|
|
25
|
+
"marker": "// podokit:auth-email-password",
|
|
26
|
+
"text": "sendResetPassword: async ({ user, url }) => {\n console.log(`[auth] password reset for ${user.email}: ${url}`);\n },"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"file": "apps/api/src/auth/auth.ts",
|
|
30
|
+
"marker": "// podokit:auth-options",
|
|
31
|
+
"text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5173\").split(\",\").map((origin) => origin.trim()),\n databaseHooks: {\n user: {\n create: {\n before: async (user) => ({\n data: {\n ...user,\n role: (process.env.ADMIN_EMAILS ?? \"\")\n .split(\",\")\n .map((email) => email.trim().toLowerCase())\n .includes(user.email.toLowerCase())\n ? \"admin\"\n : \"user\",\n },\n }),\n },\n },\n },"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"instructions": [
|
|
35
|
+
"Set ADMIN_EMAILS in .env (comma-separated). Those emails become admins on sign-up.",
|
|
36
|
+
"Re-run the auth migration to add the admin columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
|
|
37
|
+
"Start the app (npm run dev), open /signup, register an ADMIN_EMAILS address, then sign in.",
|
|
38
|
+
"Admin pages: /dashboard/users (list, ban, set role, revoke sessions). Everyone: /dashboard/sessions, /dashboard/account.",
|
|
39
|
+
"Password reset: the reset link is logged to the API console in dev; wire a real mailer via emailAndPassword.sendResetPassword for production."
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Audit log
|
|
2
|
+
|
|
3
|
+
An append-only trail of **who did what, to which resource, when** — the
|
|
4
|
+
actor / action / target model. Every entry flows through one pipeline
|
|
5
|
+
(`AuditService` → the `audit_logs` table) so they all share one shape.
|
|
6
|
+
|
|
7
|
+
## What gets recorded
|
|
8
|
+
|
|
9
|
+
Nothing is recorded automatically except the built-in **auth/admin actions**
|
|
10
|
+
(login, user create/update/delete, ban, role change, session revoke, ...),
|
|
11
|
+
which the better-auth hook (`audit-hook.ts`) maps to semantic action codes like
|
|
12
|
+
`user.create` and `auth.login`. Everything else is **opt-in** — you decide what
|
|
13
|
+
matters.
|
|
14
|
+
|
|
15
|
+
## Audit your own routes with `@Audit`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Audit } from "../audit/audit.decorator";
|
|
19
|
+
|
|
20
|
+
@Post()
|
|
21
|
+
@Audit("todo.create", (_req, todo) => ({ type: "todo", id: todo.id, label: todo.title }))
|
|
22
|
+
create(@Body() dto: CreateTodoDto) {
|
|
23
|
+
return this.todos.create(dto);
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The interceptor resolves the acting user (name + email), attaches the target you
|
|
28
|
+
return, and writes one entry when the handler succeeds.
|
|
29
|
+
|
|
30
|
+
## Record from anywhere in code
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { recordAudit } from "../audit/audit-events";
|
|
34
|
+
|
|
35
|
+
await recordAudit({
|
|
36
|
+
action: "invoice.paid",
|
|
37
|
+
actorId, actorName, actorEmail,
|
|
38
|
+
targetType: "invoice", targetId: invoice.id, targetLabel: invoice.number,
|
|
39
|
+
metadata: { amount },
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or inject `AuditService` in a Nest provider and call `audit.record({ ... })`.
|
|
44
|
+
Both never throw — a failed audit write never breaks the request.
|
|
45
|
+
|
|
46
|
+
## Fields (`AuditEntry`)
|
|
47
|
+
|
|
48
|
+
`action` (semantic code), `actorId/actorName/actorEmail` (denormalized so the log
|
|
49
|
+
survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
|
|
50
|
+
(jsonb, free-form), `createdAt`.
|
|
51
|
+
|
|
52
|
+
## Customize
|
|
53
|
+
|
|
54
|
+
- **Auth actions:** edit the `ACTIONS` map in `audit-hook.ts`.
|
|
55
|
+
- **Your routes:** add `@Audit("your.action")` where you want a trail.
|
|
56
|
+
- **Schema:** add columns to `audit-log.entity.ts` + a migration, and keep the
|
|
57
|
+
`AuditEntry` type in `audit-events.ts` in sync.
|
|
58
|
+
- **Reading:** `GET /audit-logs` returns the latest 50 (admin only); extend
|
|
59
|
+
`audit.controller.ts` for filtering/pagination.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One audit pipeline for the whole app. Every source — the @Audit decorator, the
|
|
2
|
+
// better-auth hook, and your own code — records through here, so all entries
|
|
3
|
+
// share the actor/action/target shape and one write path (audit_logs).
|
|
4
|
+
|
|
5
|
+
export type AuditEntry = {
|
|
6
|
+
// Stable semantic action code, e.g. "user.create", "invoice.paid".
|
|
7
|
+
action: string;
|
|
8
|
+
actorId?: string | null;
|
|
9
|
+
actorName?: string | null;
|
|
10
|
+
actorEmail?: string | null;
|
|
11
|
+
targetType?: string | null;
|
|
12
|
+
targetId?: string | null;
|
|
13
|
+
targetLabel?: string | null;
|
|
14
|
+
ip?: string | null;
|
|
15
|
+
metadata?: Record<string, unknown> | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Recorder = (entry: AuditEntry) => Promise<void>;
|
|
19
|
+
let recorder: Recorder | null = null;
|
|
20
|
+
|
|
21
|
+
// AuditService registers the real recorder on startup. The registry lives outside
|
|
22
|
+
// Nest's DI so code that runs outside the container (the better-auth hook,
|
|
23
|
+
// background jobs, ...) can still write through the one pipeline.
|
|
24
|
+
export function setAuditRecorder(fn: Recorder): void {
|
|
25
|
+
recorder = fn;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Record a custom audit event from anywhere. Awaits the write; a no-op until the
|
|
29
|
+
// AuditService has started. Example:
|
|
30
|
+
// await recordAudit({ action: "invoice.paid", actorId, targetLabel: invoice.number,
|
|
31
|
+
// metadata: { amount } });
|
|
32
|
+
export async function recordAudit(entry: AuditEntry): Promise<void> {
|
|
33
|
+
if (recorder) await recorder(entry);
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
2
|
+
import { recordAudit } from "./audit-events";
|
|
3
|
+
|
|
4
|
+
// Map mutating auth/admin endpoints to semantic action codes. better-auth is
|
|
5
|
+
// mounted as middleware and bypasses the NestJS interceptor, so these security
|
|
6
|
+
// events are caught here and recorded through the same pipeline.
|
|
7
|
+
const ACTIONS: Record<string, string> = {
|
|
8
|
+
"/sign-in/email": "auth.login",
|
|
9
|
+
"/sign-up/email": "auth.signup",
|
|
10
|
+
"/sign-out": "auth.logout",
|
|
11
|
+
"/change-password": "auth.password_change",
|
|
12
|
+
"/change-email": "auth.email_change",
|
|
13
|
+
"/reset-password": "auth.password_reset",
|
|
14
|
+
"/update-user": "account.update",
|
|
15
|
+
"/delete-user": "account.delete",
|
|
16
|
+
"/revoke-session": "session.revoke",
|
|
17
|
+
"/revoke-sessions": "session.revoke_all",
|
|
18
|
+
"/revoke-other-sessions": "session.revoke_others",
|
|
19
|
+
"/two-factor/enable": "2fa.enable",
|
|
20
|
+
"/two-factor/disable": "2fa.disable",
|
|
21
|
+
"/admin/create-user": "user.create",
|
|
22
|
+
"/admin/update-user": "user.update",
|
|
23
|
+
"/admin/remove-user": "user.delete",
|
|
24
|
+
"/admin/set-role": "user.role_change",
|
|
25
|
+
"/admin/set-user-password": "user.password_set",
|
|
26
|
+
"/admin/ban-user": "user.ban",
|
|
27
|
+
"/admin/unban-user": "user.unban",
|
|
28
|
+
"/admin/revoke-user-session": "user.session_revoke",
|
|
29
|
+
"/admin/revoke-user-sessions": "user.session_revoke_all",
|
|
30
|
+
"/admin/impersonate-user": "user.impersonate",
|
|
31
|
+
"/admin/stop-impersonating": "user.stop_impersonate",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// The top-level `hooks.after` is a single middleware; it runs only on success,
|
|
35
|
+
// so it records completed actions.
|
|
36
|
+
export const auditAfterHook = createAuthMiddleware(async (ctx) => {
|
|
37
|
+
const action = ACTIONS[ctx.path];
|
|
38
|
+
if (!action) return;
|
|
39
|
+
const context = ctx.context as {
|
|
40
|
+
session?: { user?: { id?: string; name?: string; email?: string } };
|
|
41
|
+
newSession?: { user?: { id?: string; name?: string; email?: string } };
|
|
42
|
+
};
|
|
43
|
+
const actor = context.session?.user ?? context.newSession?.user;
|
|
44
|
+
const body = (ctx.body ?? {}) as { email?: string; userId?: string };
|
|
45
|
+
|
|
46
|
+
// Prefer a human-readable target: the email in the body, otherwise resolve the
|
|
47
|
+
// target user's email by id (falls back to the id for already-deleted users).
|
|
48
|
+
const targetId = body.userId ?? null;
|
|
49
|
+
let targetLabel = body.email ?? null;
|
|
50
|
+
if (!targetLabel && targetId) {
|
|
51
|
+
try {
|
|
52
|
+
const adapter = (ctx.context as { internalAdapter?: { findUserById?: (id: string) => Promise<{ email?: string } | null> } })
|
|
53
|
+
.internalAdapter;
|
|
54
|
+
const target = await adapter?.findUserById?.(targetId);
|
|
55
|
+
targetLabel = target?.email ?? targetId;
|
|
56
|
+
} catch {
|
|
57
|
+
targetLabel = targetId;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await recordAudit({
|
|
62
|
+
action,
|
|
63
|
+
actorId: actor?.id ?? null,
|
|
64
|
+
actorName: actor?.name ?? null,
|
|
65
|
+
actorEmail: actor?.email ?? null,
|
|
66
|
+
targetId,
|
|
67
|
+
targetLabel,
|
|
68
|
+
ip: ctx.headers?.get("x-forwarded-for") ?? null,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -1,25 +1,46 @@
|
|
|
1
|
-
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
|
|
1
|
+
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
|
|
2
2
|
|
|
3
|
+
// An append-only audit trail. Fields follow the actor / action / target model:
|
|
4
|
+
// who did what, to which resource, when — with human-readable labels stored
|
|
5
|
+
// inline so entries stay meaningful even after users are renamed or deleted.
|
|
3
6
|
@Entity("audit_logs")
|
|
4
7
|
export class AuditLog {
|
|
5
8
|
@PrimaryGeneratedColumn("uuid")
|
|
6
9
|
id!: string;
|
|
7
10
|
|
|
11
|
+
// A stable, semantic action code, e.g. "user.create", "auth.login".
|
|
12
|
+
@Index()
|
|
13
|
+
@Column({ type: "varchar", length: 128 })
|
|
14
|
+
action!: string;
|
|
15
|
+
|
|
16
|
+
// Who performed it (denormalized display fields + id).
|
|
8
17
|
@Column({ type: "varchar", nullable: true })
|
|
9
|
-
|
|
18
|
+
actorId!: string | null;
|
|
10
19
|
|
|
11
|
-
@Column({ type: "varchar",
|
|
12
|
-
|
|
20
|
+
@Column({ type: "varchar", nullable: true })
|
|
21
|
+
actorName!: string | null;
|
|
13
22
|
|
|
14
|
-
@Column({ type: "varchar",
|
|
15
|
-
|
|
23
|
+
@Column({ type: "varchar", nullable: true })
|
|
24
|
+
actorEmail!: string | null;
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
// What it acted on (optional).
|
|
27
|
+
@Column({ type: "varchar", nullable: true })
|
|
28
|
+
targetType!: string | null;
|
|
29
|
+
|
|
30
|
+
@Column({ type: "varchar", nullable: true })
|
|
31
|
+
targetId!: string | null;
|
|
32
|
+
|
|
33
|
+
@Column({ type: "varchar", nullable: true })
|
|
34
|
+
targetLabel!: string | null;
|
|
19
35
|
|
|
20
36
|
@Column({ type: "varchar", nullable: true })
|
|
21
37
|
ip!: string | null;
|
|
22
38
|
|
|
39
|
+
// Free-form context for custom events.
|
|
40
|
+
@Column({ type: "jsonb", nullable: true })
|
|
41
|
+
metadata!: Record<string, unknown> | null;
|
|
42
|
+
|
|
43
|
+
@Index()
|
|
23
44
|
@CreateDateColumn({ type: "timestamptz" })
|
|
24
45
|
createdAt!: Date;
|
|
25
46
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Controller, Get } from "@nestjs/common";
|
|
1
|
+
import { Controller, ForbiddenException, Get } from "@nestjs/common";
|
|
2
2
|
import { InjectRepository } from "@nestjs/typeorm";
|
|
3
|
+
import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
|
|
3
4
|
import { ApiTags } from "@nestjs/swagger";
|
|
4
5
|
import { Repository } from "typeorm";
|
|
5
6
|
import { AuditLog } from "./audit-log.entity";
|
|
@@ -9,8 +10,12 @@ import { AuditLog } from "./audit-log.entity";
|
|
|
9
10
|
export class AuditController {
|
|
10
11
|
constructor(@InjectRepository(AuditLog) private readonly logs: Repository<AuditLog>) {}
|
|
11
12
|
|
|
13
|
+
// Audit entries are sensitive — admins only.
|
|
12
14
|
@Get()
|
|
13
|
-
recent(): Promise<AuditLog[]> {
|
|
15
|
+
recent(@Session() session: UserSession): Promise<AuditLog[]> {
|
|
16
|
+
if ((session.user as { role?: string | null }).role !== "admin") {
|
|
17
|
+
throw new ForbiddenException("Admins only");
|
|
18
|
+
}
|
|
14
19
|
return this.logs.find({ order: { createdAt: "DESC" }, take: 50 });
|
|
15
20
|
}
|
|
16
21
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SetMetadata } from "@nestjs/common";
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
|
|
4
|
+
export const AUDIT_KEY = "podokit:audit";
|
|
5
|
+
|
|
6
|
+
// Optionally derive the target (and extra metadata) from the request + handler
|
|
7
|
+
// result, e.g. (req, result) => ({ type: "todo", id: result.id, label: result.title }).
|
|
8
|
+
export type AuditTarget = {
|
|
9
|
+
type?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
metadata?: Record<string, unknown>;
|
|
13
|
+
};
|
|
14
|
+
export type AuditMeta = {
|
|
15
|
+
action: string;
|
|
16
|
+
resolve?: (req: Request, result: unknown) => AuditTarget | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Mark a controller handler to be audited under a semantic action code:
|
|
20
|
+
// @Audit("todo.create", (_req, todo) => ({ type: "todo", id: todo.id, label: todo.title }))
|
|
21
|
+
export function Audit(action: string, resolve?: AuditMeta["resolve"]): MethodDecorator {
|
|
22
|
+
return SetMetadata(AUDIT_KEY, { action, resolve } satisfies AuditMeta);
|
|
23
|
+
}
|
|
@@ -1,39 +1,54 @@
|
|
|
1
1
|
import { type CallHandler, type ExecutionContext, Injectable, type NestInterceptor } from "@nestjs/common";
|
|
2
|
-
import {
|
|
2
|
+
import { Reflector } from "@nestjs/core";
|
|
3
3
|
import { fromNodeHeaders } from "better-auth/node";
|
|
4
|
-
import type { Request
|
|
4
|
+
import type { Request } from "express";
|
|
5
5
|
import { type Observable, tap } from "rxjs";
|
|
6
|
-
import { Repository } from "typeorm";
|
|
7
6
|
import { auth } from "../auth/auth";
|
|
8
|
-
import {
|
|
7
|
+
import { AUDIT_KEY, type AuditMeta } from "./audit.decorator";
|
|
8
|
+
import { AuditService } from "./audit.service";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
10
|
+
// Records handlers explicitly marked with @Audit(...). Nothing is logged unless
|
|
11
|
+
// you opt a route in, so the trail stays meaningful (semantic actions, not raw
|
|
12
|
+
// HTTP). Auth/admin actions are recorded separately by the better-auth hook.
|
|
13
13
|
@Injectable()
|
|
14
14
|
export class AuditInterceptor implements NestInterceptor {
|
|
15
|
-
constructor(
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly reflector: Reflector,
|
|
17
|
+
private readonly audit: AuditService,
|
|
18
|
+
) {}
|
|
16
19
|
|
|
17
20
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
21
|
+
const meta = this.reflector.get<AuditMeta | undefined>(AUDIT_KEY, context.getHandler());
|
|
22
|
+
if (!meta) return next.handle();
|
|
18
23
|
const req = context.switchToHttp().getRequest<Request>();
|
|
19
|
-
|
|
20
|
-
if (!AUDITED_METHODS.has(req.method) || path.startsWith("/api/auth")) {
|
|
21
|
-
return next.handle();
|
|
22
|
-
}
|
|
23
|
-
const res = context.switchToHttp().getResponse<Response>();
|
|
24
|
-
return next.handle().pipe(tap(() => void this.record(req, res, path)));
|
|
24
|
+
return next.handle().pipe(tap((result) => void this.log(meta, req, result)));
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
private async
|
|
28
|
-
let
|
|
27
|
+
private async log(meta: AuditMeta, req: Request, result: unknown): Promise<void> {
|
|
28
|
+
let actorId: string | null = null;
|
|
29
|
+
let actorName: string | null = null;
|
|
30
|
+
let actorEmail: string | null = null;
|
|
29
31
|
try {
|
|
30
32
|
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
|
31
|
-
|
|
33
|
+
if (session?.user) {
|
|
34
|
+
actorId = session.user.id;
|
|
35
|
+
actorName = session.user.name ?? null;
|
|
36
|
+
actorEmail = session.user.email ?? null;
|
|
37
|
+
}
|
|
32
38
|
} catch {
|
|
33
|
-
|
|
39
|
+
/* unauthenticated */
|
|
34
40
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
const target = meta.resolve?.(req, result);
|
|
42
|
+
await this.audit.record({
|
|
43
|
+
action: meta.action,
|
|
44
|
+
actorId,
|
|
45
|
+
actorName,
|
|
46
|
+
actorEmail,
|
|
47
|
+
targetType: target?.type ?? null,
|
|
48
|
+
targetId: target?.id ?? null,
|
|
49
|
+
targetLabel: target?.label ?? null,
|
|
50
|
+
ip: req.ip ?? null,
|
|
51
|
+
metadata: target?.metadata ?? null,
|
|
52
|
+
});
|
|
38
53
|
}
|
|
39
54
|
}
|
|
@@ -3,11 +3,12 @@ import { APP_INTERCEPTOR } from "@nestjs/core";
|
|
|
3
3
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
4
4
|
import { AuditController } from "./audit.controller";
|
|
5
5
|
import { AuditInterceptor } from "./audit.interceptor";
|
|
6
|
+
import { AuditService } from "./audit.service";
|
|
6
7
|
import { AuditLog } from "./audit-log.entity";
|
|
7
8
|
|
|
8
9
|
@Module({
|
|
9
10
|
imports: [TypeOrmModule.forFeature([AuditLog])],
|
|
10
11
|
controllers: [AuditController],
|
|
11
|
-
providers: [{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor }],
|
|
12
|
+
providers: [AuditService, { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }],
|
|
12
13
|
})
|
|
13
14
|
export class AuditModule {}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Injectable, type OnModuleInit } from "@nestjs/common";
|
|
2
|
+
import { InjectRepository } from "@nestjs/typeorm";
|
|
3
|
+
import { Repository } from "typeorm";
|
|
4
|
+
import { AuditLog } from "./audit-log.entity";
|
|
5
|
+
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
6
|
+
|
|
7
|
+
// The one place audit entries are written. Inject it to record from Nest code,
|
|
8
|
+
// or call recordAudit() from anywhere else — both end up here.
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class AuditService implements OnModuleInit {
|
|
11
|
+
constructor(@InjectRepository(AuditLog) private readonly logs: Repository<AuditLog>) {}
|
|
12
|
+
|
|
13
|
+
onModuleInit(): void {
|
|
14
|
+
setAuditRecorder((entry) => this.record(entry));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async record(entry: AuditEntry): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
await this.logs.save(
|
|
20
|
+
this.logs.create({
|
|
21
|
+
action: entry.action,
|
|
22
|
+
actorId: entry.actorId ?? null,
|
|
23
|
+
actorName: entry.actorName ?? null,
|
|
24
|
+
actorEmail: entry.actorEmail ?? null,
|
|
25
|
+
targetType: entry.targetType ?? null,
|
|
26
|
+
targetId: entry.targetId ?? null,
|
|
27
|
+
targetLabel: entry.targetLabel ?? null,
|
|
28
|
+
ip: entry.ip ?? null,
|
|
29
|
+
metadata: entry.metadata ?? null,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
// An audit write must never break the request it describes.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|