@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
@@ -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>
@@ -1,14 +1,34 @@
1
1
  <script lang="ts" module>
2
+ import { cn } from "$lib/utils";
3
+
4
+ export type DataTableBreakpoint = "sm" | "md" | "lg" | "xl";
2
5
  export type DataTableColumn<Row> = {
3
6
  key: string;
4
7
  label: string;
5
8
  sortable?: boolean;
6
9
  class?: string;
10
+ hideBelow?: DataTableBreakpoint;
7
11
  // Value used for client-side sorting (defaults to row[key]). Use for nested
8
12
  // or derived fields, e.g. (s) => s.user.email.
9
13
  value?: (row: Row) => string | number | boolean | Date | null | undefined;
10
14
  };
11
15
  export type SortState = { key: string; dir: "asc" | "desc" };
16
+ export type DataTableRowContext = {
17
+ cellClass: (columnKey: string) => string | undefined;
18
+ };
19
+
20
+ const HIDE_BELOW_CLASS: Record<DataTableBreakpoint, string> = {
21
+ sm: "max-sm:hidden",
22
+ md: "max-md:hidden",
23
+ lg: "max-lg:hidden",
24
+ xl: "max-xl:hidden",
25
+ };
26
+
27
+ export function getDataTableColumnClass<Row>(
28
+ column: DataTableColumn<Row>,
29
+ ): string | undefined {
30
+ return cn(column.class, column.hideBelow ? HIDE_BELOW_CLASS[column.hideBelow] : undefined);
31
+ }
12
32
 
13
33
  // Shared page size for all admin tables — sized so a desktop viewport shows a
14
34
  // full list at a glance (rather than the old 5-row pages). Change it here to
@@ -33,9 +53,10 @@
33
53
  type Props = {
34
54
  columns: DataTableColumn<Row>[];
35
55
  rows: Row[];
36
- row: Snippet<[Row]>; // renders the <Table.Cell>s for one row
56
+ row: Snippet<[Row, DataTableRowContext]>; // renders the <Table.Cell>s for one row
37
57
  getKey?: (row: Row, index: number) => string | number;
38
58
  empty?: string;
59
+ ariaLabel?: string;
39
60
  sort?: SortState | null;
40
61
  manualSort?: boolean;
41
62
  page?: number;
@@ -51,6 +72,7 @@
51
72
  row,
52
73
  getKey = (_r, i) => i,
53
74
  empty = "No data.",
75
+ ariaLabel,
54
76
  sort = $bindable(null),
55
77
  manualSort = false,
56
78
  page = $bindable(1),
@@ -71,6 +93,13 @@
71
93
  onChange?.({ sort, page });
72
94
  }
73
95
 
