@podosoft/podokit 0.12.4 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/add.d.ts +27 -1
  2. package/dist/add.js +3 -1
  3. package/dist/index.js +6 -3
  4. package/dist/lib.d.ts +1 -1
  5. package/dist/lockfile.d.ts +4 -1
  6. package/dist/lockfile.js +4 -3
  7. package/dist/module-migrations.d.ts +25 -0
  8. package/dist/module-migrations.js +233 -0
  9. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
  10. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
  11. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
  12. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
  15. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
  16. package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +155 -2
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +109 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +21 -1
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +21 -1
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +8 -1
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +73 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
  43. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
  44. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
  47. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +70 -7
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +18 -2
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -1
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +3 -3
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -9
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +36 -3
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
  69. package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
  70. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  71. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
  72. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
  73. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
  74. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +9 -2
  75. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
  76. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
  77. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
  78. package/dist/templates/modules/auth/module.manifest.json +5 -4
  79. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  80. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  81. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  82. package/dist/templates/todo/Dockerfile.dev +3 -1
  83. package/dist/templates/todo/apps/api/package.json +1 -1
  84. package/dist/templates/todo/apps/web/package.json +1 -1
  85. package/dist/templates/todo/compose.dev.yaml +6 -0
  86. package/dist/update.d.ts +8 -1
  87. package/dist/update.js +68 -12
  88. package/package.json +1 -1
  89. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
  90. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
  91. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
  92. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
  93. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
  94. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
  95. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
  96. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
  97. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
  98. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
  99. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
  100. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
  101. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +0 -0
