@podosoft/podokit 0.9.0 → 0.10.1

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 (87) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/README.md +8 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +12 -8
  14. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +2 -2
  15. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +12 -8
  16. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  18. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +3 -1
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +4 -2
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  21. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +4 -0
  22. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  23. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +2 -0
  24. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  25. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  26. package/dist/templates/fullstack-nest-svelte/package.json +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +11 -1
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +32 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -717
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +17 -9
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +64 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +42 -13
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -3
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +14 -7
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +12 -4
  57. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  58. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  59. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +2 -2
  60. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -0
  61. package/dist/templates/modules/auth/module.manifest.json +5 -1
  62. package/dist/templates/modules/bullmq/files/infra/docker/worker.compose.example.yml +2 -1
  63. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  64. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  65. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  66. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  67. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +38 -3
  68. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  69. package/dist/templates/modules/rate-limit/module.manifest.json +6 -3
  70. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  71. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  72. package/dist/templates/todo/apps/api/Dockerfile +12 -8
  73. package/dist/templates/todo/apps/api/src/health/health.controller.ts +2 -2
  74. package/dist/templates/todo/apps/web/Dockerfile +12 -8
  75. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  76. package/dist/templates/todo/apps/web/src/lib/server/api.ts +3 -1
  77. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +4 -2
  78. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  79. package/dist/templates/todo/compose.dev.yaml +4 -0
  80. package/dist/templates/todo/dot-gitignore +6 -0
  81. package/dist/templates/todo/infra/k3s/configmap.yaml +2 -0
  82. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  83. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  84. package/dist/templates/todo/package.json +1 -1
  85. package/dist/update.js +74 -8
  86. package/package.json +1 -1
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
@@ -23,6 +23,14 @@
23
23
  --border: oklch(0.92 0.004 286.32);
24
24
  --input: oklch(0.92 0.004 286.32);
25
25
  --ring: oklch(0.705 0.015 286.067);
26
+ --sidebar: oklch(0.985 0 0);
27
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
28
+ --sidebar-primary: oklch(0.21 0.006 285.885);
29
+ --sidebar-primary-foreground: oklch(0.985 0 0);
30
+ --sidebar-accent: oklch(0.967 0.001 286.375);
31
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
32
+ --sidebar-border: oklch(0.92 0.004 286.32);
33
+ --sidebar-ring: oklch(0.705 0.015 286.067);
26
34
  }
27
35
 
28
36
  .dark {
@@ -44,6 +52,14 @@
44
52
  --border: oklch(1 0 0 / 10%);
45
53
  --input: oklch(1 0 0 / 15%);
46
54
  --ring: oklch(0.552 0.016 285.938);
55
+ --sidebar: oklch(0.21 0.006 285.885);
56
+ --sidebar-foreground: oklch(0.985 0 0);
57
+ --sidebar-primary: oklch(0.92 0.004 286.32);
58
+ --sidebar-primary-foreground: oklch(0.21 0.006 285.885);
59
+ --sidebar-accent: oklch(0.274 0.006 286.033);
60
+ --sidebar-accent-foreground: oklch(0.985 0 0);
61
+ --sidebar-border: oklch(1 0 0 / 10%);
62
+ --sidebar-ring: oklch(0.552 0.016 285.938);
47
63
  }
48
64
 
49
65
  @theme inline {
@@ -69,6 +85,14 @@
69
85
  --color-border: var(--border);
70
86
  --color-input: var(--input);
71
87
  --color-ring: var(--ring);
88
+ --color-sidebar: var(--sidebar);
89
+ --color-sidebar-foreground: var(--sidebar-foreground);
90
+ --color-sidebar-primary: var(--sidebar-primary);
91
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
92
+ --color-sidebar-accent: var(--sidebar-accent);
93
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
94
+ --color-sidebar-border: var(--sidebar-border);
95
+ --color-sidebar-ring: var(--sidebar-ring);
72
96
  }
73
97
 
74
98
  @layer base {
@@ -79,3 +103,21 @@
79
103
  @apply bg-background text-foreground;
80
104
  }
