@podosoft/podokit 1.1.6 → 1.2.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 (165) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  107. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  112. package/dist/templates/modules/auth/module.manifest.json +2 -2
  113. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  114. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  115. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  117. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  118. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  119. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  120. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  121. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  124. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  125. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  126. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  127. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  128. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  129. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  130. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  131. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  134. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  135. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  136. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  137. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  138. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  142. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  143. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  144. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  145. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  146. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  150. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  151. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  152. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  153. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  154. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  155. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  156. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  157. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  159. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  161. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  162. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  163. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  164. package/dist/templates/modules/sse/module.manifest.json +17 -4
  165. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import type { PageServerLoad } from "./$types";
2
+ import { getCapabilities, type CapabilitiesEvent } from "#lib/server/capabilities.js";
2
3
  import { requireAdmin } from "#lib/server/guards.js";
3
4
 
4
5
  // Non-secret view of the DB-backed auth config (OAuth providers, SMTP, server
@@ -20,23 +21,23 @@ export type AuthConfigView = {
20
21
  };
21
22
  };
22
23
 
23
- export const load: PageServerLoad = async ({ locals, fetch }) => {
24
- requireAdmin(locals.user, locals);
25
- let authConfig: AuthConfigView | null = null;
24
+ async function loadAuthConfig(fetch: CapabilitiesEvent["fetch"]): Promise<AuthConfigView | null> {
26
25
  try {
27
26
  const res = await fetch("/api/account/auth-config");
28
- if (res.ok) authConfig = (await res.json()) as AuthConfigView;
27
+ if (res.ok) return (await res.json()) as AuthConfigView;
29
28
  } catch {
30
29
  /* leave null — the page shows the DB-config sections as unavailable */
31
30
  }
32
- // The require-two-factor policy lives in the feature-flag store (not the typed
33
- // Capabilities), so read it from its own endpoint for the Settings toggle.
34
- let require2fa = false;
35
- try {
36
- const res = await fetch("/api/account/require-2fa");
37
- if (res.ok) require2fa = ((await res.json()) as { require2fa?: boolean }).require2fa === true;
38
- } catch {
39
- /* default off */
40
- }
41
- return { authConfig, require2fa };
31
+ return null;
32
+ }
33
+
34
+ export const load: PageServerLoad = async ({ locals, fetch }) => {
35
+ requireAdmin(locals.user, locals);
36
+ // The admin layout loads the same capabilities in this request, so the
37
+ // require-two-factor policy comes from that shared response.
38
+ const [authConfig, capabilities] = await Promise.all([
39
+ loadAuthConfig(fetch),
40
+ getCapabilities({ locals, fetch }),
41
+ ]);
42
+ return { authConfig, require2fa: capabilities.require2fa };
42
43
  };
@@ -24,8 +24,8 @@
24
24
  const ac = $derived(data.authConfig);
25
25
 
26
26
  type EditableFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
27
- // require2fa is a policy flag stored alongside the feature flags but not part of
28
- // the typed Capabilities, so it is toggled but never read from `caps`.
27
+ // require2fa is an enrolment policy rather than a feature, so its card reads
28
+ // `data.require2fa` instead of the feature flags in `caps`.
29
29
  type ToggleFlag = EditableFlag | "require2fa";
30
30
  type ServerKey = "requireEmailVerification" | "requireSignupApproval" | "hibp" | "allowDelete" | "auditLog";
31
31
 
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from "svelte";
2
3
  import { goto } from "$app/navigation";
3
4
  import { Button } from "#lib/components/ui/button/index.js";
4
5
  import { Input } from "#lib/components/ui/input/index.js";
@@ -15,6 +16,8 @@
15
16
  import PlusIcon from "@lucide/svelte/icons/plus";
16
17
  import { toast } from "svelte-sonner";
17
18
  import { api } from "#lib/api.js";
19
+ import { listAdminSessions, revokeAdminSession, type AdminSession } from "#lib/admin/sessions.js";
20
+ import { DEFAULT_USER_SORT, keepLatestFilter, listUsersQuery } from "#lib/admin/users.js";
18
21
  import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
19
22
  import { cn } from "#lib/utils.js";
20
23
  import { deviceLabel } from "#lib/device-label.js";
@@ -42,11 +45,16 @@
42
45
  };
43
46
 
44
47
  const PAGE_SIZE = DEFAULT_PAGE_SIZE;