@@ -0,0 +1,43 @@
1
+ import { afterAll, afterEach, describe, expect, it, jest } from "@jest/globals";
2
+ import { runUserDeletedHandlers } from "../auth/user-delete-handlers";
3
+ import { StorageService } from "../storage/storage.service";
4
+ import { ProfileImageService } from "./profile-image.service";
5
+
6
+ jest.mock("../auth/auth-provider", () => ({ getAuth: jest.fn() }));
7
+
8
+ describe("ProfileImageService account deletion cleanup", () => {
9
+ const storage = {
10
+ put: jest.fn(async () => undefined),
11
+ get: jest.fn(async () => Buffer.alloc(0)),
12
+ presignedGetUrl: jest.fn(async () => "https://example.com/image"),
13
+ delete: jest.fn(async () => undefined),
14
+ };
15
+ const service = new ProfileImageService(storage as unknown as StorageService);
16
+
17
+ service.onModuleInit();
18
+
19
+ afterEach(() => {
20
+ jest.clearAllMocks();
21
+ });
22
+
23
+ afterAll(() => {
24
+ service.onModuleDestroy();
25
+ });
26
+
27
+ it("deletes a managed image after the account is deleted", async () => {
28
+ await runUserDeletedHandlers({
29
+ id: "user-1",
30
+ image: "/api/profile-images/123e4567-e89b-42d3-a456-426614174000.webp",
31
+ });
32
+
33
+ expect(storage.delete).toHaveBeenCalledWith(
34
+ "profile-images/123e4567-e89b-42d3-a456-426614174000.webp",
35
+ );
36
+ });
37
+
38
+ it("does not delete an external identity-provider image", async () => {
39
+ await runUserDeletedHandlers({ id: "user-1", image: "https://example.com/avatar.png" });
40
+
41
+ expect(storage.delete).not.toHaveBeenCalled();
42
+ });
43
+ });
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
3
+ import {
4
+ AppException,
5
+ PROFILE_IMAGE_NOT_FOUND,
6
+ type ProfileImageResponse,
7
+ } from "@podosoft/podokit-contracts";
8
+ import { getAuth } from "../auth/auth-provider";
9
+ import { registerUserDeletedHandler } from "../auth/user-delete-handlers";
10
+ import { StorageService } from "../storage/storage.service";
11
+ import { validateProfileImage, type ProfileImageUpload } from "./profile-image.validation";
12
+
13
+ const FILE_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.(?:jpg|png|webp)$/;
14
+ const PUBLIC_PREFIX = "/api/profile-images/";
15
+ const STORAGE_PREFIX = "profile-images/";
16
+
17
+ const CONTENT_TYPES: Record<string, string> = {
18
+ jpg: "image/jpeg",
19
+ png: "image/png",
20
+ webp: "image/webp",
21
+ };
22
+
23
+ export interface StoredProfileImage {
24
+ body: Buffer;
25
+ contentType: string;
26
+ }
27
+
28
+ @Injectable()
29
+ export class ProfileImageService implements OnModuleInit, OnModuleDestroy {
30
+ private readonly logger = new Logger(ProfileImageService.name);
31
+ private unregisterUserDeleted?: () => void;
32
+
33
+ constructor(private readonly storage: StorageService) {}
34
+
35
+ onModuleInit(): void {
36
+ this.unregisterUserDeleted = registerUserDeletedHandler(async (user) => {
37
+ await this.deleteManagedImage(user.image ?? null);
38
+ });
39
+ }
40
+
41
+ onModuleDestroy(): void {
42
+ this.unregisterUserDeleted?.();
43
+ }
44
+
45
+ async upload(
46
+ currentImage: string | null,
47
+ file: ProfileImageUpload,
48
+ headers: Headers,
49
+ ): Promise<ProfileImageResponse> {
50
+ const metadata = validateProfileImage(file);
51
+ const fileName = `${randomUUID()}.${metadata.extension}`;
52
+ const key = STORAGE_PREFIX + fileName;
53
+ const image = PUBLIC_PREFIX + fileName;
54
+
55
+ await this.storage.put(key, file.buffer, metadata.contentType);
56
+ try {
57
+ await getAuth().api.updateUser({ body: { image }, headers });
58
+ } catch (error: unknown) {
59
+ await this.deleteKeyBestEffort(key);
60
+ throw error;
61
+ }
62
+ await this.deleteManagedImage(currentImage);
63
+ return { image };
64
+ }
65
+
66
+ async remove(currentImage: string | null, headers: Headers): Promise<ProfileImageResponse> {
67
+ await getAuth().api.updateUser({ body: { image: null }, headers });
68
+ await this.deleteManagedImage(currentImage);
69
+ return { image: null };
70
+ }
71
+
72
+ async get(fileName: string): Promise<StoredProfileImage> {
73
+ const key = this.keyFromFileName(fileName);
74
+ if (!key) {
75
+ throw new AppException(PROFILE_IMAGE_NOT_FOUND, "Profile image not found.", 404);
76
+ }
77
+ try {
78
+ const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
79
+ return { body: await this.storage.get(key), contentType: CONTENT_TYPES[extension] ?? "application/octet-stream" };
80
+ } catch {
81
+ throw new AppException(PROFILE_IMAGE_NOT_FOUND, "Profile image not found.", 404);
82
+ }
83
+ }
84
+
85
+ private keyFromFileName(fileName: string): string | null {
86
+ return FILE_NAME.test(fileName) ? STORAGE_PREFIX + fileName : null;
87
+ }
88
+
89
+ private keyFromPublicUrl(image: string | null): string | null {
90
+ if (!image?.startsWith(PUBLIC_PREFIX)) return null;
91
+ return this.keyFromFileName(image.slice(PUBLIC_PREFIX.length));
92
+ }
93
+
94
+ private async deleteManagedImage(image: string | null): Promise<void> {
95
+ const key = this.keyFromPublicUrl(image);
96
+ if (key) await this.deleteKeyBestEffort(key);
97
+ }
98
+
99
+ private async deleteKeyBestEffort(key: string): Promise<void> {
100
+ try {
101
+ await this.storage.delete(key);
102
+ } catch (error: unknown) {
103
+ this.logger.warn({
104
+ message: "Delete profile image object failed",
105
+ key,
106
+ error: error instanceof Error ? error.message : String(error),
107
+ });
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import {
3
+ AppException,
4
+ PROFILE_IMAGE_DIMENSIONS_INVALID,
5
+ PROFILE_IMAGE_POLICY,
6
+ PROFILE_IMAGE_TOO_LARGE,
7
+ PROFILE_IMAGE_TYPE_INVALID,
8
+ } from "@podosoft/podokit-contracts";
9
+ import { validateProfileImage, type ProfileImageUpload } from "./profile-image.validation";
10
+
11
+ const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC";
12
+ const JPEG = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAKAAoDAREAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAYH/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AnpuCtAAAf//Z";
13
+ const WEBP = "UklGRjQAAABXRUJQVlA4ICgAAACQAQCdASoCAAMAAgA0JaACdLoAA5gA/vCwN/8bx4FeS7K/1oWg+AAA";
14
+ const TOO_WIDE_PNG = "iVBORw0KGgoAAAANSUhEUgAACAEAAAABAQMAAACfGLePAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCScoTk3I6QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozOTo0MCswMDowMCgv/7YAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzk6NDArMDA6MDBZckcKAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM5OjQwKzAwOjAwDmdm1QAAAAxJREFUGNNjYBjpAAABAgABRf+HpwAAAABJRU5ErkJggg==";
15
+
16
+ function upload(base64: string, mimetype: string, size?: number): ProfileImageUpload {
17
+ const buffer = Buffer.from(base64, "base64");
18
+ return { buffer, mimetype, size: size ?? buffer.length };
19
+ }
20
+
21
+ function expectAppException(action: () => void, code: string, statusCode = 400): void {
22
+ try {
23
+ action();
24
+ throw new Error("Expected validation to throw");
25
+ } catch (error: unknown) {
26
+ expect(error).toBeInstanceOf(AppException);
27
+ expect(error).toMatchObject({ code, statusCode });
28
+ }
29
+ }
30
+
31
+ describe("validateProfileImage", () => {
32
+ const supported: Array<[string, string, "png" | "jpg" | "webp", number, number]> = [
33
+ [PNG, "image/png", "png", 2, 3],
34
+ [JPEG, "image/jpeg", "jpg", 10, 10],
35
+ [WEBP, "image/webp", "webp", 2, 3],
36
+ ];
37
+
38
+ it.each(supported)("accepts a valid supported image", (base64, mimetype, extension, width, height) => {
39
+ expect(validateProfileImage(upload(base64, mimetype))).toMatchObject({
40
+ contentType: mimetype,
41
+ extension,
42
+ width,
43
+ height,
44
+ });
45
+ });
46
+
47
+ it("rejects a MIME type that does not match the bytes", () => {
48
+ expectAppException(
49
+ () => validateProfileImage(upload(PNG, "image/jpeg")),
50
+ PROFILE_IMAGE_TYPE_INVALID,
51
+ );
52
+ });
53
+
54
+ it("rejects unsupported or malformed bytes", () => {
55
+ expectAppException(
56
+ () => validateProfileImage(upload(Buffer.from("not an image").toString("base64"), "image/png")),
57
+ PROFILE_IMAGE_TYPE_INVALID,
58
+ );
59
+ });
60
+
61
+ it("rejects a file larger than the shared byte limit", () => {
62
+ expectAppException(
63
+ () => validateProfileImage(upload(PNG, "image/png", PROFILE_IMAGE_POLICY.maxBytes + 1)),
64
+ PROFILE_IMAGE_TOO_LARGE,
65
+ 413,
66
+ );
67
+ });
68
+
69
+ it("accepts arbitrary aspect ratios within the limit", () => {
70
+ expect(validateProfileImage(upload(PNG, "image/png"))).toMatchObject({ width: 2, height: 3 });
71
+ });
72
+
73
+ it("rejects a dimension above 2048 pixels", () => {
74
+ expectAppException(
75
+ () => validateProfileImage(upload(TOO_WIDE_PNG, "image/png")),
76
+ PROFILE_IMAGE_DIMENSIONS_INVALID,
77
+ );
78
+ });
79
+ });
@@ -0,0 +1,74 @@
1
+ import {
2
+ AppException,
3
+ PROFILE_IMAGE_DIMENSIONS_INVALID,
4
+ PROFILE_IMAGE_POLICY,
5
+ PROFILE_IMAGE_TOO_LARGE,
6
+ PROFILE_IMAGE_TYPE_INVALID,
7
+ type ProfileImageMimeType,
8
+ } from "@podosoft/podokit-contracts";
9
+ import { imageSize } from "image-size";
10
+
11
+ export interface ProfileImageUpload {
12
+ buffer: Buffer;
13
+ mimetype: string;
14
+ size: number;
15
+ }
16
+
17
+ export interface ProfileImageMetadata {
18
+ contentType: ProfileImageMimeType;
19
+ extension: "jpg" | "png" | "webp";
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ const DETECTED_TYPES: Record<string, Pick<ProfileImageMetadata, "contentType" | "extension">> = {
25
+ jpg: { contentType: "image/jpeg", extension: "jpg" },
26
+ png: { contentType: "image/png", extension: "png" },
27
+ webp: { contentType: "image/webp", extension: "webp" },
28
+ };
29
+
30
+ /** Inspect image bytes instead of trusting the uploaded filename or MIME type. */
31
+ export function validateProfileImage(file: ProfileImageUpload): ProfileImageMetadata {
32
+ if (Math.max(file.size, file.buffer.length) > PROFILE_IMAGE_POLICY.maxBytes) {
33
+ throw new AppException(
34
+ PROFILE_IMAGE_TOO_LARGE,
35
+ "Profile image must be 2 MB or smaller.",
36
+ 413,
37
+ );
38
+ }
39
+
40
+ let dimensions: ReturnType<typeof imageSize>;
41
+ try {
42
+ dimensions = imageSize(file.buffer);
43
+ } catch {
44
+ throw new AppException(
45
+ PROFILE_IMAGE_TYPE_INVALID,
46
+ "Profile image must be a valid PNG, JPEG, or WebP file.",
47
+ );
48
+ }
49
+
50
+ const detected = dimensions.type ? DETECTED_TYPES[dimensions.type] : undefined;
51
+ if (!detected || detected.contentType !== file.mimetype) {
52
+ throw new AppException(
53
+ PROFILE_IMAGE_TYPE_INVALID,
54
+ "Profile image must be a valid PNG, JPEG, or WebP file.",
55
+ );
56
+ }
57
+ if (
58
+ dimensions.width === undefined
59
+ || dimensions.height === undefined
60
+ || dimensions.width > PROFILE_IMAGE_POLICY.maxWidth
61
+ || dimensions.height > PROFILE_IMAGE_POLICY.maxHeight
62
+ ) {
63
+ throw new AppException(
64
+ PROFILE_IMAGE_DIMENSIONS_INVALID,
65
+ "Profile image dimensions must not exceed 2048 by 2048 pixels.",
66
+ );
67
+ }
68
+
69
+ return {
70
+ ...detected,
71
+ width: dimensions.width,
72
+ height: dimensions.height,
73
+ };
74
+ }
@@ -6,7 +6,7 @@ import type { Messages } from "$lib/i18n/messages";
6
6
 
7
7
  // Module-driven admin menu registry. Modules append their sidebar nav entries and
8
8
  // settings tabs at the markers below via `podo add` injection, so the admin menu
9
- // grows and shrinks with the installed module set — no edits to app-sidebar or
9
+ // grows and shrinks with the installed module set — no edits to admin-sidebar or
10
10
  // the settings page. Empty until a module registers.
11
11
 
12
12
  /** A sidebar nav entry contributed by a module: a link, a label resolved from the
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { goto } from "$app/navigation";
2
+ import { goto, invalidateAll } from "$app/navigation";
3
3
  import QRCode from "qrcode";
4
4
  import { Button } from "$lib/components/ui/button";
5
5
  import { Input } from "$lib/components/ui/input";
@@ -14,7 +14,17 @@
14
14
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
15
15
  import { untrack } from "svelte";
16
16
  import type { SessionUser } from "../../app.d.ts";
17
- import type { Capabilities } from "@podosoft/podokit-api-client";
17
+ import UserAvatar from "./user-avatar.svelte";
18
+ import {
19
+ ApiError,
20
+ PROFILE_IMAGE_DIMENSIONS_INVALID,
21
+ PROFILE_IMAGE_POLICY,
22
+ PROFILE_IMAGE_REQUIRED,
23
+ PROFILE_IMAGE_TOO_LARGE,
24
+ PROFILE_IMAGE_TYPE_INVALID,
25
+ type Capabilities,
26
+ type ProfileImageResponse,
27
+ } from "@podosoft/podokit-api-client";
18
28
 
19
29
  let {
20
30
  data,
@@ -45,6 +55,15 @@
45
55
  let name = $state(untrack(() => data.user.name));
46
56
  let username = $state(untrack(() => data.user.username ?? ""));
47
57
  let savingProfile = $state(false);
58
+ let profileImage = $state<string | null>(untrack(() => data.user.image ?? null));
59
+ let profileImageInput = $state<HTMLInputElement | null>(null);
60
+ let profileImageBusy = $state(false);
61
+ const profileImageAccept = PROFILE_IMAGE_POLICY.mimeTypes.join(",");
62
+ const profileImageHint = fmt(i18n.t.account.profileImageHint, {
63
+ size: PROFILE_IMAGE_POLICY.maxBytes / 1024 / 1024,
64
+ width: PROFILE_IMAGE_POLICY.maxWidth,
65
+ height: PROFILE_IMAGE_POLICY.maxHeight,
66
+ });
48
67
  const nameChanged = $derived(
49
68
  (name.trim() !== "" && name !== data.user.name) || (caps.username && username !== (data.user.username ?? "")),
50
69
  );
@@ -60,6 +79,93 @@
60
79
  else toast.success(i18n.t.account.saved);
61
80
  }
62
81
 
82
+ function profileImageErrorMessage(code: string): string {
83
+ switch (code) {
84
+ case PROFILE_IMAGE_REQUIRED:
85
+ return i18n.t.account.profileImageRequired;
86
+ case PROFILE_IMAGE_TOO_LARGE:
87
+ return i18n.t.account.profileImageTooLarge;
88
+ case PROFILE_IMAGE_DIMENSIONS_INVALID:
89
+ return i18n.t.account.profileImageDimensionsInvalid;
90
+ case PROFILE_IMAGE_TYPE_INVALID:
91
+ return i18n.t.account.profileImageTypeInvalid;
92
+ default:
93
+ return i18n.t.account.profileImageFailed;
94
+ }
95
+ }
96
+
97
+ function imageDimensions(file: File): Promise<{ width: number; height: number }> {
98
+ return new Promise((resolve, reject) => {
99
+ const url = URL.createObjectURL(file);
100
+ const image = new Image();
101
+ image.onload = () => {
102
+ URL.revokeObjectURL(url);
103
+ resolve({ width: image.naturalWidth, height: image.naturalHeight });
104
+ };
105
+ image.onerror = () => {
106
+ URL.revokeObjectURL(url);
107
+ reject(new Error("Invalid image"));
108
+ };
109
+ image.src = url;
110
+ });
111
+ }
112
+
113
+ async function validateProfileImageSelection(file: File): Promise<string | null> {
114
+ if (!(PROFILE_IMAGE_POLICY.mimeTypes as readonly string[]).includes(file.type)) {
115
+ return PROFILE_IMAGE_TYPE_INVALID;
116
+ }
117
+ if (file.size > PROFILE_IMAGE_POLICY.maxBytes) return PROFILE_IMAGE_TOO_LARGE;
118
+ try {
119
+ const dimensions = await imageDimensions(file);
120
+ if (
121
+ dimensions.width > PROFILE_IMAGE_POLICY.maxWidth
122
+ || dimensions.height > PROFILE_IMAGE_POLICY.maxHeight
123
+ ) {
124
+ return PROFILE_IMAGE_DIMENSIONS_INVALID;
125
+ }
126
+ } catch {
127
+ return PROFILE_IMAGE_TYPE_INVALID;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ async function uploadProfileImage(event: Event): Promise<void> {
133
+ const input = event.currentTarget as HTMLInputElement;
134
+ const file = input.files?.[0];
135
+ input.value = "";
136
+ if (!file) return;
137
+ const validationCode = await validateProfileImageSelection(file);
138
+ if (validationCode) return void toast.error(profileImageErrorMessage(validationCode));
139
+
140
+ profileImageBusy = true;
141
+ try {
142
+ const form = new FormData();
143
+ form.set("file", file, file.name);
144
+ const response = await api.postForm<ProfileImageResponse>("/account/profile-image", form);
145
+ profileImage = response.image;
146
+ toast.success(i18n.t.account.profileImageUpdated);
147
+ await invalidateAll();
148
+ } catch (error: unknown) {
149
+ toast.error(profileImageErrorMessage(error instanceof ApiError ? error.code : ""));
150
+ } finally {
151
+ profileImageBusy = false;
152
+ }
153
+ }
154
+
155
+ async function removeProfileImage(): Promise<void> {
156
+ profileImageBusy = true;
157
+ try {
158
+ const response = await api.del<ProfileImageResponse>("/account/profile-image");
159
+ profileImage = response.image;
160
+ toast.success(i18n.t.account.profileImageRemoved);
161
+ await invalidateAll();
162
+ } catch (error: unknown) {
163
+ toast.error(profileImageErrorMessage(error instanceof ApiError ? error.code : ""));
164
+ } finally {
165
+ profileImageBusy = false;
166
+ }
167
+ }
168
+
63
169
  // Phone number — register + verify with an SMS code (dev delivery is a stub).
64
170
  let phone = $state(untrack(() => data.user.phoneNumber ?? ""));
65
171
  let phoneCode = $state("");
@@ -404,6 +510,53 @@
404
510
  <Card.Root>
405
511
  <Card.Header><Card.Title>{i18n.t.account.profile}</Card.Title></Card.Header>
406
512
  <Card.Content>
513
+ <div class="mb-6 flex max-w-md items-center gap-4 border-b pb-6">
514
+ <div data-testid="profile-image-preview">
515
+ <UserAvatar user={{ ...data.user, image: profileImage }} class="size-20 shrink-0" />
516
+ </div>
517
+ <div class="min-w-0 flex-1 space-y-2">
518
+ <Label for="profile-image">{i18n.t.account.profileImage}</Label>
519
+ <p id="profile-image-hint" class="text-muted-foreground text-xs" data-testid="profile-image-hint">
520
+ {profileImageHint}
521
+ </p>
522
+ <Input
523
+ id="profile-image"
524
+ type="file"
525
+ accept={profileImageAccept}
526
+ class="sr-only"
527
+ aria-describedby="profile-image-hint"
528
+ disabled={profileImageBusy}
529
+ bind:ref={profileImageInput}
530
+ onchange={uploadProfileImage}
531
+ />
532
+ <div class="flex flex-wrap gap-2">
533
+ <Button
534
+ type="button"
535
+ variant="outline"
536
+ size="sm"
537
+ disabled={profileImageBusy}
538
+ onclick={() => profileImageInput?.click()}
539
+ >
540
+ {profileImageBusy
541
+ ? i18n.t.account.profileImageUploading
542
+ : profileImage
543
+ ? i18n.t.account.profileImageReplace
544
+ : i18n.t.account.profileImageUpload}
545
+ </Button>
546
+ {#if profileImage}
547
+ <Button
548
+ type="button"
549
+ variant="ghost"
550
+ size="sm"
551
+ disabled={profileImageBusy}
552
+ onclick={removeProfileImage}
553
+ >
554
+ {i18n.t.account.profileImageRemove}
555
+ </Button>
556
+ {/if}
557
+ </div>
558
+ </div>
559
+ </div>
407
560
  <form class="flex max-w-md flex-col gap-4" onsubmit={saveProfile}>
408
561
  <div class="flex flex-col gap-2">
409
562
  <Label for="name">{i18n.t.account.name}</Label>
@@ -0,0 +1,109 @@
1
+ <script lang="ts">
2
+ import { goto } from "$app/navigation";
3
+ import { onMount } from "svelte";
4
+ import { api } from "$lib/api";
5
+
6
+ let { timeoutMinutes }: { timeoutMinutes: number | null } = $props();
7
+
8
+ const ACTIVITY_KEY = "podokit:session-last-activity";
9
+ const SERVER_REFRESH_INTERVAL_MS = 60_000;
10
+ const activityEvents = ["pointerdown", "keydown", "touchstart", "scroll"] as const;
11
+
12
+ let mounted = $state(false);
13
+ let lastActivityAt = $state(Date.now());
14
+ let lastServerRefreshAt = $state(Date.now());
15
+ let signingOut = false;
16
+ let previousTimeoutMs: number | null = null;
17
+ const timeoutMs = $derived(timeoutMinutes === null ? null : timeoutMinutes * 60_000);
18
+
19
+ function storeActivity(value: number): void {
20
+ try {
21
+ localStorage.setItem(ACTIVITY_KEY, String(value));
22
+ } catch {
23
+ // Storage can be unavailable in hardened browser modes; this tab still works.
24
+ }
25
+ }
26
+
27
+ async function signOutForIdleTimeout(): Promise<void> {
28
+ if (signingOut) return;
29
+ signingOut = true;
30
+ try {
31
+ await api.auth.signOut();
32
+ } finally {
33
+ try {
34
+ localStorage.removeItem(ACTIVITY_KEY);
35
+ } catch {
36
+ // Ignore unavailable storage while completing sign-out.
37
+ }
38
+ await goto("/login?reason=idle", { invalidateAll: true });
39
+ }
40
+ }
41
+
42
+ async function refreshServerSession(): Promise<void> {
43
+ const { data, error } = await api.auth.getSession();
44
+ if (error || !data) await signOutForIdleTimeout();
45
+ }
46
+
47
+ function recordActivity(): void {
48
+ if (timeoutMs === null || signingOut) return;
49
+ const now = Date.now();
50
+ if (now - lastActivityAt >= timeoutMs) {
51
+ void signOutForIdleTimeout();
52
+ return;
53
+ }
54
+ lastActivityAt = now;
55
+ storeActivity(now);
56
+ if (now - lastServerRefreshAt >= SERVER_REFRESH_INTERVAL_MS) {
57
+ lastServerRefreshAt = now;
58
+ void refreshServerSession();
59
+ }
60
+ }
61
+
62
+ function receiveActivity(event: StorageEvent): void {
63
+ if (event.key !== ACTIVITY_KEY || event.newValue === null) return;
64
+ const value = Number(event.newValue);
65
+ if (Number.isFinite(value) && value > lastActivityAt) lastActivityAt = value;
66
+ }
67
+
68
+ onMount(() => {
69
+ mounted = true;
70
+ const now = Date.now();
71
+ lastActivityAt = now;
72
+ lastServerRefreshAt = now;
73
+ storeActivity(now);
74
+ for (const event of activityEvents) {
75
+ document.addEventListener(event, recordActivity, { passive: true, capture: true });
76
+ }
77
+ window.addEventListener("focus", recordActivity);
78
+ window.addEventListener("storage", receiveActivity);
79
+ return () => {
80
+ for (const event of activityEvents) {
81
+ document.removeEventListener(event, recordActivity, { capture: true });
82
+ }
83
+ window.removeEventListener("focus", recordActivity);
84
+ window.removeEventListener("storage", receiveActivity);
85
+ };
86
+ });
87
+
88
+ $effect(() => {
89
+ if (!mounted) return;
90
+ const currentTimeoutMs = timeoutMs;
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);
108
+ });
109
+ </script>
@@ -22,7 +22,7 @@
22
22
  <Sidebar.Menu>
23
23
  <Sidebar.MenuItem>
24
24
  <DropdownMenu.Root>
25
- <DropdownMenu.Trigger>
25
+ <DropdownMenu.Trigger data-testid="sidebar-user-menu">
26
26
  {#snippet child({ props })}
27
27
  <Sidebar.MenuButton {...props} size="lg" class="data-[state=open]:bg-sidebar-accent">
28
28
  <UserAvatar {user} class="size-8" />
@@ -3,10 +3,14 @@
3
3
  import { ModeWatcher } from "mode-watcher";
4
4
  import { site, type SiteSettings } from "$lib/site.svelte";
5
5
  import { applyTheme, themeConfigFromSettings } from "$lib/site/apply-theme";
6
+ import SessionIdleTimeout from "$lib/components/session-idle-timeout.svelte";
6
7
 
7
8
  const loadedSite = $derived(
8
9
  (page.data as { site?: Partial<SiteSettings> | null }).site,
9
10
  );
11
+ const sessionIdleTimeoutMinutes = $derived(
12
+ (page.data as { sessionIdleTimeoutMinutes?: number | null }).sessionIdleTimeoutMinutes ?? null,
13
+ );
10
14
 
11
15
  // Seed the reactive site settings for every route without taking ownership of
12
16
  // the application's root layout or public pages.
@@ -37,3 +41,4 @@
37
41
  </svelte:head>
38
42
 
39
43
  <ModeWatcher />
44
+ <SessionIdleTimeout timeoutMinutes={sessionIdleTimeoutMinutes} />