96
+ function cellClass(columnKey: string): string | undefined {
97
+ const column = columns.find((candidate) => candidate.key === columnKey);
98
+ return column ? getDataTableColumnClass(column) : undefined;
99
+ }
100
+
101
+ const rowContext: DataTableRowContext = { cellClass };
102
+
74
103
  function cmp(a: unknown, b: unknown): number {
75
104
  if (a == null && b == null) return 0;
76
105
  if (a == null) return -1;
@@ -95,12 +124,12 @@
95
124
  </script>
96
125
 
97
126
  <div class="rounded-md border">
98
- <Table.Root>
127
+ <Table.Root aria-label={ariaLabel}>
99
128
  <Table.Header>
100
129
  <Table.Row>
101
130
  {#each columns as col (col.key)}
102
131
  <Table.Head
103
- class={col.class}
132
+ class={getDataTableColumnClass(col)}
104
133
  aria-sort={sort?.key === col.key ? (sort.dir === "asc" ? "ascending" : "descending") : undefined}
105
134
  >
106
135
  {#if col.sortable}
@@ -121,7 +150,7 @@
121
150
  </Table.Header>
122
151
  <Table.Body>
123
152
  {#each view as r, i (getKey(r, i))}
124
- <Table.Row>{@render row(r)}</Table.Row>
153
+ <Table.Row>{@render row(r, rowContext)}</Table.Row>
125
154
  {:else}
126
155
  <Table.Row>
127
156
  <Table.Cell colspan={columns.length} class="text-muted-foreground py-8 text-center">{empty}</Table.Cell>
@@ -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">
@@ -16,6 +16,7 @@
16
16
  import { toast } from "svelte-sonner";
17
17
  import { api } from "$lib/api";
18
18
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
19
+ import { cn } from "$lib/utils";
19
20
  import type { PageData } from "./$types";
20
21
 
21
22
  let { data }: { data: PageData } = $props();
@@ -58,9 +59,15 @@
58
59
  const columns = $derived<DataTableColumn<Row>[]>([
59
60
  { key: "name", label: i18n.t.users.name, sortable: true },
60
61
  { key: "email", label: i18n.t.users.email, sortable: true },
61
- { key: "role", label: i18n.t.users.role, sortable: true },
62
- { key: "status", label: i18n.t.users.status },
63
- { key: "createdAt", label: i18n.t.users.joined, sortable: true, value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0) },
62
+ { key: "role", label: i18n.t.users.role, sortable: true, hideBelow: "md" },
63
+ { key: "status", label: i18n.t.users.status, hideBelow: "lg" },
64
+ {
65
+ key: "createdAt",
66
+ label: i18n.t.users.joined,
67
+ sortable: true,
68
+ hideBelow: "lg",
69
+ value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0),
70
+ },
64
71
  { key: "actions", label: "", class: "w-10" },
65
72
  ]);
66
73
  const filters = $derived<ToolbarFilter[]>([
@@ -380,13 +387,14 @@
380
387
  bind:sort
381
388
  bind:page
382
389
  perPage={PAGE_SIZE}
390
+ ariaLabel={i18n.t.users.title}
383
391
  label={fmt(i18n.t.users.total, { count: filtered.length })}
384
392
  >
385
- {#snippet row(user)}
386
- <Table.Cell class="font-medium">{user.name}</Table.Cell>
387
- <Table.Cell class="text-muted-foreground">{user.email}</Table.Cell>
388
- <Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
389
- <Table.Cell>
393
+ {#snippet row(user, { cellClass })}
394
+ <Table.Cell class={cn(cellClass("name"), "font-medium")}>{user.name}</Table.Cell>
395
+ <Table.Cell class={cn(cellClass("email"), "text-muted-foreground")}>{user.email}</Table.Cell>
396
+ <Table.Cell class={cellClass("role")}><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
397
+ <Table.Cell class={cellClass("status")}>
390
398
  <div class="flex flex-wrap gap-1">
391
399
  {#if user.signupApproved === false}
392
400
  <Badge variant="secondary">{i18n.t.users.pendingApproval}</Badge>
@@ -400,8 +408,8 @@
400
408
  {/if}
401
409
  </div>
402
410
  </Table.Cell>
403
- <Table.Cell class="text-muted-foreground">{user.createdAt ? formatDateTime(user.createdAt) : "—"}</Table.Cell>
404
- <Table.Cell>
411
+ <Table.Cell class={cn(cellClass("createdAt"), "text-muted-foreground")}>{user.createdAt ? formatDateTime(user.createdAt) : "—"}</Table.Cell>
412
+ <Table.Cell class={cellClass("actions")}>
405
413
  <DropdownMenu.Root>
406
414
  <DropdownMenu.Trigger>
407
415
  {#snippet child({ props })}
@@ -1,51 +1,6 @@
1
- import { redirect } from "@sveltejs/kit";
2
1
  import type { LayoutServerLoad } from "./$types";
3
- import type { Capabilities } from "@podosoft/podokit-api-client";
4
- import { requireBackendAvailable } from "$lib/server/guards";
2
+ import { loadProtectedLayout } from "$lib/server/protected-layout";
5
3
 
6
4
  export const load: LayoutServerLoad = async ({ locals, fetch }) => {
7
- requireBackendAvailable(locals);
8
-
9
- let capabilities: Capabilities = {
10
- twoFactor: false,
11
- providers: [],
12
- deleteAccount: false,
13
- auditLog: false,
14
- emailVerification: false,
15
- signupApprovalRequired: false,
16
- passwordBreachCheck: false,
17
- magicLink: false,
18
- emailOtp: false,
19
- username: false,
20
- multiSession: false,
21
- phoneNumber: false,
22
- apiKey: false,
23
- passkey: false,
24
- organization: false,
25
- oidcProvider: false,
26
- roles: ["admin", "user"],
27
- };
28
- try {
29
- const res = await fetch("/api/account/capabilities");
30
- if (res.ok) capabilities = (await res.json()) as Capabilities;
31
- } catch {
32
- /* keep defaults */
33
- }
34
-
35
- // Require-two-factor policy: a signed-in user who hasn't enrolled is sent to the
36
- // enrolment page until they do (the API's TwoFactorRequiredGuard is the real
37
- // boundary; this is the UX). /setup-2fa lives outside this layout, so no loop.
38
- const user = locals.user as (App.Locals["user"] & { twoFactorEnabled?: boolean }) | null;
39
- let mustEnrol = false;
40
- if (user && !user.twoFactorEnabled) {
41
- try {
42
- const res = await fetch("/api/account/require-2fa");
43
- mustEnrol = res.ok && ((await res.json()) as { require2fa?: boolean }).require2fa === true;
44
- } catch {
45
- /* don't block the app if the policy check fails */
46
- }
47
- }
48
- if (mustEnrol) redirect(302, "/setup-2fa");
49
-
50
- return { user: locals.user, impersonating: !!locals.session?.impersonatedBy, capabilities };
5
+ return loadProtectedLayout({ locals, fetch });
51
6
  };
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { page } from "$app/state";
3
+ import AccountMenu from "$lib/components/account-menu.svelte";
2
4
  import { Button } from "$lib/components/ui/button";
3
5
  import LanguageSwitch from "$lib/components/language-switch.svelte";
4
6
  import ThemeToggle from "$lib/components/theme-toggle.svelte";
@@ -6,12 +8,14 @@
6
8
  import { getI18n } from "$lib/i18n";
7
9
 
8
10
  const i18n = getI18n();
11
+ const user = $derived(page.data.user ?? null);
9
12
  </script>
10
13
 
11
14
  <div class="flex min-h-svh flex-col">
12
15
  <header class="flex items-center justify-end gap-2 p-4">
13
16
  <LanguageSwitch />
14
17
  <ThemeToggle />
18
+ <AccountMenu {user} />
15
19
  </header>
16
20
  <main class="flex flex-1 flex-col items-center justify-center gap-6 px-4 text-center">
17
21
  <div class="flex flex-col gap-3">
@@ -13,19 +13,20 @@ put pagination outside the table.
13
13
  ```svelte
14
14
  <script lang="ts">
15
15
  import DataTable from "$lib/components/data-table.svelte";
16
+ import { cn } from "$lib/utils";
16
17
  const columns = [
17
18
  { key: "name", label: "Name", sortable: true },
18
19
  { key: "email", label: "Email", sortable: true },
19
- { key: "role", label: "Role", sortable: true }, // action/status columns: no `sortable`
20
+ { key: "role", label: "Role", sortable: true, hideBelow: "md" },
20
21
  ];
21
22
  let { rows } = $props(); // Svelte 5 runes only
22
23
  </script>
23
24
 
24
- <DataTable {columns} {rows} label="{rows.length} users">
25
- {#snippet row(item)}
26
- <Table.Cell>{item.name}</Table.Cell>
27
- <Table.Cell>{item.email}</Table.Cell>
28
- <Table.Cell>{item.role}</Table.Cell>
25
+ <DataTable {columns} {rows} ariaLabel="Users" label="{rows.length} users">
26
+ {#snippet row(item, { cellClass })}
27
+ <Table.Cell class={cellClass("name")}>{item.name}</Table.Cell>
28
+ <Table.Cell class={cellClass("email")}>{item.email}</Table.Cell>
29
+ <Table.Cell class={cn(cellClass("role"), "text-muted-foreground")}>{item.role}</Table.Cell>
29
30
  {/snippet}
30
31
  </DataTable>
31
32
  ```
@@ -34,6 +35,10 @@ put pagination outside the table.
34
35
  - **Server data** (huge lists): use `manualSort` + `manualPagination` + `total`
35
36
  + `onChange` (re-fetch with `sortBy`/`sortDirection`/`page`).
36
37
  - Nested/derived sort keys: give the column a `value` accessor.
38
+ - Responsive columns: set `hideBelow` to `sm`, `md`, `lg`, or `xl`, then apply
39
+ `cellClass(columnKey)` to the matching `Table.Cell`. Never repeat the
40
+ responsive class by hand; the shared class keeps headers and cells aligned.
41
+ - Give each table an `ariaLabel` and scope Playwright locators to that table.
37
42
  - No footer needed: `perPage={0}`.
38
43
  - **Search & filters**: put the shared `table-toolbar.svelte` (`TableToolbar`)
39
44
  above the DataTable. Filters + search apply together on the Search button/Enter