@podosoft/podokit 0.4.0 → 0.5.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.
- package/README.md +33 -2
- package/dist/add.d.ts +2 -0
- package/dist/add.js +5 -1
- package/dist/assemble.d.ts +14 -0
- package/dist/assemble.js +60 -0
- package/dist/create.d.ts +2 -0
- package/dist/create.js +7 -0
- package/dist/eject.d.ts +11 -0
- package/dist/eject.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +122 -1
- package/dist/inspect.d.ts +43 -0
- package/dist/inspect.js +82 -0
- package/dist/lockfile.d.ts +80 -0
- package/dist/lockfile.js +193 -0
- package/dist/templates/base/dot-env.example +11 -6
- package/dist/templates/fullstack-nest-svelte/README.md +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
- package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
- package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
- package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
- package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
- package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
- package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
- package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
- package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
- package/dist/templates/modules/auth/module.manifest.json +54 -20
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
- package/dist/templates/modules/bullmq/module.manifest.json +4 -4
- package/dist/templates/modules/file-upload/module.manifest.json +3 -3
- package/dist/templates/modules/job-progress/module.manifest.json +9 -9
- package/dist/templates/modules/logging/module.manifest.json +3 -3
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
- package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
- package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
- package/dist/templates/modules/redis/module.manifest.json +4 -4
- package/dist/templates/modules/sse/module.manifest.json +4 -4
- package/dist/templates/todo/README.md +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
- package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
- package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
- package/dist/templates/todo/apps/api/src/main.ts +1 -1
- package/dist/templates/todo/apps/web/package.json +2 -2
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
- package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
- package/dist/templates/todo/dot-env.example +10 -5
- package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
- package/dist/templates/todo/tests/README.md +1 -1
- package/dist/templates/todo/tests/package.json +2 -1
- package/dist/templates/todo/tests/playwright.config.ts +1 -1
- package/dist/update.d.ts +49 -0
- package/dist/update.js +158 -0
- package/package.json +2 -2
|
@@ -1,17 +1,49 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { ready } from "../helpers/hydration";
|
|
3
|
+
|
|
4
|
+
test("admin can create a user with a custom role @smoke", async ({ page }) => {
|
|
5
|
+
await ready(page, "/admin/users");
|
|
6
|
+
await page.getByRole("button", { name: "Add user" }).click();
|
|
7
|
+
const role = page.locator("#c-role");
|
|
8
|
+
test.skip((await role.count()) === 0, "role picker not present");
|
|
9
|
+
const email = `mod-${Date.now()}@example.com`;
|
|
10
|
+
await page.getByLabel("Name", { exact: true }).fill("Mod");
|
|
11
|
+
await page.getByLabel("Email", { exact: true }).fill(email);
|
|
12
|
+
await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
|
|
13
|
+
await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
|
|
14
|
+
// pick the Moderator role from the select
|
|
15
|
+
await role.click();
|
|
16
|
+
await page.getByRole("option", { name: "Moderator" }).click();
|
|
17
|
+
await page.getByRole("button", { name: "Create", exact: true }).click();
|
|
18
|
+
await expect(page.getByText("User created")).toBeVisible();
|
|
19
|
+
// it appears in the list, filterable by the Moderator role
|
|
20
|
+
await page.locator("#toolbar-search").fill(email);
|
|
21
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
22
|
+
await expect(page.getByRole("cell", { name: email })).toBeVisible();
|
|
23
|
+
});
|
|
2
24
|
|
|
3
25
|
test("admin sees the user list and can search @smoke", async ({ page }) => {
|
|
4
|
-
await page
|
|
26
|
+
await ready(page, "/admin/users");
|
|
5
27
|
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
|
28
|
+
// Search by exact address — robust to pagination as the user count grows.
|
|
29
|
+
await page.locator("#toolbar-search").fill("admin@example.com");
|
|
30
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
6
31
|
await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
7
|
-
await page.
|
|
32
|
+
await page.locator("#toolbar-search").fill("user@example.com");
|
|
33
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
8
34
|
await expect(page.getByRole("cell", { name: "user@example.com" })).toBeVisible();
|
|
9
35
|
await expect(page.getByRole("cell", { name: "admin@example.com" })).toHaveCount(0);
|
|
10
36
|
});
|
|
11
37
|
|
|
38
|
+
test("user list shows the joined column @smoke", async ({ page }) => {
|
|
39
|
+
await ready(page, "/admin/users");
|
|
40
|
+
await expect(page.getByRole("columnheader", { name: "Joined" })).toBeVisible();
|
|
41
|
+
});
|
|
42
|
+
|
|
12
43
|
test("row menu exposes admin actions", async ({ page }) => {
|
|
13
44
|
await page.goto("/admin/users");
|
|
14
|
-
await page.
|
|
45
|
+
await page.locator("#toolbar-search").fill("user@example.com");
|
|
46
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
15
47
|
const row = page.getByRole("row", { name: /user@example.com/ });
|
|
16
48
|
await row.getByRole("button").click();
|
|
17
49
|
await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
"auth"
|
|
6
6
|
],
|
|
7
7
|
"targetApp": "web",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"qrcode": "^1.5.4"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/qrcode": "^1.5.5"
|
|
13
|
+
},
|
|
8
14
|
"env": [
|
|
9
15
|
"# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
|
|
10
16
|
"ADMIN_EMAILS=admin@example.com"
|
|
@@ -12,23 +18,23 @@
|
|
|
12
18
|
"inject": [
|
|
13
19
|
{
|
|
14
20
|
"file": "apps/api/src/auth/auth.ts",
|
|
15
|
-
"marker": "// podokit:auth-imports",
|
|
21
|
+
"marker": "// podokit:end:auth-imports",
|
|
16
22
|
"text": "import { admin } from \"better-auth/plugins\";"
|
|
17
23
|
},
|
|
18
24
|
{
|
|
19
25
|
"file": "apps/api/src/auth/auth.ts",
|
|
20
|
-
"marker": "// podokit:auth-
|
|
21
|
-
"text": "
|
|
26
|
+
"marker": "// podokit:end:auth-imports",
|
|
27
|
+
"text": "import { ac, roles } from \"./permissions\";"
|
|
22
28
|
},
|
|
23
29
|
{
|
|
24
30
|
"file": "apps/api/src/auth/auth.ts",
|
|
25
|
-
"marker": "// podokit:auth-
|
|
26
|
-
"text": "
|
|
31
|
+
"marker": "// podokit:end:auth-plugins",
|
|
32
|
+
"text": "plugins.push(admin({ defaultRole: \"user\", adminRoles: [\"admin\"], ac, roles }));"
|
|
27
33
|
},
|
|
28
34
|
{
|
|
29
35
|
"file": "apps/api/src/auth/auth.ts",
|
|
30
|
-
"marker": "// podokit:auth-options",
|
|
31
|
-
"text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:
|
|
36
|
+
"marker": "// podokit:end:auth-options",
|
|
37
|
+
"text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5001\").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
38
|
}
|
|
33
39
|
],
|
|
34
40
|
"instructions": [
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"API_KEYS=dev-key-please-change"
|
|
9
9
|
],
|
|
10
10
|
"inject": [
|
|
11
|
-
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:imports", "text": "import { ApiKeyModule } from \"./api-key/api-key.module\";" },
|
|
12
|
-
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:module-imports", "text": "ApiKeyModule," }
|
|
11
|
+
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { ApiKeyModule } from \"./api-key/api-key.module\";" },
|
|
12
|
+
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:module-imports", "text": "ApiKeyModule," }
|
|
13
13
|
],
|
|
14
14
|
"instructions": [
|
|
15
15
|
"Set API_KEYS in .env (comma-separated).",
|
|
16
16
|
"Protect a route for machine clients with @ApiKeyProtected() (bypasses the user session, requires X-API-Key).",
|
|
17
|
-
"Call it: curl -H 'x-api-key: dev-key-please-change' localhost:
|
|
17
|
+
"Call it: curl -H 'x-api-key: dev-key-please-change' localhost:5002/machine/ping"
|
|
18
18
|
]
|
|
19
19
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { pool } from "../auth/db";
|
|
2
|
+
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
3
|
+
|
|
4
|
+
// Audit logging is an admin-managed, DB-backed server toggle (auth_config `server`
|
|
5
|
+
// row, env fallback AUDIT_LOG_ENABLED), read through the shared config store's
|
|
6
|
+
// short TTL cache — the same mechanism as the other server-enforced toggles.
|
|
7
|
+
// Toggling it on the Settings page takes effect within the cache TTL, no restart.
|
|
8
|
+
const store = createConfigStore(pool);
|
|
9
|
+
|
|
10
|
+
export async function auditEnabled(): Promise<boolean> {
|
|
11
|
+
try {
|
|
12
|
+
return (await store.capabilitiesSnapshot()).auditLog;
|
|
13
|
+
} catch {
|
|
14
|
+
return true; // never let a config read failure silently drop the audit trail
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAuthMiddleware } from "better-auth/api";
|
|
2
2
|
import { recordAudit } from "./audit-events";
|
|
3
|
+
import { auditEnabled } from "./audit-enabled";
|
|
3
4
|
|
|
4
5
|
// Map mutating auth/admin endpoints to semantic action codes. better-auth is
|
|
5
6
|
// mounted as middleware and bypasses the NestJS interceptor, so these security
|
|
@@ -11,6 +12,8 @@ const ACTIONS: Record<string, string> = {
|
|
|
11
12
|
"/change-password": "auth.password_change",
|
|
12
13
|
"/change-email": "auth.email_change",
|
|
13
14
|
"/reset-password": "auth.password_reset",
|
|
15
|
+
"/send-verification-email": "auth.verification_sent",
|
|
16
|
+
"/verify-email": "auth.email_verified",
|
|
14
17
|
"/update-user": "account.update",
|
|
15
18
|
"/delete-user": "account.delete",
|
|
16
19
|
"/revoke-session": "session.revoke",
|
|
@@ -36,6 +39,7 @@ const ACTIONS: Record<string, string> = {
|
|
|
36
39
|
export const auditAfterHook = createAuthMiddleware(async (ctx) => {
|
|
37
40
|
const action = ACTIONS[ctx.path];
|
|
38
41
|
if (!action) return;
|
|
42
|
+
if (!(await auditEnabled())) return;
|
|
39
43
|
const context = ctx.context as {
|
|
40
44
|
session?: { user?: { id?: string; name?: string; email?: string } };
|
|
41
45
|
newSession?: { user?: { id?: string; name?: string; email?: string } };
|
|
@@ -6,6 +6,7 @@ import { type Observable, tap } from "rxjs";
|
|
|
6
6
|
import { auth } from "../auth/auth";
|
|
7
7
|
import { AUDIT_KEY, type AuditMeta } from "./audit.decorator";
|
|
8
8
|
import { AuditService } from "./audit.service";
|
|
9
|
+
import { auditEnabled } from "./audit-enabled";
|
|
9
10
|
|
|
10
11
|
// Records handlers explicitly marked with @Audit(...). Nothing is logged unless
|
|
11
12
|
// you opt a route in, so the trail stays meaningful (semantic actions, not raw
|
|
@@ -25,6 +26,7 @@ export class AuditInterceptor implements NestInterceptor {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
private async log(meta: AuditMeta, req: Request, result: unknown): Promise<void> {
|
|
29
|
+
if (!(await auditEnabled())) return;
|
|
28
30
|
let actorId: string | null = null;
|
|
29
31
|
let actorName: string | null = null;
|
|
30
32
|
let actorEmail: string | null = null;
|
|
@@ -1,39 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "audit-log",
|
|
3
|
-
"description": "Audit logging: records who did each mutating request (who/method/path/status) to an audit_logs table
|
|
3
|
+
"description": "Audit logging: records who did each mutating request (who/method/path/status) to an audit_logs table \u2014 a NestJS interceptor for your own API routes plus a better-auth hook for auth/admin actions (login, ban, role changes, ...). Requires auth.",
|
|
4
4
|
"requires": [
|
|
5
5
|
"auth"
|
|
6
6
|
],
|
|
7
7
|
"targetApp": "api",
|
|
8
8
|
"env": [
|
|
9
|
-
"# audit-log:
|
|
9
|
+
"# audit-log: on/off is managed on the admin Settings page (stored in the DB,",
|
|
10
|
+
"# applied live, no restart). This env var is the fallback default used until an",
|
|
11
|
+
"# admin sets it \u2014 leave it 'true' so the trail is on out of the box; the DB value",
|
|
12
|
+
"# (Settings page) overrides it once set.",
|
|
10
13
|
"AUDIT_LOG_ENABLED=true"
|
|
11
14
|
],
|
|
12
15
|
"inject": [
|
|
13
16
|
{
|
|
14
17
|
"file": "apps/api/src/app.module.ts",
|
|
15
|
-
"marker": "// podokit:imports",
|
|
18
|
+
"marker": "// podokit:end:imports",
|
|
16
19
|
"text": "import { AuditModule } from \"./audit/audit.module\";"
|
|
17
20
|
},
|
|
18
21
|
{
|
|
19
22
|
"file": "apps/api/src/app.module.ts",
|
|
20
|
-
"marker": "// podokit:module-imports",
|
|
23
|
+
"marker": "// podokit:end:module-imports",
|
|
21
24
|
"text": "AuditModule,"
|
|
22
25
|
},
|
|
23
26
|
{
|
|
24
27
|
"file": "apps/api/src/auth/auth.ts",
|
|
25
|
-
"marker": "// podokit:auth-imports",
|
|
28
|
+
"marker": "// podokit:end:auth-imports",
|
|
26
29
|
"text": "import { auditAfterHook } from \"../audit/audit-hook\";"
|
|
27
30
|
},
|
|
28
31
|
{
|
|
29
32
|
"file": "apps/api/src/auth/auth.ts",
|
|
30
|
-
"marker": "// podokit:auth-
|
|
31
|
-
"text": "hooks
|
|
33
|
+
"marker": "// podokit:end:auth-hooks",
|
|
34
|
+
"text": "hooks.after = auditAfterHook;"
|
|
32
35
|
}
|
|
33
36
|
],
|
|
34
37
|
"instructions": [
|
|
35
38
|
"Run migrations to create the audit_logs table: npm run migration:run -w <app>-api",
|
|
36
39
|
"Auth/admin actions (login, ban, role changes, session revocation, user CRUD) are recorded via a better-auth hook; your own mutating NestJS routes are recorded by the interceptor.",
|
|
37
|
-
"List recent entries (admin session required): curl -b cookies.txt localhost:
|
|
38
|
-
]
|
|
40
|
+
"List recent entries (admin session required): curl -b cookies.txt localhost:5002/audit-logs"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@podosoft/podokit-auth": "^0.1.0"
|
|
44
|
+
}
|
|
39
45
|
}
|
|
@@ -1,30 +1,95 @@
|
|
|
1
|
-
import { Controller, Get } from "@nestjs/common";
|
|
2
|
-
import {
|
|
1
|
+
import { Body, Controller, ForbiddenException, Get, Post, Put, Req } from "@nestjs/common";
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
import { fromNodeHeaders } from "better-auth/node";
|
|
4
|
+
import { Public, Session, type UserSession } from "@thallesp/nestjs-better-auth";
|
|
3
5
|
import { ApiTags } from "@nestjs/swagger";
|
|
6
|
+
import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
|
|
7
|
+
import { ROLE_NAMES } from "../auth/permissions";
|
|
8
|
+
import { auth } from "../auth/auth";
|
|
9
|
+
import { pool } from "../auth/db";
|
|
10
|
+
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
11
|
+
import type { Capabilities } from "@podosoft/podokit-contracts";
|
|
4
12
|
|
|
5
|
-
|
|
13
|
+
// Reads OAuth/server-toggle state (DB-first, env fallback) for capabilities.
|
|
14
|
+
const configStore = createConfigStore(pool);
|
|
15
|
+
|
|
16
|
+
function isAdmin(session: UserSession): boolean {
|
|
17
|
+
const role = session.user?.role;
|
|
18
|
+
const roles = Array.isArray(role) ? role : (role ?? "").split(",").map((r: string) => r.trim());
|
|
19
|
+
return roles.includes("admin");
|
|
20
|
+
}
|
|
6
21
|
|
|
7
|
-
// Protected by the global AuthGuard. Use @Session() to read the current user.
|
|
8
22
|
@ApiTags("account")
|
|
9
23
|
@Controller("account")
|
|
10
24
|
export class AccountController {
|
|
25
|
+
constructor(private readonly settings: SettingsService) {}
|
|
26
|
+
|
|
11
27
|
@Get("me")
|
|
12
28
|
me(@Session() session: UserSession) {
|
|
13
29
|
return session.user;
|
|
14
30
|
}
|
|
15
31
|
|
|
16
32
|
// Which optional auth features are enabled, so the UI can show/hide sections.
|
|
33
|
+
// Public so the login page (unauthenticated) can offer available sign-in methods.
|
|
34
|
+
@Public()
|
|
17
35
|
@Get("capabilities")
|
|
18
|
-
capabilities(): Capabilities {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
async capabilities(): Promise<Capabilities> {
|
|
37
|
+
const flags = this.settings.flags();
|
|
38
|
+
// OAuth providers + server-enforced toggles are DB-backed (env fallback) and
|
|
39
|
+
// applied live, so read them from the config store rather than process.env.
|
|
40
|
+
const snapshot = await configStore.capabilitiesSnapshot();
|
|
23
41
|
return {
|
|
24
|
-
twoFactor:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
twoFactor: flags.twoFactor,
|
|
43
|
+
magicLink: flags.magicLink,
|
|
44
|
+
emailOtp: flags.emailOtp,
|
|
45
|
+
username: flags.username,
|
|
46
|
+
multiSession: flags.multiSession,
|
|
47
|
+
phoneNumber: flags.phoneNumber,
|
|
48
|
+
apiKey: flags.apiKey,
|
|
49
|
+
passkey: flags.passkey,
|
|
50
|
+
organization: flags.organization,
|
|
51
|
+
oidcProvider: flags.oidcProvider,
|
|
52
|
+
providers: snapshot.providers,
|
|
53
|
+
deleteAccount: snapshot.allowDelete,
|
|
54
|
+
emailVerification: snapshot.requireEmailVerification,
|
|
55
|
+
passwordBreachCheck: snapshot.passwordBreachCheck,
|
|
56
|
+
auditLog: snapshot.auditLog,
|
|
57
|
+
roles: ROLE_NAMES,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Admin-only: toggle feature flags. Stored in the DB and reflected immediately in
|
|
62
|
+
// capabilities (UI). Server-enforced behaviours (email verification, breach check)
|
|
63
|
+
// are read by the auth server at startup, so those take effect on the next restart.
|
|
64
|
+
@Put("settings")
|
|
65
|
+
async updateSettings(
|
|
66
|
+
@Session() session: UserSession,
|
|
67
|
+
@Body() body: Partial<Record<FeatureFlag, boolean>>,
|
|
68
|
+
): Promise<Record<FeatureFlag, boolean>> {
|
|
69
|
+
if (!isAdmin(session)) throw new ForbiddenException("Admins only");
|
|
70
|
+
const update: Partial<Record<FeatureFlag, boolean>> = {};
|
|
71
|
+
for (const flag of FEATURE_FLAGS) {
|
|
72
|
+
if (typeof body?.[flag] === "boolean") update[flag] = body[flag];
|
|
73
|
+
}
|
|
74
|
+
return this.settings.setMany(update);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add an existing user to an organization with a role (e.g. "manager"), so several
|
|
78
|
+
// members can hold it. better-auth's addMember is server-only; this exposes it and
|
|
79
|
+
// delegates org-level authorization to better-auth via the caller's session headers.
|
|
80
|
+
@Post("org-member")
|
|
81
|
+
async addOrgMember(
|
|
82
|
+
@Req() req: Request,
|
|
83
|
+
@Body() body: { organizationId: string; userId: string; role: string },
|
|
84
|
+
): Promise<unknown> {
|
|
85
|
+
// addMember is a server-only better-auth endpoint (not on the typed public api);
|
|
86
|
+
// it exists at runtime, so reach it through a narrow cast.
|
|
87
|
+
const api = auth.api as unknown as {
|
|
88
|
+
addMember: (opts: { body: { organizationId: string; userId: string; role: string }; headers: Headers }) => Promise<unknown>;
|
|
28
89
|
};
|
|
90
|
+
return api.addMember({
|
|
91
|
+
body: { organizationId: body.organizationId, userId: body.userId, role: body.role },
|
|
92
|
+
headers: fromNodeHeaders(req.headers),
|
|
93
|
+
});
|
|
29
94
|
}
|
|
30
95
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Module } from "@nestjs/common";
|
|
2
|
+
import { SettingsModule } from "../settings/settings.module";
|
|
2
3
|
import { AccountController } from "./account.controller";
|
|
3
4
|
|
|
4
|
-
@Module({ controllers: [AccountController] })
|
|
5
|
+
@Module({ imports: [SettingsModule], controllers: [AccountController] })
|
|
5
6
|
export class AccountModule {}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { auth as bootstrapAuth, buildAuth } from "./auth";
|
|
2
|
+
import { pool } from "./db";
|
|
3
|
+
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
4
|
+
|
|
5
|
+
// Runtime auth instance that rebuilds when the DB config changes, so admin edits
|
|
6
|
+
// (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
|
|
7
|
+
//
|
|
8
|
+
// @thallesp/nestjs-better-auth reads `auth.handler` exactly once (via toNodeHandler)
|
|
9
|
+
// and `auth.api` per request. We hand it a Proxy: `.handler` is a STABLE delegating
|
|
10
|
+
// function (so the adapter's captured reference stays valid) that redispatches each
|
|
11
|
+
// request to the currently-built instance; every other access forwards to the
|
|
12
|
+
// current instance too. The guard/session behavior is unchanged (session tokens are
|
|
13
|
+
// signed with the stable BETTER_AUTH_SECRET; trustedOrigins is baked into every build).
|
|
14
|
+
|
|
15
|
+
const store = createConfigStore(pool);
|
|
16
|
+
const TTL_MS = 3_000;
|
|
17
|
+
|
|
18
|
+
let current = bootstrapAuth;
|
|
19
|
+
let currentVersion = "env"; // matches envAuthConfig() bootstrap
|
|
20
|
+
let checkedAt = 0;
|
|
21
|
+
let inflight: Promise<void> | null = null;
|
|
22
|
+
|
|
23
|
+
export function getAuth(): typeof bootstrapAuth {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Rebuild the instance if the DB config version changed. Cheap and throttled to
|
|
28
|
+
* TTL; single-flight so concurrent requests trigger at most one rebuild; keeps the
|
|
29
|
+
* last-good instance if loading/building fails, so auth never goes down. */
|
|
30
|
+
export async function refreshIfStale(): Promise<void> {
|
|
31
|
+
if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return;
|
|
32
|
+
checkedAt = Date.now();
|
|
33
|
+
let latest: string;
|
|
34
|
+
try {
|
|
35
|
+
latest = await store.currentVersion();
|
|
36
|
+
} catch {
|
|
37
|
+
return; // DB blip — keep serving the last-good instance
|
|
38
|
+
}
|
|
39
|
+
if (latest === currentVersion) return;
|
|
40
|
+
if (inflight) return inflight;
|
|
41
|
+
inflight = (async () => {
|
|
42
|
+
try {
|
|
43
|
+
const config = await store.load();
|
|
44
|
+
current = buildAuth(config);
|
|
45
|
+
currentVersion = config.version;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error("[auth-config] rebuild failed; keeping last-good auth instance:", err instanceof Error ? err.message : err);
|
|
48
|
+
} finally {
|
|
49
|
+
inflight = null;
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
return inflight;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Stable handler reference the adapter captures once; redispatches per request.
|
|
56
|
+
const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
|
|
57
|
+
await refreshIfStale();
|
|
58
|
+
return current.handler(request);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const authRuntime: typeof bootstrapAuth = new Proxy(bootstrapAuth, {
|
|
62
|
+
get(_target, prop) {
|
|
63
|
+
if (prop === "handler") return dynamicHandler;
|
|
64
|
+
const value = Reflect.get(current, prop, current);
|
|
65
|
+
return typeof value === "function" ? value.bind(current) : value;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Optional boot preload so the first request doesn't pay the initial DB read. */
|
|
70
|
+
export async function primeAuth(): Promise<void> {
|
|
71
|
+
checkedAt = 0;
|
|
72
|
+
await refreshIfStale();
|
|
73
|
+
}
|