@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
|
@@ -27,46 +27,63 @@ interface SiteSettingRow {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface SiteFavicon {
|
|
30
|
-
|
|
30
|
+
object: ObjectData;
|
|
31
|
+
/** The type validated on upload. */
|
|
31
32
|
contentType: string;
|
|
33
|
+
/** The `faviconUpdatedAt` setting that pages put in the favicon URL. */
|
|
34
|
+
version: string | null;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// Every page render reads the site settings, so one short-lived snapshot serves
|
|
38
|
+
// concurrent renders. Writes on this replica clear it immediately; another
|
|
39
|
+
// replica sees the change within this window.
|
|
40
|
+
const SNAPSHOT_TTL_MS = 3_000;
|
|
41
|
+
|
|
42
|
+
interface Snapshot {
|
|
43
|
+
values: Record<string, string>;
|
|
44
|
+
expiresAt: number;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export class SiteSettingsService {
|
|
48
|
+
private snapshot: Snapshot | null = null;
|
|
49
|
+
private pending: Promise<Record<string, string>> | null = null;
|
|
50
|
+
private generation = 0;
|
|
51
|
+
|
|
41
52
|
constructor(
|
|
42
53
|
private readonly sql: SQL,
|
|
43
54
|
private readonly storage: ObjectStore,
|
|
55
|
+
private readonly now: () => number = Date.now,
|
|
44
56
|
) {}
|
|
45
57
|
|
|
58
|
+
/** Every `site.*` setting, keyed without the prefix. */
|
|
46
59
|
async getAll(): Promise<Record<string, string>> {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const result: Record<string, string> = {};
|
|
51
|
-
for (const row of rows) result[row.key.slice(PREFIX.length)] = row.value;
|
|
52
|
-
return result;
|
|
60
|
+
const snapshot = this.snapshot;
|
|
61
|
+
if (snapshot && snapshot.expiresAt > this.now()) return { ...snapshot.values };
|
|
62
|
+
return { ...(await this.load()) };
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
async get(key: string): Promise<string | null> {
|
|
56
|
-
|
|
57
|
-
SELECT "key", "value" FROM "app_setting" WHERE "key" = ${PREFIX + key}
|
|
58
|
-
`;
|
|
59
|
-
return rows[0]?.value ?? null;
|
|
66
|
+
return (await this.getAll())[key] ?? null;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
async setMany(update: Record<string, string>): Promise<Record<string, string>> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
const entries = Object.entries(update);
|
|
71
|
+
if (entries.length > 0) {
|
|
72
|
+
try {
|
|
73
|
+
await this.sql.begin(async (tx) => {
|
|
74
|
+
for (const [key, value] of entries) {
|
|
75
|
+
await tx`
|
|
76
|
+
INSERT INTO "app_setting" ("key", "value", "updatedAt")
|
|
77
|
+
VALUES (${PREFIX + key}, ${value}, CURRENT_TIMESTAMP)
|
|
78
|
+
ON CONFLICT ("key") DO UPDATE
|
|
79
|
+
SET "value" = EXCLUDED."value", "updatedAt" = CURRENT_TIMESTAMP
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
// Also after a failure: the outcome of an interrupted commit is unknown.
|
|
85
|
+
this.invalidate();
|
|
86
|
+
}
|
|
70
87
|
}
|
|
71
88
|
return this.getAll();
|
|
72
89
|
}
|
|
@@ -79,17 +96,50 @@ export class SiteSettingsService {
|
|
|
79
96
|
});
|
|
80
97
|
}
|
|
81
98
|
|
|
99
|
+
/** The favicon with its content unread, or null when none is set. */
|
|
82
100
|
async getFavicon(): Promise<SiteFavicon | null> {
|
|
83
|
-
const
|
|
101
|
+
const settings = await this.getAll();
|
|
102
|
+
const contentType = settings.faviconContentType;
|
|
84
103
|
if (!contentType) return null;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
104
|
+
const object = await this.storage.get(FAVICON_KEY);
|
|
105
|
+
return object ? { object, contentType, version: settings.faviconUpdatedAt ?? null } : null;
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
faviconVersion(): Promise<string | null> {
|
|
93
109
|
return this.get("faviconUpdatedAt");
|
|
94
110
|
}
|
|
111
|
+
|
|
112
|
+
/** One query at a time; concurrent readers share it. */
|
|
113
|
+
private load(): Promise<Record<string, string>> {
|
|
114
|
+
if (this.pending) return this.pending;
|
|
115
|
+
const generation = this.generation;
|
|
116
|
+
const pending = this.query()
|
|
117
|
+
.then((values) => {
|
|
118
|
+
// A write that finished meanwhile may not be in these rows.
|
|
119
|
+
if (generation === this.generation) {
|
|
120
|
+
this.snapshot = { values, expiresAt: this.now() + SNAPSHOT_TTL_MS };
|
|
121
|
+
}
|
|
122
|
+
return values;
|
|
123
|
+
})
|
|
124
|
+
.finally(() => {
|
|
125
|
+
if (this.pending === pending) this.pending = null;
|
|
126
|
+
});
|
|
127
|
+
this.pending = pending;
|
|
128
|
+
return pending;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private invalidate(): void {
|
|
132
|
+
this.generation += 1;
|
|
133
|
+
this.snapshot = null;
|
|
134
|
+
this.pending = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async query(): Promise<Record<string, string>> {
|
|
138
|
+
const rows = await this.sql<SiteSettingRow[]>`
|
|
139
|
+
SELECT "key", "value" FROM "app_setting" WHERE "key" LIKE 'site.%'
|
|
140
|
+
`;
|
|
141
|
+
const result: Record<string, string> = {};
|
|
142
|
+
for (const row of rows) result[row.key.slice(PREFIX.length)] = row.value;
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
95
145
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
1
2
|
import type { Locale } from "#lib/i18n/messages.js";
|
|
2
3
|
import type { SiteSettings } from "#lib/site.svelte.js";
|
|
3
4
|
|
|
@@ -24,6 +25,8 @@ declare global {
|
|
|
24
25
|
locale: Locale;
|
|
25
26
|
site: SiteSettings | null;
|
|
26
27
|
siteUnavailable: boolean;
|
|
28
|
+
/** Per-request memo; read it through `getCapabilities()` from `#lib/server/capabilities.js`. */
|
|
29
|
+
capabilities?: Promise<Capabilities>;
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ApiError } from "@podosoft/podokit-api-client";
|
|
2
|
+
import type { RequestEvent } from "@sveltejs/kit";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { handle } from "./hooks.server";
|
|
5
|
+
|
|
6
|
+
type SessionResult = {
|
|
7
|
+
data: { user: { id: string }; session: { id: string; impersonatedBy?: string | null } } | null;
|
|
8
|
+
error: { status: number } | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const client = vi.hoisted(() => ({
|
|
12
|
+
get: vi.fn<(path: string) => Promise<unknown>>(),
|
|
13
|
+
getSession: vi.fn<() => Promise<SessionResult>>(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("#lib/server/api.js", () => ({
|
|
17
|
+
serverApiClient: () => ({ get: client.get, auth: { getSession: client.getSession } }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const resolve = vi.fn(async (): Promise<Response> => new Response("ok"));
|
|
21
|
+
|
|
22
|
+
function requestEvent(pathname: string, cookies: Record<string, string> = {}): RequestEvent {
|
|
23
|
+
return {
|
|
24
|
+
route: { id: pathname },
|
|
25
|
+
url: new URL(`http://localhost${pathname}`),
|
|
26
|
+
cookies: { get: (name: string) => cookies[name] },
|
|
27
|
+
locals: {},
|
|
28
|
+
} as unknown as RequestEvent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const signedIn: SessionResult = {
|
|
32
|
+
data: { user: { id: "user-1" }, session: { id: "session-1", impersonatedBy: "admin-1" } },
|
|
33
|
+
error: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("handle", () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
client.get.mockReset();
|
|
39
|
+
client.getSession.mockReset();
|
|
40
|
+
resolve.mockClear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("requests the session without waiting for site settings", async () => {
|
|
44
|
+
let releaseSite: (value: unknown) => void = () => undefined;
|
|
45
|
+
client.get.mockReturnValue(new Promise((settle) => { releaseSite = settle; }));
|
|
46
|
+
client.getSession.mockResolvedValue(signedIn);
|
|
47
|
+
const event = requestEvent("/account");
|
|
48
|
+
|
|
49
|
+
const pending = handle({ event, resolve });
|
|
50
|
+
await vi.waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1));
|
|
51
|
+
releaseSite({ locale: "ko" });
|
|
52
|
+
await pending;
|
|
53
|
+
|
|
54
|
+
expect(client.get).toHaveBeenCalledWith("/site/settings");
|
|
55
|
+
expect(event.locals).toMatchObject({
|
|
56
|
+
site: { locale: "ko" },
|
|
57
|
+
siteUnavailable: false,
|
|
58
|
+
authUnavailable: false,
|
|
59
|
+
locale: "ko",
|
|
60
|
+
user: { id: "user-1" },
|
|
61
|
+
session: { id: "session-1", impersonatedBy: "admin-1" },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("treats a site settings client error as a reachable backend", async () => {
|
|
66
|
+
client.get.mockRejectedValue(new ApiError("TWO_FACTOR_REQUIRED", "Two-factor enrolment is required.", 403));
|
|
67
|
+
client.getSession.mockResolvedValue(signedIn);
|
|
68
|
+
const event = requestEvent("/account", { locale: "en" });
|
|
69
|
+
|
|
70
|
+
await handle({ event, resolve });
|
|
71
|
+
|
|
72
|
+
expect(event.locals).toMatchObject({ site: null, siteUnavailable: false, locale: "en" });
|
|
73
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("fails closed on protected pages when site settings are unavailable", async () => {
|
|
77
|
+
client.get.mockRejectedValue(new TypeError("fetch failed"));
|
|
78
|
+
client.getSession.mockResolvedValue(signedIn);
|
|
79
|
+
|
|
80
|
+
await expect(handle({ event: requestEvent("/account"), resolve })).rejects.toMatchObject({ status: 503 });
|
|
81
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps public pages available with fallbacks when the backend fails", async () => {
|
|
85
|
+
client.get.mockRejectedValue(new ApiError("UNAVAILABLE", "Unavailable", 503));
|
|
86
|
+
client.getSession.mockRejectedValue(new TypeError("fetch failed"));
|
|
87
|
+
const event = requestEvent("/login");
|
|
88
|
+
|
|
89
|
+
await handle({ event, resolve });
|
|
90
|
+
|
|
91
|
+
expect(event.locals).toMatchObject({
|
|
92
|
+
site: null,
|
|
93
|
+
siteUnavailable: true,
|
|
94
|
+
authUnavailable: true,
|
|
95
|
+
user: null,
|
|
96
|
+
session: null,
|
|
97
|
+
locale: "en",
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("confirms an anonymous visitor on a 401 session response", async () => {
|
|
102
|
+
client.get.mockResolvedValue({ locale: null });
|
|
103
|
+
client.getSession.mockResolvedValue({ data: null, error: { status: 401 } });
|
|
104
|
+
const event = requestEvent("/");
|
|
105
|
+
|
|
106
|
+
await handle({ event, resolve });
|
|
107
|
+
|
|
108
|
+
expect(event.locals).toMatchObject({ authUnavailable: false, user: null, session: null });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fails closed on protected pages when the session cannot be checked", async () => {
|
|
112
|
+
client.get.mockResolvedValue({ locale: null });
|
|
113
|
+
client.getSession.mockResolvedValue({ data: null, error: { status: 500 } });
|
|
114
|
+
|
|
115
|
+
await expect(handle({ event: requestEvent("/account"), resolve })).rejects.toMatchObject({ status: 503 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("skips runtime lookups for API proxy requests", async () => {
|
|
119
|
+
await handle({ event: requestEvent("/api/site/settings"), resolve });
|
|
120
|
+
|
|
121
|
+
expect(client.get).not.toHaveBeenCalled();
|
|
122
|
+
expect(client.getSession).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -21,28 +21,36 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
21
21
|
return applySearchIndexingHeaders(response, event.url.pathname);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Public site settings (branding, locale/timezone defaults, maintenance, …)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
24
|
+
// Public site settings (branding, locale/timezone defaults, maintenance, …)
|
|
25
|
+
// and the session are independent, so both requests run at once. The
|
|
26
|
+
// settings are loaded once here and reused by the layout load. The async
|
|
27
|
+
// wrappers turn a synchronous client failure into a settled rejection.
|
|
28
|
+
const api = serverApiClient(event);
|
|
29
|
+
const [siteResult, sessionResult] = await Promise.allSettled([
|
|
30
|
+
(async () => api.get<SiteSettings>("/site/settings"))(),
|
|
31
|
+
(async () => api.auth.getSession())(),
|
|
32
|
+
]);
|
|
33
|
+
|
|
27
34
|
event.locals.siteUnavailable = false;
|
|
28
35
|
let site: SiteSettings | null = null;
|
|
29
|
-
|
|
30
|
-
site =
|
|
31
|
-
}
|
|
32
|
-
site = null;
|
|
36
|
+
if (siteResult.status === "fulfilled") {
|
|
37
|
+
site = siteResult.value;
|
|
38
|
+
} else {
|
|
33
39
|
// A request-level 4xx proves that the backend is reachable. In particular,
|
|
34
40
|
// the require-2FA guard returns 403 until enrolment and must remain able to
|
|
35
41
|
// redirect the user to /setup-2fa instead of being masked as an outage.
|
|
36
|
-
event.locals.siteUnavailable = isTemporaryServiceFailure(
|
|
42
|
+
event.locals.siteUnavailable = isTemporaryServiceFailure(siteResult.reason);
|
|
37
43
|
}
|
|
38
44
|
event.locals.site = site;
|
|
45
|
+
// A visitor with no locale cookie falls back to the admin-configured site
|
|
46
|
+
// locale, then the app default.
|
|
39
47
|
event.locals.locale = resolveLocale(event.cookies.get("locale") ?? site?.locale ?? undefined);
|
|
40
48
|
|
|
41
49
|
event.locals.user = null;
|
|
42
50
|
event.locals.session = null;
|
|
43
51
|
event.locals.authUnavailable = false;
|
|
44
|
-
|
|
45
|
-
const { data, error } =
|
|
52
|
+
if (sessionResult.status === "fulfilled") {
|
|
53
|
+
const { data, error } = sessionResult.value;
|
|
46
54
|
// Better Auth returns HTTP authentication failures as data/error. A 401 is
|
|
47
55
|
// a confirmed anonymous session; every other error means that the session
|
|
48
56
|
// could not be checked and must not be treated as a logout.
|
|
@@ -54,7 +62,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
54
62
|
? { id: data.session.id, impersonatedBy: (data.session as { impersonatedBy?: string | null }).impersonatedBy ?? null }
|
|
55
63
|
: null;
|
|
56
64
|
}
|
|
57
|
-
}
|
|
65
|
+
} else {
|
|
58
66
|
// Transport failures do not invalidate the browser's session cookie. A
|
|
59
67
|
// protected loader will fail closed with 503 until the backend recovers.
|
|
60
68
|
event.locals.authUnavailable = true;
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts
CHANGED
|
@@ -1,43 +1,17 @@
|
|
|
1
1
|
import type { Capabilities } from "@podosoft/podokit-api-client";
|
|
2
|
+
import { getCapabilities, type CapabilitiesEvent } from "#lib/server/capabilities.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
providers: [],
|
|
8
|
-
deleteAccount: false,
|
|
9
|
-
auditLog: false,
|
|
10
|
-
emailVerification: false,
|
|
11
|
-
signupApprovalRequired: false,
|
|
12
|
-
passwordBreachCheck: false,
|
|
13
|
-
magicLink: false,
|
|
14
|
-
emailOtp: false,
|
|
15
|
-
username: false,
|
|
16
|
-
multiSession: false,
|
|
17
|
-
phoneNumber: false,
|
|
18
|
-
apiKey: false,
|
|
19
|
-
passkey: false,
|
|
20
|
-
organization: false,
|
|
21
|
-
oidcProvider: false,
|
|
22
|
-
roles: ["admin", "user"],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export async function loadAccountData(locals: App.Locals, fetch: ServerFetch): Promise<{
|
|
4
|
+
export async function loadAccountData(
|
|
5
|
+
locals: App.Locals,
|
|
6
|
+
fetch: CapabilitiesEvent["fetch"],
|
|
7
|
+
): Promise<{
|
|
26
8
|
user: App.Locals["user"];
|
|
27
9
|
currentSessionId: string | null;
|
|
28
10
|
capabilities: Capabilities;
|
|
29
11
|
}> {
|
|
30
|
-
let capabilities = defaultCapabilities;
|
|
31
|
-
try {
|
|
32
|
-
const response = await fetch("/api/account/capabilities");
|
|
33
|
-
if (response.ok) capabilities = (await response.json()) as Capabilities;
|
|
34
|
-
} catch {
|
|
35
|
-
/* Keep safe defaults if the capability endpoint is unavailable. */
|
|
36
|
-
}
|
|
37
|
-
|
|
38
12
|
return {
|
|
39
13
|
user: locals.user,
|
|
40
14
|
currentSessionId: locals.session?.id ?? null,
|
|
41
|
-
capabilities,
|
|
15
|
+
capabilities: await getCapabilities({ locals, fetch }),
|
|
42
16
|
};
|
|
43
17
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { KeysetPages, listAdminSessions, revokeAdminSession } from "./sessions";
|
|
3
|
+
|
|
4
|
+
describe("listAdminSessions", () => {
|
|
5
|
+
it("asks for one page with the cursor, trimmed search, and user", async () => {
|
|
6
|
+
const paths: string[] = [];
|
|
7
|
+
const client = {
|
|
8
|
+
get: <T>(path: string): Promise<T> => {
|
|
9
|
+
paths.push(path);
|
|
10
|
+
return Promise.resolve({ items: [], nextCursor: null, total: 0 } as unknown as T);
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
await listAdminSessions(client, { limit: 15, cursor: "abc", search: " ada ", userId: "user-1" });
|
|
15
|
+
await listAdminSessions(client, { limit: 15, cursor: null, search: " " });
|
|
16
|
+
|
|
17
|
+
expect(paths).toEqual([
|
|
18
|
+
"/admin/sessions?limit=15&cursor=abc&search=ada&userId=user-1",
|
|
19
|
+
"/admin/sessions?limit=15",
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("revokeAdminSession", () => {
|
|
25
|
+
it("revokes by id", async () => {
|
|
26
|
+
const paths: string[] = [];
|
|
27
|
+
const client = {
|
|
28
|
+
del: <T>(path: string): Promise<T> => {
|
|
29
|
+
paths.push(path);
|
|
30
|
+
return Promise.resolve(undefined as unknown as T);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
await revokeAdminSession(client, "id/with slash");
|
|
35
|
+
|
|
36
|
+
expect(paths).toEqual(["/admin/sessions/id%2Fwith%20slash"]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("KeysetPages", () => {
|
|
41
|
+
it("offers the visited pages plus the next one", () => {
|
|
42
|
+
const pages = new KeysetPages();
|
|
43
|
+
expect(pages.cursorFor(1)).toBeNull();
|
|
44
|
+
expect(pages.cursorFor(2)).toBeUndefined();
|
|
45
|
+
expect(pages.pagerTotal(100, 15)).toBe(15);
|
|
46
|
+
|
|
47
|
+
pages.record(1, "c2");
|
|
48
|
+
pages.record(2, "c3");
|
|
49
|
+
expect(pages.cursorFor(2)).toBe("c2");
|
|
50
|
+
expect(pages.cursorFor(3)).toBe("c3");
|
|
51
|
+
expect(pages.pagerTotal(100, 15)).toBe(45);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("stops at the last page and never exceeds the total", () => {
|
|
55
|
+
const pages = new KeysetPages();
|
|
56
|
+
pages.record(1, "c2");
|
|
57
|
+
pages.record(2, null);
|
|
58
|
+
expect(pages.cursorFor(3)).toBeUndefined();
|
|
59
|
+
expect(pages.pagerTotal(20, 15)).toBe(20);
|
|
60
|
+
expect(pages.pagerTotal(0, 15)).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("drops later cursors when a page is reloaded", () => {
|
|
64
|
+
const pages = new KeysetPages();
|
|
65
|
+
pages.record(1, "c2");
|
|
66
|
+
pages.record(2, "c3");
|
|
67
|
+
pages.record(1, "c2-new");
|
|
68
|
+
expect(pages.cursorFor(2)).toBe("c2-new");
|
|
69
|
+
expect(pages.cursorFor(3)).toBeUndefined();
|
|
70
|
+
|
|
71
|
+
pages.reset();
|
|
72
|
+
expect(pages.cursorFor(2)).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ApiClient } from "@podosoft/podokit-api-client";
|
|
2
|
+
|
|
3
|
+
/** An active session from `GET /admin/sessions`. The API never returns the session token. */
|
|
4
|
+
export type AdminSession = {
|
|
5
|
+
id: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
userEmail: string;
|
|
8
|
+
userName: string;
|
|
9
|
+
ipAddress: string | null;
|
|
10
|
+
userAgent: string | null;
|
|
11
|
+
impersonatedBy: string | null;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AdminSessionPage = {
|
|
17
|
+
items: AdminSession[];
|
|
18
|
+
nextCursor: string | null;
|
|
19
|
+
total: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type AdminSessionQuery = {
|
|
23
|
+
limit: number;
|
|
24
|
+
cursor?: string | null;
|
|
25
|
+
search?: string;
|
|
26
|
+
userId?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function listAdminSessions(
|
|
30
|
+
client: Pick<ApiClient, "get">,
|
|
31
|
+
query: AdminSessionQuery,
|
|
32
|
+
): Promise<AdminSessionPage> {
|
|
33
|
+
const params = new URLSearchParams({ limit: String(query.limit) });
|
|
34
|
+
if (query.cursor) params.set("cursor", query.cursor);
|
|
35
|
+
const search = query.search?.trim();
|
|
36
|
+
if (search) params.set("search", search);
|
|
37
|
+
if (query.userId) params.set("userId", query.userId);
|
|
38
|
+
return client.get<AdminSessionPage>(`/admin/sessions?${params.toString()}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Revokes by session id; the server looks up the token, so it never reaches the browser. */
|
|
42
|
+
export function revokeAdminSession(client: Pick<ApiClient, "del">, id: string): Promise<void> {
|
|
43
|
+
return client.del<void>(`/admin/sessions/${encodeURIComponent(id)}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Remembers the keyset cursor that starts each page reached so far. The API pages
|
|
48
|
+
* forward only, so the DataTable pager offers the pages already visited plus the
|
|
49
|
+
* next one, while the footer label still shows the full total.
|
|
50
|
+
*/
|
|
51
|
+
export class KeysetPages {
|
|
52
|
+
#cursors: Array<string | null> = [null];
|
|
53
|
+
|
|
54
|
+
reset(): void {
|
|
55
|
+
this.#cursors = [null];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The cursor that starts `page` (1-based): `null` for the first page, `undefined` when not reached yet. */
|
|
59
|
+
cursorFor(page: number): string | null | undefined {
|
|
60
|
+
return this.#cursors[page - 1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Records the result of loading `page`; cursors after it are stale once the page is reloaded. */
|
|
64
|
+
record(page: number, nextCursor: string | null): void {
|
|
65
|
+
this.#cursors.length = page;
|
|
66
|
+
if (nextCursor) this.#cursors.push(nextCursor);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The row count to give the DataTable pager so it shows exactly the reachable pages. */
|
|
70
|
+
pagerTotal(total: number, perPage: number): number {
|
|
71
|
+
return Math.min(total, this.#cursors.length * perPage);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { keepLatestFilter, listUsersQuery, type UserListState } from "./users";
|
|
3
|
+
|
|
4
|
+
const base: UserListState = {
|
|
5
|
+
page: 1,
|
|
6
|
+
perPage: 15,
|
|
7
|
+
sort: null,
|
|
8
|
+
search: "",
|
|
9
|
+
searchField: "email",
|
|
10
|
+
role: "",
|
|
11
|
+
status: "",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("listUsersQuery", () => {
|
|
15
|
+
it("pages newest first by default", () => {
|
|
16
|
+
expect(listUsersQuery({ ...base, page: 3 })).toEqual({
|
|
17
|
+
limit: 15,
|
|
18
|
+
offset: 30,
|
|
19
|
+
sortBy: "createdAt",
|
|
20
|
+
sortDirection: "desc",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("sorts by a table column on the server", () => {
|
|
25
|
+
expect(listUsersQuery({ ...base, sort: { key: "name", dir: "asc" } })).toMatchObject({
|
|
26
|
+
sortBy: "name",
|
|
27
|
+
sortDirection: "asc",
|
|
28
|
+
});
|
|
29
|
+
expect(listUsersQuery({ ...base, sort: { key: "status", dir: "asc" } })).toMatchObject({
|
|
30
|
+
sortBy: "createdAt",
|
|
31
|
+
sortDirection: "desc",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("searches email case-insensitively and names as typed", () => {
|
|
36
|
+
expect(listUsersQuery({ ...base, search: " Ada@Example.COM " })).toMatchObject({
|
|
37
|
+
searchValue: "ada@example.com",
|
|
38
|
+
searchField: "email",
|
|
39
|
+
searchOperator: "contains",
|
|
40
|
+
});
|
|
41
|
+
expect(listUsersQuery({ ...base, search: "Ada", searchField: "name" })).toMatchObject({
|
|
42
|
+
searchValue: "Ada",
|
|
43
|
+
searchField: "name",
|
|
44
|
+
});
|
|
45
|
+
expect(listUsersQuery({ ...base, search: " " })).not.toHaveProperty("searchValue");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("maps the role and status filters to the single server filter", () => {
|
|
49
|
+
expect(listUsersQuery({ ...base, role: "admin" })).toMatchObject({
|
|
50
|
+
filterField: "role",
|
|
51
|
+
filterValue: "admin",
|
|
52
|
+
filterOperator: "eq",
|
|
53
|
+
});
|
|
54
|
+
expect(listUsersQuery({ ...base, status: "pending" })).toMatchObject({
|
|
55
|
+
filterField: "signupApproved",
|
|
56
|
+
filterValue: false,
|
|
57
|
+
});
|
|
58
|
+
expect(listUsersQuery({ ...base, status: "banned" })).toMatchObject({
|
|
59
|
+
filterField: "banned",
|
|
60
|
+
filterValue: true,
|
|
61
|
+
});
|
|
62
|
+
expect(listUsersQuery(base)).not.toHaveProperty("filterField");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("keepLatestFilter", () => {
|
|
67
|
+
it("keeps whichever filter changed last", () => {
|
|
68
|
+
expect(keepLatestFilter({ role: "admin", status: "" }, { role: "admin", status: "banned" }))
|
|
69
|
+
.toEqual({ role: "", status: "banned" });
|
|
70
|
+
expect(keepLatestFilter({ role: "", status: "banned" }, { role: "user", status: "banned" }))
|
|
71
|
+
.toEqual({ role: "user", status: "" });
|
|
72
|
+
expect(keepLatestFilter({ role: "", status: "" }, { role: "user", status: "" }))
|
|
73
|
+
.toEqual({ role: "user", status: "" });
|
|
74
|
+
});
|
|
75
|
+
});
|