@podosoft/podokit 0.13.0 → 0.15.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 (86) hide show
  1. package/README.md +3 -0
  2. package/dist/add.d.ts +27 -1
  3. package/dist/add.js +3 -1
  4. package/dist/index.js +6 -3
  5. package/dist/lib.d.ts +1 -1
  6. package/dist/lockfile.d.ts +4 -1
  7. package/dist/lockfile.js +6 -3
  8. package/dist/module-migrations.d.ts +25 -0
  9. package/dist/module-migrations.js +233 -0
  10. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
  11. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
  12. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
  16. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
  17. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
  18. package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
  19. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -0
  20. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
  21. package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +155 -2
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +13 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +13 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +18 -10
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
  43. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
  44. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
  46. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
  49. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
  50. package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +64 -1
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +16 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +4 -7
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +4 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
  59. package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
  60. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +5 -1
  61. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
  62. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
  63. package/dist/templates/modules/auth/module.manifest.json +1 -1
  64. package/dist/templates/todo/Dockerfile.dev +3 -1
  65. package/dist/templates/todo/apps/api/package.json +1 -1
  66. package/dist/templates/todo/apps/web/package.json +1 -1
  67. package/dist/templates/todo/compose.dev.yaml +6 -0
  68. package/dist/templates/todo/tests/playwright.config.ts +6 -4
  69. package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
  70. package/dist/update.d.ts +8 -1
  71. package/dist/update.js +85 -25
  72. package/package.json +1 -1
  73. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
  74. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
  75. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
  76. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
  77. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
  78. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
  79. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
  80. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
  81. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
  82. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
  83. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +0 -0
  84. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +0 -0
  85. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
  86. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