81
105
  }
106
+
107
+ /* shadcn-svelte sidebar active-state correction.
108
+ * Upstream (shadcn-svelte registry, `nova` style) styles the active item with
109
+ * the `data-active:` Tailwind variant, which matches attribute PRESENCE
110
+ * (`[data-active]`). The menu buttons always render `data-active="true|false"`,
111
+ * so the active look (accent background, accent text, medium weight) leaks onto
112
+ * EVERY item — nearly invisible in the default palette but glaring once a theme
113
+ * raises the contrast. We keep `ui/sidebar/*` as a pristine mirror of upstream
114
+ * (so `shadcn-svelte` updates apply cleanly) and correct the behaviour here:
115
+ * neutralize the resting state of non-active items, leaving hover/press feedback
116
+ * (their `:hover`/`:active` rules) untouched. Drop this once shadcn-svelte ships
117
+ * value-matching `data-[active=true]:` (canonical shadcn/ui already does). */
118
+ [data-sidebar="menu-button"][data-active="false"]:not(:hover):not(:active),
119
+ [data-sidebar="menu-sub-button"][data-active="false"]:not(:hover):not(:active) {
120
+ background-color: transparent;
121
+ color: var(--sidebar-foreground);
122
+ font-weight: 400;
123
+ }
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ // Modules can replace this managed component with global site behavior while
3
+ // the surrounding route layout remains application-owned.
4
+ import { ModeWatcher } from "mode-watcher";
5
+ </script>
6
+
7
+ <ModeWatcher />
@@ -1,14 +1,16 @@
1
1
  import type { RequestEvent } from "@sveltejs/kit";
2
2
  import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
3
- import { backendBaseUrl } from "./backend-proxy";
3
+ import { backendBaseUrl, normalizeClientIp } from "./backend-proxy";
4
4
 
5
5
  // Server-side client for load functions and hooks: points at the internal
6
6
  // backend URL and forwards the incoming request's cookies.
