@podosoft/podokit 0.12.3 → 0.13.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 +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +109 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +73 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +6 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +32 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -0
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
- package/dist/templates/modules/auth/module.manifest.json +5 -4
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/apps/api/package.json +1 -1
- package/dist/templates/todo/apps/web/package.json +1 -1
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@ test("account opens on the profile section @smoke", async ({ page }) => {
|
|
|
14
14
|
await expect(page.getByRole("button", { name: "Save changes" })).toBeEnabled();
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test("account exposes a change-email action when the address is edited
|
|
17
|
+
test("account exposes a change-email action when the address is edited", async ({ page }) => {
|
|
18
18
|
await ready(page, "/admin/account");
|
|
19
19
|
const email = page.getByLabel("Email");
|
|
20
20
|
await expect(email).toHaveValue("admin@example.com");
|
|
@@ -27,14 +27,14 @@ test("account exposes a change-email action when the address is edited @smoke",
|
|
|
27
27
|
await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
test("account shows a username field when enabled
|
|
30
|
+
test("account shows a username field when enabled", async ({ page }) => {
|
|
31
31
|
await ready(page, "/admin/account");
|
|
32
32
|
const username = page.getByLabel("Username", { exact: true });
|
|
33
33
|
test.skip((await username.count()) === 0, "username not enabled");
|
|
34
34
|
await expect(username).toBeVisible();
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
test("account can create and revoke an API key when enabled
|
|
37
|
+
test("account can create and revoke an API key when enabled", async ({ page }) => {
|
|
38
38
|
await ready(page, "/admin/account");
|
|
39
39
|
const nav = page.getByRole("button", { name: "API keys" });
|
|
40
40
|
test.skip((await nav.count()) === 0, "api keys not enabled");
|
|
@@ -52,7 +52,7 @@ test("account can create and revoke an API key when enabled @smoke", async ({ pa
|
|
|
52
52
|
await expect(page.getByText("Key revoked")).toBeVisible();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("account can register and remove a passkey when enabled
|
|
55
|
+
test("account can register and remove a passkey when enabled", async ({ page }) => {
|
|
56
56
|
await ready(page, "/admin/account");
|
|
57
57
|
await page.getByRole("button", { name: "Security" }).click();
|
|
58
58
|
const addBtn = page.getByRole("button", { name: "Add passkey" });
|
|
@@ -74,7 +74,7 @@ test("account can register and remove a passkey when enabled @smoke", async ({ p
|
|
|
74
74
|
await expect(page.getByText("Passkey removed")).toBeVisible();
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test("account shows a phone field when enabled
|
|
77
|
+
test("account shows a phone field when enabled", async ({ page }) => {
|
|
78
78
|
await ready(page, "/admin/account");
|
|
79
79
|
const phone = page.getByLabel("Phone number", { exact: true });
|
|
80
80
|
test.skip((await phone.count()) === 0, "phone number not enabled");
|
|
@@ -111,7 +111,7 @@ test("account nav shows the core sections", async ({ page }) => {
|
|
|
111
111
|
await expect(page.getByRole("button", { name: "Sessions" })).toBeVisible();
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test("two-factor setup shows a scannable QR code when enabled
|
|
114
|
+
test("two-factor setup shows a scannable QR code when enabled", async ({ page }) => {
|
|
115
115
|
await ready(page, "/admin/account");
|
|
116
116
|
await page.getByRole("button", { name: "Security" }).click();
|
|
117
117
|
const heading = page.getByText("Two-factor authentication");
|
|
@@ -12,7 +12,7 @@ async function openAppearance(page: import("@playwright/test").Page): Promise<vo
|
|
|
12
12
|
}).toPass();
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
test("appearance: has its own tab with theme controls
|
|
15
|
+
test("appearance: has its own tab with theme controls", async ({ page }) => {
|
|
16
16
|
await openAppearance(page);
|
|
17
17
|
await expect(page.getByRole("heading", { name: "Featured themes" })).toBeVisible();
|
|
18
18
|
await expect(page.getByRole("button", { name: "Default", exact: true })).toBeVisible();
|
|
@@ -10,7 +10,7 @@ test("overview shows the signed-in admin @smoke", async ({ page }) => {
|
|
|
10
10
|
await expect(page.getByRole("main").getByText(ADMIN.email)).toBeVisible();
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
test("sidebar navigates to sessions and account", async ({ page }) => {
|
|
13
|
+
test("sidebar navigates to sessions and account @smoke", async ({ page }) => {
|
|
14
14
|
await ready(page, "/admin");
|
|
15
15
|
await page.getByRole("link", { name: "Sessions" }).click();
|
|
16
16
|
await expect(page).toHaveURL(/\/admin\/sessions/);
|
|
@@ -19,7 +19,7 @@ test("sidebar navigates to sessions and account", async ({ page }) => {
|
|
|
19
19
|
await expect(page.getByRole("link", { name: "Account" })).toHaveCount(0);
|
|
20
20
|
await page.getByRole("button", { name: /admin@example.com/ }).click();
|
|
21
21
|
await page.getByRole("menuitem", { name: "Account" }).click();
|
|
22
|
-
await expect(page).toHaveURL(
|
|
22
|
+
await expect(page).toHaveURL((url) => url.pathname === "/account");
|
|
23
23
|
await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
|
|
24
24
|
await expect(page.getByLabel("Name", { exact: true })).toBeVisible(); // profile section (exact: "Username" also contains "Name")
|
|
25
25
|
});
|
|
@@ -3,7 +3,7 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
// Sorting via the shared DataTable. Users is server-sorted (manual), audit is
|
|
5
5
|
// client-sorted — both drive the same clickable headers + aria-sort.
|
|
6
|
-
test("users table sorts by a column header (server)
|
|
6
|
+
test("users table sorts by a column header (server)", async ({ page }) => {
|
|
7
7
|
await ready(page, "/admin/users");
|
|
8
8
|
const header = page.getByRole("columnheader", { name: /Name/ });
|
|
9
9
|
// Click the sort button *inside* the header — a bare name match collides with
|
|
@@ -9,7 +9,7 @@ const origin = { origin: base };
|
|
|
9
9
|
// The reset flow is for signed-out users — run without the seeded session.
|
|
10
10
|
test.use({ storageState: anonState });
|
|
11
11
|
|
|
12
|
-
test("forgot password: request, open the emailed link, set a new password, sign in
|
|
12
|
+
test("forgot password: request, open the emailed link, set a new password, sign in", async ({ page, playwright }) => {
|
|
13
13
|
test.skip(!(await mailpitReachable()), "Mailpit not available");
|
|
14
14
|
const email = `ui-reset-${Date.now()}@example.com`;
|
|
15
15
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -43,7 +43,7 @@ test("general: empty locale displays the application default", async ({ page })
|
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
test("general: footer text and links render on public pages
|
|
46
|
+
test("general: footer text and links render on public pages", async ({ page, browser }) => {
|
|
47
47
|
await ready(page, "/admin/settings");
|
|
48
48
|
const origin = new URL(page.url()).origin;
|
|
49
49
|
const footer = page.getByLabel("Footer text");
|
|
@@ -78,7 +78,7 @@ test("general: footer text and links render on public pages @smoke", async ({ pa
|
|
|
78
78
|
}
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
test("general: maintenance mode holds non-admins on the maintenance page
|
|
81
|
+
test("general: maintenance mode holds non-admins on the maintenance page", async ({ page, browser }) => {
|
|
82
82
|
await ready(page, "/admin/settings");
|
|
83
83
|
const origin = new URL(page.url()).origin;
|
|
84
84
|
await page.getByRole("switch", { name: "Maintenance mode" }).click();
|
|
@@ -102,7 +102,7 @@ test("general: maintenance mode holds non-admins on the maintenance page @smoke"
|
|
|
102
102
|
}
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
test("general: closing sign-up redirects /signup and hides the link
|
|
105
|
+
test("general: closing sign-up redirects /signup and hides the link", async ({ page, browser }) => {
|
|
106
106
|
await ready(page, "/admin/settings");
|
|
107
107
|
const origin = new URL(page.url()).origin;
|
|
108
108
|
await page.getByRole("switch", { name: "Allow public sign-up" }).click();
|
|
@@ -10,7 +10,7 @@ test.describe("unauthenticated", () => {
|
|
|
10
10
|
}
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
test("private and authentication routes prevent search indexing
|
|
13
|
+
test("private and authentication routes prevent search indexing", async ({ request }) => {
|
|
14
14
|
for (const route of ["/admin", "/account", "/login", "/api/account/me"]) {
|
|
15
15
|
const response = await request.get(route, { maxRedirects: 0 });
|
|
16
16
|
expect(response.headers()["x-robots-tag"]).toBe("noindex, nofollow");
|
|
@@ -32,7 +32,7 @@ test.describe("normal user (non-admin)", () => {
|
|
|
32
32
|
expect((await page.goto("/admin/settings"))?.status()).toBe(403);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
test("account is available without the admin shell
|
|
35
|
+
test("account is available without the admin shell", async ({ page }) => {
|
|
36
36
|
await page.goto("/account");
|
|
37
37
|
await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
|
|
38
38
|
await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
|
|
@@ -4,7 +4,7 @@ import { USER } from "../helpers/accounts";
|
|
|
4
4
|
|
|
5
5
|
// admin storageState (project default)
|
|
6
6
|
|
|
7
|
-
test("admin can impersonate a user and stop
|
|
7
|
+
test("admin can impersonate a user and stop", async ({ page }) => {
|
|
8
8
|
await ready(page, "/admin/users");
|
|
9
9
|
await page.locator("#toolbar-search").fill(USER.email);
|
|
10
10
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
@@ -8,7 +8,7 @@ test.use({ storageState: anonState });
|
|
|
8
8
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
9
9
|
const origin = { origin: base };
|
|
10
10
|
|
|
11
|
-
test("login: magic link shows a sent confirmation
|
|
11
|
+
test("login: magic link shows a sent confirmation", async ({ page, playwright }) => {
|
|
12
12
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
13
13
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
14
14
|
await api.dispose();
|
|
@@ -20,7 +20,7 @@ test("login: magic link shows a sent confirmation @smoke", async ({ page, playwr
|
|
|
20
20
|
await expect(page.getByTestId("magic-link-sent")).toBeVisible();
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("login: an emailed one-time code signs a user in
|
|
23
|
+
test("login: an emailed one-time code signs a user in", async ({ page, playwright }) => {
|
|
24
24
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
25
25
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
26
26
|
test.skip(!caps?.emailOtp, "email otp disabled");
|
|
@@ -10,7 +10,7 @@ const REDIRECT = "http://localhost:9999/callback";
|
|
|
10
10
|
// (with PKCE) that validates the client and hands off to the consent page. The
|
|
11
11
|
// back-channel token exchange is driven by an OIDC client SDK / real RP — see
|
|
12
12
|
// docs/modules.md for the manual round-trip checklist.
|
|
13
|
-
test("OIDC provider: discovery, JWKS, client registration, authorize handoff
|
|
13
|
+
test("OIDC provider: discovery, JWKS, client registration, authorize handoff", async ({ page, baseURL }) => {
|
|
14
14
|
const headers = { origin: baseURL ?? "" };
|
|
15
15
|
await page.request.put("/api/account/settings", { data: { oidcProvider: true }, headers });
|
|
16
16
|
await expect
|
|
@@ -19,7 +19,7 @@ test("admin can create an organization @smoke", async ({ page }) => {
|
|
|
19
19
|
await expect(page.getByText("Organization deleted").first()).toBeVisible();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test("create an org with a non-ascii name, a parent, and a manager
|
|
22
|
+
test("create an org with a non-ascii name, a parent, and a manager", async ({ page }) => {
|
|
23
23
|
await ready(page, "/admin/organizations");
|
|
24
24
|
const newBtn = page.getByRole("button", { name: "New organization" });
|
|
25
25
|
test.skip((await newBtn.count()) === 0, "organizations not enabled");
|
|
@@ -54,7 +54,7 @@ test("create an org with a non-ascii name, a parent, and a manager @smoke", asyn
|
|
|
54
54
|
await expect(page.getByText("Organization deleted").first()).toBeVisible();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("organization manage: remove a member, invite one, and cancel the invite
|
|
57
|
+
test("organization manage: remove a member, invite one, and cancel the invite", async ({ page }) => {
|
|
58
58
|
await ready(page, "/admin/organizations");
|
|
59
59
|
const newBtn = page.getByRole("button", { name: "New organization" });
|
|
60
60
|
test.skip((await newBtn.count()) === 0, "organizations not enabled");
|
|
@@ -4,7 +4,7 @@ import { ready } from "../helpers/hydration";
|
|
|
4
4
|
// Full passkey round trip on a throwaway account — never touches the shared admin.
|
|
5
5
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
6
6
|
|
|
7
|
-
test("register a passkey and sign in with it
|
|
7
|
+
test("register a passkey and sign in with it", async ({ page, baseURL }) => {
|
|
8
8
|
const origin = { origin: baseURL ?? "" };
|
|
9
9
|
// A virtual authenticator answers both the register (create) and sign-in (get)
|
|
10
10
|
// WebAuthn ceremonies without hardware.
|
|
@@ -2,12 +2,12 @@ import { expect, test } from "@playwright/test";
|
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
// admin storageState (project default) — sessions is an admin-only view
|
|
5
|
-
test("sessions table shows the expires column
|
|
5
|
+
test("sessions table shows the expires column", async ({ page }) => {
|
|
6
6
|
await ready(page, "/admin/sessions");
|
|
7
7
|
await expect(page.getByRole("columnheader", { name: "Expires" })).toBeVisible();
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
test("admin sees user sessions across users
|
|
10
|
+
test("admin sees user sessions across users", async ({ page }) => {
|
|
11
11
|
await ready(page, "/admin/sessions");
|
|
12
12
|
await expect(page.getByRole("heading", { name: "User sessions" })).toBeVisible();
|
|
13
13
|
// Search by exact address — robust to pagination as sessions accumulate (the
|
|
@@ -3,7 +3,7 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
// admin storageState (project default)
|
|
5
5
|
|
|
6
|
-
test("settings: add and remove a social login provider
|
|
6
|
+
test("settings: add and remove a social login provider", async ({ page }) => {
|
|
7
7
|
const dialog = page.getByRole("dialog");
|
|
8
8
|
const addBtn = dialog.getByRole("button", { name: "Add provider" });
|
|
9
9
|
const openConfigure = async (): Promise<void> => {
|
|
@@ -47,7 +47,7 @@ test("settings: add and remove a social login provider @smoke", async ({ page })
|
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test("settings: open and save the SMTP dialog
|
|
50
|
+
test("settings: open and save the SMTP dialog", async ({ page }) => {
|
|
51
51
|
await ready(page, "/admin/settings");
|
|
52
52
|
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
53
53
|
await page.getByRole("button", { name: "SMTP settings" }).click();
|
|
@@ -66,3 +66,33 @@ test("settings: open and save the SMTP dialog @smoke", async ({ page }) => {
|
|
|
66
66
|
.catch(() => undefined);
|
|
67
67
|
}
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
test("settings: configure automatic logout", async ({ page }) => {
|
|
71
|
+
await page.request.put("/api/account/auth-config", {
|
|
72
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
73
|
+
});
|
|
74
|
+
await ready(page, "/admin/settings");
|
|
75
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
76
|
+
|
|
77
|
+
const toggle = page.getByRole("switch", { name: "Automatic logout" });
|
|
78
|
+
await expect(toggle).not.toBeChecked();
|
|
79
|
+
await toggle.click();
|
|
80
|
+
await expect(toggle).toBeChecked();
|
|
81
|
+
|
|
82
|
+
const card = page
|
|
83
|
+
.getByText("Automatic logout", { exact: true })
|
|
84
|
+
.locator('xpath=ancestor::div[@data-slot="card"]');
|
|
85
|
+
await card.getByRole("button", { name: "Configure" }).click();
|
|
86
|
+
const dialog = page.getByRole("dialog");
|
|
87
|
+
await dialog.getByLabel("Idle time (minutes)").fill("45");
|
|
88
|
+
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
89
|
+
await expect(page.getByText("45 min", { exact: true })).toBeVisible();
|
|
90
|
+
|
|
91
|
+
const saved = await (await page.request.get("/api/account/auth-config")).json();
|
|
92
|
+
expect(saved.server.sessionIdleTimeoutMinutes).toBe(45);
|
|
93
|
+
const caps = await (await page.request.get("/api/account/capabilities")).json();
|
|
94
|
+
expect(caps.sessionIdleTimeoutMinutes).toBe(45);
|
|
95
|
+
|
|
96
|
+
await toggle.click();
|
|
97
|
+
await expect(toggle).not.toBeChecked();
|
|
98
|
+
});
|
|
@@ -3,6 +3,8 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
// admin storageState (project default)
|
|
5
5
|
|
|
6
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
7
|
+
|
|
6
8
|
async function saveGeneral(page: Page): Promise<void> {
|
|
7
9
|
const saved = page.waitForResponse(
|
|
8
10
|
(response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
|
|
@@ -12,7 +14,7 @@ async function saveGeneral(page: Page): Promise<void> {
|
|
|
12
14
|
await expect(page.getByText("General settings saved.").last()).toBeVisible();
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
test("settings has General and Authentication tabs
|
|
17
|
+
test("settings has General and Authentication tabs", async ({ page }) => {
|
|
16
18
|
await ready(page, "/admin/settings");
|
|
17
19
|
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
|
|
18
20
|
await expect(page.getByRole("tab", { name: "General" })).toBeVisible();
|
|
@@ -21,13 +23,13 @@ test("settings has General and Authentication tabs @smoke", async ({ page }) =>
|
|
|
21
23
|
await expect(page.getByLabel("Site name")).toBeVisible();
|
|
22
24
|
});
|
|
23
25
|
|
|
24
|
-
test("general settings: shows the favicon uploader
|
|
26
|
+
test("general settings: shows the favicon uploader", async ({ page }) => {
|
|
25
27
|
await ready(page, "/admin/settings");
|
|
26
28
|
await expect(page.getByText("Browser icon (favicon)")).toBeVisible();
|
|
27
29
|
await expect(page.locator('input[type="file"]')).toBeVisible();
|
|
28
30
|
});
|
|
29
31
|
|
|
30
|
-
test("general settings: edit the site name, save, and apply live
|
|
32
|
+
test("general settings: edit the site name, save, and apply live", async ({ page }) => {
|
|
31
33
|
await ready(page, "/admin/settings");
|
|
32
34
|
const name = page.getByLabel("Site name");
|
|
33
35
|
const original = await name.inputValue();
|
|
@@ -40,7 +42,7 @@ test("general settings: edit the site name, save, and apply live @smoke", async
|
|
|
40
42
|
await saveGeneral(page);
|
|
41
43
|
});
|
|
42
44
|
|
|
43
|
-
test("settings lists the auth features under the Authentication tab
|
|
45
|
+
test("settings lists the auth features under the Authentication tab", async ({ page }) => {
|
|
44
46
|
await ready(page, "/admin/settings");
|
|
45
47
|
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
46
48
|
await expect(page.getByText("Email & password")).toBeVisible();
|
|
@@ -51,7 +53,7 @@ test("settings lists the auth features under the Authentication tab @smoke", asy
|
|
|
51
53
|
await expect(page.getByText("Enabled", { exact: true }).first()).toBeVisible();
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
test("sidebar: back-to-home link returns to the landing page
|
|
56
|
+
test("sidebar: back-to-home link returns to the landing page", async ({ page }) => {
|
|
55
57
|
await ready(page, "/admin/settings");
|
|
56
58
|
// the home link lives in the sidebar footer, above the user menu
|
|
57
59
|
await page.getByRole("link", { name: "Back to home" }).click();
|
|
@@ -60,7 +62,7 @@ test("sidebar: back-to-home link returns to the landing page @smoke", async ({ p
|
|
|
60
62
|
await expect(page.locator("main").first()).toBeVisible();
|
|
61
63
|
});
|
|
62
64
|
|
|
63
|
-
test("settings is reachable from the sidebar
|
|
65
|
+
test("settings is reachable from the sidebar", async ({ page }) => {
|
|
64
66
|
await ready(page, "/admin");
|
|
65
67
|
await page.getByRole("link", { name: "Settings" }).click();
|
|
66
68
|
await expect(page).toHaveURL(/\/admin\/settings/);
|
|
@@ -81,7 +83,7 @@ test("admin can toggle a feature flag and it applies live @smoke", async ({ page
|
|
|
81
83
|
await expect(toggle).toBeChecked();
|
|
82
84
|
});
|
|
83
85
|
|
|
84
|
-
test("settings: the require-two-factor toggle is available
|
|
86
|
+
test("settings: the require-two-factor toggle is available", async ({ page }) => {
|
|
85
87
|
await ready(page, "/admin/settings");
|
|
86
88
|
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
87
89
|
// The policy toggle renders under Authentication; its enforcement is covered by
|
|
@@ -89,4 +91,72 @@ test("settings: the require-two-factor toggle is available @smoke", async ({ pag
|
|
|
89
91
|
// admin session.
|
|
90
92
|
await expect(page.getByRole("switch", { name: "Require two-factor" })).toBeVisible();
|
|
91
93
|
await expect(page.getByRole("switch", { name: "Sign-up approval" })).toBeVisible();
|
|
94
|
+
await expect(page.getByRole("switch", { name: "Automatic logout" })).toBeVisible();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("inactive browser sessions are signed out automatically @smoke", async ({ browser, page: adminPage }) => {
|
|
98
|
+
// Idle sign-out revokes the active server session. Use a dedicated login so
|
|
99
|
+
// this test cannot invalidate the shared admin storageState used by later specs.
|
|
100
|
+
const context = await browser.newContext({ baseURL: base });
|
|
101
|
+
const page = await context.newPage();
|
|
102
|
+
const email = `idle-admin-${Date.now()}@example.com`;
|
|
103
|
+
let signOutRequests = 0;
|
|
104
|
+
let userId = "";
|
|
105
|
+
try {
|
|
106
|
+
const created = await adminPage.request.post("/api/auth/admin/create-user", {
|
|
107
|
+
headers: { origin: base },
|
|
108
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
|
|
109
|
+
});
|
|
110
|
+
expect(created.ok()).toBeTruthy();
|
|
111
|
+
userId = ((await created.json()).user?.id ?? "") as string;
|
|
112
|
+
expect(userId).toBeTruthy();
|
|
113
|
+
|
|
114
|
+
const signIn = await context.request.post("/api/auth/sign-in/email", {
|
|
115
|
+
headers: { origin: base },
|
|
116
|
+
data: { email, password: "Podokit3e-Str0ng!pw" },
|
|
117
|
+
});
|
|
118
|
+
expect(signIn.ok()).toBeTruthy();
|
|
119
|
+
|
|
120
|
+
const configured = await context.request.put("/api/account/auth-config", {
|
|
121
|
+
data: { server: { sessionIdleTimeoutMinutes: 5 } },
|
|
122
|
+
});
|
|
123
|
+
expect(configured.ok()).toBeTruthy();
|
|
124
|
+
await page.route("**/api/auth/sign-out", async (route) => {
|
|
125
|
+
signOutRequests += 1;
|
|
126
|
+
await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
|
|
127
|
+
});
|
|
128
|
+
await page.clock.install();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await ready(page, "/admin");
|
|
132
|
+
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
|
133
|
+
} finally {
|
|
134
|
+
// Keep the loaded page policy at five minutes while restoring the shared
|
|
135
|
+
// DB policy before the isolated session is revoked.
|
|
136
|
+
const restored = await context.request.put("/api/account/auth-config", {
|
|
137
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
138
|
+
});
|
|
139
|
+
expect(restored.ok()).toBeTruthy();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const removed = await adminPage.request.post("/api/auth/admin/remove-user", {
|
|
143
|
+
headers: { origin: base },
|
|
144
|
+
data: { userId },
|
|
145
|
+
});
|
|
146
|
+
expect(removed.ok()).toBeTruthy();
|
|
147
|
+
userId = "";
|
|
148
|
+
|
|
149
|
+
await page.clock.fastForward(5 * 60_000 + 1_000);
|
|
150
|
+
await expect.poll(() => signOutRequests).toBe(1);
|
|
151
|
+
await expect(page).toHaveURL(/\/login\?reason=idle$/);
|
|
152
|
+
await expect(page.getByTestId("session-timeout-message")).toBeVisible();
|
|
153
|
+
} finally {
|
|
154
|
+
if (userId) {
|
|
155
|
+
await adminPage.request.post("/api/auth/admin/remove-user", {
|
|
156
|
+
headers: { origin: base },
|
|
157
|
+
data: { userId },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
await context.close();
|
|
161
|
+
}
|
|
92
162
|
});
|
|
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
|
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
// admin storageState (project default)
|
|
5
|
-
test("collapsing the sidebar hides the brand instead of overflowing
|
|
5
|
+
test("collapsing the sidebar hides the brand instead of overflowing", async ({ page }) => {
|
|
6
6
|
await ready(page, "/admin");
|
|
7
7
|
const brand = page.getByTestId("sidebar-brand");
|
|
8
8
|
const toggle = page.getByRole("button", { name: "Toggle Sidebar" });
|
|
@@ -3,7 +3,7 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
// The shared toolbar: filters + search both commit on the Search button; the
|
|
5
5
|
// search field itself is selectable (Email, Name, ...).
|
|
6
|
-
test("users toolbar searches only when Search is pressed
|
|
6
|
+
test("users toolbar searches only when Search is pressed", async ({ page }) => {
|
|
7
7
|
await ready(page, "/admin/users");
|
|
8
8
|
const main = page.getByRole("main");
|
|
9
9
|
await page.locator("#toolbar-search").fill("admin@example.com");
|
|
@@ -17,7 +17,7 @@ async function openHydratedLogin(page: Page, redirect?: string): Promise<void> {
|
|
|
17
17
|
|
|
18
18
|
// A disposable account (never the shared admin/user) so enabling 2FA here can't
|
|
19
19
|
// invalidate the seeded sessions the other auth specs rely on.
|
|
20
|
-
test("two-factor: sign in with a backup code from the login page
|
|
20
|
+
test("two-factor: sign in with a backup code from the login page", async ({ page, playwright }) => {
|
|
21
21
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
22
22
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
23
23
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -55,7 +55,7 @@ test("two-factor: sign in with a backup code from the login page @smoke", async
|
|
|
55
55
|
await expect(page).toHaveURL(/\/admin\/account/);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
test("require-2fa: a new sign-up is forced through the enrolment page
|
|
58
|
+
test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
|
|
59
59
|
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
60
60
|
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
61
61
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -106,7 +106,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page @smoke", a
|
|
|
106
106
|
}
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
test("account: regenerate backup codes shows a fresh set
|
|
109
|
+
test("account: regenerate backup codes shows a fresh set", async ({ page, playwright }) => {
|
|
110
110
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
111
111
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
112
112
|
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
@@ -51,7 +51,7 @@ test("create rejects mismatched passwords", async ({ page }) => {
|
|
|
51
51
|
await expect(dialog.getByText("Passwords do not match")).toBeVisible();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
test("manage: edit profile name
|
|
54
|
+
test("manage: edit profile name", async ({ page }) => {
|
|
55
55
|
await ready(page, "/admin/users");
|
|
56
56
|
const email = `ui-edit-${Date.now()}@example.com`;
|
|
57
57
|
await seedUser(page, email);
|
|
@@ -3,7 +3,7 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
|
|
6
|
-
test("admin can create a user with a custom role
|
|
6
|
+
test("admin can create a user with a custom role", async ({ page }) => {
|
|
7
7
|
await ready(page, "/admin/users");
|
|
8
8
|
await page.getByRole("button", { name: "Add user" }).click();
|
|
9
9
|
const role = page.locator("#c-role");
|
|
@@ -24,7 +24,7 @@ test("admin can create a user with a custom role @smoke", async ({ page }) => {
|
|
|
24
24
|
await expect(page.getByRole("cell", { name: email })).toBeVisible();
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("admin sees the user list and can search
|
|
27
|
+
test("admin sees the user list and can search", async ({ page }) => {
|
|
28
28
|
await ready(page, "/admin/users");
|
|
29
29
|
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
|
30
30
|
// Search by exact address — robust to pagination as the user count grows.
|
|
@@ -37,7 +37,7 @@ test("admin sees the user list and can search @smoke", async ({ page }) => {
|
|
|
37
37
|
await expect(page.getByRole("cell", { name: "admin@example.com" })).toHaveCount(0);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
test("user list shows the joined column
|
|
40
|
+
test("user list shows the joined column", async ({ page }) => {
|
|
41
41
|
await ready(page, "/admin/users");
|
|
42
42
|
await expect(page.getByRole("columnheader", { name: "Joined" })).toBeVisible();
|
|
43
43
|
});
|
|
@@ -52,7 +52,7 @@ test("row menu exposes admin actions", async ({ page }) => {
|
|
|
52
52
|
await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("admin can approve a pending registration from the user list
|
|
55
|
+
test("admin can approve a pending registration from the user list", async ({ page }) => {
|
|
56
56
|
const email = `pending-ui-${Date.now()}@example.com`;
|
|
57
57
|
const created = await page.request.post("/api/auth/admin/create-user", {
|
|
58
58
|
headers: { origin: base },
|
|
@@ -4,7 +4,7 @@ import { ready } from "../helpers/hydration";
|
|
|
4
4
|
|
|
5
5
|
test.use({ storageState: anonState });
|
|
6
6
|
|
|
7
|
-
test("verify-email renders the resend form with the address prefilled
|
|
7
|
+
test("verify-email renders the resend form with the address prefilled", async ({ page }) => {
|
|
8
8
|
await ready(page, "/verify-email?email=someone%40example.com");
|
|
9
9
|
await expect(page.getByLabel("Email")).toHaveValue("someone@example.com");
|
|
10
10
|
await expect(page.getByRole("button", { name: "Resend verification email" })).toBeVisible();
|
|
@@ -17,6 +17,19 @@
|
|
|
17
17
|
"apps/web/src/lib/i18n/locales/**",
|
|
18
18
|
"apps/web/src/lib/i18n/catalogs/app/**"
|
|
19
19
|
],
|
|
20
|
+
"managedOverrides": [
|
|
21
|
+
"apps/web/src/routes/(app)/+layout.server.ts",
|
|
22
|
+
"apps/web/src/routes/(app)/admin/account/+page.server.ts",
|
|
23
|
+
"apps/web/src/routes/(app)/admin/audit/+page.server.ts",
|
|
24
|
+
"apps/web/src/routes/(app)/admin/organizations/+page.server.ts",
|
|
25
|
+
"apps/web/src/routes/(app)/admin/sessions/+page.server.ts",
|
|
26
|
+
"apps/web/src/routes/(app)/admin/settings/+page.server.ts",
|
|
27
|
+
"apps/web/src/routes/(app)/admin/users/+page.server.ts",
|
|
28
|
+
"apps/web/src/routes/(auth)/login/+page.server.ts",
|
|
29
|
+
"apps/web/src/routes/(auth)/signup/+page.server.ts",
|
|
30
|
+
"apps/web/src/routes/account/+page.server.ts",
|
|
31
|
+
"apps/web/src/routes/setup-2fa/+page.server.ts"
|
|
32
|
+
],
|
|
20
33
|
"packageOverlays": {
|
|
21
34
|
"api": {
|
|
22
35
|
"scripts": {
|
|
@@ -6,13 +6,9 @@ import { ApiTags } from "@nestjs/swagger";
|
|
|
6
6
|
import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
|
|
7
7
|
import { ROLE_NAMES } from "../auth/permissions";
|
|
8
8
|
import { auth } from "../auth/auth";
|
|
9
|
-
import {
|
|
10
|
-
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
9
|
+
import { authConfigStore } from "../auth/auth-config-store";
|
|
11
10
|
import type { Capabilities } from "@podosoft/podokit-contracts";
|
|
12
11
|
|
|
13
|
-
// Reads OAuth/server-toggle state (DB-first, env fallback) for capabilities.
|
|
14
|
-
const configStore = createConfigStore(pool);
|
|
15
|
-
|
|
16
12
|
function isAdmin(session: UserSession): boolean {
|
|
17
13
|
const role = session.user?.role;
|
|
18
14
|
const roles = Array.isArray(role) ? role : (role ?? "").split(",").map((r: string) => r.trim());
|
|
@@ -45,7 +41,7 @@ export class AccountController {
|
|
|
45
41
|
const flags = this.settings.flags();
|
|
46
42
|
// OAuth providers + server-enforced toggles are DB-backed (env fallback) and
|
|
47
43
|
// applied live, so read them from the config store rather than process.env.
|
|
48
|
-
const snapshot = await
|
|
44
|
+
const snapshot = await authConfigStore.capabilitiesSnapshot();
|
|
49
45
|
return {
|
|
50
46
|
twoFactor: flags.twoFactor,
|
|
51
47
|
magicLink: flags.magicLink,
|
|
@@ -63,6 +59,7 @@ export class AccountController {
|
|
|
63
59
|
signupApprovalRequired: snapshot.requireSignupApproval,
|
|
64
60
|
passwordBreachCheck: snapshot.passwordBreachCheck,
|
|
65
61
|
auditLog: snapshot.auditLog,
|
|
62
|
+
sessionIdleTimeoutMinutes: snapshot.sessionIdleTimeoutMinutes,
|
|
66
63
|
roles: ROLE_NAMES,
|
|
67
64
|
};
|
|
68
65
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
2
|
+
import { pool } from "./db";
|
|
3
|
+
|
|
4
|
+
// One process-wide store keeps the auth runtime and capabilities endpoint on the
|
|
5
|
+
// same cache snapshot. Admin writes invalidate this store before rebuilding auth.
|
|
6
|
+
export const authConfigStore = createConfigStore(pool);
|