45
- const LOAD_LIMIT = 500; // bounded client-side list; add server-side filtering for larger deployments
48
+ // Sessions shown in the manage dialog: the newest page for one user.
49
+ const MANAGED_SESSIONS_LIMIT = 100;
46
50
  let users = $state<Row[]>([]);
51
+ let total = $state(0);
47
52
  let page = $state(1);
48
- let sort = $state<SortState | null>(null);
53
+ let sort = $state<SortState | null>({ ...DEFAULT_USER_SORT });
54
+ let loading = $state(false);
49
55
  let busy = $state(false);
56
+ let latestRequest = 0;
57
+ let loadedSortKey = `${DEFAULT_USER_SORT.key}:${DEFAULT_USER_SORT.dir}`;
50
58
 
51
59
  // Toolbar: filters + search both commit on the Search button (nothing applies
52
60
  // until then), so multiple filter/search conditions apply together.
@@ -56,6 +64,19 @@
56
64
  let appliedSearchField = $state("email");
57
65
  let filterValues = $state<Record<string, string>>({ role: "", status: "" });
58
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
+ });
59
80
 
60
81
  const columns = $derived<DataTableColumn<Row>[]>([
61
82
  { key: "name", label: i18n.t.users.name, sortable: true },
@@ -82,7 +103,6 @@
82
103
  label: i18n.t.users.status,
83
104
  options: [
84
105
  { value: "", label: i18n.t.toolbar.all },
85
- { value: "active", label: i18n.t.users.active },
86
106
  { value: "pending", label: i18n.t.users.pendingApproval },
87
107
  { value: "banned", label: i18n.t.users.banned },
88
108
  ],
@@ -93,32 +113,44 @@
93
113
  { value: "name", label: i18n.t.users.name },
94
114
  ]);
95
115
 
96
- const filtered = $derived(
97
- users.filter((u) => {
98
- if (appliedSearch) {
99
- const hay = (appliedSearchField === "name" ? u.name : u.email).toLowerCase();
100
- if (!hay.includes(appliedSearch.toLowerCase())) return false;
101
- }
102
- if (appliedFilters.role && (u.role ?? "user") !== appliedFilters.role) return false;
103
- if (appliedFilters.status === "banned" && !u.banned) return false;
104
- if (appliedFilters.status === "pending" && u.signupApproved !== false) return false;
105
- if (appliedFilters.status === "active" && (u.banned || u.signupApproved === false)) return false;
106
- return true;
107
- }),
108
- );
109
-
116
+ // Loads only the current page; a newer request supersedes one still in flight.
110
117
  async function load(): Promise<void> {
111
- const { data: res, error } = await api.auth.admin.listUsers({ query: { limit: LOAD_LIMIT } });
112
- if (error) return void toast.error(error.message ?? i18n.t.users.loadFailed);
113
- users = (res?.users ?? []) as Row[];
118
+ const request = ++latestRequest;
119
+ const sortKey = sort ? `${sort.key}:${sort.dir}` : "";
120
+ if (sortKey !== loadedSortKey) page = 1;
121
+ const query = listUsersQuery({
122
+ page,
123
+ perPage: PAGE_SIZE,
124
+ sort,
125
+ search: appliedSearch,
126
+ searchField: appliedSearchField,
127
+ role: appliedFilters.role ?? "",
128
+ status: appliedFilters.status ?? "",
129
+ });
130
+ loading = true;
131
+ const { data: res, error } = await api.auth.admin.listUsers({ query });
132
+ if (request !== latestRequest) return;
133
+ loading = false;
134
+ if (error) return void toast.error(i18n.t.users.loadFailed);
135
+ const rows = (res?.users ?? []) as Row[];
136
+ const count = res?.total ?? rows.length;
137
+ // An action can empty the last page; show the one before it instead.
138
+ const lastPage = Math.max(Math.ceil(count / PAGE_SIZE), 1);
139
+ if (rows.length === 0 && page > lastPage) {
140
+ page = lastPage;
141
+ return load();
142
+ }
143
+ loadedSortKey = sortKey;
144
+ users = rows;
145
+ total = count;
114
146
  }
115
147
 