7
7
  export function serverApiClient(event: RequestEvent): ApiClient {
8
8
  const cookie = event.request.headers.get("cookie") ?? "";
9
+ const clientIp = normalizeClientIp(event.getClientAddress());
9
10
  const fetchWithCookies: typeof fetch = (input, init) => {
10
11
  const headers = new Headers(init?.headers);
11
12
  if (cookie) headers.set("cookie", cookie);
13
+ if (clientIp) headers.set("x-forwarded-for", clientIp);
12
14
  return event.fetch(input, { ...init, headers });
13
15
  };
14
16
  return createApiClient({
@@ -12,7 +12,7 @@ export function backendBaseUrl(): string {
12
12
  // IPv4 as-is but anonymizes IPv6 (masks the host bits), so an IPv6 loopback would
13
13
  // be recorded as "::" — map loopback to 127.0.0.1 for a clean local value, and
14
14
  // unwrap IPv4-mapped addresses.
15
- function normalizeClientIp(address: string | undefined): string | undefined {
15
+ export function normalizeClientIp(address: string | undefined): string | undefined {
16
16
  if (!address) return undefined;
17
17
  if (address === "::1" || address === "::") return "127.0.0.1";
18
18
  if (address.startsWith("::ffff:")) return address.slice("::ffff:".length);
@@ -37,7 +37,9 @@ export async function proxyRequest(
37
37
  const upstream = await fetch(targetUrl, {
38
38
  method: request.method,
39
39
  headers,
40
- body: hasBody ? await request.text() : undefined,
40
+ // Preserve multipart uploads and other binary bodies byte-for-byte. Reading
41
+ // them as text corrupts bytes that are not valid UTF-8 before forwarding.
42
+ body: hasBody ? await request.arrayBuffer() : undefined,
41
43
  redirect: "manual",
42
44
  });
43
45
 
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">
2
2
  import "../app.css";
3
- import { ModeWatcher } from "mode-watcher";
3
+ import SiteRuntime from "$lib/components/site-runtime.svelte";
4
4
 
5
5
  let { children } = $props();
6
6
  </script>
7
7
 
8
- <ModeWatcher />
8
+ <SiteRuntime />
9
9
  {@render children()}
@@ -48,6 +48,10 @@ services:
48
48
  environment:
49
49
  - VITE_DOCKER=1
50
50
  - BACKEND_INTERNAL_URL=http://api:5002
51
+ # One trusted Traefik proxy sits in front of SvelteKit. This keeps
52
+ # getClientAddress() tied to the visitor instead of the proxy container.
53
+ - ADDRESS_HEADER=x-forwarded-for
54
+ - XFF_DEPTH=1
51
55
  # Match Traefik's published port so HMR's WebSocket targets the right port
52
56
  # (default 80). Without this, HMR connects to :80 and a non-80 stack falls
53
57
  # back to a full page reload on every edit.
@@ -12,3 +12,9 @@ build/
12
12
  tests/playwright/
13
13
  tests/playwright-report/
14
14
  tests/test-results/
15
+ playwright/.auth/
16
+ playwright-report/
17
+ test-results/
18
+
19
+ # local performance reports
20
+ output/lighthouse/
@@ -8,3 +8,5 @@ data:
8
8
  PORT: "3000"
9
9
  CORS_ORIGIN: "https://example.com"
10
10
  BACKEND_INTERNAL_URL: "http://api:3000"
11
+ ADDRESS_HEADER: "x-forwarded-for"
12
+ XFF_DEPTH: "1"
@@ -1,8 +1,18 @@
1
+ apiVersion: traefik.io/v1alpha1
2
+ kind: Middleware
3
+ metadata:
4
+ name: compression
5
+ namespace: podokit
6
+ spec:
7
+ compress: {}
8
+ ---
1
9
  apiVersion: networking.k8s.io/v1
2
10
  kind: Ingress
3
11
  metadata:
4
12
  name: app
5
13
  namespace: podokit
14
+ annotations:
15
+ traefik.ingress.kubernetes.io/router.middlewares: podokit-compression@kubernetescrd
6
16
  spec:
7
17
  rules:
8
18
  - host: example.com
@@ -5,7 +5,11 @@ http:
5
5
  web:
6
6
  rule: "PathPrefix(`/`)" # any host on this dedicated proxy -> web
7
7
  entryPoints: [web]
8
+ middlewares: [compression]
8
9
  service: web
10
+ middlewares:
11
+ compression:
12
+ compress: {}
9
13
  services:
10
14
  web:
11
15
  loadBalancer:
@@ -3,7 +3,7 @@
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
5
  "engines": {
6
- "node": ">=20"
6
+ "node": ">=22.22.1"
7
7
  },
8
8
  "workspaces": [
9
9
  "apps/*",
@@ -26,6 +26,65 @@ const ALLOWED = new Set<string>(PUBLIC_SITE_KEYS);
26
26
  const MAX_ICON_BYTES = 1024 * 1024; // 1 MB
27
27
  const ICON_TYPES = new Set(["image/svg+xml", "image/png", "image/x-icon", "image/vnd.microsoft.icon"]);
28
28
 
29
+ // Theme values are injected into a client-side <style>, so validate them to
30
+ // prevent CSS injection. Colors must be hex, radius a bounded rem number,
31
+ // preset a slug, and overrides a JSON map of hex colors under known token keys.
32
+ const HEX = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
33
+ const PRESET = /^[a-z0-9-]{1,32}$/;
34
+ const THEME_TOKEN_KEYS = new Set([
35
+ "background",
36
+ "card",
37
+ "foreground",
38
+ "mutedForeground",
39
+ "border",
40
+ "secondary",
41
+ "accent",
42
+ "primary",
43
+ "primaryForeground",
44
+ ]);
45
+
46
+ /** Validate/normalize a single theme setting value. Empty string clears it. */
47
+ function validateThemeValue(key: string, value: string): string {
48
+ if (value === "") return "";
49
+ if (key === "brandColor") {
50
+ if (!HEX.test(value)) throw new BadRequestException("brandColor must be a hex color");
51
+ return value;
52
+ }
53
+ if (key === "themePreset") {
54
+ if (!PRESET.test(value)) throw new BadRequestException("Invalid themePreset");
55
+ return value;
56
+ }
57
+ if (key === "themeRadius") {
58
+ const n = Number(value);
59
+ if (!Number.isFinite(n) || n < 0 || n > 4) throw new BadRequestException("themeRadius must be 0–4");
60
+ return String(n);
61
+ }
62
+ if (key === "themeOverrides") {
63
+ let parsed: unknown;
64
+ try {
65
+ parsed = JSON.parse(value);
66
+ } catch {
67
+ throw new BadRequestException("themeOverrides must be valid JSON");
68
+ }
69
+ if (!parsed || typeof parsed !== "object") throw new BadRequestException("themeOverrides must be an object");
70
+ const out: Record<string, Record<string, string>> = {};
71
+ for (const mode of ["light", "dark"] as const) {
72
+ const block = (parsed as Record<string, unknown>)[mode];
73
+ if (block === undefined) continue;
74
+ if (!block || typeof block !== "object") throw new BadRequestException(`themeOverrides.${mode} must be an object`);
75
+ const clean: Record<string, string> = {};
76
+ for (const [k, v] of Object.entries(block as Record<string, unknown>)) {
77
+ if (!THEME_TOKEN_KEYS.has(k)) throw new BadRequestException(`Unknown theme token: ${k}`);
78
+ if (typeof v !== "string" || !HEX.test(v)) throw new BadRequestException(`themeOverrides.${mode}.${k} must be a hex color`);
79
+ clean[k] = v;
80
+ }
81
+ if (Object.keys(clean).length) out[mode] = clean;
82
+ }
83
+ return JSON.stringify(out);
84
+ }
85
+ return value;
86
+ }
87
+
29
88
  @ApiTags("site")
30
89
  @Controller("site")
31
90
  export class SiteSettingsController {
@@ -53,7 +112,7 @@ export class SiteSettingsController {
53
112
  const update: Record<string, string> = {};
54
113
  for (const [key, value] of Object.entries(body ?? {})) {
55
114
  if (!ALLOWED.has(key)) throw new BadRequestException(`Unknown setting: ${key}`);
56
- update[key] = String(value ?? "");
115
+ update[key] = validateThemeValue(key, String(value ?? ""));
57
116
  }
58
117
  return this.site.setMany(update);
59
118
  }
@@ -18,6 +18,9 @@ export const PUBLIC_SITE_KEYS = [
18
18
  "supportEmail",
19
19
  "footerText",
20
20
  "brandColor",
21
+ "themePreset",
22
+ "themeRadius",
23
+ "themeOverrides",
21
24
  "termsUrl",
22
25
  "privacyUrl",
23
26
  "locale",
@@ -1,9 +1,18 @@
1
1
  import type { Handle } from "@sveltejs/kit";
2
2
  import { serverApiClient } from "$lib/server/api";
3
3
  import { resolveLocale } from "$lib/i18n/messages";
4
+ import { applySearchIndexingHeaders } from "$lib/server/search-indexing";
4
5
  import type { SiteSettings } from "$lib/site.svelte";
5
6
 
6
7
  export const handle: Handle = async ({ event, resolve }) => {
8
+ // Static assets and API proxy requests do not render application layouts.
9
+ // Avoid multiplying settings/session API calls for every asset and proxied
10
+ // request; page and data routes still receive the complete runtime context.
11
+ if (event.route.id === null || event.url.pathname.startsWith("/api/")) {
12
+ const response = await resolve(event);
13
+ return applySearchIndexingHeaders(response, event.url.pathname);
14
+ }
15
+
7
16
  // Public site settings (branding, locale/timezone defaults, maintenance, …),
8
17
  // loaded once here and reused by the layout load. A visitor with no locale
9
18
  // cookie falls back to the admin-configured site locale, then the app default.
@@ -25,7 +34,8 @@ export const handle: Handle = async ({ event, resolve }) => {
25
34
  event.locals.user = null;
26
35
  event.locals.session = null;
27
36
  }
28
- return resolve(event, {
37
+ const response = await resolve(event, {
29
38
  transformPageChunk: ({ html }) => html.replace('lang="en"', `lang="${event.locals.locale}"`),
30
39
  });
40
+ return applySearchIndexingHeaders(response, event.url.pathname);
31
41
  };
@@ -0,0 +1,42 @@
1
+ import type { Capabilities } from "@podosoft/podokit-api-client";
2
+
3
+ type ServerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
4
+
5
+ const defaultCapabilities: Capabilities = {
6
+ twoFactor: false,
7
+ providers: [],
8
+ deleteAccount: false,
9
+ auditLog: false,
10
+ emailVerification: false,
11
+ passwordBreachCheck: false,
12
+ magicLink: false,
13
+ emailOtp: false,
14
+ username: false,
15
+ multiSession: false,
16
+ phoneNumber: false,
17
+ apiKey: false,
18
+ passkey: false,
19
+ organization: false,
20
+ oidcProvider: false,
21
+ roles: ["admin", "user"],
22
+ };
23
+
24
+ export async function loadAccountData(locals: App.Locals, fetch: ServerFetch): Promise<{
25
+ user: App.Locals["user"];
26
+ currentSessionId: string | null;
27
+ capabilities: Capabilities;
28
+ }> {
29
+ let capabilities = defaultCapabilities;
30
+ try {
31
+ const response = await fetch("/api/account/capabilities");
32
+ if (response.ok) capabilities = (await response.json()) as Capabilities;
33
+ } catch {
34
+ /* Keep safe defaults if the capability endpoint is unavailable. */
35
+ }
36
+
37
+ return {
38
+ user: locals.user,
39
+ currentSessionId: locals.session?.id ?? null,
40
+ capabilities,
41
+ };
42
+ }
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import { goto } from "$app/navigation";
3
+ import { Button } from "$lib/components/ui/button";
4
+ import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
5
+ import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
6
+ import LogOutIcon from "@lucide/svelte/icons/log-out";
7
+ import UserIcon from "@lucide/svelte/icons/user";
8
+ import { api } from "$lib/api";
9
+ import { getI18n } from "$lib/i18n";
10
+ import type { SessionUser } from "../../app.d.ts";
11
+ import UserAvatar from "./user-avatar.svelte";
12
+
13
+ let { user }: { user: SessionUser | null } = $props();
14
+ const i18n = getI18n();
15
+
16
+ async function signOut(): Promise<void> {
17
+ await api.auth.signOut();
18
+ await goto("/login", { invalidateAll: true });
19
+ }
20
+ </script>
21
+
22
+ {#if user}
23
+ <DropdownMenu.Root>
24
+ <DropdownMenu.Trigger
25
+ 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"
26
+ aria-label={i18n.t.nav.account}
27
+ >
28
+ <UserAvatar {user} class="size-8" />
29
+ </DropdownMenu.Trigger>
30
+ <DropdownMenu.Content align="end" class="w-56">
31
+ <DropdownMenu.Label>
32
+ <span class="block truncate font-medium">{user.name}</span>
33
+ <span class="text-muted-foreground block truncate text-xs font-normal">{user.email}</span>
34
+ </DropdownMenu.Label>
35
+ <DropdownMenu.Separator />
36
+ <DropdownMenu.Item onSelect={() => goto("/account")}><UserIcon class="mr-2 size-4" /> {i18n.t.nav.account}</DropdownMenu.Item>
37
+ {#if user.role === "admin"}
38
+ <DropdownMenu.Item onSelect={() => goto("/admin")}><LayoutDashboardIcon class="mr-2 size-4" /> {i18n.t.common.appName}</DropdownMenu.Item>
39
+ {/if}
40
+ <DropdownMenu.Separator />
41
+ <DropdownMenu.Item onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
42
+ </DropdownMenu.Content>
43
+ </DropdownMenu.Root>
44
+ {:else}
45
+ <Button href="/login" variant="ghost">{i18n.t.auth.signIn}</Button>
46
+ {/if}