@podosoft/podokit 0.4.0 → 0.5.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 (141) hide show
  1. package/README.md +2 -2
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +5 -1
  4. package/dist/assemble.d.ts +14 -0
  5. package/dist/assemble.js +60 -0
  6. package/dist/create.d.ts +2 -0
  7. package/dist/create.js +7 -0
  8. package/dist/eject.d.ts +11 -0
  9. package/dist/eject.js +27 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +122 -1
  12. package/dist/inspect.d.ts +43 -0
  13. package/dist/inspect.js +82 -0
  14. package/dist/lockfile.d.ts +80 -0
  15. package/dist/lockfile.js +193 -0
  16. package/dist/templates/base/dot-env.example +11 -6
  17. package/dist/templates/fullstack-nest-svelte/README.md +2 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
  23. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  29. package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
  30. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
  31. package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
  33. package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
  34. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
  63. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
  64. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
  66. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
  67. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
  68. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
  72. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +52 -0
  81. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
  82. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
  83. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
  84. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
  85. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
  86. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
  87. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
  88. package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
  90. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
  91. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
  92. package/dist/templates/modules/audit-log/module.manifest.json +15 -9
  93. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
  94. package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
  104. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
  106. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
  113. package/dist/templates/modules/auth/module.manifest.json +54 -20
  114. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
  115. package/dist/templates/modules/bullmq/module.manifest.json +4 -4
  116. package/dist/templates/modules/file-upload/module.manifest.json +3 -3
  117. package/dist/templates/modules/job-progress/module.manifest.json +9 -9
  118. package/dist/templates/modules/logging/module.manifest.json +3 -3
  119. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
  120. package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
  121. package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
  122. package/dist/templates/modules/redis/module.manifest.json +4 -4
  123. package/dist/templates/modules/sse/module.manifest.json +4 -4
  124. package/dist/templates/todo/README.md +2 -2
  125. package/dist/templates/todo/apps/api/package.json +1 -0
  126. package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
  127. package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
  128. package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
  129. package/dist/templates/todo/apps/api/src/main.ts +1 -1
  130. package/dist/templates/todo/apps/web/package.json +2 -2
  131. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  132. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  134. package/dist/templates/todo/dot-env.example +10 -5
  135. package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
  136. package/dist/templates/todo/tests/README.md +1 -1
  137. package/dist/templates/todo/tests/package.json +2 -1
  138. package/dist/templates/todo/tests/playwright.config.ts +1 -1
  139. package/dist/update.d.ts +49 -0
  140. package/dist/update.js +158 -0
  141. package/package.json +2 -2
