@podosoft/podokit 0.13.0 → 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 (66) 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/sidebar-user-menu.svelte +1 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +13 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +13 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
  35. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +64 -1
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +16 -0
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +4 -7
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +4 -1
  40. package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
  41. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +5 -1
  42. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
  43. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
  44. package/dist/templates/modules/auth/module.manifest.json +1 -1
  45. package/dist/templates/todo/Dockerfile.dev +3 -1
  46. package/dist/templates/todo/apps/api/package.json +1 -1
  47. package/dist/templates/todo/apps/web/package.json +1 -1
  48. package/dist/templates/todo/compose.dev.yaml +6 -0
  49. package/dist/update.d.ts +8 -1
  50. package/dist/update.js +68 -12
  51. package/package.json +1 -1
  52. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
  53. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
  54. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
  55. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
  56. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
  57. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
  58. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
  59. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
  60. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
  61. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
  62. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +0 -0
  63. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +0 -0
  64. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
  65. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
  66. /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>
@@ -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" />
@@ -502,6 +502,19 @@
502
502
  "account": {
503
503
  "title": "Account",
504
504
  "profile": "Profile",
505
+ "profileImage": "Profile image",
506
+ "profileImageHint": "PNG, JPEG, or WebP. Up to {size} MB and {width} × {height} px.",
507
+ "profileImageUpload": "Upload image",
508
+ "profileImageReplace": "Replace image",
509
+ "profileImageRemove": "Remove image",
510
+ "profileImageUploading": "Uploading…",
511
+ "profileImageUpdated": "Profile image updated",
512
+ "profileImageRemoved": "Profile image removed",
513
+ "profileImageRequired": "Choose an image to upload",
514
+ "profileImageTypeInvalid": "Choose a valid PNG, JPEG, or WebP image",
515
+ "profileImageTooLarge": "The image must be 2 MB or smaller",
516
+ "profileImageDimensionsInvalid": "The image must not exceed 2048 × 2048 px",
517
+ "profileImageFailed": "Failed to update profile image",
505
518
  "name": "Name",
506
519
  "username": "Username",
507
520
  "phone": "Phone number",
@@ -502,6 +502,19 @@
502
502
  "account": {
503
503
  "title": "계정",
504
504
  "profile": "프로필",
505
+ "profileImage": "프로필 이미지",
506
+ "profileImageHint": "PNG, JPEG, WebP · 최대 {size}MB · 최대 {width} × {height}px",
507
+ "profileImageUpload": "이미지 업로드",
508
+ "profileImageReplace": "이미지 교체",
509
+ "profileImageRemove": "이미지 제거",
510
+ "profileImageUploading": "업로드 중…",
511
+ "profileImageUpdated": "프로필 이미지를 변경했습니다",
512
+ "profileImageRemoved": "프로필 이미지를 제거했습니다",
513
+ "profileImageRequired": "업로드할 이미지를 선택해 주세요",
514
+ "profileImageTypeInvalid": "올바른 PNG, JPEG 또는 WebP 이미지를 선택해 주세요",
515
+ "profileImageTooLarge": "이미지는 2MB 이하여야 합니다",
516
+ "profileImageDimensionsInvalid": "이미지는 2048 × 2048px를 초과할 수 없습니다",
517
+ "profileImageFailed": "프로필 이미지 변경에 실패했습니다",
505
518
  "name": "이름",
506
519
  "username": "사용자명",
507
520
  "phone": "전화번호",
@@ -0,0 +1,78 @@
1
+ import { redirect } from "@sveltejs/kit";
2
+ import type { Capabilities } from "@podosoft/podokit-api-client";
3
+ import { requireBackendAvailable } from "./guards";
4
+
5
+ type ServerFetch = (
6
+ input: RequestInfo | URL,
7
+ init?: RequestInit,
8
+ ) => Promise<Response>;
9
+
10
+ export interface ProtectedLayoutInput {
11
+ locals: App.Locals;
12
+ fetch: ServerFetch;
13
+ }
14
+
15
+ export interface ProtectedLayoutData {
16
+ user: App.Locals["user"];
17
+ impersonating: boolean;
18
+ capabilities: Capabilities;
19
+ }
20
+
21
+ /**
22
+ * Load the shared signed-in route context without imposing an application shell.
23
+ * The `(app)` group stays available for product UI, while `(admin)` adds admin chrome.
24
+ */
25
+ export async function loadProtectedLayout({
26
+ locals,
27
+ fetch,
28
+ }: ProtectedLayoutInput): Promise<ProtectedLayoutData> {
29
+ requireBackendAvailable(locals);
30
+
31
+ let capabilities: Capabilities = {
32
+ twoFactor: false,
33
+ providers: [],
34
+ deleteAccount: false,
35
+ auditLog: false,
36
+ emailVerification: false,
37
+ signupApprovalRequired: false,
38
+ passwordBreachCheck: false,
39
+ magicLink: false,
40
+ emailOtp: false,
41
+ username: false,
42
+ multiSession: false,
43
+ phoneNumber: false,
44
+ apiKey: false,
45
+ passkey: false,
46
+ organization: false,
47
+ oidcProvider: false,
48
+ roles: ["admin", "user"],
49
+ };
50
+ try {
51
+ const response = await fetch("/api/account/capabilities");
52
+ if (response.ok) capabilities = (await response.json()) as Capabilities;
53
+ } catch {
54
+ /* keep defaults */
55
+ }
56
+
57
+ // Require-two-factor policy: a signed-in user who has not enrolled is sent to
58
+ // setup. The API guard remains authoritative; this redirect provides the UX.
59
+ const user = locals.user as (App.Locals["user"] & { twoFactorEnabled?: boolean }) | null;
60
+ let mustEnrol = false;
61
+ if (user && !user.twoFactorEnabled) {
62
+ try {
63
+ const response = await fetch("/api/account/require-2fa");
64
+ mustEnrol =
65
+ response.ok &&
66
+ ((await response.json()) as { require2fa?: boolean }).require2fa === true;
67
+ } catch {
68
+ /* do not block the app if the policy check fails */
69
+ }
70
+ }
71
+ if (mustEnrol) redirect(302, "/setup-2fa");
72
+
73
+ return {
74
+ user: locals.user,
75
+ impersonating: Boolean(locals.session?.impersonatedBy),
76
+ capabilities,
77
+ };
78
+ }
@@ -0,0 +1,8 @@
1
+ import type { LayoutServerLoad } from "./$types";
2
+ import { loadProtectedLayout } from "$lib/server/protected-layout";
3
+ import { requireAdmin } from "$lib/server/guards";
4
+
5
+ export const load: LayoutServerLoad = async ({ locals, fetch }) => {
6
+ requireAdmin(locals.user, locals);
7
+ return loadProtectedLayout({ locals, fetch });
8
+ };
@@ -3,7 +3,7 @@
3
3
  import * as Sidebar from "$lib/components/ui/sidebar";
4
4
  import { Separator } from "$lib/components/ui/separator";
5
5
  import { Button } from "$lib/components/ui/button";
6
- import AppSidebar from "$lib/components/app-sidebar.svelte";
6
+ import AdminSidebar from "$lib/components/admin-sidebar.svelte";
7
7
  import ThemeToggle from "$lib/components/theme-toggle.svelte";
8
8
  import LanguageSwitch from "$lib/components/language-switch.svelte";
9
9
  import { Toaster } from "$lib/components/ui/sonner";
@@ -27,7 +27,7 @@
27
27
  </script>
28
28
 
29
29
  <Sidebar.Provider>
30
- <AppSidebar user={data.user} capabilities={data.capabilities} />
30
+ <AdminSidebar user={data.user} capabilities={data.capabilities} />
31
31
  <Sidebar.Inset>
32
32
  {#if data.impersonating}
33
33
  <div class="bg-primary text-primary-foreground flex items-center justify-between gap-2 px-4 py-2 text-sm">