116
- async function applySearch(): Promise<void> {
117
- await load(); // refresh from the server on explicit search, then filter client-side
148
+ function applySearch(): void {
118
149
  appliedSearch = search;
119
150
  appliedSearchField = searchField;
120
151
  appliedFilters = { ...filterValues };
121
152
  page = 1;
153
+ void load();
122
154
  }
123
155
 
124
156
  // Impersonate — become the user, then continue on the signed-in account page
@@ -176,7 +208,6 @@
176
208
 
177
209
  // Manage user — a two-pane modal mirroring the account settings, admin-scoped.
178
210
  type ManageSection = "profile" | "security" | "sessions" | "danger";
179
- type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
180
211
  let manageOpen = $state(false);
181
212
  let mUser = $state<Row | null>(null);
182
213
  let mSection = $state<ManageSection>("profile");
@@ -192,10 +223,11 @@
192
223
  let mBanReason = $state("");
193
224
  let mBanDays = $state("");
194
225
  // sessions
195
- let mSessions = $state<Session[]>([]);
226
+ let mSessions = $state<AdminSession[]>([]);
227
+ let mSessionsTotal = $state(0);
196
228
  let mSessionsPage = $state(1);
197
229
  let mSessionsSort = $state<SortState | null>(null);
198
- const mSessionsColumns: DataTableColumn<Session>[] = [
230
+ const mSessionsColumns: DataTableColumn<AdminSession>[] = [
199
231
  { key: "userAgent", label: i18n.t.adminSessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
200
232
  { key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
201
233
  { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
@@ -217,6 +249,7 @@
217
249
  mBanDays = "";
218
250
  mDeleteArmed = false;
219
251
  mSessions = [];
252
+ mSessionsTotal = 0;
220
253
  mSessionsPage = 1;
221
254
  manageOpen = true;
222
255
  await loadManagedSessions();
@@ -224,8 +257,15 @@
224
257
 
225
258
  async function loadManagedSessions(): Promise<void> {
226
259
  if (!mUser) return;
227
- const { data: res } = await api.auth.admin.listUserSessions({ userId: mUser.id });
228
- mSessions = (res?.sessions ?? []) as Session[];
260
+ const userId = mUser.id;
261
+ try {
262
+ const result = await listAdminSessions(api, { limit: MANAGED_SESSIONS_LIMIT, userId });
263
+ if (mUser?.id !== userId) return;
264
+ mSessions = result.items;
265
+ mSessionsTotal = result.total;
266
+ } catch {
267
+ toast.error(i18n.t.adminSessions.loadFailed);
268
+ }
229
269
  }
230
270
 
231
271
  async function saveProfile(event: Event): Promise<void> {
@@ -329,11 +369,15 @@
329
369
  await load();
330
370
  }
331
371
 
332
- async function revokeSession(token: string): Promise<void> {
372
+ async function revokeSession(id: string): Promise<void> {
333
373
  busy = true;
334
- const { error } = await api.auth.admin.revokeUserSession({ sessionToken: token });
335
- busy = false;
336
- if (error) return void toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
374
+ try {
375
+ await revokeAdminSession(api, id);
376
+ } catch {
377
+ return void toast.error(i18n.t.adminSessions.revokeFailed);
378
+ } finally {
379
+ busy = false;
380
+ }
337
381
  toast.success(i18n.t.adminSessions.revoked);
338
382
  await loadManagedSessions();
339
383
  }
@@ -343,7 +387,7 @@
343
387
  busy = true;
344
388
  const { error } = await api.auth.admin.revokeUserSessions({ userId: mUser.id });
345
389
  busy = false;
346
- if (error) return void toast.error(error.message ?? i18n.t.adminSessions.loadFailed);
390
+ if (error) return void toast.error(error.message ?? i18n.t.adminSessions.revokeFailed);
347
391
  toast.success(i18n.t.adminSessions.allRevoked);
348
392
  await loadManagedSessions();
349
393
  }
@@ -360,7 +404,7 @@
360
404
  }
361
405
 
362
406
  $effect(() => {
363
- void load();
407
+ untrack(() => void load());
364
408
  });
365
409
  </script>
366
410
 
@@ -383,14 +427,18 @@
383
427
 
384
428
  <DataTable
385
429
  {columns}
386
- rows={filtered}
430
+ rows={users}
387
431
  getKey={(u) => u.id}
388
- empty={i18n.t.users.empty}
432
+ empty={loading ? "…" : i18n.t.users.empty}
389
433
  bind:sort
390
434
  bind:page
435
+ manualSort
436
+ manualPagination
391
437
  perPage={PAGE_SIZE}
438
+ {total}
392
439
  ariaLabel={i18n.t.users.title}
393
- label={fmt(i18n.t.users.total, { count: filtered.length })}
440
+ label={fmt(i18n.t.users.total, { count: total })}
441
+ onChange={() => void load()}
394
442
  >
395
443
  {#snippet row(user, { cellClass })}
396
444
  <Table.Cell class={cn(cellClass("name"), "font-medium")}>{user.name}</Table.Cell>
@@ -555,14 +603,14 @@
555
603
  bind:sort={mSessionsSort}
556
604
  bind:page={mSessionsPage}
557
605
  perPage={PAGE_SIZE}
558
- label={fmt(i18n.t.adminSessions.total, { count: mSessions.length })}
606
+ label={fmt(i18n.t.adminSessions.total, { count: mSessionsTotal })}
559
607
  empty={i18n.t.adminSessions.empty}
560
608
  >
561
609
  {#snippet row(s)}
562
610
  <Table.Cell class="max-w-40 truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
563
611
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
564
612
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
565
- <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
613
+ <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.id)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
566
614
  {/snippet}
567
615
  </DataTable>
568
616
  </div>
@@ -1,9 +1,8 @@
1
1
  import type { LayoutServerLoad } from "./$types";
2
2
  import { redirect } from "@sveltejs/kit";
3
3
  import { safeAuthRedirect } from "#lib/auth-redirect.js";
4
- import { loadMessages } from "#lib/i18n/messages.js";
4
+ import { getCapabilities } from "#lib/server/capabilities.js";
5
5
  import { isPublicPath, requireBackendAvailable } from "#lib/server/guards.js";
6
- import type { Capabilities } from "@podosoft/podokit-api-client";
7
6
 
8
7
  // "/" is a public landing page. Auth pages return signed-in users to their safe
9
8
  // requested page, and the admin area requires a session. /maintenance is public
@@ -43,19 +42,13 @@ export const load: LayoutServerLoad = async (event) => {
43
42
  if (!locals.user && !isPublicPath(pathname)) {
44
43
  redirect(303, `/login?redirect=${encodeURIComponent(`${pathname}${url.search}`)}`);
45
44
  }
46
- let sessionIdleTimeoutMinutes: number | null = null;
47
- if (locals.user) {
48
- try {
49
- const response = await event.fetch("/api/account/capabilities");
50
- if (response.ok) {
51
- const capabilities = (await response.json()) as Capabilities;
52
- sessionIdleTimeoutMinutes = capabilities.sessionIdleTimeoutMinutes ?? null;
53
- }
54
- } catch {
55
- // Keep automatic logout disabled in the browser if policy lookup fails.
56
- // The server-side session expiration remains authoritative.
57
- }
58
- }
59
- const messages = await loadMessages(locals.locale, site?.locale);
60
- return { user: locals.user, locale: locals.locale, messages, site, sessionIdleTimeoutMinutes };
45
+ // Automatic logout stays disabled in the browser if the policy lookup fails;
46
+ // the server-side session expiration remains authoritative. Protected loads
47
+ // of the same request reuse this capabilities response.
48
+ const sessionIdleTimeoutMinutes = locals.user
49
+ ? ((await getCapabilities(event)).sessionIdleTimeoutMinutes ?? null)
50
+ : null;
51
+ // Messages are resolved by the universal +layout.ts. This load re-runs on every
52
+ // client navigation for the path checks above and must not resend them.
53
+ return { user: locals.user, locale: locals.locale, site, sessionIdleTimeoutMinutes };
61
54
  };
@@ -0,0 +1,10 @@
1
+ import { loadMessages } from "#lib/i18n/messages.js";
2
+ import type { LayoutLoad } from "./$types";
3
+
4
+ // Runs on the server for the first render and in the browser afterwards, so the
5
+ // catalogs never travel in `__data.json`. loadMessages() reuses one merged
6
+ // object per locale pair; the language switch changes `data.locale`.
7
+ export const load: LayoutLoad = async ({ data }) => ({
8
+ ...data,
9
+ messages: await loadMessages(data.locale, data.site?.locale),
10
+ });
@@ -3,6 +3,7 @@
3
3
  import { Button } from "#lib/components/ui/button/index.js";
4
4
  import * as Card from "#lib/components/ui/card/index.js";
5
5
  import { getI18n } from "#lib/i18n/index.js";
6
+ import { signedOAuthQuery } from "#lib/oauth-query.js";
6
7
 
7
8
  const i18n = getI18n();
8
9
  // The OAuth provider redirects here with the authorization request in the query
@@ -14,13 +15,13 @@
14
15
  async function decide(accept: boolean): Promise<void> {
15
16
  busy = true;
16
17
  // The authorization request is carried in the signed query params we were
17
- // redirected here with; forward them verbatim so the server can validate and
18
- // restore it. Credentials carry the user's session. The response says where next.
18
+ // redirected here with; the server verifies them from the body, then restores
19
+ // the request. Credentials carry the user's session. The response says where next.
19
20
  const res = await fetch(`/api/auth/oauth2/consent${page.url.search}`, {
20
21
  method: "POST",
21
22
  credentials: "include",
22
23
  headers: { "content-type": "application/json" },
23
- body: JSON.stringify({ accept }),
24
+ body: JSON.stringify({ accept, oauth_query: signedOAuthQuery(page.url.search) }),
24
25
  });
25
26
  const data = (await res.json().catch(() => ({}))) as { url?: string; redirectURI?: string };
26
27
  const next = data.url ?? data.redirectURI;
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { goto } from "$app/navigation";
3
- import QRCode from "qrcode";
4
3
  import { Button } from "#lib/components/ui/button/index.js";
5
4
  import { Input } from "#lib/components/ui/input/index.js";
6
5
  import { Label } from "#lib/components/ui/label/index.js";
@@ -21,13 +20,20 @@
21
20
 
22
21
  $effect(() => {
23
22
  const uri = setup?.totpURI;
24
- if (!uri) {
25
- qrDataUrl = "";
26
- return;
27
- }
28
- QRCode.toDataURL(uri, { margin: 1, width: 176 })
29
- .then((url) => (qrDataUrl = url))
30
- .catch(() => (qrDataUrl = ""));
23
+ qrDataUrl = "";
24
+ if (!uri) return;
25
+ let active = true;
26
+ import("qrcode")
27
+ .then(({ toDataURL }) => toDataURL(uri, { margin: 1, width: 176 }))
28
+ .then((url) => {
29
+ if (active && setup?.totpURI === uri) qrDataUrl = url;
30
+ })
31
+ .catch(() => {
32
+ if (active) qrDataUrl = "";
33
+ });
34
+ return () => {
35
+ active = false;
36
+ };
31
37
  });
32
38
 
33
39
  async function begin(): Promise<void> {
@@ -44,5 +44,6 @@ put pagination outside the table.
44
44
  above the DataTable. Filters + search apply together on the Search button/Enter
45
45
  (don't query on every keystroke); the search field is a `select` of columns.
46
46
 
47
- The `/admin/users` page is the reference implementation. See `docs/development.md`
47
+ The `/admin/users` page is the reference implementation (server offset paging);
48
+ `/admin/sessions` shows keyset paging with a cursor. See `docs/development.md`
48
49
  ("Data tables", "Search & filters") on the PodoKit repo.
@@ -0,0 +1,124 @@
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
+ const PASSWORD = "Podokit3e-Str0ng!pw";
8
+ // The rate-limit module's default ceiling for /api/auth/* per window. Listing
9
+ // sessions through Better Auth took 1 + one request per user from that budget;
10
+ // the sessions endpoint takes one general request per page.
11
+ const DEFAULT_AUTH_RATE_LIMIT = 20;
12
+
13
+ type SessionItem = {
14
+ id: string;
15
+ userId: string;
16
+ userEmail: string;
17
+ createdAt: string;
18
+ [key: string]: unknown;
19
+ };
20
+ type SessionPage = { items: SessionItem[]; nextCursor: string | null; total: number };
21
+
22
+ async function signedIn(
23
+ playwright: PlaywrightWorkerArgs["playwright"],
24
+ account: { email: string; password: string },
25
+ ): Promise<APIRequestContext> {
26
+ const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
27
+ expect((await context.post("/api/auth/sign-in/email", { data: account })).ok()).toBeTruthy();
28
+ return context;
29
+ }
30
+
31
+ test("admin pages through every user's session with a few requests outside the auth budget", async ({
32
+ playwright,
33
+ disposableUsers,
34
+ }) => {
35
+ test.setTimeout(180_000);
36
+ const prefix = `sess-page-${Date.now()}`;
37
+ const userCount = 27;
38
+ const pageSize = 10;
39
+ const userIds = new Set<string>();
40
+ const signIns = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
41
+ for (let index = 0; index < userCount; index += 1) {
42
+ const email = `${prefix}-${String(index).padStart(2, "0")}@example.com`;
43
+ userIds.add(await disposableUsers.create({ email, name: `Session pager ${index}` }));
44
+ expect((await signIns.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).ok())
45
+ .toBeTruthy();
46
+ }
47
+ await signIns.dispose();
48
+
49
+ const admin = await signedIn(playwright, ADMIN);
50
+ const seen: SessionItem[] = [];
51
+ let requests = 0;
52
+ let cursor: string | null = null;
53
+ do {
54
+ const query = new URLSearchParams({ limit: String(pageSize), search: prefix });
55
+ if (cursor) query.set("cursor", cursor);
56
+ const response = await admin.get(`/api/admin/sessions?${query.toString()}`);
57
+ requests += 1;
58
+ expect(response.status()).toBe(200);
59
+ const remaining = response.headers()["ratelimit-remaining"];
60
+ if (remaining !== undefined) expect(Number(remaining)).toBeGreaterThan(0);
61
+ const page = (await response.json()) as SessionPage;
62
+ expect(page.total).toBe(userCount);
63
+ for (const item of page.items) expect(item).not.toHaveProperty("token");
64
+ seen.push(...page.items);
65
+ cursor = page.nextCursor;
66
+ } while (cursor && requests <= userCount);
67
+
68
+ expect(requests).toBe(Math.ceil(userCount / pageSize));
69
+ expect(requests).toBeLessThan(DEFAULT_AUTH_RATE_LIMIT);
70
+ expect(new Set(seen.map((item) => item.id)).size).toBe(userCount);
71
+ expect(new Set(seen.map((item) => item.userId))).toEqual(userIds);
72
+ const created = seen.map((item) => Date.parse(item.createdAt));
73
+ expect(created).toEqual([...created].sort((left, right) => right - left));
74
+ await admin.dispose();
75
+ });
76
+
77
+ test("a normal user cannot list or revoke sessions", async ({ playwright }) => {
78
+ const user = await signedIn(playwright, USER);
79
+ expect((await user.get("/api/admin/sessions")).status()).toBe(403);
80
+ expect((await user.delete("/api/admin/sessions/any-session")).status()).toBe(403);
81
+ await user.dispose();
82
+ });
83
+
84
+ test("admin revokes a session by id and the action is audited", async ({ playwright, disposableUsers }) => {
85
+ const email = `sess-revoke-${Date.now()}@example.com`;
86
+ const userId = await disposableUsers.create({ email, name: "Revoke by id" });
87
+ const theirs = await signedIn(playwright, { email, password: PASSWORD });
88
+ const admin = await signedIn(playwright, ADMIN);
89
+
90
+ const listed = await admin.get(`/api/admin/sessions?userId=${encodeURIComponent(userId)}`);
91
+ expect(listed.ok()).toBeTruthy();
92
+ const page = (await listed.json()) as SessionPage;
93
+ expect(page.items).toHaveLength(1);
94
+ const sessionId = page.items[0]?.id ?? "";
95
+
96
+ const revoked = await admin.delete(`/api/admin/sessions/${encodeURIComponent(sessionId)}`);
97
+ expect(revoked.status()).toBe(204);
98
+ expect(await (await theirs.get("/api/auth/get-session")).json()).toBeNull();
99
+ expect((await admin.delete(`/api/admin/sessions/${encodeURIComponent(sessionId)}`)).status()).toBe(404);
100
+
101
+ // The revoke runs through Better Auth, so the audit-log module records it when
102
+ // the module is installed and auditing is on.
103
+ const capabilities = (await (await admin.get("/api/account/capabilities")).json()) as { auditLog?: boolean };
104
+ const audit = await admin.get("/api/audit-logs");
105
+ if (capabilities.auditLog === true && audit.ok()) {
106
+ const body = (await audit.json()) as unknown;
107
+ const entries = (Array.isArray(body) ? body : (body as { items?: unknown[] }).items ?? []) as Array<{
108
+ action?: string;
109
+ actorEmail?: string | null;
110
+ }>;
111
+ expect(entries.some((entry) => entry.action === "user.session_revoke" && entry.actorEmail === ADMIN.email))
112
+ .toBeTruthy();
113
+ }
114
+ await theirs.dispose();
115
+ await admin.dispose();
116
+ });
117
+
118
+ test("a forged cursor is rejected", async ({ playwright }) => {
119
+ const admin = await signedIn(playwright, ADMIN);
120
+ const response = await admin.get("/api/admin/sessions?cursor=forged");
121
+ expect(response.status()).toBe(400);
122
+ expect((await response.json())?.error?.code).toBe("ADMIN_SESSION_CURSOR_INVALID");
123
+ await admin.dispose();
124
+ });