@podosoft/podokit 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/add.d.ts +3 -1
- package/dist/add.js +116 -14
- package/dist/index.js +8 -0
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +3 -2
- package/dist/lockfile.d.ts +3 -1
- package/dist/lockfile.js +17 -6
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
- package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
- 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 +14 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +2 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +35 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +18 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/modules/sse/module.manifest.json +3 -1
- package/dist/update.d.ts +17 -0
- package/dist/update.js +33 -8
- package/package.json +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import type { ApiClient } from "@podosoft/podokit-api-client";
|
|
2
|
+
|
|
3
|
+
// Builds the `GET /admin/users` query for the server-paged users table.
|
|
2
4
|
|
|
3
5
|
export type UserListSort = { key: string; dir: "asc" | "desc" };
|
|
4
6
|
|
|
@@ -12,6 +14,82 @@ export type UserListState = {
|
|
|
12
14
|
status: string;
|
|
13
15
|
};
|
|
14
16
|
|
|
17
|
+
export type AdminUserStatus = "active" | "pending" | "banned";
|
|
18
|
+
|
|
19
|
+
/** A user from `GET /admin/users`. */
|
|
20
|
+
export type AdminUser = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
email: string;
|
|
24
|
+
role: string | null;
|
|
25
|
+
emailVerified: boolean;
|
|
26
|
+
signupApproved: boolean;
|
|
27
|
+
banned: boolean;
|
|
28
|
+
banReason: string | null;
|
|
29
|
+
banExpires: string | null;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type AdminUserPage = { items: AdminUser[]; total: number };
|
|
34
|
+
|
|
35
|
+
export type AdminUserQuery = {
|
|
36
|
+
limit: number;
|
|
37
|
+
offset: number;
|
|
38
|
+
sortBy: string;
|
|
39
|
+
sortDirection: "asc" | "desc";
|
|
40
|
+
search?: string;
|
|
41
|
+
searchField?: "email" | "name";
|
|
42
|
+
role?: string;
|
|
43
|
+
status?: AdminUserStatus;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_USER_SORT: UserListSort = { key: "createdAt", dir: "desc" };
|
|
47
|
+
|
|
48
|
+
const SORTABLE_FIELDS = new Set(["name", "email", "role", "createdAt"]);
|
|
49
|
+
const STATUSES = new Set<string>(["active", "pending", "banned"]);
|
|
50
|
+
|
|
51
|
+
function isStatus(value: string): value is AdminUserStatus {
|
|
52
|
+
return STATUSES.has(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function adminUsersQuery(state: UserListState): AdminUserQuery {
|
|
56
|
+
const sort = state.sort && SORTABLE_FIELDS.has(state.sort.key) ? state.sort : DEFAULT_USER_SORT;
|
|
57
|
+
const search = state.search.trim();
|
|
58
|
+
return {
|
|
59
|
+
limit: state.perPage,
|
|
60
|
+
offset: (Math.max(state.page, 1) - 1) * state.perPage,
|
|
61
|
+
sortBy: sort.key,
|
|
62
|
+
sortDirection: sort.dir,
|
|
63
|
+
// The server matches case-insensitively, so the text is sent as typed.
|
|
64
|
+
...(search ? { search, searchField: state.searchField === "name" ? "name" as const : "email" as const } : {}),
|
|
65
|
+
...(state.role ? { role: state.role } : {}),
|
|
66
|
+
...(isStatus(state.status) ? { status: state.status } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Reads one page; the role and status filters combine on the server. */
|
|
71
|
+
export function listAdminUsers(client: Pick<ApiClient, "get">, query: AdminUserQuery): Promise<AdminUserPage> {
|
|
72
|
+
const params = new URLSearchParams({
|
|
73
|
+
limit: String(query.limit),
|
|
74
|
+
offset: String(query.offset),
|
|
75
|
+
sortBy: query.sortBy,
|
|
76
|
+
sortDirection: query.sortDirection,
|
|
77
|
+
});
|
|
78
|
+
if (query.search) {
|
|
79
|
+
params.set("search", query.search);
|
|
80
|
+
params.set("searchField", query.searchField ?? "email");
|
|
81
|
+
}
|
|
82
|
+
if (query.role) params.set("role", query.role);
|
|
83
|
+
if (query.status) params.set("status", query.status);
|
|
84
|
+
return client.get<AdminUserPage>(`/admin/users?${params.toString()}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The 1.2 users page is application-owned and still imports the helpers below,
|
|
88
|
+
// which build a Better Auth `admin.listUsers` query. Keep them until the next
|
|
89
|
+
// major release so `podo update` does not break that page; new pages use
|
|
90
|
+
// `adminUsersQuery` and `listAdminUsers`.
|
|
91
|
+
|
|
92
|
+
/** @deprecated Use `AdminUserQuery` with `listAdminUsers`. */
|
|
15
93
|
export type ListUsersQuery = {
|
|
16
94
|
limit: number;
|
|
17
95
|
offset: number;
|
|
@@ -25,10 +103,6 @@ export type ListUsersQuery = {
|
|
|
25
103
|
filterOperator?: "eq";
|
|
26
104
|
};
|
|
27
105
|
|
|
28
|
-
export const DEFAULT_USER_SORT: UserListSort = { key: "createdAt", dir: "desc" };
|
|
29
|
-
|
|
30
|
-
const SORTABLE_FIELDS = new Set(["name", "email", "role", "createdAt"]);
|
|
31
|
-
|
|
32
106
|
function filterFor(role: string, status: string): Pick<ListUsersQuery, "filterField" | "filterValue" | "filterOperator"> {
|
|
33
107
|
if (status === "pending") return { filterField: "signupApproved", filterValue: false, filterOperator: "eq" };
|
|
34
108
|
if (status === "banned") return { filterField: "banned", filterValue: true, filterOperator: "eq" };
|
|
@@ -36,6 +110,7 @@ function filterFor(role: string, status: string): Pick<ListUsersQuery, "filterFi
|
|
|
36
110
|
return {};
|
|
37
111
|
}
|
|
38
112
|
|
|
113
|
+
/** @deprecated Use `adminUsersQuery`; `listUsers` accepts only one filter. */
|
|
39
114
|
export function listUsersQuery(state: UserListState): ListUsersQuery {
|
|
40
115
|
const sort = state.sort && SORTABLE_FIELDS.has(state.sort.key) ? state.sort : DEFAULT_USER_SORT;
|
|
41
116
|
const searchField = state.searchField === "name" ? "name" : "email";
|
|
@@ -47,8 +122,6 @@ export function listUsersQuery(state: UserListState): ListUsersQuery {
|
|
|
47
122
|
sortDirection: sort.dir,
|
|
48
123
|
...(search
|
|
49
124
|
? {
|
|
50
|
-
// Better Auth matches with a case-sensitive LIKE on PostgreSQL and stores
|
|
51
|
-
// emails in lower case, so lower-casing makes email search case-insensitive.
|
|
52
125
|
searchValue: searchField === "email" ? search.toLowerCase() : search,
|
|
53
126
|
searchField,
|
|
54
127
|
searchOperator: "contains" as const,
|
|
@@ -58,10 +131,7 @@ export function listUsersQuery(state: UserListState): ListUsersQuery {
|
|
|
58
131
|
};
|
|
59
132
|
}
|
|
60
133
|
|
|
61
|
-
/**
|
|
62
|
-
* `listUsers` accepts one filter, so the role and status selects are mutually
|
|
63
|
-
* exclusive: when both hold a value, the one that just changed wins.
|
|
64
|
-
*/
|
|
134
|
+
/** @deprecated `GET /admin/users` combines the role and status filters. */
|
|
65
135
|
export function keepLatestFilter(
|
|
66
136
|
previous: { role: string; status: string },
|
|
67
137
|
next: { role: string; status: string },
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Stands in for the application's own list in the app-owned public-paths.ts.
|
|
4
|
+
vi.mock("./public-paths", () => ({ APP_PUBLIC_PATHS: ["/pricing", "/robots.txt"] }));
|
|
5
|
+
|
|
6
|
+
const { isPublicPath } = await import("./guards");
|
|
7
|
+
|
|
8
|
+
describe("isPublicPath", () => {
|
|
9
|
+
it("keeps the sign-in flow and the landing page public", () => {
|
|
10
|
+
expect(isPublicPath("/")).toBe(true);
|
|
11
|
+
expect(isPublicPath("/login")).toBe(true);
|
|
12
|
+
expect(isPublicPath("/reset-password/token")).toBe(true);
|
|
13
|
+
expect(isPublicPath("/admin")).toBe(false);
|
|
14
|
+
expect(isPublicPath("/account")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("adds the application's public prefixes and their subpaths", () => {
|
|
18
|
+
expect(isPublicPath("/pricing")).toBe(true);
|
|
19
|
+
expect(isPublicPath("/pricing/teams")).toBe(true);
|
|
20
|
+
expect(isPublicPath("/robots.txt")).toBe(true);
|
|
21
|
+
expect(isPublicPath("/pricingx")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { error } from "@sveltejs/kit";
|
|
2
|
+
import { APP_PUBLIC_PATHS } from "./public-paths";
|
|
2
3
|
|
|
3
4
|
/** Roles that may reach the admin console. Kept as a set so custom roles can be
|
|
4
5
|
* added here (and, later, replaced by a permission check) without touching every
|
|
@@ -13,6 +14,7 @@ const PUBLIC_PATHS = [
|
|
|
13
14
|
"/verify-email",
|
|
14
15
|
"/pending-approval",
|
|
15
16
|
"/maintenance",
|
|
17
|
+
...APP_PUBLIC_PATHS,
|
|
16
18
|
// Modules add their public (no-session) page prefixes here.
|
|
17
19
|
// podokit:begin:public-paths
|
|
18
20
|
// podokit:end:public-paths
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-owned public page prefixes. A listed path also covers its
|
|
3
|
+
* subpaths ("/pricing" covers "/pricing/teams"). These pages render for
|
|
4
|
+
* anonymous visitors and keep rendering when the API or site settings are
|
|
5
|
+
* unavailable; every other page requires a session and fails closed with 503
|
|
6
|
+
* during an outage. Modules add their own prefixes in guards.ts, so do not
|
|
7
|
+
* repeat them here. PodoKit creates this file once and never overwrites it.
|
|
8
|
+
*/
|
|
9
|
+
export const APP_PUBLIC_PATHS: readonly string[] = [];
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { toast } from "svelte-sonner";
|
|
18
18
|
import { api } from "#lib/api.js";
|
|
19
19
|
import { listAdminSessions, revokeAdminSession, type AdminSession } from "#lib/admin/sessions.js";
|
|
20
|
-
import { DEFAULT_USER_SORT,
|
|
20
|
+
import { DEFAULT_USER_SORT, adminUsersQuery, listAdminUsers } from "#lib/admin/users.js";
|
|
21
21
|
import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
|
|
22
22
|
import { cn } from "#lib/utils.js";
|
|
23
23
|
import { deviceLabel } from "#lib/device-label.js";
|
|
@@ -64,19 +64,6 @@
|
|
|
64
64
|
let appliedSearchField = $state("email");
|
|
65
65
|
let filterValues = $state<Record<string, string>>({ role: "", status: "" });
|
|
66
66
|
let appliedFilters = $state<Record<string, string>>({ role: "", status: "" });
|
|
67
|
-
let stagedFilters = { role: "", status: "" };
|
|
68
|
-
|
|
69
|
-
// The list endpoint takes a single filter, so picking a role clears the status
|
|
70
|
-
// and vice versa (see keepLatestFilter).
|
|
71
|
-
$effect(() => {
|
|
72
|
-
const next = keepLatestFilter(stagedFilters, {
|
|
73
|
-
role: filterValues.role ?? "",
|
|
74
|
-
status: filterValues.status ?? "",
|
|
75
|
-
});
|
|
76
|
-
stagedFilters = next;
|
|
77
|
-
if (filterValues.role !== next.role) filterValues.role = next.role;
|
|
78
|
-
if (filterValues.status !== next.status) filterValues.status = next.status;
|
|
79
|
-
});
|
|
80
67
|
|
|
81
68
|
const columns = $derived<DataTableColumn<Row>[]>([
|
|
82
69
|
{ key: "name", label: i18n.t.users.name, sortable: true },
|
|
@@ -103,6 +90,7 @@
|
|
|
103
90
|
label: i18n.t.users.status,
|
|
104
91
|
options: [
|
|
105
92
|
{ value: "", label: i18n.t.toolbar.all },
|
|
93
|
+
{ value: "active", label: i18n.t.users.active },
|
|
106
94
|
{ value: "pending", label: i18n.t.users.pendingApproval },
|
|
107
95
|
{ value: "banned", label: i18n.t.users.banned },
|
|
108
96
|
],
|
|
@@ -118,7 +106,7 @@
|
|
|
118
106
|
const request = ++latestRequest;
|
|
119
107
|
const sortKey = sort ? `${sort.key}:${sort.dir}` : "";
|
|
120
108
|
if (sortKey !== loadedSortKey) page = 1;
|
|
121
|
-
const query =
|
|
109
|
+
const query = adminUsersQuery({
|
|
122
110
|
page,
|
|
123
111
|
perPage: PAGE_SIZE,
|
|
124
112
|
sort,
|
|
@@ -128,12 +116,18 @@
|
|
|
128
116
|
status: appliedFilters.status ?? "",
|
|
129
117
|
});
|
|
130
118
|
loading = true;
|
|
131
|
-
|
|
119
|
+
let result: { items: Row[]; total: number };
|
|
120
|
+
try {
|
|
121
|
+
result = await listAdminUsers(api, query);
|
|
122
|
+
} catch {
|
|
123
|
+
if (request !== latestRequest) return;
|
|
124
|
+
loading = false;
|
|
125
|
+
return void toast.error(i18n.t.users.loadFailed);
|
|
126
|
+
}
|
|
132
127
|
if (request !== latestRequest) return;
|
|
133
128
|
loading = false;
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
const count = res?.total ?? rows.length;
|
|
129
|
+
const rows = result.items;
|
|
130
|
+
const count = result.total;
|
|
137
131
|
// An action can empty the last page; show the one before it instead.
|
|
138
132
|
const lastPage = Math.max(Math.ceil(count / PAGE_SIZE), 1);
|
|
139
133
|
if (rows.length === 0 && page > lastPage) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { APIRequestContext, PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
3
|
+
import { ADMIN, USER } from "../helpers/accounts";
|
|
4
|
+
|
|
5
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
|
+
const origin = { origin: base };
|
|
7
|
+
|
|
8
|
+
type UserItem = {
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
role: string | null;
|
|
12
|
+
banned: boolean;
|
|
13
|
+
signupApproved: boolean;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
};
|
|
16
|
+
type UserPage = { items: UserItem[]; total: number };
|
|
17
|
+
|
|
18
|
+
async function signedIn(
|
|
19
|
+
playwright: PlaywrightWorkerArgs["playwright"],
|
|
20
|
+
account: { email: string; password: string },
|
|
21
|
+
): Promise<APIRequestContext> {
|
|
22
|
+
const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
23
|
+
expect((await context.post("/api/auth/sign-in/email", { data: account })).ok()).toBeTruthy();
|
|
24
|
+
return context;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function list(admin: APIRequestContext, query: Record<string, string>): Promise<UserPage> {
|
|
28
|
+
const response = await admin.get(`/api/admin/users?${new URLSearchParams(query).toString()}`);
|
|
29
|
+
expect(response.status(), await response.text()).toBe(200);
|
|
30
|
+
return (await response.json()) as UserPage;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("the admin user list combines role and status filters with a search", async ({
|
|
34
|
+
playwright,
|
|
35
|
+
disposableUsers,
|
|
36
|
+
}) => {
|
|
37
|
+
const prefix = `users-filter-${Date.now()}`;
|
|
38
|
+
const ids = {
|
|
39
|
+
activeAdmin: await disposableUsers.create({ email: `${prefix}-a@example.com`, role: "admin" }),
|
|
40
|
+
bannedAdmin: await disposableUsers.create({ email: `${prefix}-b@example.com`, role: "admin" }),
|
|
41
|
+
activeUser: await disposableUsers.create({ email: `${prefix}-c@example.com` }),
|
|
42
|
+
pendingUser: await disposableUsers.create({ email: `${prefix}-d@example.com` }),
|
|
43
|
+
};
|
|
44
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
45
|
+
try {
|
|
46
|
+
expect((await admin.post("/api/auth/admin/ban-user", { data: { userId: ids.bannedAdmin } })).ok())
|
|
47
|
+
.toBeTruthy();
|
|
48
|
+
expect((await admin.post("/api/auth/admin/update-user", {
|
|
49
|
+
data: { userId: ids.pendingUser, data: { signupApproved: false } },
|
|
50
|
+
})).ok()).toBeTruthy();
|
|
51
|
+
|
|
52
|
+
const matching = async (role: string, status: string): Promise<string[]> =>
|
|
53
|
+
(await list(admin, { search: prefix.toUpperCase(), role, status })).items.map((user) => user.id);
|
|
54
|
+
expect(await matching("admin", "active")).toEqual([ids.activeAdmin]);
|
|
55
|
+
expect(await matching("admin", "banned")).toEqual([ids.bannedAdmin]);
|
|
56
|
+
expect(await matching("user", "pending")).toEqual([ids.pendingUser]);
|
|
57
|
+
expect(await matching("user", "active")).toEqual([ids.activeUser]);
|
|
58
|
+
|
|
59
|
+
const all = await list(admin, { search: prefix, limit: "2", sortBy: "email", sortDirection: "asc" });
|
|
60
|
+
expect(all.total).toBe(4);
|
|
61
|
+
expect(all.items.map((user) => user.id)).toEqual([ids.activeAdmin, ids.bannedAdmin]);
|
|
62
|
+
const banned = all.items[1];
|
|
63
|
+
expect(banned?.banned).toBe(true);
|
|
64
|
+
expect(banned?.signupApproved).toBe(true);
|
|
65
|
+
expect(banned).not.toHaveProperty("password");
|
|
66
|
+
} finally {
|
|
67
|
+
await admin.dispose();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the admin user list is for administrators and validates its query", async ({ playwright }) => {
|
|
72
|
+
const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
73
|
+
const user = await signedIn(playwright, USER);
|
|
74
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
75
|
+
try {
|
|
76
|
+
expect((await anonymous.get("/api/admin/users")).status()).toBe(401);
|
|
77
|
+
expect((await user.get("/api/admin/users")).status()).toBe(403);
|
|
78
|
+
for (const query of ["status=deleted", "sortBy=password", "limit=101"]) {
|
|
79
|
+
expect((await admin.get(`/api/admin/users?${query}`)).status(), query).toBe(400);
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
await anonymous.dispose();
|
|
83
|
+
await user.dispose();
|
|
84
|
+
await admin.dispose();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
@@ -31,6 +31,7 @@ test("admin can enable Google OAuth from the DB and it applies without a restart
|
|
|
31
31
|
});
|
|
32
32
|
expect(put.ok()).toBeTruthy();
|
|
33
33
|
const view = await put.json();
|
|
34
|
+
expect(view.social.google.enabled).toBe(true);
|
|
34
35
|
expect(view.social.google.hasSecret).toBe(true);
|
|
35
36
|
expect(view.social.google.clientId).toBe("dummy-google-client-id");
|
|
36
37
|
expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
|
|
@@ -291,7 +291,7 @@ test("a user can create, list and revoke a personal API key", async ({ playwrigh
|
|
|
291
291
|
const body = (await (await ctx.get("/api/auth/api-key/list")).json()) as { apiKeys: Array<{ id: string; name: string | null }> };
|
|
292
292
|
expect(body.apiKeys.some((k) => k.name === "ci")).toBeTruthy();
|
|
293
293
|
|
|
294
|
-
const del = await ctx.post("/api/auth/api-key/delete", { data: { keyId: body.apiKeys[0]
|
|
294
|
+
const del = await ctx.post("/api/auth/api-key/delete", { data: { keyId: body.apiKeys[0]?.id } });
|
|
295
295
|
expect(del.ok()).toBeTruthy();
|
|
296
296
|
await ctx.dispose();
|
|
297
297
|
});
|
|
@@ -15,14 +15,17 @@ type CreateUserInput = {
|
|
|
15
15
|
role?: string;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// UI specs pass the browser's create-user response; API specs pass a request response.
|
|
19
|
+
type CreatedUserResponse = Pick<APIResponse, "ok" | "json">;
|
|
20
|
+
|
|
18
21
|
export type DisposableUsers = {
|
|
19
22
|
create(input: CreateUserInput): Promise<string>;
|
|
20
|
-
trackResponse(response:
|
|
23
|
+
trackResponse(response: CreatedUserResponse): Promise<string>;
|
|
21
24
|
track(userId: string): void;
|
|
22
25
|
forget(userId: string): void;
|
|
23
26
|
};
|
|
24
27
|
|
|
25
|
-
async function readCreatedUserId(response:
|
|
28
|
+
async function readCreatedUserId(response: CreatedUserResponse): Promise<string> {
|
|
26
29
|
expect(response.ok()).toBeTruthy();
|
|
27
30
|
const body = (await response.json()) as { user?: { id?: unknown } };
|
|
28
31
|
expect(typeof body.user?.id).toBe("string");
|
|
@@ -2,7 +2,7 @@ import { expect, test, type Response } from "@playwright/test";
|
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
// Sorting via the shared DataTable. Users sorts on the server (manual mode
|
|
5
|
-
// refetches
|
|
5
|
+
// refetches GET /admin/users), and the audit log asks the server
|
|
6
6
|
// for the other time order — both drive the same clickable headers + aria-sort.
|
|
7
7
|
test("users table sorts by a column header (server)", async ({ page }) => {
|
|
8
8
|
await ready(page, "/admin/users");
|
|
@@ -13,7 +13,7 @@ test("users table sorts by a column header (server)", async ({ page }) => {
|
|
|
13
13
|
const firstName = (): Promise<string> => page.getByRole("row").nth(1).getByRole("cell").first().innerText();
|
|
14
14
|
const sortedBy = (direction: string): Promise<Response> => page.waitForResponse((response) => {
|
|
15
15
|
const url = new URL(response.url());
|
|
16
|
-
return url.pathname === "/api/
|
|
16
|
+
return url.pathname === "/api/admin/users" &&
|
|
17
17
|
url.searchParams.get("sortBy") === "name" &&
|
|
18
18
|
url.searchParams.get("sortDirection") === direction;
|
|
19
19
|
});
|
|
@@ -3,7 +3,6 @@ import { ready } from "../helpers/hydration";
|
|
|
3
3
|
import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
|
|
4
4
|
|
|
5
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
|
-
const origin = { origin: base };
|
|
7
6
|
|
|
8
7
|
// Verification badges/actions are gated by the emailVerification capability. The
|
|
9
8
|
// seeded suite runs with verification off, so assert the gating here (nothing
|
|
@@ -28,9 +28,7 @@ test("forgot password: request, open the emailed link, set a new password, sign
|
|
|
28
28
|
await expect(page.getByText(/reset link is on its way/i)).toBeVisible();
|
|
29
29
|
|
|
30
30
|
// Open the emailed link: the API validates it and redirects to the reset page.
|
|
31
|
-
await page
|
|
32
|
-
await page.waitForLoadState("load");
|
|
33
|
-
await page.waitForTimeout(400);
|
|
31
|
+
await ready(page, await waitForLink(email));
|
|
34
32
|
// `exact` because "Confirm new password" also contains "New password".
|
|
35
33
|
await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
|
|
36
34
|
// A mismatch is refused, and the single-use token survives it: the same page
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
3
4
|
|
|
4
5
|
// admin storageState (project default) — the admin is the resource owner.
|
|
5
6
|
const b64url = (b: Buffer): string => b.toString("base64url");
|
|
@@ -64,7 +65,8 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", a
|
|
|
64
65
|
// The consent page renders (reached in a real browser navigation), and allowing
|
|
65
66
|
// it sends the browser back to the client with an authorization code.
|
|
66
67
|
await page.route(`${REDIRECT}**`, (route) => route.fulfill({ status: 200, body: "callback" }));
|
|
67
|
-
|
|
68
|
+
// Wait for hydration: a click on the server-rendered button sends no consent.
|
|
69
|
+
await ready(page, consentUrl);
|
|
68
70
|
const allow = page.getByRole("button", { name: "Allow" });
|
|
69
71
|
await expect(allow).toBeVisible();
|
|
70
72
|
const callback = page.waitForURL((url) => url.href.startsWith(REDIRECT));
|
|
@@ -21,26 +21,25 @@ test("client navigation keeps the server checks without resending messages", asy
|
|
|
21
21
|
await ready(page, "/admin");
|
|
22
22
|
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
|
23
23
|
|
|
24
|
-
const bodies: Promise<string>[] = [];
|
|
25
|
-
page.on("response", (response) => {
|
|
26
|
-
if (new URL(response.url()).pathname.endsWith("/__data.json")) bodies.push(response.text());
|
|
27
|
-
});
|
|
28
24
|
const sidebar = page.locator('[data-sidebar="sidebar"]');
|
|
29
|
-
const stops: Array<[link: string, heading: string]> = [
|
|
30
|
-
["Users", "Users"],
|
|
31
|
-
["Sessions", "User sessions"],
|
|
32
|
-
["Settings", "Settings"],
|
|
33
|
-
["Overview", "Dashboard"],
|
|
25
|
+
const stops: Array<[link: string, path: string, heading: string]> = [
|
|
26
|
+
["Users", "/admin/users", "Users"],
|
|
27
|
+
["Sessions", "/admin/sessions", "User sessions"],
|
|
28
|
+
["Settings", "/admin/settings", "Settings"],
|
|
29
|
+
["Overview", "/admin", "Dashboard"],
|
|
34
30
|
];
|
|
35
|
-
for (const [link, heading] of stops) {
|
|
31
|
+
for (const [link, path, heading] of stops) {
|
|
32
|
+
// Read each body before the next click: once the page navigates on, the
|
|
33
|
+
// previous response body is no longer available.
|
|
34
|
+
const data = page.waitForResponse(
|
|
35
|
+
(response) => new URL(response.url()).pathname === `${path}/__data.json`,
|
|
36
|
+
);
|
|
36
37
|
await sidebar.getByRole("link", { name: link, exact: true }).click();
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
expect(bodies.length).toBeGreaterThanOrEqual(stops.length);
|
|
41
|
-
for (const body of await Promise.all(bodies)) {
|
|
38
|
+
const body = await (await data).text();
|
|
42
39
|
expect(body).not.toContain('"messages"');
|
|
43
40
|
expect(body.length).toBeLessThan(20_000);
|
|
41
|
+
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
|
|
44
42
|
}
|
|
43
|
+
|
|
45
44
|
await expect(sidebar.getByRole("link", { name: "Back to home" })).toBeVisible();
|
|
46
45
|
});
|
|
@@ -20,31 +20,36 @@ test("users toolbar applies filter + search together on Search", async ({ page }
|
|
|
20
20
|
// Selecting a filter stages it; nothing applies until Search is pressed.
|
|
21
21
|
const filtered = page.waitForResponse((response) => {
|
|
22
22
|
const url = new URL(response.url());
|
|
23
|
-
return url.pathname === "/api/
|
|
23
|
+
return url.pathname === "/api/admin/users" && url.searchParams.get("role") === "admin";
|
|
24
24
|
});
|
|
25
25
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
26
|
-
expect(
|
|
26
|
+
expect((await filtered).ok()).toBeTruthy();
|
|
27
27
|
await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
28
28
|
await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test("users toolbar
|
|
31
|
+
test("users toolbar combines the role and status filters", async ({ page }) => {
|
|
32
32
|
await ready(page, "/admin/users");
|
|
33
|
+
const main = page.getByRole("main");
|
|
33
34
|
const roleSelect = page.locator('button[data-slot="select-trigger"]').first();
|
|
34
35
|
const statusSelect = page.locator('button[data-slot="select-trigger"]').nth(1);
|
|
35
36
|
await roleSelect.click();
|
|
36
37
|
await page.getByRole("option", { name: "Admin", exact: true }).click();
|
|
37
38
|
await statusSelect.click();
|
|
38
|
-
await page.getByRole("option", { name: "
|
|
39
|
-
await expect(roleSelect).toContainText("
|
|
40
|
-
await expect(statusSelect).toContainText("
|
|
39
|
+
await page.getByRole("option", { name: "Active", exact: true }).click();
|
|
40
|
+
await expect(roleSelect).toContainText("Admin");
|
|
41
|
+
await expect(statusSelect).toContainText("Active");
|
|
41
42
|
|
|
42
43
|
const filtered = page.waitForResponse((response) => {
|
|
43
44
|
const url = new URL(response.url());
|
|
44
|
-
return url.pathname === "/api/
|
|
45
|
+
return url.pathname === "/api/admin/users" &&
|
|
46
|
+
url.searchParams.get("role") === "admin" &&
|
|
47
|
+
url.searchParams.get("status") === "active";
|
|
45
48
|
});
|
|
46
49
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
47
|
-
expect(
|
|
50
|
+
expect((await filtered).ok()).toBeTruthy();
|
|
51
|
+
await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
52
|
+
await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
test("users toolbar can pick the search field", async ({ page }) => {
|
|
@@ -140,7 +140,7 @@ test("users list shows pagination beyond one page", async ({ page, disposableUse
|
|
|
140
140
|
await expect(nav).toBeVisible();
|
|
141
141
|
const secondPage = page.waitForResponse((response) => {
|
|
142
142
|
const url = new URL(response.url());
|
|
143
|
-
return url.pathname === "/api/
|
|
143
|
+
return url.pathname === "/api/admin/users" && url.searchParams.get("offset") === "15";
|
|
144
144
|
});
|
|
145
145
|
await nav.getByRole("button", { name: "Page 2", exact: true }).first().click();
|
|
146
146
|
expect((await secondPage).ok()).toBeTruthy();
|
|
@@ -44,7 +44,9 @@ test("admin sees the user list and can search", async ({ page }) => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
test("the user list asks the server for one page at a time", async ({ page }) => {
|
|
47
|
-
const isListUsers = (url: string): boolean => new URL(url).pathname === "/api/
|
|
47
|
+
const isListUsers = (url: string): boolean => new URL(url).pathname === "/api/admin/users";
|
|
48
|
+
const requested: string[] = [];
|
|
49
|
+
page.on("request", (request) => requested.push(request.url()));
|
|
48
50
|
const first = page.waitForResponse((response) => isListUsers(response.url()));
|
|
49
51
|
await ready(page, "/admin/users");
|
|
50
52
|
const initial = new URL((await first).url()).searchParams;
|
|
@@ -53,14 +55,16 @@ test("the user list asks the server for one page at a time", async ({ page }) =>
|
|
|
53
55
|
expect(initial.get("sortDirection")).toBe("desc");
|
|
54
56
|
await expect(page.getByRole("columnheader", { name: "Joined" })).toHaveAttribute("aria-sort", "descending");
|
|
55
57
|
|
|
58
|
+
// The server matches case-insensitively, so the search goes as typed.
|
|
56
59
|
const searched = page.waitForResponse((response) => isListUsers(response.url()));
|
|
57
60
|
await page.locator("#toolbar-search").fill("Admin@Example.com");
|
|
58
61
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
59
62
|
const query = new URL((await searched).url()).searchParams;
|
|
60
|
-
expect(query.get("
|
|
63
|
+
expect(query.get("search")).toBe("Admin@Example.com");
|
|
61
64
|
expect(query.get("searchField")).toBe("email");
|
|
62
65
|
expect(query.get("offset")).toBe("0");
|
|
63
66
|
await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
67
|
+
expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
|
|
64
68
|
});
|
|
65
69
|
|
|
66
70
|
test("user list shows the joined column", async ({ page }) => {
|
|
@@ -69,9 +73,16 @@ test("user list shows the joined column", async ({ page }) => {
|
|
|
69
73
|
});
|
|
70
74
|
|
|
71
75
|
test("row menu exposes admin actions", async ({ page }) => {
|
|
72
|
-
|
|
76
|
+
// The list shows one server page, so the search must run before the row exists.
|
|
77
|
+
await ready(page, "/admin/users");
|
|
78
|
+
const searched = page.waitForResponse((response) => {
|
|
79
|
+
const url = new URL(response.url());
|
|
80
|
+
return url.pathname === "/api/admin/users" &&
|
|
81
|
+
url.searchParams.get("search") === "user@example.com";
|
|
82
|
+
});
|
|
73
83
|
await page.locator("#toolbar-search").fill("user@example.com");
|
|
74
84
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
85
|
+
expect((await searched).ok()).toBeTruthy();
|
|
75
86
|
const row = page.getByRole("row", { name: /user@example.com/ });
|
|
76
87
|
await row.getByRole("button").click();
|
|
77
88
|
await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"ownedGlobs": [
|
|
15
15
|
"apps/web/src/lib/i18n/locales/**",
|
|
16
|
-
"apps/web/src/lib/i18n/catalogs/app/**"
|
|
16
|
+
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
17
|
+
"apps/web/src/lib/server/public-paths.ts"
|
|
17
18
|
],
|
|
18
19
|
"managedOverrides": [
|
|
19
20
|
"apps/web/src/lib/components/ui/input/input.svelte",
|
|
@@ -117,7 +118,7 @@
|
|
|
117
118
|
"file": "AGENTS.md",
|
|
118
119
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
119
120
|
"optional": true,
|
|
120
|
-
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
121
|
+
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
121
122
|
},
|
|
122
123
|
{
|
|
123
124
|
"file": "apps/api/src/app.ts",
|