@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.
- package/dist/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Builds the Better Auth `admin.listUsers` query for the server-paged users table.
|
|
2
|
+
|
|
3
|
+
export type UserListSort = { key: string; dir: "asc" | "desc" };
|
|
4
|
+
|
|
5
|
+
export type UserListState = {
|
|
6
|
+
page: number;
|
|
7
|
+
perPage: number;
|
|
8
|
+
sort: UserListSort | null;
|
|
9
|
+
search: string;
|
|
10
|
+
searchField: string;
|
|
11
|
+
role: string;
|
|
12
|
+
status: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ListUsersQuery = {
|
|
16
|
+
limit: number;
|
|
17
|
+
offset: number;
|
|
18
|
+
sortBy: string;
|
|
19
|
+
sortDirection: "asc" | "desc";
|
|
20
|
+
searchValue?: string;
|
|
21
|
+
searchField?: "email" | "name";
|
|
22
|
+
searchOperator?: "contains";
|
|
23
|
+
filterField?: "role" | "banned" | "signupApproved";
|
|
24
|
+
filterValue?: string | boolean;
|
|
25
|
+
filterOperator?: "eq";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_USER_SORT: UserListSort = { key: "createdAt", dir: "desc" };
|
|
29
|
+
|
|
30
|
+
const SORTABLE_FIELDS = new Set(["name", "email", "role", "createdAt"]);
|
|
31
|
+
|
|
32
|
+
function filterFor(role: string, status: string): Pick<ListUsersQuery, "filterField" | "filterValue" | "filterOperator"> {
|
|
33
|
+
if (status === "pending") return { filterField: "signupApproved", filterValue: false, filterOperator: "eq" };
|
|
34
|
+
if (status === "banned") return { filterField: "banned", filterValue: true, filterOperator: "eq" };
|
|
35
|
+
if (role) return { filterField: "role", filterValue: role, filterOperator: "eq" };
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function listUsersQuery(state: UserListState): ListUsersQuery {
|
|
40
|
+
const sort = state.sort && SORTABLE_FIELDS.has(state.sort.key) ? state.sort : DEFAULT_USER_SORT;
|
|
41
|
+
const searchField = state.searchField === "name" ? "name" : "email";
|
|
42
|
+
const search = state.search.trim();
|
|
43
|
+
return {
|
|
44
|
+
limit: state.perPage,
|
|
45
|
+
offset: (Math.max(state.page, 1) - 1) * state.perPage,
|
|
46
|
+
sortBy: sort.key,
|
|
47
|
+
sortDirection: sort.dir,
|
|
48
|
+
...(search
|
|
49
|
+
? {
|
|
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
|
+
searchValue: searchField === "email" ? search.toLowerCase() : search,
|
|
53
|
+
searchField,
|
|
54
|
+
searchOperator: "contains" as const,
|
|
55
|
+
}
|
|
56
|
+
: {}),
|
|
57
|
+
...filterFor(state.role, state.status),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
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
|
+
*/
|
|
65
|
+
export function keepLatestFilter(
|
|
66
|
+
previous: { role: string; status: string },
|
|
67
|
+
next: { role: string; status: string },
|
|
68
|
+
): { role: string; status: string } {
|
|
69
|
+
if (!next.role || !next.status) return next;
|
|
70
|
+
return next.status !== previous.status ? { role: "", status: next.status } : { role: next.role, status: "" };
|
|
71
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
auditLogUrl,
|
|
4
|
+
cursorForPage,
|
|
5
|
+
emptyTrail,
|
|
6
|
+
fetchAuditLogPage,
|
|
7
|
+
recordPage,
|
|
8
|
+
trailCount,
|
|
9
|
+
} from "./audit-log";
|
|
10
|
+
|
|
11
|
+
const none = { action: "", actor: "", target: "" };
|
|
12
|
+
|
|
13
|
+
describe("auditLogUrl", () => {
|
|
14
|
+
it("sends only the filters that are set", () => {
|
|
15
|
+
expect(auditLogUrl(none, "desc", null, 15)).toBe("/api/audit-logs?limit=15");
|
|
16
|
+
expect(auditLogUrl({ action: "user.ban", actor: " a@example.com ", target: "" }, "asc", "abc", 15))
|
|
17
|
+
.toBe("/api/audit-logs?limit=15&order=asc&cursor=abc&action=user.ban&actor=a%40example.com");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("fetchAuditLogPage", () => {
|
|
22
|
+
it("reads the entries and the next cursor header", async () => {
|
|
23
|
+
const fetcher = async (): Promise<Response> =>
|
|
24
|
+
new Response(JSON.stringify([{ id: "1" }]), { headers: { "x-next-cursor": "next" } });
|
|
25
|
+
await expect(fetchAuditLogPage(fetcher, none, "desc", null, 15)).resolves.toEqual({
|
|
26
|
+
entries: [{ id: "1" }],
|
|
27
|
+
nextCursor: "next",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("fails on an error status", async () => {
|
|
32
|
+
const fetcher = async (): Promise<Response> => new Response("{}", { status: 403 });
|
|
33
|
+
await expect(fetchAuditLogPage(fetcher, none, "desc", null, 15)).rejects.toThrow("403");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("cursor trail", () => {
|
|
38
|
+
it("reaches one page past the last one loaded until the end appears", () => {
|
|
39
|
+
let trail = emptyTrail();
|
|
40
|
+
expect(cursorForPage(trail, 1)).toBeNull();
|
|
41
|
+
expect(cursorForPage(trail, 2)).toBeUndefined();
|
|
42
|
+
|
|
43
|
+
trail = recordPage(trail, 1, 15, "c2");
|
|
44
|
+
expect(cursorForPage(trail, 2)).toBe("c2");
|
|
45
|
+
expect(trailCount(trail, 15)).toBe(30);
|
|
46
|
+
|
|
47
|
+
trail = recordPage(trail, 2, 15, "c3");
|
|
48
|
+
trail = recordPage(trail, 3, 4, null);
|
|
49
|
+
expect(trail.cursors).toEqual([null, "c2", "c3"]);
|
|
50
|
+
expect(trailCount(trail, 15)).toBe(34);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps later pages when an earlier page still leads to them", () => {
|
|
54
|
+
let trail = recordPage(recordPage(emptyTrail(), 1, 15, "c2"), 2, 15, "c3");
|
|
55
|
+
trail = recordPage(trail, 1, 15, "c2");
|
|
56
|
+
expect(cursorForPage(trail, 3)).toBe("c3");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("forgets later pages when new entries shift an earlier page", () => {
|
|
60
|
+
let trail = recordPage(recordPage(emptyTrail(), 1, 15, "c2"), 2, 3, null);
|
|
61
|
+
trail = recordPage(trail, 1, 15, "c2-shifted");
|
|
62
|
+
expect(trail).toEqual({ cursors: [null, "c2-shifted"], end: null });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("counts an empty first page as no rows", () => {
|
|
66
|
+
expect(trailCount(recordPage(emptyTrail(), 1, 0, null), 15)).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Client for the audit-log module's cursor-paged list (GET /api/audit-logs).
|
|
2
|
+
// The body is an array; the cursor for the next page arrives in a header.
|
|
3
|
+
|
|
4
|
+
export type AuditLogEntry = {
|
|
5
|
+
id: string;
|
|
6
|
+
action: string;
|
|
7
|
+
actorId: string | null;
|
|
8
|
+
actorName: string | null;
|
|
9
|
+
actorEmail: string | null;
|
|
10
|
+
targetLabel: string | null;
|
|
11
|
+
ip: string | null;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AuditLogOrder = "asc" | "desc";
|
|
16
|
+
|
|
17
|
+
export type AuditLogFilters = {
|
|
18
|
+
action: string;
|
|
19
|
+
/** Exact actor email. */
|
|
20
|
+
actor: string;
|
|
21
|
+
/** Exact target label. */
|
|
22
|
+
target: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type AuditLogPage = {
|
|
26
|
+
entries: AuditLogEntry[];
|
|
27
|
+
nextCursor: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const AUDIT_NEXT_CURSOR_HEADER = "x-next-cursor";
|
|
31
|
+
|
|
32
|
+
export type Fetcher = (url: string) => Promise<Response>;
|
|
33
|
+
|
|
34
|
+
export function auditLogUrl(
|
|
35
|
+
filters: AuditLogFilters,
|
|
36
|
+
order: AuditLogOrder,
|
|
37
|
+
cursor: string | null,
|
|
38
|
+
limit: number,
|
|
39
|
+
): string {
|
|
40
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
41
|
+
if (order === "asc") params.set("order", "asc");
|
|
42
|
+
if (cursor) params.set("cursor", cursor);
|
|
43
|
+
for (const key of ["action", "actor", "target"] as const) {
|
|
44
|
+
const value = filters[key].trim();
|
|
45
|
+
if (value) params.set(key, value);
|
|
46
|
+
}
|
|
47
|
+
return `/api/audit-logs?${params.toString()}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function fetchAuditLogPage(
|
|
51
|
+
fetcher: Fetcher,
|
|
52
|
+
filters: AuditLogFilters,
|
|
53
|
+
order: AuditLogOrder,
|
|
54
|
+
cursor: string | null,
|
|
55
|
+
limit: number,
|
|
56
|
+
): Promise<AuditLogPage> {
|
|
57
|
+
const response = await fetcher(auditLogUrl(filters, order, cursor, limit));
|
|
58
|
+
if (!response.ok) throw new Error(`Audit log request failed with status ${response.status}`);
|
|
59
|
+
return {
|
|
60
|
+
entries: (await response.json()) as AuditLogEntry[],
|
|
61
|
+
nextCursor: response.headers.get(AUDIT_NEXT_CURSOR_HEADER),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function fetchAuditActions(fetcher: Fetcher): Promise<string[]> {
|
|
66
|
+
const response = await fetcher("/api/audit-logs/actions");
|
|
67
|
+
if (!response.ok) throw new Error(`Audit action request failed with status ${response.status}`);
|
|
68
|
+
return (await response.json()) as string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A cursor-paged list has no total, so the pager learns its pages as they load:
|
|
72
|
+
// `cursors[n]` opens page n + 1, and `end` is set once a page comes back
|
|
73
|
+
// without a next cursor.
|
|
74
|
+
export type CursorTrail = {
|
|
75
|
+
cursors: Array<string | null>;
|
|
76
|
+
end: { page: number; size: number } | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function emptyTrail(): CursorTrail {
|
|
80
|
+
return { cursors: [null], end: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The cursor that opens `page`, or undefined when that page is not reachable yet. */
|
|
84
|
+
export function cursorForPage(trail: CursorTrail, page: number): string | null | undefined {
|
|
85
|
+
return page >= 1 ? trail.cursors[page - 1] : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Records what loading `page` revealed about the pages after it. */
|
|
89
|
+
export function recordPage(
|
|
90
|
+
trail: CursorTrail,
|
|
91
|
+
page: number,
|
|
92
|
+
size: number,
|
|
93
|
+
nextCursor: string | null,
|
|
94
|
+
): CursorTrail {
|
|
95
|
+
// An unchanged next cursor means the later pages learned earlier still line up.
|
|
96
|
+
if (nextCursor !== null && trail.cursors[page] === nextCursor) return trail;
|
|
97
|
+
return {
|
|
98
|
+
cursors: [...trail.cursors.slice(0, page), ...(nextCursor === null ? [] : [nextCursor])],
|
|
99
|
+
end: nextCursor === null ? { page, size } : null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Row count for the pager: exact once the end is known, else every reachable page as full. */
|
|
104
|
+
export function trailCount(trail: CursorTrail, perPage: number): number {
|
|
105
|
+
return trail.end
|
|
106
|
+
? (trail.end.page - 1) * perPage + trail.end.size
|
|
107
|
+
: trail.cursors.length * perPage;
|
|
108
|
+
}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { goto, invalidateAll } 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";
|
|
@@ -252,13 +251,20 @@
|
|
|
252
251
|
let qrDataUrl = $state("");
|
|
253
252
|
$effect(() => {
|
|
254
253
|
const uri = setup?.totpURI;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
.then((url) =>
|
|
261
|
-
|
|
254
|
+
qrDataUrl = "";
|
|
255
|
+
if (!uri) return;
|
|
256
|
+
let active = true;
|
|
257
|
+
import("qrcode")
|
|
258
|
+
.then(({ toDataURL }) => toDataURL(uri, { margin: 1, width: 176 }))
|
|
259
|
+
.then((url) => {
|
|
260
|
+
if (active && setup?.totpURI === uri) qrDataUrl = url;
|
|
261
|
+
})
|
|
262
|
+
.catch(() => {
|
|
263
|
+
if (active) qrDataUrl = "";
|
|
264
|
+
});
|
|
265
|
+
return () => {
|
|
266
|
+
active = false;
|
|
267
|
+
};
|
|
262
268
|
});
|
|
263
269
|
async function enable2fa(): Promise<void> {
|
|
264
270
|
twoFaBusy = true;
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { goto } from "$app/navigation";
|
|
3
|
-
import { onMount } from "svelte";
|
|
4
3
|
import { api } from "#lib/api.js";
|
|
5
4
|
|
|
6
5
|
let { timeoutMinutes }: { timeoutMinutes: number | null } = $props();
|
|
7
6
|
|
|
8
7
|
const ACTIVITY_KEY = "podokit:session-last-activity";
|
|
9
8
|
const SERVER_REFRESH_INTERVAL_MS = 60_000;
|
|
9
|
+
// Every localStorage write fires a `storage` event in each other open tab.
|
|
10
|
+
const ACTIVITY_WRITE_INTERVAL_MS = 5_000;
|
|
10
11
|
const activityEvents = ["pointerdown", "keydown", "touchstart", "scroll"] as const;
|
|
11
12
|
|
|
12
|
-
let mounted = $state(false);
|
|
13
|
-
let lastActivityAt = $state(Date.now());
|
|
14
|
-
let lastServerRefreshAt = $state(Date.now());
|
|
15
13
|
let signingOut = false;
|
|
16
|
-
let previousTimeoutMs: number | null = null;
|
|
17
14
|
const timeoutMs = $derived(timeoutMinutes === null ? null : timeoutMinutes * 60_000);
|
|
18
15
|
|
|
16
|
+
function readStoredActivity(): number {
|
|
17
|
+
try {
|
|
18
|
+
const value = Number(localStorage.getItem(ACTIVITY_KEY));
|
|
19
|
+
return Number.isFinite(value) ? value : 0;
|
|
20
|
+
} catch {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
function storeActivity(value: number): void {
|
|
20
26
|
try {
|
|
21
27
|
localStorage.setItem(ACTIVITY_KEY, String(value));
|
|
@@ -44,66 +50,85 @@
|
|
|
44
50
|
if (error || !data) await signOutForIdleTimeout();
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
function trackActivity(limit: number): () => void {
|
|
54
|
+
// The policy only exists for a signed-in user, so a new tracker means a new session.
|
|
55
|
+
signingOut = false;
|
|
56
|
+
let lastActivityAt = Date.now();
|
|
57
|
+
let storedActivityAt = lastActivityAt;
|
|
58
|
+
let lastWriteAt = lastActivityAt;
|
|
59
|
+
let lastServerRefreshAt = lastActivityAt;
|
|
60
|
+
let deadlineTimer: number | undefined;
|
|
61
|
+
let flushTimer: number | undefined;
|
|
62
|
+
storeActivity(lastActivityAt);
|
|
63
|
+
|
|
64
|
+
function flush(): void {
|
|
65
|
+
window.clearTimeout(flushTimer);
|
|
66
|
+
flushTimer = undefined;
|
|
67
|
+
if (signingOut || lastActivityAt <= storedActivityAt) return;
|
|
68
|
+
storedActivityAt = lastActivityAt;
|
|
69
|
+
lastWriteAt = Date.now();
|
|
70
|
+
storeActivity(storedActivityAt);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function expire(): void {
|
|
74
|
+
window.clearTimeout(deadlineTimer);
|
|
75
|
+
window.clearTimeout(flushTimer);
|
|
51
76
|
void signOutForIdleTimeout();
|
|
52
|
-
return;
|
|
53
77
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
|
|
79
|
+
// The deadline is re-read when the timer fires rather than re-armed on every
|
|
80
|
+
// event; other tabs' activity arrives through storage.
|
|
81
|
+
function check(): void {
|
|
82
|
+
if (signingOut) return;
|
|
83
|
+
const latest = Math.max(lastActivityAt, readStoredActivity());
|
|
84
|
+
const remaining = limit - (Date.now() - latest);
|
|
85
|
+
if (remaining <= 0) return expire();
|
|
86
|
+
window.clearTimeout(deadlineTimer);
|
|
87
|
+
deadlineTimer = window.setTimeout(check, remaining);
|
|
59
88
|
}
|
|
60
|
-
}
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
function recordActivity(): void {
|
|
91
|
+
if (signingOut) return;
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
// Timers can be suspended (sleep, background throttling), so a late event may arrive past the deadline.
|
|
94
|
+
if (now - lastActivityAt >= limit && now - readStoredActivity() >= limit) return expire();
|
|
95
|
+
lastActivityAt = now;
|
|
96
|
+
const sinceWrite = now - lastWriteAt;
|
|
97
|
+
if (sinceWrite >= ACTIVITY_WRITE_INTERVAL_MS) flush();
|
|
98
|
+
else flushTimer ??= window.setTimeout(flush, ACTIVITY_WRITE_INTERVAL_MS - sinceWrite);
|
|
99
|
+
if (now - lastServerRefreshAt >= SERVER_REFRESH_INTERVAL_MS) {
|
|
100
|
+
lastServerRefreshAt = now;
|
|
101
|
+
void refreshServerSession();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function onVisibilityChange(): void {
|
|
106
|
+
if (document.visibilityState === "hidden") flush();
|
|
107
|
+
else check();
|
|
108
|
+
}
|
|
67
109
|
|
|
68
|
-
onMount(() => {
|
|
69
|
-
mounted = true;
|
|
70
|
-
const now = Date.now();
|
|
71
|
-
lastActivityAt = now;
|
|
72
|
-
lastServerRefreshAt = now;
|
|
73
|
-
storeActivity(now);
|
|
74
110
|
for (const event of activityEvents) {
|
|
75
111
|
document.addEventListener(event, recordActivity, { passive: true, capture: true });
|
|
76
112
|
}
|
|
77
113
|
window.addEventListener("focus", recordActivity);
|
|
78
|
-
|
|
114
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
115
|
+
window.addEventListener("pagehide", flush);
|
|
116
|
+
deadlineTimer = window.setTimeout(check, limit);
|
|
117
|
+
|
|
79
118
|
return () => {
|
|
80
119
|
for (const event of activityEvents) {
|
|
81
120
|
document.removeEventListener(event, recordActivity, { capture: true });
|
|
82
121
|
}
|
|
83
122
|
window.removeEventListener("focus", recordActivity);
|
|
84
|
-
|
|
123
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
124
|
+
window.removeEventListener("pagehide", flush);
|
|
125
|
+
window.clearTimeout(deadlineTimer);
|
|
126
|
+
window.clearTimeout(flushTimer);
|
|
85
127
|
};
|
|
86
|
-
}
|
|
128
|
+
}
|
|
87
129
|
|
|
88
130
|
$effect(() => {
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
if (currentTimeoutMs !== previousTimeoutMs) {
|
|
92
|
-
previousTimeoutMs = currentTimeoutMs;
|
|
93
|
-
if (currentTimeoutMs !== null) {
|
|
94
|
-
const now = Date.now();
|
|
95
|
-
lastActivityAt = now;
|
|
96
|
-
lastServerRefreshAt = now;
|
|
97
|
-
storeActivity(now);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
if (currentTimeoutMs === null) return;
|
|
101
|
-
const remaining = currentTimeoutMs - (Date.now() - lastActivityAt);
|
|
102
|
-
if (remaining <= 0) {
|
|
103
|
-
void signOutForIdleTimeout();
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const timer = window.setTimeout(() => void signOutForIdleTimeout(), remaining);
|
|
107
|
-
return () => window.clearTimeout(timer);
|
|
131
|
+
if (timeoutMs === null) return;
|
|
132
|
+
return trackActivity(timeoutMs);
|
|
108
133
|
});
|
|
109
134
|
</script>
|
|
@@ -393,7 +393,10 @@
|
|
|
393
393
|
"empty": "No audit entries.",
|
|
394
394
|
"loadFailed": "Failed to load audit log",
|
|
395
395
|
"total": "{count} entries",
|
|
396
|
-
"
|
|
396
|
+
"range": "Entries {from}–{to}",
|
|
397
|
+
"searchField": "Actor or target",
|
|
398
|
+
"actorHint": "Full email address",
|
|
399
|
+
"targetHint": "Full target as shown"
|
|
397
400
|
},
|
|
398
401
|
"users": {
|
|
399
402
|
"title": "Users",
|
|
@@ -503,6 +506,8 @@
|
|
|
503
506
|
"revoked": "Session revoked",
|
|
504
507
|
"allRevoked": "All sessions revoked for user",
|
|
505
508
|
"loadFailed": "Failed to load sessions",
|
|
509
|
+
"revokeFailed": "Failed to revoke the session",
|
|
510
|
+
"searchPlaceholder": "Email or name",
|
|
506
511
|
"total": "{count} sessions"
|
|
507
512
|
},
|
|
508
513
|
"account": {
|
|
@@ -393,7 +393,10 @@
|
|
|
393
393
|
"empty": "감사 기록이 없습니다.",
|
|
394
394
|
"loadFailed": "감사 로그를 불러오지 못했습니다",
|
|
395
395
|
"total": "항목 {count}개",
|
|
396
|
-
"
|
|
396
|
+
"range": "{from}–{to}번째 항목",
|
|
397
|
+
"searchField": "수행자·대상",
|
|
398
|
+
"actorHint": "전체 이메일 주소",
|
|
399
|
+
"targetHint": "표시된 대상 전체"
|
|
397
400
|
},
|
|
398
401
|
"users": {
|
|
399
402
|
"title": "회원",
|
|
@@ -503,6 +506,8 @@
|
|
|
503
506
|
"revoked": "세션을 종료했습니다",
|
|
504
507
|
"allRevoked": "사용자의 세션을 모두 종료했습니다",
|
|
505
508
|
"loadFailed": "세션을 불러오지 못했습니다",
|
|
509
|
+
"revokeFailed": "세션을 종료하지 못했습니다",
|
|
510
|
+
"searchPlaceholder": "이메일 또는 이름",
|
|
506
511
|
"total": "세션 {count}개"
|
|
507
512
|
},
|
|
508
513
|
"account": {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { loadMessages } from "./messages";
|
|
3
|
+
|
|
4
|
+
describe("loadMessages", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllEnvs();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("reuses one merged catalog per locale pair in a build", async () => {
|
|
10
|
+
vi.stubEnv("DEV", false);
|
|
11
|
+
|
|
12
|
+
const korean = await loadMessages("ko", "en");
|
|
13
|
+
|
|
14
|
+
expect(korean.nav.users).toBe("회원");
|
|
15
|
+
expect(await loadMessages("ko", "en")).toBe(korean);
|
|
16
|
+
expect(await loadMessages("KO", null)).toBe(korean);
|
|
17
|
+
expect(await loadMessages("en", "en")).not.toBe(korean);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("merges again in development so edited catalogs appear", async () => {
|
|
21
|
+
vi.stubEnv("DEV", true);
|
|
22
|
+
|
|
23
|
+
const first = await loadMessages("en");
|
|
24
|
+
const second = await loadMessages("en");
|
|
25
|
+
|
|
26
|
+
expect(second).not.toBe(first);
|
|
27
|
+
expect(second).toEqual(first);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -83,12 +83,7 @@ async function loadCatalog(code: string): Promise<JsonObject> {
|
|
|
83
83
|
return catalog;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
locale: string,
|
|
88
|
-
configuredFallback?: string | null,
|
|
89
|
-
): Promise<Messages> {
|
|
90
|
-
const selected = resolveLocale(locale);
|
|
91
|
-
const fallback = resolveLocale(configuredFallback);
|
|
86
|
+
async function mergeMessages(selected: Locale, fallback: Locale): Promise<Messages> {
|
|
92
87
|
const candidates = [defaultLocale, fallback, selected];
|
|
93
88
|
// A later catalog has higher priority. Keep the last occurrence so selecting
|
|
94
89
|
// the built-in English fallback still overrides a non-English site default.
|
|
@@ -97,3 +92,25 @@ export async function loadMessages(
|
|
|
97
92
|
for (const code of chain) catalog = mergeCatalog(catalog, await loadCatalog(code));
|
|
98
93
|
return catalog as Messages;
|
|
99
94
|
}
|
|
95
|
+
|
|
96
|
+
// Catalogs are static in a build, so each locale pair is merged once and every
|
|
97
|
+
// caller shares the same object. Treat it as read-only.
|
|
98
|
+
const merged = new Map<string, Promise<Messages>>();
|
|
99
|
+
|
|
100
|
+
export function loadMessages(
|
|
101
|
+
locale: string,
|
|
102
|
+
configuredFallback?: string | null,
|
|
103
|
+
): Promise<Messages> {
|
|
104
|
+
const selected = resolveLocale(locale);
|
|
105
|
+
const fallback = resolveLocale(configuredFallback);
|
|
106
|
+
// Development re-merges so an edited catalog shows up on the next load.
|
|
107
|
+
if (import.meta.env.DEV) return mergeMessages(selected, fallback);
|
|
108
|
+
const key = `${selected}\u0000${fallback}`;
|
|
109
|
+
let messages = merged.get(key);
|
|
110
|
+
if (!messages) {
|
|
111
|
+
messages = mergeMessages(selected, fallback);
|
|
112
|
+
messages.catch(() => merged.delete(key));
|
|
113
|
+
merged.set(key, messages);
|
|
114
|
+
}
|
|
115
|
+
return messages;
|
|
116
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { signedOAuthQuery } from "./oauth-query";
|
|
3
|
+
|
|
4
|
+
describe("signed OAuth query", () => {
|
|
5
|
+
it("keeps only the signed parameters", () => {
|
|
6
|
+
const search = "?client_id=c1&scope=openid&exp=9&ba_param=client_id&ba_param=exp&ba_param=scope&sig=abc&extra=1";
|
|
7
|
+
const query = new URLSearchParams(signedOAuthQuery(search));
|
|
8
|
+
expect(query.get("client_id")).toBe("c1");
|
|
9
|
+
expect(query.get("sig")).toBe("abc");
|
|
10
|
+
expect(query.getAll("ba_param")).toEqual(["client_id", "exp", "scope"]);
|
|
11
|
+
expect(query.has("extra")).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("forwards the whole query when the server lists no signed names", () => {
|
|
15
|
+
expect(signedOAuthQuery("?client_id=c1&sig=abc")).toBe("client_id=c1&sig=abc");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns nothing without a signature", () => {
|
|
19
|
+
expect(signedOAuthQuery("?client_id=c1")).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The OAuth provider's consent endpoint verifies a signed copy of the authorization
|
|
2
|
+
// request sent in the body. Like Better Auth's own client plugin, forward only the
|
|
3
|
+
// parameters the server signed, so extra page parameters cannot break the signature.
|
|
4
|
+
export function signedOAuthQuery(search: string): string | undefined {
|
|
5
|
+
const params = new URLSearchParams(search);
|
|
6
|
+
if (!params.has("sig")) return undefined;
|
|
7
|
+
const signedNames = params.getAll("ba_param");
|
|
8
|
+
if (signedNames.length === 0) return params.toString();
|
|
9
|
+
const allowed = new Set(signedNames);
|
|
10
|
+
const signed = new URLSearchParams();
|
|
11
|
+
for (const [key, value] of params.entries()) {
|
|
12
|
+
if (key === "sig" || key === "ba_param" || allowed.has(key)) signed.append(key, value);
|
|
13
|
+
}
|
|
14
|
+
return signed.toString();
|
|
15
|
+
}
|