@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.
Files changed (61) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/index.js +8 -0
  4. package/dist/lib.d.ts +2 -2
  5. package/dist/lib.js +3 -2
  6. package/dist/lockfile.d.ts +3 -1
  7. package/dist/lockfile.js +17 -6
  8. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  9. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +1 -0
  10. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  11. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  12. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  13. package/dist/templates/fullstack/apps/web/package.json +1 -1
  14. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  15. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  17. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
  31. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  32. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  33. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
  34. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  35. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
  40. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
  43. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
  45. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
  46. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  47. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  48. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
  49. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  50. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  51. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  52. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +2 -2
  53. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
  54. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
  55. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +35 -0
  56. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +18 -1
  57. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  58. package/dist/templates/modules/sse/module.manifest.json +3 -1
  59. package/dist/update.d.ts +17 -0
  60. package/dist/update.js +33 -8
  61. package/package.json +1 -1
@@ -0,0 +1,76 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { AppException } from "@podosoft/podokit-contracts";
4
+ import { Elysia } from "elysia";
5
+ import type { AuthService, AuthSession } from "../auth/auth.service";
6
+ import { adminUserRoutes } from "./admin-users.routes";
7
+ import { AdminUsersService } from "./admin-users.service";
8
+
9
+ // Callers identify themselves with a test header; only "admin" passes.
10
+ const auth: Pick<AuthService, "requireAdmin"> = {
11
+ requireAdmin: async (request: Request): Promise<AuthSession> => {
12
+ const role = request.headers.get("x-test-role");
13
+ if (!role) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
14
+ if (role !== "admin") throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
15
+ return { user: { id: "admin-1", role: "admin" }, session: {} };
16
+ },
17
+ };
18
+
19
+ describe("admin user routes", () => {
20
+ let db: SQL;
21
+ let handle: (path: string, role?: string) => Promise<Response>;
22
+
23
+ beforeEach(async () => {
24
+ db = new SQL(":memory:");
25
+ await db`
26
+ CREATE TABLE "user" (
27
+ "id" TEXT PRIMARY KEY, "name" TEXT NOT NULL, "email" TEXT NOT NULL,
28
+ "emailVerified" INTEGER NOT NULL, "createdAt" DATE NOT NULL, "role" TEXT,
29
+ "banned" INTEGER, "banReason" TEXT, "banExpires" DATE, "signupApproved" INTEGER
30
+ )
31
+ `;
32
+ await db`
33
+ INSERT INTO "user" VALUES
34
+ ('u1', 'Ada', 'ada@example.com', 1, '2026-01-01T00:00:01.000Z', 'admin', 0, NULL, NULL, 1),
35
+ ('u2', 'Bob', 'bob@example.com', 1, '2026-01-01T00:00:02.000Z', 'admin', 1, 'spam', NULL, 1)
36
+ `;
37
+ const app = new Elysia()
38
+ .onError(({ code, error, set }) => {
39
+ if (error instanceof AppException) {
40
+ set.status = error.statusCode;
41
+ return { code: error.code };
42
+ }
43
+ if (code === "VALIDATION") {
44
+ set.status = 400;
45
+ return { code: "VALIDATION_ERROR" };
46
+ }
47
+ return undefined;
48
+ })
49
+ .use(adminUserRoutes({ auth, users: new AdminUsersService(db, "sqlite") }));
50
+ handle = (path, role) =>
51
+ app.handle(new Request(`http://localhost${path}`, { headers: role ? { "x-test-role": role } : {} }));
52
+ });
53
+
54
+ afterEach(async () => {
55
+ await db.close();
56
+ });
57
+
58
+ test("requires an administrator", async () => {
59
+ expect((await handle("/admin/users")).status).toBe(401);
60
+ expect((await handle("/admin/users", "user")).status).toBe(403);
61
+ });
62
+
63
+ test("applies role and status together", async () => {
64
+ const response = await handle("/admin/users?role=admin&status=active&limit=10&offset=0", "admin");
65
+ expect(response.status).toBe(200);
66
+ const page = await response.json() as { items: Array<{ id: string; banned: boolean }>; total: number };
67
+ expect(page.items).toEqual([expect.objectContaining({ id: "u1", banned: false })]);
68
+ expect(page.total).toBe(1);
69
+ });
70
+
71
+ test("rejects unknown sort fields, statuses, and out-of-range limits", async () => {
72
+ for (const query of ["sortBy=password", "status=deleted", "sortDirection=up", "limit=0", "limit=101", "offset=-1", "searchField=role"]) {
73
+ expect((await handle(`/admin/users?${query}`, "admin")).status, query).toBe(400);
74
+ }
75
+ });
76
+ });
@@ -0,0 +1,41 @@
1
+ import { Elysia, t } from "elysia";
2
+ import type { AuthService } from "../auth/auth.service";
3
+ import type { ElysiaPlugin } from "../core/services";
4
+ import {
5
+ ADMIN_USER_SORT_FIELDS,
6
+ ADMIN_USER_STATUSES,
7
+ ADMIN_USERS_MAX_LIMIT,
8
+ type AdminUsersService,
9
+ } from "./admin-users.service";
10
+
11
+ export interface AdminUserRoutesOptions {
12
+ auth: Pick<AuthService, "requireAdmin">;
13
+ users: AdminUsersService;
14
+ }
15
+
16
+ export function adminUserRoutes({ auth, users }: AdminUserRoutesOptions): ElysiaPlugin {
17
+ return new Elysia({ name: "podokit.admin.users" })
18
+ .get("/admin/users", async ({ request, query }) => {
19
+ await auth.requireAdmin(request);
20
+ return users.list(query);
21
+ }, {
22
+ query: t.Object({
23
+ limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_USERS_MAX_LIMIT })),
24
+ offset: t.Optional(t.Numeric({ minimum: 0 })),
25
+ search: t.Optional(t.String({ maxLength: 200 })),
26
+ searchField: t.Optional(t.Union([t.Literal("email"), t.Literal("name")])),
27
+ role: t.Optional(t.String({ minLength: 1, maxLength: 64 })),
28
+ status: t.Optional(t.Union(ADMIN_USER_STATUSES.map((status) => t.Literal(status)))),
29
+ sortBy: t.Optional(t.Union(ADMIN_USER_SORT_FIELDS.map((field) => t.Literal(field)))),
30
+ sortDirection: t.Optional(t.Union([t.Literal("asc"), t.Literal("desc")])),
31
+ }),
32
+ detail: {
33
+ tags: ["admin"],
34
+ summary: "List users",
35
+ description:
36
+ "Administrators only. Offset-paged with `limit` and `offset`; newest first unless " +
37
+ "`sortBy`/`sortDirection` say otherwise. `search` matches `searchField` (email by " +
38
+ "default) case-insensitively. `role` and `status` (active, pending, banned) combine.",
39
+ },
40
+ });
41
+ }
@@ -0,0 +1,124 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { AdminUsersService, type AdminUserQuery } from "./admin-users.service";
4
+
5
+ interface SeedUser {
6
+ id: string;
7
+ name: string;
8
+ email: string;
9
+ role?: string | null;
10
+ banned?: boolean | null;
11
+ signupApproved?: boolean | null;
12
+ emailVerified?: boolean;
13
+ createdAt: string;
14
+ }
15
+
16
+ // Better Auth stores booleans as 0 or 1 and dates as ISO text on SQLite.
17
+ async function createSchema(db: SQL): Promise<void> {
18
+ await db`
19
+ CREATE TABLE "user" (
20
+ "id" TEXT PRIMARY KEY, "name" TEXT NOT NULL, "email" TEXT NOT NULL,
21
+ "emailVerified" INTEGER NOT NULL, "image" TEXT, "createdAt" DATE NOT NULL,
22
+ "updatedAt" DATE NOT NULL, "role" TEXT, "banned" INTEGER, "banReason" TEXT,
23
+ "banExpires" DATE, "signupApproved" INTEGER
24
+ )
25
+ `;
26
+ }
27
+
28
+ function flag(value: boolean | null | undefined): number | null {
29
+ return value === undefined || value === null ? null : value ? 1 : 0;
30
+ }
31
+
32
+ async function seed(db: SQL, users: SeedUser[]): Promise<void> {
33
+ for (const user of users) {
34
+ await db`
35
+ INSERT INTO "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt",
36
+ "role", "banned", "banReason", "signupApproved")
37
+ VALUES (${user.id}, ${user.name}, ${user.email}, ${user.emailVerified ? 1 : 0},
38
+ ${user.createdAt}, ${user.createdAt}, ${user.role ?? null}, ${flag(user.banned)},
39
+ ${user.banned ? "spam" : null}, ${flag(user.signupApproved)})
40
+ `;
41
+ }
42
+ }
43
+
44
+ const at = (minute: number): string => `2026-01-01T00:${String(minute).padStart(2, "0")}:00.000Z`;
45
+
46
+ const USERS: SeedUser[] = [
47
+ { id: "u1", name: "Ada Admin", email: "ada@example.com", role: "admin", signupApproved: true, createdAt: at(1) },
48
+ { id: "u2", name: "Bob", email: "bob@example.com", role: "user", signupApproved: true, createdAt: at(2) },
49
+ { id: "u3", name: "Cy Banned", email: "cy@example.com", role: "user", banned: true, signupApproved: true, createdAt: at(3) },
50
+ { id: "u4", name: "Di Pending", email: "di@example.com", role: "user", signupApproved: false, createdAt: at(4) },
51
+ { id: "u5", name: "Eve Banned Admin", email: "eve@example.com", role: "admin,user", banned: true, createdAt: at(5) },
52
+ { id: "u6", name: "Fay Legacy", email: "fay@example.com", role: null, banned: null, signupApproved: null, createdAt: at(6) },
53
+ ];
54
+
55
+ describe("AdminUsersService", () => {
56
+ let db: SQL;
57
+ let service: AdminUsersService;
58
+
59
+ beforeEach(async () => {
60
+ db = new SQL(":memory:");
61
+ await createSchema(db);
62
+ await seed(db, USERS);
63
+ service = new AdminUsersService(db, "sqlite");
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await db.close();
68
+ });
69
+
70
+ const ids = async (query: AdminUserQuery): Promise<string[]> =>
71
+ (await service.list(query)).items.map((user) => user.id);
72
+
73
+ test("pages newest first by default with a total", async () => {
74
+ const page = await service.list({ limit: 2, offset: 2 });
75
+ expect(page.items.map((user) => user.id)).toEqual(["u4", "u3"]);
76
+ expect(page.total).toBe(6);
77
+ });
78
+
79
+ test("combines the role and status filters", async () => {
80
+ expect(await ids({ role: "admin", status: "banned" })).toEqual(["u5"]);
81
+ expect(await ids({ role: "admin", status: "active" })).toEqual(["u1"]);
82
+ expect(await ids({ role: "user", status: "pending" })).toEqual(["u4"]);
83
+ expect((await service.list({ role: "user", status: "active" })).total).toBe(1);
84
+ });
85
+
86
+ test("treats unset approval and ban flags as an active user", async () => {
87
+ expect(await ids({ status: "active" })).toEqual(["u6", "u2", "u1"]);
88
+ expect(await ids({ status: "banned" })).toEqual(["u5", "u3"]);
89
+ expect(await ids({ status: "pending" })).toEqual(["u4"]);
90
+ });
91
+
92
+ test("matches one role in a comma-separated role list", async () => {
93
+ expect(await ids({ role: "user" })).toEqual(["u5", "u4", "u3", "u2"]);
94
+ expect(await ids({ role: "use" })).toEqual([]);
95
+ });
96
+
97
+ test("searches email or name case-insensitively with literal wildcards", async () => {
98
+ expect(await ids({ search: "ADA@EXAMPLE", searchField: "email" })).toEqual(["u1"]);
99
+ expect(await ids({ search: "banned", searchField: "name" })).toEqual(["u5", "u3"]);
100
+ expect(await ids({ search: "%", searchField: "name" })).toEqual([]);
101
+ expect(await ids({ search: "banned", searchField: "name", status: "banned", role: "admin" })).toEqual(["u5"]);
102
+ });
103
+
104
+ test("sorts by an allowed column with the id as a tiebreaker", async () => {
105
+ expect(await ids({ sortBy: "name", sortDirection: "asc", limit: 3 })).toEqual(["u1", "u2", "u3"]);
106
+ expect(await ids({ sortBy: "email", sortDirection: "desc", limit: 2 })).toEqual(["u6", "u5"]);
107
+ });
108
+
109
+ test("returns booleans and ISO dates without exposing other columns", async () => {
110
+ const [pending] = (await service.list({ status: "pending" })).items;
111
+ expect(pending).toEqual({
112
+ id: "u4",
113
+ name: "Di Pending",
114
+ email: "di@example.com",
115
+ role: "user",
116
+ emailVerified: false,
117
+ signupApproved: false,
118
+ banned: false,
119
+ banReason: null,
120
+ banExpires: null,
121
+ createdAt: at(4),
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,153 @@
1
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
2
+ import type { SQL } from "bun";
3
+
4
+ export const ADMIN_USERS_DEFAULT_LIMIT = 15;
5
+ export const ADMIN_USERS_MAX_LIMIT = 100;
6
+ export const ADMIN_USER_SORT_FIELDS = ["createdAt", "name", "email", "role"] as const;
7
+ export const ADMIN_USER_STATUSES = ["active", "pending", "banned"] as const;
8
+
9
+ export type AdminUserSortField = (typeof ADMIN_USER_SORT_FIELDS)[number];
10
+ export type AdminUserStatus = (typeof ADMIN_USER_STATUSES)[number];
11
+
12
+ /** A user as the admin list shows it. Credentials and sessions stay on the server. */
13
+ export interface AdminUserView {
14
+ id: string;
15
+ name: string;
16
+ email: string;
17
+ role: string | null;
18
+ emailVerified: boolean;
19
+ signupApproved: boolean;
20
+ banned: boolean;
21
+ banReason: string | null;
22
+ banExpires: string | null;
23
+ createdAt: string;
24
+ }
25
+
26
+ export interface AdminUserPage {
27
+ items: AdminUserView[];
28
+ total: number;
29
+ }
30
+
31
+ export interface AdminUserQuery {
32
+ limit?: number;
33
+ offset?: number;
34
+ search?: string;
35
+ searchField?: "email" | "name";
36
+ role?: string;
37
+ status?: AdminUserStatus;
38
+ sortBy?: AdminUserSortField;
39
+ sortDirection?: "asc" | "desc";
40
+ }
41
+
42
+ type Flag = boolean | number | null;
43
+
44
+ interface UserRow {
45
+ id: string;
46
+ name: string;
47
+ email: string;
48
+ role: string | null;
49
+ emailVerified: Flag;
50
+ signupApproved: Flag;
51
+ banned: Flag;
52
+ banReason: string | null;
53
+ banExpires: Date | string | null;
54
+ createdAt: Date | string;
55
+ }
56
+
57
+ // SQLite stores booleans as 0 or 1. An unset approval predates sign-up approval.
58
+ function flag(value: Flag, unset: boolean): boolean {
59
+ return value === null ? unset : value === true || value === 1;
60
+ }
61
+
62
+ function iso(value: Date | string): string {
63
+ return (value instanceof Date ? value : new Date(value)).toISOString();
64
+ }
65
+
66
+ function likePattern(search: string): string {
67
+ return `%${search.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
68
+ }
69
+
70
+ /**
71
+ * Lists users for the admin console with every filter applied in one query.
72
+ * Better Auth `listUsers` accepts a single filter, so role and status could not
73
+ * combine there. Mutations stay on Better Auth so its permission checks and the
74
+ * audit hooks still run.
75
+ */
76
+ export class AdminUsersService {
77
+ constructor(
78
+ private readonly sql: SQL,
79
+ private readonly provider: DatabaseProviderName,
80
+ ) {}
81
+
82
+ async list(query: AdminUserQuery = {}): Promise<AdminUserPage> {
83
+ const limit = Math.min(Math.max(query.limit ?? ADMIN_USERS_DEFAULT_LIMIT, 1), ADMIN_USERS_MAX_LIMIT);
84
+ const offset = Math.max(query.offset ?? 0, 0);
85
+ const search = query.search?.trim() ?? "";
86
+ const filters = (): SQL.Query<unknown> => this.sql`
87
+ WHERE 1 = 1
88
+ ${search ? this.searchFilter(query.searchField ?? "email", likePattern(search)) : this.sql``}
89
+ ${query.role ? this.sql`AND (',' || u."role" || ',') LIKE ${`%,${query.role},%`}` : this.sql``}
90
+ ${query.status ? this.statusFilter(query.status) : this.sql``}
91
+ `;
92
+ const [rows, totals] = await Promise.all([
93
+ this.sql<UserRow[]>`
94
+ SELECT u."id", u."name", u."email", u."role", u."emailVerified", u."signupApproved",
95
+ u."banned", u."banReason", u."banExpires", u."createdAt"
96
+ FROM "user" u
97
+ ${filters()}
98
+ ${this.order(query.sortBy ?? "createdAt", query.sortDirection ?? "desc")}
99
+ LIMIT ${limit} OFFSET ${offset}
100
+ `,
101
+ this.sql<Array<{ total: number | string | bigint }>>`
102
+ SELECT COUNT(*) AS "total" FROM "user" u ${filters()}
103
+ `,
104
+ ]);
105
+ return {
106
+ items: rows.map((row) => ({
107
+ id: row.id,
108
+ name: row.name,
109
+ email: row.email,
110
+ role: row.role,
111
+ emailVerified: flag(row.emailVerified, false),
112
+ signupApproved: flag(row.signupApproved, true),
113
+ banned: flag(row.banned, false),
114
+ banReason: row.banReason,
115
+ banExpires: row.banExpires === null ? null : iso(row.banExpires),
116
+ createdAt: iso(row.createdAt),
117
+ })),
118
+ total: Number(totals[0]?.total ?? 0),
119
+ };
120
+ }
121
+
122
+ private searchFilter(field: "email" | "name", pattern: string): SQL.Query<unknown> {
123
+ const column = field === "name" ? this.sql`u."name"` : this.sql`u."email"`;
124
+ // SQLite LIKE ignores ASCII case; PostgreSQL needs ILIKE.
125
+ return this.provider === "sqlite"
126
+ ? this.sql`AND ${column} LIKE ${pattern} ESCAPE '\\'`
127
+ : this.sql`AND ${column} ILIKE ${pattern} ESCAPE '\\'`;
128
+ }
129
+
130
+ private statusFilter(status: AdminUserStatus): SQL.Query<unknown> {
131
+ switch (status) {
132
+ case "pending":
133
+ return this.sql`AND u."signupApproved" = FALSE`;
134
+ case "banned":
135
+ return this.sql`AND u."banned" = TRUE`;
136
+ case "active":
137
+ return this.sql`AND COALESCE(u."banned", FALSE) = FALSE AND COALESCE(u."signupApproved", TRUE) = TRUE`;
138
+ }
139
+ }
140
+
141
+ private order(field: AdminUserSortField, direction: "asc" | "desc"): SQL.Query<unknown> {
142
+ const column = {
143
+ createdAt: this.sql`u."createdAt"`,
144
+ name: this.sql`u."name"`,
145
+ email: this.sql`u."email"`,
146
+ role: this.sql`u."role"`,
147
+ }[field];
148
+ // The id breaks ties so offset pages neither skip nor repeat users.
149
+ return direction === "asc"
150
+ ? this.sql`ORDER BY ${column} ASC, u."id" ASC`
151
+ : this.sql`ORDER BY ${column} DESC, u."id" DESC`;
152
+ }
153
+ }
@@ -8,6 +8,8 @@ import { Elysia, t } from "elysia";
8
8
  import { adminSessionRoutes } from "./admin-sessions/admin-sessions.routes";
9
9
  import { createSessionRevoker, type RevokeUserSessionApi } from "./admin-sessions/admin-sessions.revoker";
10
10
  import { AdminSessionsService } from "./admin-sessions/admin-sessions.service";
11
+ import { adminUserRoutes } from "./admin-users/admin-users.routes";
12
+ import { AdminUsersService } from "./admin-users/admin-users.service";
11
13
  import { AUTH } from "./auth/auth.module";
12
14
  import { getAuth } from "./auth/auth-provider";
13
15
  import {
@@ -30,6 +32,7 @@ import { validateSiteSetting } from "./site-settings/site-settings.validation";
30
32
  export const SITE_SETTINGS = Symbol("site-settings") as ServiceKey<SiteSettingsService>;
31
33
  export const PROFILE_IMAGES = Symbol("profile-images") as ServiceKey<ProfileImageService>;
32
34
  export const ADMIN_SESSIONS = Symbol("admin-sessions") as ServiceKey<AdminSessionsService>;
35
+ export const ADMIN_USERS = Symbol("admin-users") as ServiceKey<AdminUsersService>;
33
36
 
34
37
  const ICON_TYPES = new Set([
35
38
  "image/svg+xml",
@@ -54,6 +57,7 @@ const adminPlugin: AppPlugin = ({ services }) => {
54
57
  sessions: services.resolve(ADMIN_SESSIONS),
55
58
  revoke: createSessionRevoker(() => getAuth().api as unknown as RevokeUserSessionApi),
56
59
  }))
60
+ .use(adminUserRoutes({ auth, users: services.resolve(ADMIN_USERS) }))
57
61
  .use(siteAssetRoutes(site, profileImages))
58
62
  .get("/site/settings", async () => {
59
63
  // The favicon fields are site.* rows too, so one read serves the response.
@@ -127,6 +131,7 @@ export const adminModule: PodokitModule = {
127
131
  configure: (_env, services): void => {
128
132
  const database = services.resolve(DATABASE);
129
133
  services.register(ADMIN_SESSIONS, new AdminSessionsService(database.sql, database.provider));
134
+ services.register(ADMIN_USERS, new AdminUsersService(database.sql, database.provider));
130
135
  const storage = services.resolve(OBJECT_STORAGE);
131
136
  const site = new SiteSettingsService(services.resolve(DATABASE).sql, storage);
132
137
  const profileImages = new ProfileImageService(storage, services.resolve(LOGGER));
@@ -0,0 +1,60 @@
1
+ // Bundled entry for scripts/bootstrap-admin.mjs. The build bundles only its
2
+ // top-level targets, so the script cannot import auth modules from dist directly.
3
+ // The command runs against a migrated schema and reports a missing table from its
4
+ // own query. Better Auth's asynchronous startup check would only race the
5
+ // connection the command closes when it finishes.
6
+ import "./auth/skip-schema-validation";
7
+ import { verifyPassword } from "better-auth/crypto";
8
+ import { auth } from "./auth/auth";
9
+ import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
10
+
11
+ export interface BootstrapUser {
12
+ id: string;
13
+ email: string;
14
+ role: string | null;
15
+ emailVerified: boolean;
16
+ signupApproved: boolean;
17
+ passwordHash: string | null;
18
+ }
19
+
20
+ interface BootstrapUserRow extends Omit<BootstrapUser, "emailVerified" | "signupApproved"> {
21
+ // SQLite stores booleans as 0 or 1.
22
+ emailVerified: boolean | number | null;
23
+ signupApproved: boolean | number | null;
24
+ }
25
+
26
+ function userQuery(placeholder: string): string {
27
+ return `SELECT u.id,
28
+ u.email,
29
+ u.role,
30
+ u."emailVerified" AS "emailVerified",
31
+ u."signupApproved" AS "signupApproved",
32
+ a.password AS "passwordHash"
33
+ FROM "user" AS u
34
+ LEFT JOIN "account" AS a
35
+ ON a."userId" = u.id
36
+ AND a."providerId" = 'credential'
37
+ WHERE lower(u.email) = lower(${placeholder})
38
+ LIMIT 1`;
39
+ }
40
+
41
+ function flag(value: boolean | number | null): boolean {
42
+ return value === true || value === 1;
43
+ }
44
+
45
+ export async function findBootstrapUser(email: string): Promise<BootstrapUser | null> {
46
+ let row: BootstrapUserRow | undefined;
47
+ if (postgresPool) {
48
+ row = (await postgresPool.query<BootstrapUserRow>(userQuery("$1"), [email])).rows[0];
49
+ } else if (sqliteDatabase) {
50
+ row = sqliteDatabase.query<BootstrapUserRow, [string]>(userQuery("?1")).get(email) ?? undefined;
51
+ }
52
+ if (!row) return null;
53
+ return {
54
+ ...row,
55
+ emailVerified: flag(row.emailVerified),
56
+ signupApproved: flag(row.signupApproved),
57
+ };
58
+ }
59
+
60
+ export { auth, closeAuthDatabase, verifyPassword };
@@ -0,0 +1,35 @@
1
+ import type { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ // The admin session list pages active sessions newest first with a keyset on
4
+ // ("createdAt", "id"). Better Auth's schema indexes only "userId" and "token",
5
+ // so without this index every page sorts the whole session table.
6
+ const INDEX = "IDX_session_createdAt_id";
7
+
8
+ export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
9
+ name = "IndexSessionCreatedAt1792060000000";
10
+ // CONCURRENTLY cannot run inside a transaction block. The runner therefore
11
+ // uses the "each" transaction mode. SQLite migrations always use one.
12
+ transaction = false;
13
+
14
+ async up(queryRunner: QueryRunner): Promise<void> {
15
+ if (queryRunner.connection.options.type !== "postgres") {
16
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
17
+ return;
18
+ }
19
+ // Sign-ins keep writing sessions while the index builds. An interrupted
20
+ // build leaves an invalid index that IF NOT EXISTS would keep.
21
+ const invalid = await queryRunner.query(
22
+ `SELECT 1 AS "invalid" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid`,
23
+ [`"${INDEX}"`],
24
+ ) as unknown[];
25
+ if (invalid.length > 0) await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${INDEX}"`);
26
+ await queryRunner.query(
27
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`,
28
+ );
29
+ }
30
+
31
+ // A revert runs inside a transaction, so it uses a plain statement.
32
+ async down(queryRunner: QueryRunner): Promise<void> {
33
+ await queryRunner.query(`DROP INDEX IF EXISTS "${INDEX}"`);
34
+ }
35
+ }
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { keepLatestFilter, listUsersQuery, type UserListState } from "./users";
2
+ import {
3
+ adminUsersQuery,
4
+ keepLatestFilter,
5
+ listAdminUsers,
6
+ listUsersQuery,
7
+ type UserListState,
8
+ } from "./users";
3
9
 
4
10
  const base: UserListState = {
5
11
  page: 1,
@@ -11,9 +17,9 @@ const base: UserListState = {
11
17
  status: "",
12
18
  };
13
19
 
14
- describe("listUsersQuery", () => {
20
+ describe("adminUsersQuery", () => {
15
21
  it("pages newest first by default", () => {
16
- expect(listUsersQuery({ ...base, page: 3 })).toEqual({
22
+ expect(adminUsersQuery({ ...base, page: 3 })).toEqual({
17
23
  limit: 15,
18
24
  offset: 30,
19
25
  sortBy: "createdAt",
@@ -22,54 +28,75 @@ describe("listUsersQuery", () => {
22
28
  });
23
29
 
24
30
  it("sorts by a table column on the server", () => {
25
- expect(listUsersQuery({ ...base, sort: { key: "name", dir: "asc" } })).toMatchObject({
31
+ expect(adminUsersQuery({ ...base, sort: { key: "name", dir: "asc" } })).toMatchObject({
26
32
  sortBy: "name",
27
33
  sortDirection: "asc",
28
34
  });
29
- expect(listUsersQuery({ ...base, sort: { key: "status", dir: "asc" } })).toMatchObject({
35
+ expect(adminUsersQuery({ ...base, sort: { key: "status", dir: "asc" } })).toMatchObject({
30
36
  sortBy: "createdAt",
31
37
  sortDirection: "desc",
32
38
  });
33
39
  });
34
40
 
35
- it("searches email case-insensitively and names as typed", () => {
36
- expect(listUsersQuery({ ...base, search: " Ada@Example.COM " })).toMatchObject({
37
- searchValue: "ada@example.com",
41
+ it("searches the chosen field with the text trimmed", () => {
42
+ expect(adminUsersQuery({ ...base, search: " Ada@Example.COM " })).toMatchObject({
43
+ search: "Ada@Example.COM",
38
44
  searchField: "email",
39
- searchOperator: "contains",
40
45
  });
41
- expect(listUsersQuery({ ...base, search: "Ada", searchField: "name" })).toMatchObject({
42
- searchValue: "Ada",
46
+ expect(adminUsersQuery({ ...base, search: "Ada", searchField: "name" })).toMatchObject({
47
+ search: "Ada",
43
48
  searchField: "name",
44
49
  });
45
- expect(listUsersQuery({ ...base, search: " " })).not.toHaveProperty("searchValue");
50
+ expect(adminUsersQuery({ ...base, search: " " })).not.toHaveProperty("search");
46
51
  });
47
52
 
48
- it("maps the role and status filters to the single server filter", () => {
49
- expect(listUsersQuery({ ...base, role: "admin" })).toMatchObject({
50
- filterField: "role",
51
- filterValue: "admin",
52
- filterOperator: "eq",
53
+ it("sends the role and status filters together", () => {
54
+ expect(adminUsersQuery({ ...base, role: "admin", status: "active" })).toMatchObject({
55
+ role: "admin",
56
+ status: "active",
53
57
  });
54
- expect(listUsersQuery({ ...base, status: "pending" })).toMatchObject({
55
- filterField: "signupApproved",
56
- filterValue: false,
57
- });
58
- expect(listUsersQuery({ ...base, status: "banned" })).toMatchObject({
59
- filterField: "banned",
60
- filterValue: true,
61
- });
62
- expect(listUsersQuery(base)).not.toHaveProperty("filterField");
58
+ expect(adminUsersQuery({ ...base, status: "pending" })).toMatchObject({ status: "pending" });
59
+ expect(adminUsersQuery({ ...base, status: "deleted" })).not.toHaveProperty("status");
60
+ expect(adminUsersQuery(base)).not.toHaveProperty("role");
61
+ });
62
+ });
63
+
64
+ describe("listAdminUsers", () => {
65
+ it("asks GET /admin/users for one page", async () => {
66
+ const paths: string[] = [];
67
+ const client = {
68
+ get: <T>(path: string): Promise<T> => {
69
+ paths.push(path);
70
+ return Promise.resolve({ items: [], total: 0 } as unknown as T);
71
+ },
72
+ };
73
+
74
+ await listAdminUsers(client, adminUsersQuery({ ...base, page: 2, search: "ada", role: "admin", status: "banned" }));
75
+ await listAdminUsers(client, adminUsersQuery(base));
76
+
77
+ expect(paths).toEqual([
78
+ "/admin/users?limit=15&offset=15&sortBy=createdAt&sortDirection=desc&search=ada&searchField=email&role=admin&status=banned",
79
+ "/admin/users?limit=15&offset=0&sortBy=createdAt&sortDirection=desc",
80
+ ]);
63
81
  });
64
82
  });
65
83
 
66
- describe("keepLatestFilter", () => {
67
- it("keeps whichever filter changed last", () => {
84
+ // Application-owned users pages from 1.2 still import these.
85
+ describe("deprecated listUsers helpers", () => {
86
+ it("still builds the single-filter Better Auth query", () => {
87
+ expect(listUsersQuery({ ...base, page: 2, search: " Ada@Example.COM ", status: "banned" })).toEqual({
88
+ limit: 15,
89
+ offset: 15,
90
+ sortBy: "createdAt",
91
+ sortDirection: "desc",
92
+ searchValue: "ada@example.com",
93
+ searchField: "email",
94
+ searchOperator: "contains",
95
+ filterField: "banned",
96
+ filterValue: true,
97
+ filterOperator: "eq",
98
+ });
68
99
  expect(keepLatestFilter({ role: "admin", status: "" }, { role: "admin", status: "banned" }))
69
100
  .toEqual({ role: "", status: "banned" });
70
- expect(keepLatestFilter({ role: "", status: "banned" }, { role: "user", status: "banned" }))
71
- .toEqual({ role: "user", status: "" });
72
- expect(keepLatestFilter({ role: "", status: "" }, { role: "user", status: "" }))
73
- .toEqual({ role: "user", status: "" });
74
101
  });
75
102
  });