@@ -0,0 +1,39 @@
1
+ import {
2
+ type CallHandler,
3
+ type ExecutionContext,
4
+ Injectable,
5
+ PayloadTooLargeException,
6
+ } from "@nestjs/common";
7
+ import { FileInterceptor } from "@nestjs/platform-express";
8
+ import {
9
+ AppException,
10
+ PROFILE_IMAGE_POLICY,
11
+ PROFILE_IMAGE_TOO_LARGE,
12
+ } from "@podosoft/podokit-contracts";
13
+ import type { Observable } from "rxjs";
14
+
15
+ const ProfileImageFileInterceptor = FileInterceptor("file", {
16
+ limits: { fileSize: PROFILE_IMAGE_POLICY.maxBytes },
17
+ });
18
+
19
+ /** Preserve a stable error code when Multer rejects an oversized request. */
20
+ @Injectable()
21
+ export class ProfileImageUploadInterceptor extends ProfileImageFileInterceptor {
22
+ override async intercept(
23
+ context: ExecutionContext,
24
+ next: CallHandler,
25
+ ): Promise<Observable<unknown>> {
26
+ try {
27
+ return await super.intercept(context, next);
28
+ } catch (error: unknown) {
29
+ if (error instanceof PayloadTooLargeException) {
30
+ throw new AppException(
31
+ PROFILE_IMAGE_TOO_LARGE,
32
+ "Profile image must be 2 MB or smaller.",
33
+ 413,
34
+ );
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,73 @@
1
+ /// <reference types="multer" />
2
+ import {
3
+ Controller,
4
+ Delete,
5
+ Get,
6
+ Param,
7
+ Post,
8
+ Req,
9
+ Res,
10
+ StreamableFile,
11
+ UploadedFile,
12
+ UseInterceptors,
13
+ } from "@nestjs/common";
14
+ import { ApiConsumes, ApiTags } from "@nestjs/swagger";
15
+ import { Public, Session, type UserSession } from "@thallesp/nestjs-better-auth";
16
+ import {
17
+ AppException,
18
+ PROFILE_IMAGE_REQUIRED,
19
+ type ProfileImageResponse,
20
+ } from "@podosoft/podokit-contracts";
21
+ import { fromNodeHeaders } from "better-auth/node";
22
+ import type { Request, Response } from "express";
23
+ import { ProfileImageService } from "./profile-image.service";
24
+ import { ProfileImageUploadInterceptor } from "./profile-image-upload.interceptor";
25
+
26
+ @ApiTags("account")
27
+ @Controller()
28
+ export class ProfileImageController {
29
+ constructor(private readonly profileImages: ProfileImageService) {}
30
+
31
+ @Post("account/profile-image")
32
+ @ApiConsumes("multipart/form-data")
33
+ @UseInterceptors(ProfileImageUploadInterceptor)
34
+ async upload(
35
+ @Session() session: UserSession,
36
+ @Req() req: Request,
37
+ @UploadedFile() file?: Express.Multer.File,
38
+ ): Promise<ProfileImageResponse> {
39
+ if (!file) {
40
+ throw new AppException(
41
+ PROFILE_IMAGE_REQUIRED,
42
+ "A profile image file is required.",
43
+ );
44
+ }
45
+ return this.profileImages.upload(
46
+ session.user.image ?? null,
47
+ file,
48
+ fromNodeHeaders(req.headers),
49
+ );
50
+ }
51
+
52
+ @Delete("account/profile-image")
53
+ async remove(
54
+ @Session() session: UserSession,
55
+ @Req() req: Request,
56
+ ): Promise<ProfileImageResponse> {
57
+ return this.profileImages.remove(
58
+ session.user.image ?? null,
59
+ fromNodeHeaders(req.headers),
60
+ );
61
+ }
62
+
63
+ @Public()
64
+ @Get("profile-images/:fileName")
65
+ async get(
66
+ @Param("fileName") fileName: string,
67
+ @Res({ passthrough: true }) response: Response,
68
+ ): Promise<StreamableFile> {
69
+ const image = await this.profileImages.get(fileName);
70
+ response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
71
+ return new StreamableFile(image.body, { type: image.contentType });
72
+ }
73
+ }
@@ -0,0 +1,9 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { ProfileImageController } from "./profile-image.controller";
3
+ import { ProfileImageService } from "./profile-image.service";
4
+
5
+ @Module({
6
+ controllers: [ProfileImageController],
7
+ providers: [ProfileImageService],
8
+ })
9
+ export class ProfileImageModule {}
@@ -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
@@ -7,12 +7,30 @@
7
7
  import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
8
8
  import LogOutIcon from "@lucide/svelte/icons/log-out";
9
9
  import UserIcon from "@lucide/svelte/icons/user";
10
+ import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
10
11
  import { api } from "$lib/api";
11
12
  import { getI18n } from "$lib/i18n";
13
+ import { cn } from "$lib/utils";
12
14
  import type { SessionUser } from "../../app.d.ts";
13
15
  import UserAvatar from "./user-avatar.svelte";
14
16
 
15
- let { user }: { user: SessionUser | null } = $props();
17
+ type MenuSide = "top" | "right" | "bottom" | "left";
18
+ type MenuAlign = "start" | "center" | "end";
19
+ type Props = {
20
+ user: SessionUser | null;
21
+ variant?: "avatar" | "identity";
22
+ side?: MenuSide;
23
+ align?: MenuAlign;
24
+ class?: string;
25
+ };
26
+
27
+ let {
28
+ user,
29
+ variant = "avatar",
30
+ side = "bottom",
31
+ align = "end",
32
+ class: className,
33
+ }: Props = $props();
16
34
  const i18n = getI18n();
17
35
  const loginHref = $derived(withAuthRedirect("/login", currentPath(page.url)));
18
36
 
@@ -27,12 +45,25 @@
27
45
  <DropdownMenu.Root>
28
46
  <DropdownMenu.Trigger
29
47
  data-testid="account-menu"
30
- class="hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 inline-flex size-8 items-center justify-center rounded-full outline-none focus-visible:ring-3"
48
+ class={cn(
49
+ "hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-3",
50
+ variant === "avatar"
51
+ ? "inline-flex size-8 items-center justify-center rounded-full"
52
+ : "flex w-full items-center gap-2 rounded-md p-2 text-left",
53
+ className,
54
+ )}
31
55
  aria-label={i18n.t.nav.account}
32
56
  >
33
57
  <UserAvatar {user} class="size-8" />
58
+ {#if variant === "identity"}
59
+ <div class="min-w-0 flex-1 text-sm leading-tight">
60
+ <span class="block truncate font-medium">{user.name}</span>
61
+ <span class="text-muted-foreground block truncate text-xs">{user.email}</span>
62
+ </div>
63
+ <ChevronsUpDownIcon class="size-4 shrink-0" />
64
+ {/if}
34
65
  </DropdownMenu.Trigger>
35
- <DropdownMenu.Content align="end" class="w-56">
66
+ <DropdownMenu.Content {align} {side} class="w-56">
36
67
  <DropdownMenu.Label>
37
68
  <span class="block truncate font-medium">{user.name}</span>
38
69
  <span class="text-muted-foreground block truncate text-xs font-normal">{user.email}</span>
@@ -47,5 +78,5 @@
47
78
  </DropdownMenu.Content>
48
79
  </DropdownMenu.Root>
49
80
  {:else}
50
- <Button data-testid="sign-in-link" href={loginHref} variant="ghost">{i18n.t.auth.signIn}</Button>
81
+ <Button data-testid="sign-in-link" href={loginHref} variant="ghost" class={className}>{i18n.t.auth.signIn}</Button>
51
82
  {/if}