@podosoft/podokit 1.1.5 → 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 +17 -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/schema-validation.ts +13 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +5 -0
- 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/migrate.ts +3 -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
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
2
|
+
import { describe, expect, it, vi, type Mock } from "vitest";
|
|
3
|
+
import { DEFAULT_CAPABILITIES, getCapabilities } from "./capabilities";
|
|
4
|
+
import { loadProtectedLayout } from "./protected-layout";
|
|
5
|
+
|
|
6
|
+
type TestUser = NonNullable<App.Locals["user"]>;
|
|
7
|
+
type FetchMock = Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>;
|
|
8
|
+
|
|
9
|
+
function fetchMock(respond: () => Promise<Response>): FetchMock {
|
|
10
|
+
return vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(respond);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function locals(user: TestUser | null = null): App.Locals {
|
|
14
|
+
return {
|
|
15
|
+
user,
|
|
16
|
+
session: user ? { id: "session-1", impersonatedBy: null } : null,
|
|
17
|
+
authUnavailable: false,
|
|
18
|
+
locale: "en",
|
|
19
|
+
site: null,
|
|
20
|
+
siteUnavailable: false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function capabilitiesFetch(capabilities: Partial<Capabilities>): FetchMock {
|
|
25
|
+
return fetchMock(async () => Response.json({ ...DEFAULT_CAPABILITIES, twoFactor: true, ...capabilities }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const member: TestUser = { id: "user-1", email: "user@example.com", name: "User", twoFactorEnabled: false };
|
|
29
|
+
|
|
30
|
+
describe("getCapabilities", () => {
|
|
31
|
+
it("fetches once for every load in the same request", async () => {
|
|
32
|
+
const fetch = capabilitiesFetch({ auditLog: true });
|
|
33
|
+
const request = locals(member);
|
|
34
|
+
|
|
35
|
+
const [first, second] = await Promise.all([
|
|
36
|
+
getCapabilities({ locals: request, fetch }),
|
|
37
|
+
getCapabilities({ locals: request, fetch }),
|
|
38
|
+
]);
|
|
39
|
+
const third = await getCapabilities({ locals: request, fetch });
|
|
40
|
+
|
|
41
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(fetch).toHaveBeenCalledWith("/api/account/capabilities");
|
|
43
|
+
expect(first.auditLog).toBe(true);
|
|
44
|
+
expect(second).toBe(first);
|
|
45
|
+
expect(third).toBe(first);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("fetches again for a new request", async () => {
|
|
49
|
+
const fetch = capabilitiesFetch({});
|
|
50
|
+
|
|
51
|
+
await getCapabilities({ locals: locals(member), fetch });
|
|
52
|
+
await getCapabilities({ locals: locals(member), fetch });
|
|
53
|
+
|
|
54
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("resolves to safe defaults when the endpoint fails", async () => {
|
|
58
|
+
const rejected = fetchMock(async () => {
|
|
59
|
+
throw new TypeError("fetch failed");
|
|
60
|
+
});
|
|
61
|
+
const unavailable = fetchMock(async () => new Response("", { status: 503 }));
|
|
62
|
+
|
|
63
|
+
await expect(getCapabilities({ locals: locals(), fetch: rejected })).resolves.toEqual(DEFAULT_CAPABILITIES);
|
|
64
|
+
await expect(getCapabilities({ locals: locals(), fetch: unavailable })).resolves.toEqual(DEFAULT_CAPABILITIES);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("loadProtectedLayout", () => {
|
|
69
|
+
it("sends an un-enrolled user to setup when the policy requires two-factor", async () => {
|
|
70
|
+
const fetch = capabilitiesFetch({ require2fa: true });
|
|
71
|
+
|
|
72
|
+
await expect(loadProtectedLayout({ locals: locals(member), fetch })).rejects.toMatchObject({
|
|
73
|
+
status: 302,
|
|
74
|
+
location: "/setup-2fa",
|
|
75
|
+
});
|
|
76
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(fetch).not.toHaveBeenCalledWith("/api/account/require-2fa");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("lets enrolled users through and shares the capabilities response", async () => {
|
|
81
|
+
const fetch = capabilitiesFetch({ require2fa: true });
|
|
82
|
+
const request = locals({ ...member, twoFactorEnabled: true });
|
|
83
|
+
|
|
84
|
+
const data = await loadProtectedLayout({ locals: request, fetch });
|
|
85
|
+
|
|
86
|
+
expect(data.capabilities.require2fa).toBe(true);
|
|
87
|
+
expect(data.capabilities).toBe(await getCapabilities({ locals: request, fetch }));
|
|
88
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("fails closed before fetching when the backend could not be checked", async () => {
|
|
92
|
+
const fetch = capabilitiesFetch({});
|
|
93
|
+
const request = { ...locals(member), authUnavailable: true };
|
|
94
|
+
|
|
95
|
+
await expect(loadProtectedLayout({ locals: request, fetch })).rejects.toMatchObject({ status: 503 });
|
|
96
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
2
|
+
|
|
3
|
+
type ServerFetch = (
|
|
4
|
+
input: RequestInfo | URL,
|
|
5
|
+
init?: RequestInit,
|
|
6
|
+
) => Promise<Response>;
|
|
7
|
+
|
|
8
|
+
export interface CapabilitiesEvent {
|
|
9
|
+
locals: App.Locals;
|
|
10
|
+
fetch: ServerFetch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Safe values used when the capability endpoint cannot be reached. */
|
|
14
|
+
export const DEFAULT_CAPABILITIES: Capabilities = {
|
|
15
|
+
twoFactor: false,
|
|
16
|
+
providers: [],
|
|
17
|
+
deleteAccount: false,
|
|
18
|
+
auditLog: false,
|
|
19
|
+
emailVerification: false,
|
|
20
|
+
signupApprovalRequired: false,
|
|
21
|
+
passwordBreachCheck: false,
|
|
22
|
+
magicLink: false,
|
|
23
|
+
emailOtp: false,
|
|
24
|
+
username: false,
|
|
25
|
+
multiSession: false,
|
|
26
|
+
phoneNumber: false,
|
|
27
|
+
apiKey: false,
|
|
28
|
+
passkey: false,
|
|
29
|
+
organization: false,
|
|
30
|
+
oidcProvider: false,
|
|
31
|
+
require2fa: false,
|
|
32
|
+
roles: ["admin", "user"],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function fetchCapabilities(fetch: ServerFetch): Promise<Capabilities> {
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch("/api/account/capabilities");
|
|
38
|
+
if (response.ok) return (await response.json()) as Capabilities;
|
|
39
|
+
} catch {
|
|
40
|
+
/* Keep safe defaults if the capability endpoint is unavailable. */
|
|
41
|
+
}
|
|
42
|
+
return DEFAULT_CAPABILITIES;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load `/account/capabilities` at most once per request. Every server load of
|
|
47
|
+
* one page render or `__data.json` request shares `locals`, so layouts and
|
|
48
|
+
* pages can call this freely. The promise never rejects; failures resolve to
|
|
49
|
+
* {@link DEFAULT_CAPABILITIES}.
|
|
50
|
+
*/
|
|
51
|
+
export function getCapabilities({ locals, fetch }: CapabilitiesEvent): Promise<Capabilities> {
|
|
52
|
+
locals.capabilities ??= fetchCapabilities(fetch);
|
|
53
|
+
return locals.capabilities;
|
|
54
|
+
}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import { redirect } from "@sveltejs/kit";
|
|
2
2
|
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
3
|
+
import { getCapabilities, type CapabilitiesEvent } from "./capabilities";
|
|
3
4
|
import { requireBackendAvailable } from "./guards";
|
|
4
5
|
|
|
5
|
-
type
|
|
6
|
-
input: RequestInfo | URL,
|
|
7
|
-
init?: RequestInit,
|
|
8
|
-
) => Promise<Response>;
|
|
9
|
-
|
|
10
|
-
export interface ProtectedLayoutInput {
|
|
11
|
-
locals: App.Locals;
|
|
12
|
-
fetch: ServerFetch;
|
|
13
|
-
}
|
|
6
|
+
export type ProtectedLayoutInput = CapabilitiesEvent;
|
|
14
7
|
|
|
15
8
|
export interface ProtectedLayoutData {
|
|
16
9
|
user: App.Locals["user"];
|
|
@@ -22,53 +15,17 @@ export interface ProtectedLayoutData {
|
|
|
22
15
|
* Load the shared signed-in route context without imposing an application shell.
|
|
23
16
|
* The `(app)` group stays available for product UI, while `(admin)` adds admin chrome.
|
|
24
17
|
*/
|
|
25
|
-
export async function loadProtectedLayout({
|
|
26
|
-
locals
|
|
27
|
-
fetch,
|
|
28
|
-
}: ProtectedLayoutInput): Promise<ProtectedLayoutData> {
|
|
18
|
+
export async function loadProtectedLayout(event: ProtectedLayoutInput): Promise<ProtectedLayoutData> {
|
|
19
|
+
const { locals } = event;
|
|
29
20
|
requireBackendAvailable(locals);
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
twoFactor: false,
|
|
33
|
-
providers: [],
|
|
34
|
-
deleteAccount: false,
|
|
35
|
-
auditLog: false,
|
|
36
|
-
emailVerification: false,
|
|
37
|
-
signupApprovalRequired: false,
|
|
38
|
-
passwordBreachCheck: false,
|
|
39
|
-
magicLink: false,
|
|
40
|
-
emailOtp: false,
|
|
41
|
-
username: false,
|
|
42
|
-
multiSession: false,
|
|
43
|
-
phoneNumber: false,
|
|
44
|
-
apiKey: false,
|
|
45
|
-
passkey: false,
|
|
46
|
-
organization: false,
|
|
47
|
-
oidcProvider: false,
|
|
48
|
-
roles: ["admin", "user"],
|
|
49
|
-
};
|
|
50
|
-
try {
|
|
51
|
-
const response = await fetch("/api/account/capabilities");
|
|
52
|
-
if (response.ok) capabilities = (await response.json()) as Capabilities;
|
|
53
|
-
} catch {
|
|
54
|
-
/* keep defaults */
|
|
55
|
-
}
|
|
22
|
+
const capabilities = await getCapabilities(event);
|
|
56
23
|
|
|
57
24
|
// Require-two-factor policy: a signed-in user who has not enrolled is sent to
|
|
58
25
|
// setup. The API guard remains authoritative; this redirect provides the UX.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (user && !user.twoFactorEnabled) {
|
|
62
|
-
try {
|
|
63
|
-
const response = await fetch("/api/account/require-2fa");
|
|
64
|
-
mustEnrol =
|
|
65
|
-
response.ok &&
|
|
66
|
-
((await response.json()) as { require2fa?: boolean }).require2fa === true;
|
|
67
|
-
} catch {
|
|
68
|
-
/* do not block the app if the policy check fails */
|
|
69
|
-
}
|
|
26
|
+
if (locals.user && !locals.user.twoFactorEnabled && capabilities.require2fa) {
|
|
27
|
+
redirect(302, "/setup-2fa");
|
|
70
28
|
}
|
|
71
|
-
if (mustEnrol) redirect(302, "/setup-2fa");
|
|
72
29
|
|
|
73
30
|
return {
|
|
74
31
|
user: locals.user,
|
|
@@ -1,47 +1,54 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte";
|
|
2
3
|
import { Badge } from "#lib/components/ui/badge/index.js";
|
|
3
4
|
import * as Table from "#lib/components/ui/table/index.js";
|
|
4
5
|
import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "#lib/components/data-table.svelte";
|
|
5
6
|
import TableToolbar, { type ToolbarFilter, type ToolbarSearchField } from "#lib/components/table-toolbar.svelte";
|
|
6
7
|
import { toast } from "svelte-sonner";
|
|
7
8
|
import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
|
|
9
|
+
import {
|
|
10
|
+
cursorForPage,
|
|
11
|
+
emptyTrail,
|
|
12
|
+
fetchAuditActions,
|
|
13
|
+
fetchAuditLogPage,
|
|
14
|
+
recordPage,
|
|
15
|
+
trailCount,
|
|
16
|
+
type AuditLogEntry,
|
|
17
|
+
type AuditLogFilters,
|
|
18
|
+
type AuditLogOrder,
|
|
19
|
+
} from "#lib/audit-log.js";
|
|
8
20
|
|
|
9
21
|
const i18n = getI18n();
|
|
10
|
-
|
|
11
|
-
id: string;
|
|
12
|
-
action: string;
|
|
13
|
-
actorId: string | null;
|
|
14
|
-
actorName: string | null;
|
|
15
|
-
actorEmail: string | null;
|
|
16
|
-
targetLabel: string | null;
|
|
17
|
-
ip: string | null;
|
|
18
|
-
createdAt: string;
|
|
19
|
-
};
|
|
22
|
+
const PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
20
23
|
|
|
21
|
-
let entries = $state<
|
|
24
|
+
let entries = $state<AuditLogEntry[]>([]);
|
|
25
|
+
let actions = $state<string[]>([]);
|
|
22
26
|
let page = $state(1);
|
|
27
|
+
let shownPage = $state(1);
|
|
28
|
+
let trail = $state(emptyTrail());
|
|
23
29
|
let sort = $state<SortState | null>({ key: "createdAt", dir: "desc" });
|
|
24
30
|
let search = $state("");
|
|
25
|
-
let appliedSearch = $state("");
|
|
26
|
-
let filterValues = $state<Record<string, string>>({ action: "" });
|
|
27
|
-
let appliedFilters = $state<Record<string, string>>({ action: "" });
|
|
28
31
|
let searchField = $state("actor");
|
|
29
|
-
let
|
|
32
|
+
let filterValues = $state<Record<string, string>>({ action: "" });
|
|
33
|
+
let applied = $state<AuditLogFilters>({ action: "", actor: "", target: "" });
|
|
34
|
+
let trailOrder: AuditLogOrder = "desc";
|
|
35
|
+
let latestRequest = 0;
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
// The server pages by time with a cursor, so time is the only sortable column.
|
|
38
|
+
const columns = $derived<DataTableColumn<AuditLogEntry>[]>([
|
|
32
39
|
{ key: "createdAt", label: i18n.t.audit.when, sortable: true, class: "whitespace-nowrap" },
|
|
33
|
-
{ key: "actor", label: i18n.t.audit.actor
|
|
34
|
-
{ key: "action", label: i18n.t.audit.action
|
|
35
|
-
{ key: "target", label: i18n.t.audit.target
|
|
36
|
-
{ key: "ip", label: i18n.t.audit.ip
|
|
40
|
+
{ key: "actor", label: i18n.t.audit.actor },
|
|
41
|
+
{ key: "action", label: i18n.t.audit.action },
|
|
42
|
+
{ key: "target", label: i18n.t.audit.target },
|
|
43
|
+
{ key: "ip", label: i18n.t.audit.ip },
|
|
37
44
|
]);
|
|
38
45
|
|
|
39
|
-
const
|
|
46
|
+
const actionOptions = $derived([...new Set([...actions, ...(applied.action ? [applied.action] : [])])].sort());
|
|
40
47
|
const filters = $derived<ToolbarFilter[]>([
|
|
41
48
|
{
|
|
42
49
|
key: "action",
|
|
43
50
|
label: i18n.t.audit.action,
|
|
44
|
-
options: [{ value: "", label: i18n.t.toolbar.all }, ...
|
|
51
|
+
options: [{ value: "", label: i18n.t.toolbar.all }, ...actionOptions.map((a) => ({ value: a, label: a }))],
|
|
45
52
|
},
|
|
46
53
|
]);
|
|
47
54
|
|
|
@@ -50,33 +57,63 @@
|
|
|
50
57
|
{ value: "target", label: i18n.t.audit.target },
|
|
51
58
|
]);
|
|
52
59
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
? (e.targetLabel ?? "")
|
|
60
|
-
: `${e.actorName ?? ""} ${e.actorEmail ?? ""}`
|
|
61
|
-
).toLowerCase();
|
|
62
|
-
if (!hay.includes(appliedSearch.toLowerCase())) return false;
|
|
63
|
-
}
|
|
64
|
-
return true;
|
|
65
|
-
}),
|
|
60
|
+
const count = $derived(trailCount(trail, PAGE_SIZE));
|
|
61
|
+
const first = $derived((shownPage - 1) * PAGE_SIZE + 1);
|
|
62
|
+
const label = $derived(
|
|
63
|
+
trail.end || entries.length === 0
|
|
64
|
+
? fmt(i18n.t.audit.total, { count })
|
|
65
|
+
: fmt(i18n.t.audit.range, { from: first, to: first + entries.length - 1 }),
|
|
66
66
|
);
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
function currentOrder(): AuditLogOrder {
|
|
69
|
+
return sort?.dir === "asc" ? "asc" : "desc";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function load(target: number): Promise<void> {
|
|
73
|
+
const cursor = cursorForPage(trail, target);
|
|
74
|
+
if (cursor === undefined) {
|
|
75
|
+
page = shownPage;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const request = ++latestRequest;
|
|
69
79
|
try {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
entries =
|
|
80
|
+
const result = await fetchAuditLogPage(fetch, applied, trailOrder, cursor, PAGE_SIZE);
|
|
81
|
+
if (request !== latestRequest) return;
|
|
82
|
+
entries = result.entries;
|
|
83
|
+
page = target;
|
|
84
|
+
shownPage = target;
|
|
85
|
+
trail = recordPage(trail, target, result.entries.length, result.nextCursor);
|
|
73
86
|
} catch {
|
|
87
|
+
if (request !== latestRequest) return;
|
|
88
|
+
page = shownPage;
|
|
74
89
|
toast.error(i18n.t.audit.loadFailed);
|
|
75
90
|
}
|
|
76
91
|
}
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
function restart(): void {
|
|
94
|
+
trailOrder = currentOrder();
|
|
95
|
+
trail = emptyTrail();
|
|
96
|
+
void load(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applySearch(): void {
|
|
100
|
+
const value = search.trim();
|
|
101
|
+
applied = {
|
|
102
|
+
action: filterValues.action ?? "",
|
|
103
|
+
actor: searchField === "actor" ? value : "",
|
|
104
|
+
target: searchField === "target" ? value : "",
|
|
105
|
+
};
|
|
106
|
+
restart();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onTableChange(state: { page: number }): void {
|
|
110
|
+
if (currentOrder() !== trailOrder) restart();
|
|
111
|
+
else void load(state.page);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onMount(() => {
|
|
115
|
+
restart();
|
|
116
|
+
void fetchAuditActions(fetch).then((codes) => { actions = codes; }, () => undefined);
|
|
80
117
|
});
|
|
81
118
|
</script>
|
|
82
119
|
|
|
@@ -89,21 +126,27 @@
|
|
|
89
126
|
{searchFields}
|
|
90
127
|
bind:searchField
|
|
91
128
|
bind:search
|
|
129
|
+
searchPlaceholder={searchField === "target" ? i18n.t.audit.targetHint : i18n.t.audit.actorHint}
|
|
92
130
|
filterHeading={i18n.t.toolbar.filter}
|
|
93
131
|
searchHeading={i18n.t.toolbar.search}
|
|
94
132
|
searchButton={i18n.t.toolbar.searchButton}
|
|
95
|
-
onSearch={
|
|
133
|
+
onSearch={applySearch}
|
|
96
134
|
/>
|
|
97
135
|
|
|
98
136
|
<DataTable
|
|
99
137
|
{columns}
|
|
100
|
-
rows={
|
|
138
|
+
rows={entries}
|
|
101
139
|
getKey={(e) => e.id}
|
|
102
140
|
empty={i18n.t.audit.empty}
|
|
141
|
+
ariaLabel={i18n.t.audit.title}
|
|
103
142
|
bind:sort
|
|
143
|
+
manualSort
|
|
104
144
|
bind:page
|
|
105
|
-
perPage={
|
|
106
|
-
|
|
145
|
+
perPage={PAGE_SIZE}
|
|
146
|
+
total={count}
|
|
147
|
+
manualPagination
|
|
148
|
+
{label}
|
|
149
|
+
onChange={onTableChange}
|
|
107
150
|
>
|
|
108
151
|
{#snippet row(e)}
|
|
109
152
|
<Table.Cell class="text-muted-foreground whitespace-nowrap">{formatDateTime(e.createdAt)}</Table.Cell>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
2
3
|
import { Button } from "#lib/components/ui/button/index.js";
|
|
3
4
|
import { Badge } from "#lib/components/ui/badge/index.js";
|
|
4
5
|
import * as Table from "#lib/components/ui/table/index.js";
|
|
@@ -8,100 +9,94 @@
|
|
|
8
9
|
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
|
9
10
|
import { toast } from "svelte-sonner";
|
|
10
11
|
import { api } from "#lib/api.js";
|
|
12
|
+
import { KeysetPages, listAdminSessions, revokeAdminSession, type AdminSession } from "#lib/admin/sessions.js";
|
|
11
13
|
import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
|
|
12
14
|
import { deviceLabel } from "#lib/device-label.js";
|
|
13
15
|
|
|
14
16
|
const i18n = getI18n();
|
|
17
|
+
const PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
18
|
+
// The API pages newest first by keyset, so the order is fixed rather than sortable.
|
|
19
|
+
const sort: SortState = { key: "createdAt", dir: "desc" };
|
|
20
|
+
const pages = new KeysetPages();
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const USER_LIMIT = 50;
|
|
20
|
-
type Row = {
|
|
21
|
-
id: string;
|
|
22
|
-
token: string;
|
|
23
|
-
userId: string;
|
|
24
|
-
userName: string;
|
|
25
|
-
userEmail: string;
|
|
26
|
-
userAgent?: string | null;
|
|
27
|
-
ipAddress?: string | null;
|
|
28
|
-
createdAt: string | Date;
|
|
29
|
-
expiresAt?: string | Date | null;
|
|
30
|
-
impersonatedBy?: string | null;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
let rows = $state<Row[]>([]);
|
|
22
|
+
let rows = $state<AdminSession[]>([]);
|
|
23
|
+
let total = $state(0);
|
|
24
|
+
let pagerTotal = $state(0);
|
|
34
25
|
let page = $state(1);
|
|
35
|
-
// No default sort: keep the fan-out order so the newest session isn't required
|
|
36
|
-
// to sit on page 1. Sorting is available by clicking a column header.
|
|
37
|
-
let sort = $state<SortState | null>(null);
|
|
38
26
|
let loading = $state(false);
|
|
39
27
|
let busy = $state(false);
|
|
40
28
|
let search = $state("");
|
|
41
29
|
let appliedSearch = $state("");
|
|
42
|
-
let searchField = $state("
|
|
43
|
-
let
|
|
30
|
+
let searchField = $state("user");
|
|
31
|
+
let latestRequest = 0;
|
|
32
|
+
let shownPage = 1;
|
|
44
33
|
|
|
45
|
-
const columns = $derived<DataTableColumn<
|
|
46
|
-
{ key: "user", label: i18n.t.adminSessions.user
|
|
47
|
-
{ key: "device", label: i18n.t.adminSessions.device
|
|
48
|
-
{ key: "ip", label: i18n.t.adminSessions.ip
|
|
49
|
-
{ key: "createdAt", label: i18n.t.adminSessions.since
|
|
50
|
-
{ key: "expiresAt", label: i18n.t.adminSessions.expires
|
|
34
|
+
const columns = $derived<DataTableColumn<AdminSession>[]>([
|
|
35
|
+
{ key: "user", label: i18n.t.adminSessions.user },
|
|
36
|
+
{ key: "device", label: i18n.t.adminSessions.device },
|
|
37
|
+
{ key: "ip", label: i18n.t.adminSessions.ip },
|
|
38
|
+
{ key: "createdAt", label: i18n.t.adminSessions.since },
|
|
39
|
+
{ key: "expiresAt", label: i18n.t.adminSessions.expires },
|
|
51
40
|
{ key: "actions", label: "", class: "w-10" },
|
|
52
41
|
]);
|
|
53
42
|
|
|
54
|
-
const searchFields = $derived<ToolbarSearchField[]>([
|
|
55
|
-
{ value: "email", label: i18n.t.users.email },
|
|
56
|
-
{ value: "name", label: i18n.t.users.name },
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
const filtered = $derived(
|
|
60
|
-
rows.filter((r) => {
|
|
61
|
-
if (!appliedSearch) return true;
|
|
62
|
-
const hay = (appliedSearchField === "name" ? r.userName : r.userEmail).toLowerCase();
|
|
63
|
-
return hay.includes(appliedSearch.toLowerCase());
|
|
64
|
-
}),
|
|
65
|
-
);
|
|
43
|
+
const searchFields = $derived<ToolbarSearchField[]>([{ value: "user", label: i18n.t.adminSessions.user }]);
|
|
66
44
|
|
|
67
|
-
|
|
45
|
+
// Loads one page; a newer request supersedes any that is still in flight.
|
|
46
|
+
async function load(target: number): Promise<void> {
|
|
47
|
+
const cursor = pages.cursorFor(target);
|
|
48
|
+
if (cursor === undefined) return;
|
|
49
|
+
const request = ++latestRequest;
|
|
68
50
|
loading = true;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return;
|
|
51
|
+
try {
|
|
52
|
+
const result = await listAdminSessions(api, { limit: PAGE_SIZE, cursor, search: appliedSearch });
|
|
53
|
+
if (request !== latestRequest) return;
|
|
54
|
+
// A revoke can empty the last page; show the one before it instead.
|
|
55
|
+
if (result.items.length === 0 && target > 1) return load(target - 1);
|
|
56
|
+
pages.record(target, result.nextCursor);
|
|
57
|
+
rows = result.items;
|
|
58
|
+
total = result.total;
|
|
59
|
+
pagerTotal = pages.pagerTotal(result.total, PAGE_SIZE);
|
|
60
|
+
page = shownPage = target;
|
|
61
|
+
} catch {
|
|
62
|
+
if (request !== latestRequest) return;
|
|
63
|
+
page = shownPage;
|
|
64
|
+
toast.error(i18n.t.adminSessions.loadFailed);
|
|
65
|
+
} finally {
|
|
66
|
+
if (request === latestRequest) loading = false;
|
|
74
67
|
}
|
|
75
|
-
const users = (data?.users ?? []) as Array<{ id: string; name: string; email: string }>;
|
|
76
|
-
const perUser = await Promise.all(
|
|
77
|
-
users.map(async (u) => {
|
|
78
|
-
const { data: res } = await api.auth.admin.listUserSessions({ userId: u.id });
|
|
79
|
-
const sessions = (res?.sessions ?? []) as Array<Omit<Row, "userId" | "userName" | "userEmail">>;
|
|
80
|
-
return sessions.map((s) => ({ ...s, userId: u.id, userName: u.name, userEmail: u.email }));
|
|
81
|
-
}),
|
|
82
|
-
);
|
|
83
|
-
rows = perUser.flat();
|
|
84
|
-
loading = false;
|
|
85
68
|
}
|
|
86
69
|
|
|
87
|
-
|
|
70
|
+
function applySearch(): void {
|
|
71
|
+
appliedSearch = search;
|
|
72
|
+
pages.reset();
|
|
73
|
+
void load(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function revoke(row: AdminSession): Promise<void> {
|
|
88
77
|
busy = true;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
78
|
+
try {
|
|
79
|
+
await revokeAdminSession(api, row.id);
|
|
80
|
+
toast.success(i18n.t.adminSessions.revoked);
|
|
81
|
+
await load(page);
|
|
82
|
+
} catch {
|
|
83
|
+
toast.error(i18n.t.adminSessions.revokeFailed);
|
|
84
|
+
} finally {
|
|
85
|
+
busy = false;
|
|
95
86
|
}
|
|
96
87
|
}
|
|
97
88
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
89
|
+
async function revokeAll(row: AdminSession): Promise<void> {
|
|
90
|
+
busy = true;
|
|
91
|
+
const { error } = await api.auth.admin.revokeUserSessions({ userId: row.userId });
|
|
92
|
+
busy = false;
|
|
93
|
+
if (error) return void toast.error(i18n.t.adminSessions.revokeFailed);
|
|
94
|
+
toast.success(i18n.t.adminSessions.allRevoked);
|
|
95
|
+
await load(page);
|
|
96
|
+
}
|
|
102
97
|
|
|
103
98
|
$effect(() => {
|
|
104
|
-
void load();
|
|
99
|
+
untrack(() => void load(1));
|
|
105
100
|
});
|
|
106
101
|
</script>
|
|
107
102
|
|
|
@@ -112,20 +107,26 @@
|
|
|
112
107
|
{searchFields}
|
|
113
108
|
bind:searchField
|
|
114
109
|
bind:search
|
|
110
|
+
searchPlaceholder={i18n.t.adminSessions.searchPlaceholder}
|
|
115
111
|
searchHeading={i18n.t.toolbar.search}
|
|
116
112
|
searchButton={i18n.t.toolbar.searchButton}
|
|
117
|
-
onSearch={
|
|
113
|
+
onSearch={applySearch}
|
|
118
114
|
/>
|
|
119
115
|
|
|
120
116
|
<DataTable
|
|
121
117
|
{columns}
|
|
122
|
-
rows
|
|
118
|
+
{rows}
|
|
123
119
|
getKey={(r) => r.id}
|
|
124
120
|
empty={loading ? "…" : i18n.t.adminSessions.empty}
|
|
125
|
-
|
|
121
|
+
ariaLabel={i18n.t.adminSessions.title}
|
|
122
|
+
{sort}
|
|
123
|
+
manualSort
|
|
124
|
+
manualPagination
|
|
126
125
|
bind:page
|
|
127
|
-
perPage={
|
|
128
|
-
|
|
126
|
+
perPage={PAGE_SIZE}
|
|
127
|
+
total={pagerTotal}
|
|
128
|
+
label={fmt(i18n.t.adminSessions.total, { count: total })}
|
|
129
|
+
onChange={(state) => void load(state.page)}
|
|
129
130
|
>
|
|
130
131
|
{#snippet row(r)}
|
|
131
132
|
<Table.Cell>
|
|
@@ -138,7 +139,7 @@
|
|
|
138
139
|
<Table.Cell class="max-w-xs truncate" title={r.userAgent ?? ""}>{deviceLabel(r.userAgent) ?? r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
|
|
139
140
|
<Table.Cell class="text-muted-foreground">{r.ipAddress ?? "—"}</Table.Cell>
|
|
140
141
|
<Table.Cell class="text-muted-foreground">{formatDateTime(r.createdAt)}</Table.Cell>
|
|
141
|
-
<Table.Cell class="text-muted-foreground">{
|
|
142
|
+
<Table.Cell class="text-muted-foreground">{formatDateTime(r.expiresAt)}</Table.Cell>
|
|
142
143
|
<Table.Cell>
|
|
143
144
|
<DropdownMenu.Root>
|
|
144
145
|
<DropdownMenu.Trigger>
|