@podosoft/podokit 0.7.0 → 0.9.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/index.js +25 -0
- package/dist/remove.d.ts +28 -0
- package/dist/remove.js +170 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +8 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +10 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +58 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +39 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +13 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +61 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +129 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
- package/dist/templates/modules/auth/module.manifest.json +10 -0
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/apps/web/vite.config.ts +8 -2
- package/dist/templates/todo/compose.dev.yaml +10 -3
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { ready } from "../helpers/hydration";
|
|
3
|
+
|
|
4
|
+
// admin storageState (project default)
|
|
5
|
+
|
|
6
|
+
test("settings: add and remove a social login provider @smoke", async ({ page }) => {
|
|
7
|
+
const dialog = page.getByRole("dialog");
|
|
8
|
+
const addBtn = dialog.getByRole("button", { name: "Add provider" });
|
|
9
|
+
const openConfigure = async (): Promise<void> => {
|
|
10
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
11
|
+
await page.getByRole("button", { name: "Configure" }).click();
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
await ready(page, "/admin/settings");
|
|
15
|
+
// The auth-config catalog can be momentarily unavailable right after another
|
|
16
|
+
// spec toggles auth config (the auth instance rebuilds). Reload until the
|
|
17
|
+
// catalog is loaded (Add provider becomes enabled).
|
|
18
|
+
await expect(async () => {
|
|
19
|
+
await page.reload();
|
|
20
|
+
await openConfigure();
|
|
21
|
+
await expect(addBtn).toBeEnabled({ timeout: 3000 });
|
|
22
|
+
}).toPass({ timeout: 25000 });
|
|
23
|
+
|
|
24
|
+
await addBtn.click();
|
|
25
|
+
const select = dialog.locator("#social-provider");
|
|
26
|
+
await expect(select.locator("option").first()).toBeAttached();
|
|
27
|
+
const providerId = await select.inputValue(); // first addable provider (not hard-coded)
|
|
28
|
+
try {
|
|
29
|
+
await dialog.locator("#social-id").fill("test-client-id");
|
|
30
|
+
await dialog.locator("#social-secret").fill("test-secret");
|
|
31
|
+
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
32
|
+
// Save returns to the list view, where the new provider row has an enable switch.
|
|
33
|
+
await expect(dialog.getByRole("switch").first()).toBeVisible();
|
|
34
|
+
} finally {
|
|
35
|
+
if (providerId) {
|
|
36
|
+
await page.request
|
|
37
|
+
.put("/api/account/auth-config", { data: { social: { [providerId]: { delete: true } } } })
|
|
38
|
+
.catch(() => undefined);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("settings: open and save the SMTP dialog @smoke", async ({ page }) => {
|
|
44
|
+
await ready(page, "/admin/settings");
|
|
45
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
46
|
+
await page.getByRole("button", { name: "SMTP settings" }).click();
|
|
47
|
+
const dialog = page.getByRole("dialog");
|
|
48
|
+
try {
|
|
49
|
+
// Point at the same Mailpit the env already uses, so saving can't re-route mail
|
|
50
|
+
// for the other email specs.
|
|
51
|
+
await dialog.locator("#smtp-host").fill("localhost");
|
|
52
|
+
await dialog.locator("#smtp-port").fill("1125");
|
|
53
|
+
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
54
|
+
await expect(page.getByText("Setting updated").first()).toBeVisible();
|
|
55
|
+
} finally {
|
|
56
|
+
// Reset SMTP so the config store falls back to environment for later specs.
|
|
57
|
+
await page.request
|
|
58
|
+
.put("/api/account/auth-config", { data: { smtp: { enabled: false, host: "", port: 587, user: "", from: "", secure: false } } })
|
|
59
|
+
.catch(() => undefined);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
@@ -73,3 +73,12 @@ test("admin can toggle a feature flag and it applies live @smoke", async ({ page
|
|
|
73
73
|
await toggle.click();
|
|
74
74
|
await expect(toggle).toBeChecked();
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
test("settings: the require-two-factor toggle is available @smoke", async ({ page }) => {
|
|
78
|
+
await ready(page, "/admin/settings");
|
|
79
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
80
|
+
// The policy toggle renders under Authentication; its enforcement is covered by
|
|
81
|
+
// the require-2fa api/ui specs. Not toggled here — turning it on would gate this
|
|
82
|
+
// admin session.
|
|
83
|
+
await expect(page.getByRole("switch", { name: "Require two-factor" })).toBeVisible();
|
|
84
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { anonState } from "../helpers/accounts";
|
|
3
|
+
import { totpCode } from "../helpers/totp";
|
|
4
|
+
|
|
5
|
+
test.use({ storageState: anonState });
|
|
6
|
+
|
|
7
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const origin = { origin: base };
|
|
9
|
+
|
|
10
|
+
// A disposable account (never the shared admin/user) so enabling 2FA here can't
|
|
11
|
+
// invalidate the seeded sessions the other auth specs rely on.
|
|
12
|
+
test("two-factor: sign in with a backup code from the login page @smoke", async ({ page, playwright }) => {
|
|
13
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
14
|
+
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
15
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
16
|
+
|
|
17
|
+
const email = `tf-ui-${Date.now()}@example.com`;
|
|
18
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
19
|
+
await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "UI" } });
|
|
20
|
+
const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
21
|
+
await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
22
|
+
const backupCode = (enable.backupCodes as string[])[0]!;
|
|
23
|
+
await api.dispose();
|
|
24
|
+
|
|
25
|
+
// Password sign-in hands off to the second-factor step (no session yet).
|
|
26
|
+
await page.goto(`/login?redirect=${encodeURIComponent("/admin/account")}`);
|
|
27
|
+
await page.getByLabel("Email").fill(email);
|
|
28
|
+
await page.getByLabel("Password").fill(pw);
|
|
29
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
30
|
+
|
|
31
|
+
const step = page.getByTestId("two-factor-step");
|
|
32
|
+
await expect(step).toBeVisible();
|
|
33
|
+
|
|
34
|
+
// Switch to the backup-code input.
|
|
35
|
+
await page.getByRole("button", { name: "Use a backup code instead" }).click();
|
|
36
|
+
|
|
37
|
+
// A wrong code surfaces a localized, mapped message — not better-auth's raw
|
|
38
|
+
// English string ("Invalid backup code").
|
|
39
|
+
await page.getByLabel("Backup code").fill("00000000");
|
|
40
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
41
|
+
await expect(page.getByRole("alert")).toContainText("isn't valid");
|
|
42
|
+
await expect(page.getByRole("alert")).not.toContainText("Invalid backup code");
|
|
43
|
+
|
|
44
|
+
// The real code completes the login.
|
|
45
|
+
await page.getByLabel("Backup code").fill(backupCode);
|
|
46
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
47
|
+
await expect(page).toHaveURL(/\/admin\/account/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("require-2fa: a new sign-up is forced through the enrolment page @smoke", async ({ page, playwright }) => {
|
|
51
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
52
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
53
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
54
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: "Podokit3e-Str0ng!pw" } });
|
|
55
|
+
|
|
56
|
+
const email = `rq-ui-${Date.now()}@example.com`;
|
|
57
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
58
|
+
try {
|
|
59
|
+
await admin.put("/api/account/settings", { data: { require2fa: true } });
|
|
60
|
+
// A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
|
|
61
|
+
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
62
|
+
await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
|
|
63
|
+
await expect(async () => {
|
|
64
|
+
expect((await probe.get("/api/account/me")).status()).toBe(403);
|
|
65
|
+
}).toPass({ timeout: 8000 });
|
|
66
|
+
await probe.dispose();
|
|
67
|
+
|
|
68
|
+
// Signing in through the UI lands on the mandatory enrolment page.
|
|
69
|
+
await page.goto("/login");
|
|
70
|
+
await page.getByLabel("Email").fill(email);
|
|
71
|
+
await page.getByLabel("Password").fill(pw);
|
|
72
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
73
|
+
await expect(page).toHaveURL(/\/setup-2fa/);
|
|
74
|
+
|
|
75
|
+
// Complete enrolment entirely through the UI: password → QR + backup codes →
|
|
76
|
+
// authenticator code → activate. The code is computed from the on-page URI.
|
|
77
|
+
await page.getByLabel("Confirm your password").fill(pw);
|
|
78
|
+
await page.getByRole("button", { name: "Continue" }).click();
|
|
79
|
+
await expect(page.getByTestId("backup-codes")).toBeVisible();
|
|
80
|
+
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
|
81
|
+
const uri = (await page.locator("code").first().innerText()).trim();
|
|
82
|
+
await page.getByRole("checkbox").click();
|
|
83
|
+
await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
|
|
84
|
+
await page.getByRole("button", { name: "Activate and continue" }).click();
|
|
85
|
+
|
|
86
|
+
// Enrolled → the gate lets them into the app (no longer bounced to /setup-2fa).
|
|
87
|
+
await expect(page).toHaveURL(/\/admin/);
|
|
88
|
+
} finally {
|
|
89
|
+
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
90
|
+
await expect(async () => {
|
|
91
|
+
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
92
|
+
await probe.post("/api/auth/sign-in/email", { data: { email: "user@example.com", password: "Podokit3e-Str0ng!pw" } });
|
|
93
|
+
const status = (await probe.get("/api/account/me")).status();
|
|
94
|
+
await probe.dispose();
|
|
95
|
+
expect(status).toBe(200);
|
|
96
|
+
}).toPass({ timeout: 8000 });
|
|
97
|
+
await admin.dispose();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("account: regenerate backup codes shows a fresh set @smoke", async ({ page, playwright }) => {
|
|
102
|
+
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
103
|
+
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
104
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
105
|
+
const email = `regen-ui-${Date.now()}@example.com`;
|
|
106
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
107
|
+
await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RG" } });
|
|
108
|
+
const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
109
|
+
await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
110
|
+
const backupCode = (enable.backupCodes as string[])[0]!;
|
|
111
|
+
await api.dispose();
|
|
112
|
+
|
|
113
|
+
// Sign in (backup-code path) straight to the account page.
|
|
114
|
+
await page.goto(`/login?redirect=${encodeURIComponent("/admin/account")}`);
|
|
115
|
+
await page.getByLabel("Email").fill(email);
|
|
116
|
+
await page.getByLabel("Password").fill(pw);
|
|
117
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
118
|
+
await page.getByRole("button", { name: "Use a backup code instead" }).click();
|
|
119
|
+
await page.getByLabel("Backup code").fill(backupCode);
|
|
120
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
121
|
+
await expect(page).toHaveURL(/\/admin\/account/);
|
|
122
|
+
|
|
123
|
+
// Security → regenerate backup codes → a fresh set is shown.
|
|
124
|
+
await page.getByRole("button", { name: "Security" }).click();
|
|
125
|
+
await page.locator("#tf-off-pw").fill(pw);
|
|
126
|
+
await page.getByRole("button", { name: "Regenerate backup codes" }).click();
|
|
127
|
+
await expect(page.getByTestId("backup-codes")).toBeVisible();
|
|
128
|
+
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
|
129
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
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("verify-email renders the resend form with the address prefilled @smoke", async ({ page }) => {
|
|
8
|
+
await ready(page, "/verify-email?email=someone%40example.com");
|
|
9
|
+
await expect(page.getByLabel("Email")).toHaveValue("someone@example.com");
|
|
10
|
+
await expect(page.getByRole("button", { name: "Resend verification email" })).toBeVisible();
|
|
11
|
+
await expect(page.getByRole("link", { name: /sign in/i })).toBeVisible();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("verify-email shows the link-error alert on a bad link", async ({ page }) => {
|
|
15
|
+
await ready(page, "/verify-email?error=invalid");
|
|
16
|
+
await expect(page.getByRole("alert")).toBeVisible();
|
|
17
|
+
});
|
|
@@ -29,6 +29,14 @@ export class AccountController {
|
|
|
29
29
|
return session.user;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// The "require two-factor" policy, for the web enrolment gate. Kept out of the
|
|
33
|
+
// typed Capabilities (that lives in a published package); allow-listed in
|
|
34
|
+
// TwoFactorRequiredGuard so a not-yet-enrolled user can still read it.
|
|
35
|
+
@Get("require-2fa")
|
|
36
|
+
requireTwoFactor(): { require2fa: boolean } {
|
|
37
|
+
return { require2fa: this.settings.getBool("require2fa") };
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
// Which optional auth features are enabled, so the UI can show/hide sections.
|
|
33
41
|
// Public so the login page (unauthenticated) can offer available sign-in methods.
|
|
34
42
|
@Public()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type CanActivate, type ExecutionContext, Injectable } from "@nestjs/common";
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
import { fromNodeHeaders } from "better-auth/node";
|
|
4
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
5
|
+
import { FLAG_DEFAULTS } from "../settings/flag-defaults";
|
|
6
|
+
import { pool } from "./db";
|
|
7
|
+
import { auth } from "./auth";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Enforces the "require two-factor" policy (admin Settings): while it is on, a
|
|
11
|
+
* signed-in user who has not enrolled in 2FA is blocked from every app route
|
|
12
|
+
* until they do — the security boundary behind the web enrolment gate (the web
|
|
13
|
+
* redirect is only UX).
|
|
14
|
+
*
|
|
15
|
+
* Registered after the better-auth AuthGuard. Requests with no user session
|
|
16
|
+
* (unauthenticated, or machine api-key/bearer calls) are not gated here — the
|
|
17
|
+
* auth guard owns authentication. A short allow-list keeps the endpoints needed
|
|
18
|
+
* to read the policy and enrol reachable; the 2FA enrol/verify endpoints live
|
|
19
|
+
* under /api/auth/two-factor (better-auth), never behind this guard.
|
|
20
|
+
*
|
|
21
|
+
* `require2fa` is read from app_setting through a small TTL cache (self-contained,
|
|
22
|
+
* so this global guard needs no DI), the same way auth/feature-gate.ts reads flags.
|
|
23
|
+
*/
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class TwoFactorRequiredGuard implements CanActivate {
|
|
26
|
+
// Reachable while gated: read the policy/capabilities, and — break-glass — the
|
|
27
|
+
// admin-only flag toggle, so an admin who turned the policy on (and isn't yet
|
|
28
|
+
// enrolled) can still turn it back off instead of being hard-locked.
|
|
29
|
+
private static readonly ALLOW = [
|
|
30
|
+
"/account/capabilities",
|
|
31
|
+
"/account/require-2fa",
|
|
32
|
+
"/account/settings",
|
|
33
|
+
"/health",
|
|
34
|
+
];
|
|
35
|
+
private static readonly TTL_MS = 3_000;
|
|
36
|
+
private cached = FLAG_DEFAULTS.require2fa;
|
|
37
|
+
private fetchedAt = 0;
|
|
38
|
+
|
|
39
|
+
private async enforced(): Promise<boolean> {
|
|
40
|
+
if (Date.now() - this.fetchedAt < TwoFactorRequiredGuard.TTL_MS) return this.cached;
|
|
41
|
+
try {
|
|
42
|
+
const res = await pool.query<{ value: string }>(
|
|
43
|
+
'SELECT "value" FROM "app_setting" WHERE "key" = $1',
|
|
44
|
+
["require2fa"],
|
|
45
|
+
);
|
|
46
|
+
this.cached = res.rows[0]?.value === "true";
|
|
47
|
+
} catch {
|
|
48
|
+
this.cached = FLAG_DEFAULTS.require2fa;
|
|
49
|
+
}
|
|
50
|
+
this.fetchedAt = Date.now();
|
|
51
|
+
return this.cached;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
55
|
+
if (context.getType() !== "http") return true;
|
|
56
|
+
const req = context.switchToHttp().getRequest<Request>();
|
|
57
|
+
const path = req.path || req.url || "";
|
|
58
|
+
if (TwoFactorRequiredGuard.ALLOW.some((p) => path === p || path.startsWith(`${p}/`))) return true;
|
|
59
|
+
if (!(await this.enforced())) return true;
|
|
60
|
+
|
|
61
|
+
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
|
62
|
+
const user = session?.user as { twoFactorEnabled?: boolean } | undefined;
|
|
63
|
+
if (!user) return true; // unauthenticated or non-session (machine) request
|
|
64
|
+
if (user.twoFactorEnabled) return true;
|
|
65
|
+
|
|
66
|
+
throw new AppException("TWO_FACTOR_REQUIRED", "Two-factor enrolment is required.", 403);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Must match the rows seeded by the InitAppSettings migration. phoneNumber is
|
|
8
8
|
* off by default because real delivery needs an SMS provider. */
|
|
9
|
-
export type FeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
|
|
9
|
+
export type FeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider" | "require2fa";
|
|
10
10
|
|
|
11
11
|
export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
|
|
12
12
|
twoFactor: true,
|
|
@@ -20,6 +20,10 @@ export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
|
|
|
20
20
|
organization: true,
|
|
21
21
|
// Acting as an OIDC identity provider is an enterprise opt-in; off by default.
|
|
22
22
|
oidcProvider: false,
|
|
23
|
+
// Policy (not a feature endpoint): when on, every signed-in user must enrol in
|
|
24
|
+
// two-factor before using the app. Enforced by TwoFactorRequiredGuard + the web
|
|
25
|
+
// enrolment gate — the feature gate ignores it (no endpoint prefix). Off by default.
|
|
26
|
+
require2fa: false,
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
export const FEATURE_FLAGS = Object.keys(FLAG_DEFAULTS) as FeatureFlag[];
|
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
"marker": "// podokit:end:imports",
|
|
68
68
|
"text": "import { AuthConfigModule } from \"./auth-config/auth-config.module\";"
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"file": "apps/api/src/app.module.ts",
|
|
72
|
+
"marker": "// podokit:end:imports",
|
|
73
|
+
"text": "import { TwoFactorRequiredGuard } from \"./auth/two-factor-required.guard\";"
|
|
74
|
+
},
|
|
70
75
|
{
|
|
71
76
|
"file": "apps/api/src/app.module.ts",
|
|
72
77
|
"marker": "// podokit:end:module-imports",
|
|
@@ -87,6 +92,11 @@
|
|
|
87
92
|
"marker": "// podokit:end:providers",
|
|
88
93
|
"text": "{ provide: APP_GUARD, useClass: AuthGuard },"
|
|
89
94
|
},
|
|
95
|
+
{
|
|
96
|
+
"file": "apps/api/src/app.module.ts",
|
|
97
|
+
"marker": "// podokit:end:providers",
|
|
98
|
+
"text": "{ provide: APP_GUARD, useClass: TwoFactorRequiredGuard },"
|
|
99
|
+
},
|
|
90
100
|
{
|
|
91
101
|
"file": "AGENTS.md",
|
|
92
102
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
@@ -67,6 +67,7 @@ in `.podokit/files.lock` — respect it:
|
|
|
67
67
|
Useful commands:
|
|
68
68
|
|
|
69
69
|
- `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
|
|
70
|
+
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
70
71
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
71
72
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
72
73
|
|
|
@@ -3,8 +3,14 @@ import tailwindcss from "@tailwindcss/vite";
|
|
|
3
3
|
import { defineConfig } from "vite";
|
|
4
4
|
|
|
5
5
|
// Inside the dev container (compose.dev.yaml sets VITE_DOCKER=1): bind to all
|
|
6
|
-
// interfaces, allow the Traefik hostname, and route HMR
|
|
6
|
+
// interfaces, allow the Traefik hostname, and route HMR's WebSocket back through
|
|
7
|
+
// Traefik. The browser reaches the app at Traefik's published host port, so the
|
|
8
|
+
// HMR client must target that same port — otherwise the ws connects to :80,
|
|
9
|
+
// fails, and Vite silently falls back to a full page reload on every change
|
|
10
|
+
// (which wipes in-progress form input). compose.dev.yaml injects
|
|
11
|
+
// VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
|
|
7
12
|
const inDocker = process.env.VITE_DOCKER === "1";
|
|
13
|
+
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
|
|
8
14
|
|
|
9
15
|
export default defineConfig({
|
|
10
16
|
plugins: [tailwindcss(), sveltekit()],
|
|
@@ -13,7 +19,7 @@ export default defineConfig({
|
|
|
13
19
|
host: true,
|
|
14
20
|
port: 5001,
|
|
15
21
|
allowedHosts: true,
|
|
16
|
-
hmr: { clientPort:
|
|
22
|
+
hmr: { clientPort: hmrClientPort },
|
|
17
23
|
}
|
|
18
24
|
: undefined,
|
|
19
25
|
});
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
# docker compose -f compose.dev.yaml --profile cache \
|
|
5
5
|
# --profile storage --profile queue watch # with modules
|
|
6
6
|
#
|
|
7
|
-
# Only Traefik binds a host port (80). The app is at http://app.localhost.
|
|
7
|
+
# Only Traefik binds a host port (default 80). The app is at http://app.localhost.
|
|
8
|
+
# Set TRAEFIK_PORT to publish on another port (e.g. TRAEFIK_PORT=8001) so several
|
|
9
|
+
# containerized stacks can run at once without colliding — the web container reads
|
|
10
|
+
# the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
|
|
8
11
|
# api/postgres/redis/minio have NO published ports — they talk by service name
|
|
9
12
|
# on the internal network, so this stack never collides with other projects.
|
|
10
13
|
#
|
|
@@ -29,8 +32,8 @@ services:
|
|
|
29
32
|
- --api.dashboard=true
|
|
30
33
|
- --api.insecure=true
|
|
31
34
|
ports:
|
|
32
|
-
- "80:80"
|
|
33
|
-
- "127.0.0.1
|
|
35
|
+
- "${TRAEFIK_PORT:-80}:80"
|
|
36
|
+
- "127.0.0.1:${TRAEFIK_DASHBOARD_PORT:-8080}:8080" # Traefik dashboard (dev only)
|
|
34
37
|
volumes:
|
|
35
38
|
- ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
|
36
39
|
networks: [podokit]
|
|
@@ -45,6 +48,10 @@ services:
|
|
|
45
48
|
environment:
|
|
46
49
|
- VITE_DOCKER=1
|
|
47
50
|
- BACKEND_INTERNAL_URL=http://api:5002
|
|
51
|
+
# Match Traefik's published port so HMR's WebSocket targets the right port
|
|
52
|
+
# (default 80). Without this, HMR connects to :80 and a non-80 stack falls
|
|
53
|
+
# back to a full page reload on every edit.
|
|
54
|
+
- VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
|
|
48
55
|
depends_on: [api]
|
|
49
56
|
networks: [podokit]
|
|
50
57
|
develop:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|