@@ -0,0 +1,322 @@
1
+ <script lang="ts">
2
+ import { invalidateAll } from "$app/navigation";
3
+ import { Badge } from "$lib/components/ui/badge";
4
+ import { Switch } from "$lib/components/ui/switch";
5
+ import { Input } from "$lib/components/ui/input";
6
+ import { Label } from "$lib/components/ui/label";
7
+ import { Button } from "$lib/components/ui/button";
8
+ import * as Card from "$lib/components/ui/card";
9
+ import * as Dialog from "$lib/components/ui/dialog";
10
+ import { toast } from "svelte-sonner";
11
+ import { api } from "$lib/api";
12
+ import { getI18n } from "$lib/i18n";
13
+ import type { Capabilities } from "@podosoft/podokit-api-client";
14
+ import type { AuthConfigView, SocialProviderView } from "./+page.server";
15
+
16
+ let { data }: { data: { capabilities: Capabilities; authConfig: AuthConfigView | null } } = $props();
17
+ const i18n = getI18n();
18
+ const caps = $derived(data.capabilities);
19
+ const ac = $derived(data.authConfig);
20
+
21
+ type EditableFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider";
22
+ type ServerKey = "requireEmailVerification" | "hibp" | "allowDelete" | "auditLog";
23
+
24
+ // Every card renders from this one shape, so they share size and structure: a
25
+ // title (+ optional enable switch), a description, and a footer with a status
26
+ // badge and optional configure action.
27
+ type Item = {
28
+ key: string;
29
+ name: string;
30
+ desc: string;
31
+ status: { on: boolean; label: string };
32
+ toggle?: { checked: boolean; disabled: boolean; onChange: (v: boolean) => void };
33
+ configure?: { label: string; onClick: () => void };
34
+ };
35
+
36
+ let saving = $state(false);
37
+ // Writes DB-backed auth config (OAuth providers, SMTP, server toggles) — applied
38
+ // live (the auth instance rebuilds on the next request), no restart.
39
+ async function put(body: unknown): Promise<boolean> {
40
+ saving = true;
41
+ let ok = false;
42
+ try {
43
+ await api.put("/account/auth-config", body);
44
+ await invalidateAll();
45
+ toast.success(i18n.t.settings.saved);
46
+ ok = true;
47
+ } catch {
48
+ toast.error(i18n.t.settings.saveFailed);
49
+ }
50
+ saving = false;
51
+ return ok;
52
+ }
53
+ // Feature flags live in a separate store (app_setting) with their own endpoint.
54
+ async function toggleFlag(flag: EditableFlag, next: boolean): Promise<void> {
55
+ saving = true;
56
+ try {
57
+ await api.put("/account/settings", { [flag]: next });
58
+ await invalidateAll();
59
+ toast.success(i18n.t.settings.saved);
60
+ } catch {
61
+ toast.error(i18n.t.settings.saveFailed);
62
+ }
63
+ saving = false;
64
+ }
65
+
66
+ const callbackBase = $derived(typeof window === "undefined" ? "" : window.location.origin);
67
+ const labelOf = (id: string): string => ac?.catalog.find((c) => c.id === id)?.label ?? id;
68
+ const configured = $derived(Object.values(ac?.social ?? {}));
69
+ const enabledProviders = $derived(configured.filter((p) => p.enabled && p.clientId && p.hasSecret));
70
+ const configuredIds = $derived(new Set(Object.keys(ac?.social ?? {})));
71
+ const addable = $derived((ac?.catalog ?? []).filter((p) => !configuredIds.has(p.id)));
72
+
73
+ function socialStatus(v: SocialProviderView): { on: boolean; label: string } {
74
+ if (v.enabled && v.clientId && v.hasSecret) return { on: true, label: i18n.t.settings.enabled };
75
+ if (v.clientId || v.hasSecret || v.enabled) return { on: false, label: i18n.t.settings.configuredOff };
76
+ return { on: false, label: i18n.t.settings.notConfigured };
77
+ }
78
+ const socialCardStatus = $derived<{ on: boolean; label: string }>(
79
+ enabledProviders.length > 0
80
+ ? { on: true, label: enabledProviders.map((p) => labelOf(p.id)).join(", ") }
81
+ : { on: false, label: configured.length > 0 ? i18n.t.settings.configuredOff : i18n.t.settings.notConfigured },
82
+ );
83
+
84
+ // ── Dialogs. Secret fields are write-only; leaving them blank keeps the stored
85
+ // secret. The Social login dialog manages every provider (list ⇄ add/edit
86
+ // form) in one popup. ──
87
+ let openDialog = $state<"social" | "smtp" | null>(null);
88
+ let socialView = $state<"list" | "form">("list");
89
+ let socialForm = $state({ id: "", clientId: "", clientSecret: "", redirectURI: "", enabled: true, adding: false });
90
+ let smtp = $state({ enabled: false, host: "", port: 587, secure: false, user: "", from: "", pass: "" });
91
+ $effect(() => {
92
+ if (!ac) return;
93
+ smtp = { enabled: ac.smtp.enabled, host: ac.smtp.host, port: ac.smtp.port, secure: ac.smtp.secure, user: ac.smtp.user, from: ac.smtp.from, pass: "" };
94
+ });
95
+ const editingView = $derived(socialForm.adding ? undefined : ac?.social[socialForm.id]);
96
+
97
+ function openSocialManager(): void {
98
+ socialView = "list";
99
+ openDialog = "social";
100
+ }
101
+ function startEditSocial(id: string): void {
102
+ const v = ac?.social[id];
103
+ socialForm = { id, clientId: v?.clientId ?? "", clientSecret: "", redirectURI: v?.redirectURI ?? "", enabled: v?.enabled ?? true, adding: false };
104
+ socialView = "form";
105
+ }
106
+ function startAddSocial(): void {
107
+ socialForm = { id: addable[0]?.id ?? "", clientId: "", clientSecret: "", redirectURI: "", enabled: true, adding: true };
108
+ socialView = "form";
109
+ }
110
+ async function saveSocial(): Promise<void> {
111
+ if (!socialForm.id) return;
112
+ const done = await put({
113
+ social: {
114
+ [socialForm.id]: {
115
+ enabled: socialForm.enabled,
116
+ clientId: socialForm.clientId,
117
+ redirectURI: socialForm.redirectURI,
118
+ ...(socialForm.clientSecret ? { clientSecret: socialForm.clientSecret } : {}),
119
+ },
120
+ },
121
+ });
122
+ if (done) socialView = "list"; // back to the list; keep the dialog open to add more
123
+ }
124
+ async function removeSocial(id: string): Promise<void> {
125
+ await put({ social: { [id]: { delete: true } } });
126
+ }
127
+ async function saveSmtp(): Promise<void> {
128
+ const done = await put({ smtp: { enabled: smtp.enabled, host: smtp.host, port: Number(smtp.port), secure: smtp.secure, user: smtp.user, from: smtp.from, ...(smtp.pass ? { pass: smtp.pass } : {}) } });
129
+ if (done) openDialog = null;
130
+ }
131
+
132
+ // ── Card builders (uniform Item shape) ──
133
+ const flagCard = (flag: EditableFlag, name: string, desc: string): Item => ({
134
+ key: `flag-${flag}`,
135
+ name,
136
+ desc,
137
+ status: { on: caps[flag], label: caps[flag] ? i18n.t.settings.enabled : i18n.t.settings.disabled },
138
+ toggle: { checked: caps[flag], disabled: saving, onChange: (v) => toggleFlag(flag, v) },
139
+ });
140
+ const serverCard = (key: ServerKey, name: string, desc: string, on: boolean): Item => ({
141
+ key: `srv-${key}`,
142
+ name,
143
+ desc,
144
+ status: { on, label: on ? i18n.t.settings.enabled : i18n.t.settings.disabled },
145
+ toggle: { checked: on, disabled: saving || !ac, onChange: (v) => put({ server: { [key]: v } }) },
146
+ });
147
+
148
+ const signInMethods = $derived<Item[]>([
149
+ { key: "emailPassword", name: i18n.t.settings.emailPassword, desc: i18n.t.settings.emailPasswordDesc, status: { on: true, label: i18n.t.settings.enabled } },
150
+ // A single Social login card; providers (Google, GitHub, Apple, …) are added,
151
+ // edited and removed dynamically inside its dialog.
152
+ { key: "social", name: i18n.t.settings.socialTitle, desc: i18n.t.settings.socialCardDesc, status: socialCardStatus, configure: { label: i18n.t.settings.configure, onClick: openSocialManager } },
153
+ flagCard("magicLink", i18n.t.settings.magicLink, i18n.t.settings.magicLinkDesc),
154
+ flagCard("emailOtp", i18n.t.settings.emailOtp, i18n.t.settings.emailOtpDesc),
155
+ flagCard("username", i18n.t.settings.username, i18n.t.settings.usernameDesc),
156
+ flagCard("passkey", i18n.t.settings.passkey, i18n.t.settings.passkeyDesc),
157
+ flagCard("phoneNumber", i18n.t.settings.phoneNumber, i18n.t.settings.phoneNumberDesc),
158
+ flagCard("multiSession", i18n.t.settings.multiSession, i18n.t.settings.multiSessionDesc),
159
+ ]);
160
+ // SMTP delivery is configured inside the email-verification card (its "SMTP
161
+ // settings" popup): verification needs outbound mail, so they live together.
162
+ const emailSettings = $derived<Item[]>([
163
+ {
164
+ key: "email",
165
+ name: i18n.t.settings.emailVerification,
166
+ desc: i18n.t.settings.emailVerificationDesc,
167
+ status: { on: caps.emailVerification, label: caps.emailVerification ? i18n.t.settings.enabled : i18n.t.settings.disabled },
168
+ toggle: { checked: caps.emailVerification, disabled: saving || !ac, onChange: (v) => put({ server: { requireEmailVerification: v } }) },
169
+ configure: { label: i18n.t.settings.smtpConfigure, onClick: () => (openDialog = "smtp") },
170
+ },
171
+ ]);
172
+ const security = $derived<Item[]>([
173
+ flagCard("twoFactor", i18n.t.settings.twoFactor, i18n.t.settings.twoFactorDesc),
174
+ serverCard("hibp", i18n.t.settings.breachCheck, i18n.t.settings.breachCheckDesc, caps.passwordBreachCheck),
175
+ serverCard("allowDelete", i18n.t.settings.accountDeletion, i18n.t.settings.accountDeletionDesc, caps.deleteAccount),
176
+ serverCard("auditLog", i18n.t.settings.auditLog, i18n.t.settings.auditLogDesc, caps.auditLog),
177
+ ]);
178
+ const advanced = $derived<Item[]>([
179
+ flagCard("apiKey", i18n.t.settings.apiKey, i18n.t.settings.apiKeyDesc),
180
+ flagCard("organization", i18n.t.settings.organization, i18n.t.settings.organizationDesc),
181
+ flagCard("oidcProvider", i18n.t.settings.oidcProvider, i18n.t.settings.oidcProviderDesc),
182
+ ]);
183
+ const sections = $derived([
184
+ { title: i18n.t.settings.signInTitle, subtitle: i18n.t.settings.signInSubtitle, items: signInMethods },
185
+ { title: i18n.t.settings.emailTitle, subtitle: i18n.t.settings.emailSubtitle, items: emailSettings },
186
+ { title: i18n.t.settings.securityTitle, subtitle: i18n.t.settings.securitySubtitle, items: security },
187
+ { title: i18n.t.settings.advancedTitle, subtitle: i18n.t.settings.advancedSubtitle, items: advanced },
188
+ ]);
189
+ </script>
190
+
191
+ {#snippet statusBadge(on: boolean, label: string)}
192
+ <Badge variant={on ? "secondary" : "outline"} class="max-w-full gap-1.5">
193
+ <span class={`size-1.5 shrink-0 rounded-full ${on ? "bg-green-500" : "bg-muted-foreground/40"}`}></span><span class="truncate">{label}</span>
194
+ </Badge>
195
+ {/snippet}
196
+
197
+ {#snippet card(item: Item)}
198
+ <Card.Root class="flex min-h-[8.5rem] flex-col gap-2 p-4">
199
+ <div class="flex items-start justify-between gap-3">
200
+ <span class="font-medium">{item.name}</span>
201
+ {#if item.toggle}
202
+ {@const t = item.toggle}
203
+ <Switch aria-label={item.name} checked={t.checked} disabled={t.disabled} onCheckedChange={(v) => t.onChange(v === true)} class="shrink-0" />
204
+ {/if}
205
+ </div>
206
+ <p class="text-muted-foreground flex-1 text-sm">{item.desc}</p>
207
+ <div class="mt-auto flex items-center justify-between gap-2 pt-1">
208
+ {@render statusBadge(item.status.on, item.status.label)}
209
+ {#if item.configure}
210
+ {@const c = item.configure}
211
+ <Button variant="outline" size="sm" class="shrink-0" disabled={!ac} onclick={c.onClick}>{c.label}</Button>
212
+ {/if}
213
+ </div>
214
+ </Card.Root>
215
+ {/snippet}
216
+
217
+ <div class="flex flex-col gap-6">
218
+ <div>
219
+ <h1 class="text-2xl font-semibold">{i18n.t.settings.title}</h1>
220
+ <p class="text-muted-foreground text-sm">{i18n.t.settings.subtitle}</p>
221
+ </div>
222
+
223
+ {#each sections as section (section.title)}
224
+ <section class="flex flex-col gap-3">
225
+ <div>
226
+ <h2 class="text-lg font-semibold">{section.title}</h2>
227
+ <p class="text-muted-foreground text-sm">{section.subtitle}</p>
228
+ </div>
229
+ <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
230
+ {#each section.items as item (item.key)}
231
+ {@render card(item)}
232
+ {/each}
233
+ </div>
234
+ </section>
235
+ {/each}
236
+ </div>
237
+
238
+ <!-- Social login: manage every provider (list ⇄ add/edit form) in one dialog -->
239
+ <Dialog.Root open={openDialog === "social"} onOpenChange={(v) => { if (!v) openDialog = null; }}>
240
+ <Dialog.Content>
241
+ <Dialog.Header>
242
+ <Dialog.Title>{i18n.t.settings.socialTitle}</Dialog.Title>
243
+ <Dialog.Description>{socialView === "list" ? i18n.t.settings.socialSubtitle : i18n.t.settings.socialDialogDesc}</Dialog.Description>
244
+ </Dialog.Header>
245
+
246
+ {#if socialView === "list"}
247
+ <div class="flex flex-col gap-2">
248
+ {#if configured.length === 0}
249
+ <p class="text-muted-foreground py-2 text-sm">{i18n.t.settings.noProviders}</p>
250
+ {/if}
251
+ {#each configured as p (p.id)}
252
+ {@const st = socialStatus(p)}
253
+ <div class="flex items-center gap-3 rounded-md border p-3">
254
+ <div class="flex min-w-0 flex-1 flex-col gap-1">
255
+ <span class="font-medium">{labelOf(p.id)}</span>
256
+ {@render statusBadge(st.on, st.label)}
257
+ </div>
258
+ <Switch aria-label={labelOf(p.id)} checked={p.enabled} disabled={saving} onCheckedChange={(v) => put({ social: { [p.id]: { enabled: v === true } } })} class="shrink-0" />
259
+ <Button variant="outline" size="sm" class="shrink-0" onclick={() => startEditSocial(p.id)}>{i18n.t.settings.edit}</Button>
260
+ <Button variant="ghost" size="sm" class="text-muted-foreground shrink-0" disabled={saving} onclick={() => removeSocial(p.id)}>{i18n.t.settings.remove}</Button>
261
+ </div>
262
+ {/each}
263
+ </div>
264
+ <Dialog.Footer class="sm:justify-between">
265
+ <Button onclick={startAddSocial} disabled={addable.length === 0}>{i18n.t.settings.addProvider}</Button>
266
+ <Button variant="outline" onclick={() => (openDialog = null)}>{i18n.t.settings.close}</Button>
267
+ </Dialog.Footer>
268
+ {:else}
269
+ <div class="flex flex-col gap-3">
270
+ {#if socialForm.adding}
271
+ <div class="flex flex-col gap-1">
272
+ <Label for="social-provider">{i18n.t.settings.provider}</Label>
273
+ <select
274
+ id="social-provider"
275
+ bind:value={socialForm.id}
276
+ class="border-input bg-background ring-offset-background focus-visible:ring-ring h-9 rounded-md border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
277
+ >
278
+ {#each addable as p (p.id)}
279
+ <option value={p.id}>{p.label}</option>
280
+ {/each}
281
+ </select>
282
+ </div>
283
+ {/if}
284
+ <label class="flex items-center justify-between gap-3 text-sm font-medium">{i18n.t.settings.smtpEnabled}<Switch aria-label={i18n.t.settings.smtpEnabled} bind:checked={socialForm.enabled} /></label>
285
+ <div class="flex flex-col gap-1"><Label for="social-id">{i18n.t.settings.clientId}</Label><Input id="social-id" bind:value={socialForm.clientId} autocomplete="off" /></div>
286
+ <div class="flex flex-col gap-1"><Label for="social-secret">{i18n.t.settings.clientSecret}</Label><Input id="social-secret" type="password" bind:value={socialForm.clientSecret} autocomplete="off" placeholder={editingView?.hasSecret ? i18n.t.settings.secretSet : ""} /></div>
287
+ {#if socialForm.id}
288
+ <p class="text-muted-foreground text-xs">{i18n.t.settings.redirectUri}: <code class="break-all">{callbackBase}/api/auth/callback/{socialForm.id}</code></p>
289
+ {/if}
290
+ </div>
291
+ <Dialog.Footer>
292
+ <Button variant="outline" onclick={() => (socialView = "list")}>{i18n.t.settings.back}</Button>
293
+ <Button disabled={saving || !socialForm.id} onclick={saveSocial}>{i18n.t.settings.save}</Button>
294
+ </Dialog.Footer>
295
+ {/if}
296
+ </Dialog.Content>
297
+ </Dialog.Root>
298
+
299
+ <!-- SMTP (email delivery) dialog — reached from the email-verification card -->
300
+ <Dialog.Root open={openDialog === "smtp"} onOpenChange={(v) => { if (!v) openDialog = null; }}>
301
+ <Dialog.Content>
302
+ <Dialog.Header>
303
+ <Dialog.Title>{i18n.t.settings.smtpTitle}</Dialog.Title>
304
+ <Dialog.Description>{i18n.t.settings.smtpSubtitle}</Dialog.Description>
305
+ </Dialog.Header>
306
+ <div class="flex flex-col gap-3">
307
+ <label class="flex items-center justify-between gap-3 text-sm font-medium">{i18n.t.settings.smtpEnabled}<Switch aria-label={i18n.t.settings.smtpEnabled} bind:checked={smtp.enabled} /></label>
308
+ <div class="grid gap-3 sm:grid-cols-2">
309
+ <div class="flex flex-col gap-1"><Label for="smtp-host">{i18n.t.settings.smtpHost}</Label><Input id="smtp-host" bind:value={smtp.host} autocomplete="off" /></div>
310
+ <div class="flex flex-col gap-1"><Label for="smtp-port">{i18n.t.settings.smtpPort}</Label><Input id="smtp-port" type="number" bind:value={smtp.port} /></div>
311
+ <div class="flex flex-col gap-1"><Label for="smtp-user">{i18n.t.settings.smtpUser}</Label><Input id="smtp-user" bind:value={smtp.user} autocomplete="off" /></div>
312
+ <div class="flex flex-col gap-1"><Label for="smtp-pass">{i18n.t.settings.smtpPass}</Label><Input id="smtp-pass" type="password" bind:value={smtp.pass} autocomplete="off" placeholder={ac?.smtp.hasSecret ? i18n.t.settings.secretSet : ""} /></div>
313
+ <div class="flex flex-col gap-1"><Label for="smtp-from">{i18n.t.settings.smtpFrom}</Label><Input id="smtp-from" bind:value={smtp.from} autocomplete="off" /></div>
314
+ <label class="flex items-center gap-2 self-end pb-2 text-sm"><Switch aria-label={i18n.t.settings.smtpSecure} bind:checked={smtp.secure} />{i18n.t.settings.smtpSecure}</label>
315
+ </div>
316
+ </div>
317
+ <Dialog.Footer>
318
+ <Button variant="outline" onclick={() => (openDialog = null)}>{i18n.t.settings.cancel}</Button>
319
+ <Button disabled={saving} onclick={saveSmtp}>{i18n.t.settings.save}</Button>
320
+ </Dialog.Footer>
321
+ </Dialog.Content>
322
+ </Dialog.Root>
@@ -1,9 +1,7 @@
1
1
  import type { PageServerLoad } from "./$types";
2
- import { error } from "@sveltejs/kit";
2
+ import { requireAdmin } from "$lib/server/guards";
3
3
 
4
4
  export const load: PageServerLoad = ({ locals }) => {
5
- if (locals.user?.role !== "admin") {
6
- error(403, "Admins only");
7
- }
8
- return { currentUserId: locals.user.id };
5
+ requireAdmin(locals.user);
6
+ return { currentUserId: locals.user!.id };
9
7
  };