@riducms/admin 0.1.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 (226) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +217 -0
  4. package/package.json +62 -0
  5. package/src/app/admin-core-view-route.svelte +89 -0
  6. package/src/app/admin-layout.svelte +457 -0
  7. package/src/app/admin-navigation-progress.svelte +204 -0
  8. package/src/app/admin-page-title.svelte +94 -0
  9. package/src/app/admin-provider-layer.svelte +26 -0
  10. package/src/app/admin-router-root.svelte +9 -0
  11. package/src/app/admin-routes.svelte +116 -0
  12. package/src/app/deferred-redirect.svelte +23 -0
  13. package/src/app/loading-boundary.svelte +70 -0
  14. package/src/app/not-found-route.svelte +29 -0
  15. package/src/app-meta.ts +2 -0
  16. package/src/app.css +467 -0
  17. package/src/app.svelte +88 -0
  18. package/src/assets/ridu-logo-arch.svg +3 -0
  19. package/src/assets/ridu-word-logo.svg +7 -0
  20. package/src/components/brand/ridu-logo.svelte +24 -0
  21. package/src/components/code-block/code-block.svelte +107 -0
  22. package/src/components/json-tree/json-tree-node.svelte +77 -0
  23. package/src/components/json-tree/json-viewer.svelte +113 -0
  24. package/src/components/ui/banner/banner.svelte +41 -0
  25. package/src/components/ui/banner/index.ts +5 -0
  26. package/src/components/ui/bulk-action-bar/bulk-action-bar.svelte +50 -0
  27. package/src/components/ui/bulk-action-bar/index.ts +1 -0
  28. package/src/components/ui/button/index.ts +8 -0
  29. package/src/components/ui/calendar/calendar-caption.svelte +72 -0
  30. package/src/components/ui/calendar/calendar-cell.svelte +20 -0
  31. package/src/components/ui/calendar/calendar-day.svelte +27 -0
  32. package/src/components/ui/calendar/calendar-grid-row.svelte +13 -0
  33. package/src/components/ui/calendar/calendar-grid.svelte +17 -0
  34. package/src/components/ui/calendar/calendar-head-cell.svelte +20 -0
  35. package/src/components/ui/calendar/calendar-header.svelte +20 -0
  36. package/src/components/ui/calendar/calendar-heading.svelte +17 -0
  37. package/src/components/ui/calendar/calendar-month-select.svelte +45 -0
  38. package/src/components/ui/calendar/calendar-month.svelte +16 -0
  39. package/src/components/ui/calendar/calendar-months.svelte +20 -0
  40. package/src/components/ui/calendar/calendar-nav.svelte +20 -0
  41. package/src/components/ui/calendar/calendar-next-button.svelte +30 -0
  42. package/src/components/ui/calendar/calendar-prev-button.svelte +30 -0
  43. package/src/components/ui/calendar/calendar-year-select.svelte +44 -0
  44. package/src/components/ui/calendar/calendar.svelte +118 -0
  45. package/src/components/ui/calendar/index.ts +1 -0
  46. package/src/components/ui/checkbox/checkbox.svelte +35 -0
  47. package/src/components/ui/checkbox/index.ts +1 -0
  48. package/src/components/ui/confirmation-dialog/confirmation-dialog.svelte +37 -0
  49. package/src/components/ui/confirmation-dialog/index.ts +1 -0
  50. package/src/components/ui/date-value-control/date-value-calendar.svelte +37 -0
  51. package/src/components/ui/date-value-control/date-value-control-loader.svelte +36 -0
  52. package/src/components/ui/date-value-control/date-value-control.svelte +177 -0
  53. package/src/components/ui/date-value-control/index.ts +3 -0
  54. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  55. package/src/components/ui/dialog/dialog-content.svelte +52 -0
  56. package/src/components/ui/dialog/dialog-description.svelte +20 -0
  57. package/src/components/ui/dialog/dialog-footer.svelte +20 -0
  58. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  59. package/src/components/ui/dialog/dialog-overlay.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  62. package/src/components/ui/dialog/dialog.svelte +7 -0
  63. package/src/components/ui/dialog/index.ts +8 -0
  64. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +27 -0
  65. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +20 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +16 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  69. package/src/components/ui/dropdown-menu/index.ts +5 -0
  70. package/src/components/ui/empty-state/empty-state.svelte +34 -0
  71. package/src/components/ui/empty-state/index.ts +1 -0
  72. package/src/components/ui/input/index.ts +1 -0
  73. package/src/components/ui/kbd/index.ts +1 -0
  74. package/src/components/ui/kbd/kbd.svelte +24 -0
  75. package/src/components/ui/pagination/index.ts +1 -0
  76. package/src/components/ui/pagination/pagination.svelte +88 -0
  77. package/src/components/ui/popover/index.ts +3 -0
  78. package/src/components/ui/popover/popover-content.svelte +29 -0
  79. package/src/components/ui/popover/popover-trigger.svelte +11 -0
  80. package/src/components/ui/popover/popover.svelte +7 -0
  81. package/src/components/ui/radio-group/index.ts +3 -0
  82. package/src/components/ui/radio-group/radio-card-item.svelte +38 -0
  83. package/src/components/ui/radio-group/radio-group-item.svelte +17 -0
  84. package/src/components/ui/radio-group/radio-group.svelte +19 -0
  85. package/src/components/ui/select/index.ts +4 -0
  86. package/src/components/ui/select/select-content.svelte +38 -0
  87. package/src/components/ui/select/select-item.svelte +40 -0
  88. package/src/components/ui/select/select-scroll-down-button.svelte +23 -0
  89. package/src/components/ui/select/select-scroll-up-button.svelte +23 -0
  90. package/src/components/ui/select/select-trigger.svelte +29 -0
  91. package/src/components/ui/select/select.svelte +11 -0
  92. package/src/components/ui/sheet/index.ts +6 -0
  93. package/src/components/ui/sheet/sheet-close.svelte +7 -0
  94. package/src/components/ui/sheet/sheet-content.svelte +77 -0
  95. package/src/components/ui/sheet/sheet-description.svelte +17 -0
  96. package/src/components/ui/sheet/sheet-overlay.svelte +36 -0
  97. package/src/components/ui/sheet/sheet-title.svelte +17 -0
  98. package/src/components/ui/sheet/sheet-trigger.svelte +7 -0
  99. package/src/components/ui/sheet/sheet.svelte +7 -0
  100. package/src/components/ui/skeleton/index.ts +1 -0
  101. package/src/components/ui/skeleton/skeleton.svelte +31 -0
  102. package/src/components/ui/slider/index.ts +3 -0
  103. package/src/components/ui/slider/slider-content.svelte +59 -0
  104. package/src/components/ui/slider/slider.svelte +35 -0
  105. package/src/components/ui/sonner/index.ts +1 -0
  106. package/src/components/ui/sonner/sonner.svelte +55 -0
  107. package/src/components/ui/status-indicator/index.ts +5 -0
  108. package/src/components/ui/status-indicator/status-indicator.svelte +54 -0
  109. package/src/components/ui/table/index.ts +6 -0
  110. package/src/components/ui/table/table-body.svelte +20 -0
  111. package/src/components/ui/table/table-cell.svelte +23 -0
  112. package/src/components/ui/table/table-head.svelte +23 -0
  113. package/src/components/ui/table/table-header.svelte +20 -0
  114. package/src/components/ui/table/table-row.svelte +23 -0
  115. package/src/components/ui/table/table.svelte +22 -0
  116. package/src/components/ui/tabs/index.ts +8 -0
  117. package/src/components/ui/tabs/tabs-content.svelte +17 -0
  118. package/src/components/ui/tabs/tabs-list.svelte +40 -0
  119. package/src/components/ui/tabs/tabs-trigger.svelte +23 -0
  120. package/src/components/ui/tabs/tabs.svelte +19 -0
  121. package/src/components/ui/textarea/index.ts +1 -0
  122. package/src/components/ui/tooltip/index.ts +4 -0
  123. package/src/components/ui/tooltip/tooltip-content.svelte +50 -0
  124. package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
  125. package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  126. package/src/components/ui/tooltip/tooltip.svelte +7 -0
  127. package/src/core/api/admin-client.ts +45 -0
  128. package/src/core/forms/field-condition.ts +63 -0
  129. package/src/core/forms/form-controller.svelte.ts +369 -0
  130. package/src/core/forms/form-draft-recovery.ts +80 -0
  131. package/src/core/forms/form-schema.ts +609 -0
  132. package/src/core/forms/form-validation.ts +750 -0
  133. package/src/core/i18n/admin-i18n.svelte.ts +364 -0
  134. package/src/core/i18n/localized-schema.ts +111 -0
  135. package/src/core/notifications/notification-center.svelte.ts +69 -0
  136. package/src/core/notifications/validation-toast.svelte +18 -0
  137. package/src/core/plugins/field-registry.ts +60 -0
  138. package/src/core/plugins/row-label-registry.ts +62 -0
  139. package/src/core/preferences/preference-write-queue.ts +107 -0
  140. package/src/core/routing/admin-paths.ts +137 -0
  141. package/src/core/runtime/admin-runtime.svelte.ts +625 -0
  142. package/src/features/auth/account-menu.svelte +159 -0
  143. package/src/features/auth/account-navigation.svelte +44 -0
  144. package/src/features/auth/account-route.svelte +395 -0
  145. package/src/features/auth/account-security-controller.svelte.ts +119 -0
  146. package/src/features/auth/account-security-route.svelte +460 -0
  147. package/src/features/auth/auth-brand.svelte +17 -0
  148. package/src/features/auth/auth-frame.svelte +18 -0
  149. package/src/features/auth/create-first-user-controller.svelte.ts +220 -0
  150. package/src/features/auth/create-first-user-route.svelte +174 -0
  151. package/src/features/auth/forgot-password-route.svelte +78 -0
  152. package/src/features/auth/login-route.svelte +221 -0
  153. package/src/features/auth/request-verification-route.svelte +73 -0
  154. package/src/features/auth/reset-password-route.svelte +107 -0
  155. package/src/features/auth/verify-email-route.svelte +60 -0
  156. package/src/features/collections/collection-list-bulk-actions.svelte +143 -0
  157. package/src/features/collections/collection-list-bulk-editor.svelte +157 -0
  158. package/src/features/collections/collection-list-controller.svelte.ts +717 -0
  159. package/src/features/collections/collection-list-filter-popover.svelte +238 -0
  160. package/src/features/collections/collection-list-preferences-controller.svelte.ts +393 -0
  161. package/src/features/collections/collection-list-results.svelte +410 -0
  162. package/src/features/collections/collection-list-route.svelte +951 -0
  163. package/src/features/collections/collection-list-workspace-toolbar.svelte +409 -0
  164. package/src/features/collections/collection-table-skeleton.svelte +44 -0
  165. package/src/features/collections/list-workspace.ts +265 -0
  166. package/src/features/dashboard/dashboard-route.svelte +141 -0
  167. package/src/features/development/development-tools.svelte +217 -0
  168. package/src/features/documents/document-api-view-loader.svelte +31 -0
  169. package/src/features/documents/document-api-view.svelte +547 -0
  170. package/src/features/documents/document-controller.svelte.ts +1252 -0
  171. package/src/features/documents/document-field-partition.ts +18 -0
  172. package/src/features/documents/document-field-sections.svelte +46 -0
  173. package/src/features/documents/document-heading.ts +20 -0
  174. package/src/features/documents/document-route-dirty.ts +43 -0
  175. package/src/features/documents/document-route-loader.svelte +22 -0
  176. package/src/features/documents/document-route.svelte +1124 -0
  177. package/src/features/documents/live-preview-panel.svelte +390 -0
  178. package/src/features/documents/live-preview.ts +61 -0
  179. package/src/features/documents/upload-document-preview-loader.svelte +40 -0
  180. package/src/features/documents/upload-document-preview.svelte +525 -0
  181. package/src/features/localization/content-locale-switcher.svelte +93 -0
  182. package/src/features/navigation/admin-breadcrumbs.svelte +124 -0
  183. package/src/features/navigation/admin-command-menu.svelte +245 -0
  184. package/src/features/navigation/admin-resource-groups.ts +72 -0
  185. package/src/features/reference-browser/reference-browser-loader.svelte +15 -0
  186. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +691 -0
  187. package/src/features/reference-browser/reference-browser.svelte +844 -0
  188. package/src/features/uploads/bulk-upload-route.svelte +624 -0
  189. package/src/features/uploads/bulk-upload.ts +39 -0
  190. package/src/features/uploads/upload-metadata-fields.svelte +112 -0
  191. package/src/features/versions/version-diff.ts +72 -0
  192. package/src/features/versions/version-history-route.svelte +581 -0
  193. package/src/fields/field-clipboard.ts +132 -0
  194. package/src/fields/field-layout.svelte +60 -0
  195. package/src/fields/field-layout.ts +57 -0
  196. package/src/fields/field-messages.svelte +29 -0
  197. package/src/fields/field-renderer.svelte +152 -0
  198. package/src/fields/field-shell.svelte +30 -0
  199. package/src/fields/field-tab-layout.svelte +99 -0
  200. package/src/fields/join/join-field.svelte +358 -0
  201. package/src/fields/localized-field-presentation.ts +17 -0
  202. package/src/fields/nested/nested-field.svelte +602 -0
  203. package/src/fields/nested/scoped-field.ts +78 -0
  204. package/src/fields/nested/sortable-row.svelte +32 -0
  205. package/src/fields/point/point-field.svelte +52 -0
  206. package/src/fields/radio/radio-field.svelte +34 -0
  207. package/src/fields/relationship/relationship-field-controller.svelte.ts +245 -0
  208. package/src/fields/relationship/relationship-field.svelte +258 -0
  209. package/src/fields/relationship/relationship-lookup-controller.svelte.ts +129 -0
  210. package/src/fields/relationship/relationship-query.ts +88 -0
  211. package/src/fields/relationship/relationship-quick-picker-controller.svelte.ts +198 -0
  212. package/src/fields/relationship/relationship-quick-picker.svelte +327 -0
  213. package/src/fields/relationship/relationship-value.ts +47 -0
  214. package/src/fields/scalar/date-control-value.ts +60 -0
  215. package/src/fields/scalar/date-value.ts +26 -0
  216. package/src/fields/scalar/scalar-field.svelte +137 -0
  217. package/src/fields/select/select-field.svelte +132 -0
  218. package/src/fields/select/select-value.ts +14 -0
  219. package/src/fields/text/slug-field.svelte +87 -0
  220. package/src/fields/text/slug.ts +38 -0
  221. package/src/fields/text/text-field.svelte +41 -0
  222. package/src/fields/ui/ui-field.svelte +12 -0
  223. package/src/fields/virtual/virtual-field.svelte +28 -0
  224. package/src/index.ts +49 -0
  225. package/src/main.ts +12 -0
  226. package/src/vite-env.d.ts +9 -0
@@ -0,0 +1,119 @@
1
+ import type { APIKey, APIKeyInfo, AuthSessionInfo } from "@riducms/protocol";
2
+ import { RiduError } from "@riducms/sdk";
3
+ import type { AdminI18n } from "@riducms/translations";
4
+
5
+ import type { AdminClient } from "@admin/core/api/admin-client";
6
+
7
+ export type AccountSecurityStatus = "idle" | "loading" | "ready" | "error";
8
+
9
+ export class AccountSecurityController {
10
+ status = $state<AccountSecurityStatus>("idle");
11
+ error = $state<string>();
12
+ operation = $state<string>();
13
+ sessions = $state.raw<readonly AuthSessionInfo[]>([]);
14
+ apiKeys = $state.raw<readonly APIKeyInfo[]>([]);
15
+ createdAPIKey = $state.raw<APIKey>();
16
+ #request?: AbortController;
17
+ #generation = 0;
18
+
19
+ constructor(
20
+ private readonly client: AdminClient,
21
+ readonly apiKeysEnabled: boolean,
22
+ private readonly translate: AdminI18n["t"]
23
+ ) {}
24
+
25
+ load = async () => {
26
+ this.#request?.abort();
27
+ const request = new AbortController();
28
+ this.#request = request;
29
+ const generation = ++this.#generation;
30
+ this.status = "loading";
31
+ this.error = undefined;
32
+ try {
33
+ const [sessions, apiKeys] = await Promise.all([
34
+ this.client.sessions({ signal: request.signal }),
35
+ this.apiKeysEnabled ? this.client.apiKeys({ signal: request.signal }) : Promise.resolve([]),
36
+ ]);
37
+ if (request.signal.aborted || generation !== this.#generation) return;
38
+ this.sessions = sessions;
39
+ this.apiKeys = apiKeys;
40
+ this.status = "ready";
41
+ } catch (cause) {
42
+ if (request.signal.aborted || generation !== this.#generation) return;
43
+ this.error = messageFrom(cause, this.translate("account:securityLoadFailed"));
44
+ this.status = "error";
45
+ }
46
+ };
47
+
48
+ revokeSession = async (id: string) => {
49
+ return this.#run("session:" + id, async () => {
50
+ await this.client.revokeSession(id);
51
+ this.sessions = this.sessions.filter((session) => session.id !== id);
52
+ });
53
+ };
54
+
55
+ logoutAll = async () => {
56
+ return this.#run("logout-all", async () => {
57
+ await this.client.logoutAll();
58
+ this.sessions = [];
59
+ });
60
+ };
61
+
62
+ createAPIKey = async (name: string, expiresAt?: string) => {
63
+ return this.#run("api-key:create", async () => {
64
+ const created = await this.client.createAPIKey({ name, expiresAt });
65
+ this.createdAPIKey = created;
66
+ this.apiKeys = [
67
+ {
68
+ id: created.id,
69
+ name: created.name,
70
+ createdAt: created.createdAt,
71
+ expiresAt: created.expiresAt,
72
+ },
73
+ ...this.apiKeys,
74
+ ];
75
+ });
76
+ };
77
+
78
+ dismissCreatedAPIKey = () => {
79
+ this.createdAPIKey = undefined;
80
+ };
81
+
82
+ revokeAPIKey = async (id: string) => {
83
+ return this.#run("api-key:" + id, async () => {
84
+ await this.client.revokeAPIKey(id);
85
+ this.apiKeys = this.apiKeys.filter((key) => key.id !== id);
86
+ if (this.createdAPIKey?.id === id) this.createdAPIKey = undefined;
87
+ });
88
+ };
89
+
90
+ changePassword = async (currentPassword: string, password: string) => {
91
+ return this.#run("password", async () => {
92
+ await this.client.changePassword(currentPassword, password);
93
+ });
94
+ };
95
+
96
+ destroy() {
97
+ this.#generation += 1;
98
+ this.#request?.abort();
99
+ }
100
+
101
+ async #run(name: string, operation: () => Promise<void>) {
102
+ if (this.operation !== undefined) return false;
103
+ this.operation = name;
104
+ this.error = undefined;
105
+ try {
106
+ await operation();
107
+ return true;
108
+ } catch (cause) {
109
+ this.error = messageFrom(cause, this.translate("account:securityOperationFailed"));
110
+ return false;
111
+ } finally {
112
+ this.operation = undefined;
113
+ }
114
+ }
115
+ }
116
+
117
+ function messageFrom(cause: unknown, fallback: string) {
118
+ return cause instanceof RiduError || cause instanceof Error ? cause.message : fallback;
119
+ }
@@ -0,0 +1,460 @@
1
+ <script lang="ts">
2
+ import { useNavigate } from "@hvniel/svelte-router";
3
+ import KeyIcon from "~icons/lucide/key-round";
4
+ import LaptopIcon from "~icons/lucide/laptop";
5
+ import LockIcon from "~icons/lucide/lock-keyhole";
6
+ import ShieldIcon from "~icons/lucide/shield-check";
7
+
8
+ import { Banner } from "@admin/components/ui/banner";
9
+ import { Button } from "@admin/components/ui/button";
10
+ import { DateValueControl } from "@admin/components/ui/date-value-control";
11
+ import { Input } from "@admin/components/ui/input";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from "@admin/components/ui/dialog";
20
+ import { adminRoutePatterns } from "@admin/core/routing/admin-paths";
21
+ import { getNotificationCenter } from "@admin/core/notifications/notification-center.svelte";
22
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
23
+ import { AccountSecurityController } from "@admin/features/auth/account-security-controller.svelte";
24
+ import AccountNavigation from "@admin/features/auth/account-navigation.svelte";
25
+
26
+ const runtime = getAdminRuntime();
27
+ const notifications = getNotificationCenter();
28
+ const navigate = useNavigate();
29
+ // The authenticated route table is remounted when the session changes, so this
30
+ // manifest capability is an intentional controller-lifetime snapshot.
31
+ const controller = new AccountSecurityController(
32
+ runtime.client,
33
+ runtime.authCollection?.authSettings?.apiKeys === true,
34
+ runtime.i18n.t
35
+ );
36
+ const { status, error, operation, sessions, apiKeys, createdAPIKey, apiKeysEnabled } =
37
+ $derived(controller);
38
+ let currentPassword = $state("");
39
+ let nextPassword = $state("");
40
+ let confirmPassword = $state("");
41
+ let apiKeyName = $state("");
42
+ let apiKeyExpiry = $state("");
43
+ let destructiveAction = $state<{ kind: "session" | "api-key" | "logout-all"; id?: string }>();
44
+ const accountComponents = $derived(
45
+ runtime.accountComponents.filter((component) => component.surface === "security")
46
+ );
47
+ const replacement = $derived(
48
+ accountComponents.find((component) => component.position === "replace")
49
+ );
50
+ const beforeComponents = $derived(
51
+ accountComponents.filter((component) => component.position === "before")
52
+ );
53
+ const afterComponents = $derived(
54
+ accountComponents.filter(
55
+ (component) => component.position === undefined || component.position === "after"
56
+ )
57
+ );
58
+ const host = {
59
+ refreshUser,
60
+ logout,
61
+ notify: (tone: "success" | "error", title: string, message?: string) =>
62
+ notifications[tone]({ title, message }),
63
+ };
64
+
65
+ $effect(() => {
66
+ controller.load();
67
+ return () => controller.destroy();
68
+ });
69
+
70
+ async function changePassword(event: SubmitEvent) {
71
+ event.preventDefault();
72
+ if (nextPassword !== confirmPassword) {
73
+ notifications.error({ title: runtime.i18n.t("account:passwordMismatch") });
74
+ return;
75
+ }
76
+ if (!(await controller.changePassword(currentPassword, nextPassword))) return;
77
+ runtime.session = undefined;
78
+ notifications.success({ title: runtime.i18n.t("account:passwordUpdated") });
79
+ navigate(adminRoutePatterns.login, { replace: true });
80
+ }
81
+
82
+ async function createAPIKey(event: SubmitEvent) {
83
+ event.preventDefault();
84
+ const expiry = apiKeyExpiry === "" ? undefined : new Date(apiKeyExpiry).toISOString();
85
+ if (!(await controller.createAPIKey(apiKeyName, expiry))) return;
86
+ apiKeyName = "";
87
+ apiKeyExpiry = "";
88
+ notifications.success({ title: runtime.i18n.t("account:apiKeyCreated") });
89
+ }
90
+
91
+ async function revokeSession(id: string) {
92
+ if (!(await controller.revokeSession(id))) return;
93
+ notifications.success({ title: runtime.i18n.t("account:sessionRevoked") });
94
+ }
95
+
96
+ async function revokeAPIKey(id: string) {
97
+ if (!(await controller.revokeAPIKey(id))) return;
98
+ notifications.success({ title: runtime.i18n.t("account:apiKeyRevoked") });
99
+ }
100
+
101
+ async function logoutAll() {
102
+ if (!(await controller.logoutAll())) return;
103
+ runtime.session = undefined;
104
+ notifications.success({ title: runtime.i18n.t("account:signedOutAll") });
105
+ navigate(adminRoutePatterns.login, { replace: true });
106
+ }
107
+
108
+ async function refreshUser() {
109
+ const collection = runtime.authCollection;
110
+ const user = runtime.session?.user;
111
+ if (collection === undefined || user === undefined) return undefined;
112
+ const document = await runtime.client.find(collection.slug, user.id);
113
+ if (runtime.session !== undefined) runtime.session = { ...runtime.session, user: document };
114
+ return document;
115
+ }
116
+
117
+ async function logout() {
118
+ await runtime.client.logout();
119
+ runtime.session = undefined;
120
+ navigate(adminRoutePatterns.login, { replace: true });
121
+ }
122
+
123
+ async function confirmDestructiveAction() {
124
+ const action = destructiveAction;
125
+ if (action === undefined) return;
126
+ if (action.kind === "session" && action.id !== undefined) await revokeSession(action.id);
127
+ if (action.kind === "api-key" && action.id !== undefined) await revokeAPIKey(action.id);
128
+ if (action.kind === "logout-all") await logoutAll();
129
+ destructiveAction = undefined;
130
+ }
131
+
132
+ async function copyAPIKey() {
133
+ if (createdAPIKey === undefined) return;
134
+ try {
135
+ await navigator.clipboard.writeText(createdAPIKey.key);
136
+ notifications.success({ title: runtime.i18n.t("account:apiKeyCopied") });
137
+ } catch {
138
+ notifications.error({ title: runtime.i18n.t("account:apiKeyCopyFailed") });
139
+ }
140
+ }
141
+
142
+ function formatDate(value: string | undefined) {
143
+ if (value === undefined || value === "") return runtime.i18n.t("account:never");
144
+ return runtime.i18n.formatDate(value, { dateStyle: "medium", timeStyle: "short" });
145
+ }
146
+ </script>
147
+
148
+ {#snippet defaultView()}
149
+ <div class="mx-auto grid w-full max-w-[820px] gap-8 px-5 py-8 sm:px-8 sm:py-11">
150
+ <header class="grid gap-2 border-b border-control-border pb-6">
151
+ <p class="font-mono text-[9.5px] tracking-[0.16em] text-foreground-faint uppercase">
152
+ {runtime.i18n.t("account:account")}
153
+ </p>
154
+ <h1 class="text-[28px] leading-tight font-semibold tracking-[-0.015em] text-foreground">
155
+ {runtime.i18n.t("account:security")}
156
+ </h1>
157
+ <p class="max-w-[570px] text-[13.5px] leading-5.5 text-foreground-muted">
158
+ {runtime.i18n.t("account:securityDescription")}
159
+ </p>
160
+ </header>
161
+
162
+ {#if runtime.manifest !== undefined}
163
+ {#each beforeComponents as component (component.key)}
164
+ <component.component
165
+ manifest={runtime.manifest}
166
+ user={runtime.session?.user}
167
+ surface="security"
168
+ {defaultView}
169
+ {host}
170
+ i18n={runtime.i18n}
171
+ />
172
+ {/each}
173
+ {/if}
174
+
175
+ <AccountNavigation active="security" />
176
+
177
+ {#if error !== undefined}
178
+ <Banner tone="destructive">{error}</Banner>
179
+ {/if}
180
+
181
+ <section class="grid gap-4" aria-labelledby="password-heading">
182
+ <header class="flex items-center gap-3">
183
+ <LockIcon class="size-4 text-foreground-sub" aria-hidden="true" />
184
+ <div>
185
+ <h2 id="password-heading" class="text-[15px] font-semibold text-foreground">
186
+ {runtime.i18n.t("account:password")}
187
+ </h2>
188
+ <p class="text-[12px] text-foreground-faint">
189
+ {runtime.i18n.t("account:passwordDescription")}
190
+ </p>
191
+ </div>
192
+ </header>
193
+ <form
194
+ class="grid gap-3 rounded-[4px] border border-control-border bg-control p-5"
195
+ onsubmit={changePassword}
196
+ >
197
+ <label class="grid gap-2" for="current-password">
198
+ <span class="ridu-field-label">{runtime.i18n.t("account:currentPassword")}</span>
199
+ <Input
200
+ id="current-password"
201
+ type="password"
202
+ autocomplete="current-password"
203
+ bind:value={currentPassword}
204
+ required
205
+ />
206
+ </label>
207
+ <div class="grid gap-3 sm:grid-cols-2">
208
+ <label class="grid gap-2" for="next-password">
209
+ <span class="ridu-field-label">{runtime.i18n.t("account:newPassword")}</span>
210
+ <Input
211
+ id="next-password"
212
+ type="password"
213
+ autocomplete="new-password"
214
+ minlength={runtime.authCollection?.authSettings?.passwordMinLength ?? 8}
215
+ bind:value={nextPassword}
216
+ required
217
+ />
218
+ </label>
219
+ <label class="grid gap-2" for="confirm-password">
220
+ <span class="ridu-field-label">{runtime.i18n.t("auth:confirmPassword")}</span>
221
+ <Input
222
+ id="confirm-password"
223
+ type="password"
224
+ autocomplete="new-password"
225
+ bind:value={confirmPassword}
226
+ required
227
+ />
228
+ </label>
229
+ </div>
230
+ <Button class="mt-1 justify-self-start" type="submit" disabled={operation !== undefined}>
231
+ {operation === "password"
232
+ ? runtime.i18n.t("account:updatingPassword")
233
+ : runtime.i18n.t("account:updatePassword")}
234
+ </Button>
235
+ </form>
236
+ </section>
237
+
238
+ <section class="grid gap-4" aria-labelledby="sessions-heading">
239
+ <header class="flex items-center gap-3">
240
+ <LaptopIcon class="size-4 text-foreground-sub" aria-hidden="true" />
241
+ <div class="min-w-0 flex-1">
242
+ <h2 id="sessions-heading" class="text-[15px] font-semibold text-foreground">
243
+ {runtime.i18n.t("account:activeSessions")}
244
+ </h2>
245
+ <p class="text-[12px] text-foreground-faint">
246
+ {runtime.i18n.t("account:sessionsDescription")}
247
+ </p>
248
+ </div>
249
+ <Button
250
+ variant="outline"
251
+ onclick={() => (destructiveAction = { kind: "logout-all" })}
252
+ disabled={operation !== undefined || sessions.length === 0}
253
+ >
254
+ {runtime.i18n.t("account:signOutAll")}
255
+ </Button>
256
+ </header>
257
+ <div class="overflow-hidden rounded-[4px] border border-control-border bg-control">
258
+ {const sessionMessage = $derived(
259
+ status === "loading"
260
+ ? runtime.i18n.t("account:loadingSessions")
261
+ : sessions.length === 0
262
+ ? runtime.i18n.t("account:noSessions")
263
+ : undefined
264
+ )}
265
+
266
+ {#if sessionMessage !== undefined}
267
+ <p class="px-5 py-6 text-[13px] text-foreground-muted">{sessionMessage}</p>
268
+ {:else}
269
+ {#each sessions as session (session.id)}
270
+ <div
271
+ class="flex flex-wrap items-center gap-3 border-b border-control-border px-5 py-4 last:border-b-0"
272
+ >
273
+ <ShieldIcon class="size-4 text-foreground-faint" aria-hidden="true" />
274
+ <div class="min-w-[180px] flex-1">
275
+ <p class="truncate text-[13px] text-foreground">
276
+ {session.userAgent || runtime.i18n.t("account:unknownClient")}
277
+ </p>
278
+ <p class="mt-0.5 text-[10.5px] text-foreground-faint">
279
+ {runtime.i18n.t("account:lastSeen", {
280
+ date: formatDate(session.lastSeenAt),
281
+ })}{session.ipAddress ? ` · ${session.ipAddress}` : ""}
282
+ </p>
283
+ </div>
284
+ {#if session.current}
285
+ <span
286
+ class="rounded-full bg-success/10 px-2 py-1 text-[10px] font-medium text-success"
287
+ >
288
+ {runtime.i18n.t("account:currentSession")}
289
+ </span>
290
+ {:else}
291
+ <Button
292
+ variant="ghost"
293
+ onclick={() => (destructiveAction = { kind: "session", id: session.id })}
294
+ disabled={operation !== undefined}
295
+ >
296
+ {runtime.i18n.t("account:revoke")}
297
+ </Button>
298
+ {/if}
299
+ </div>
300
+ {/each}
301
+ {/if}
302
+ </div>
303
+ </section>
304
+
305
+ {#if apiKeysEnabled}
306
+ <section class="grid gap-4" aria-labelledby="api-keys-heading">
307
+ <header class="flex items-center gap-3">
308
+ <KeyIcon class="size-4 text-foreground-sub" aria-hidden="true" />
309
+ <div>
310
+ <h2 id="api-keys-heading" class="text-[15px] font-semibold text-foreground">
311
+ {runtime.i18n.t("account:apiKeys")}
312
+ </h2>
313
+ <p class="text-[12px] text-foreground-faint">
314
+ {runtime.i18n.t("account:apiKeysDescription")}
315
+ </p>
316
+ </div>
317
+ </header>
318
+ <form
319
+ class="grid gap-3 rounded-[4px] border border-control-border bg-control p-5 sm:grid-cols-[1fr_190px_auto] sm:items-end"
320
+ onsubmit={createAPIKey}
321
+ >
322
+ <label class="grid gap-2" for="api-key-name">
323
+ <span class="ridu-field-label">{runtime.i18n.t("account:name")}</span>
324
+ <Input
325
+ id="api-key-name"
326
+ bind:value={apiKeyName}
327
+ maxlength={100}
328
+ required
329
+ placeholder={runtime.i18n.t("account:apiKeyNamePlaceholder")}
330
+ />
331
+ </label>
332
+ <label class="grid gap-2" for="api-key-expiry">
333
+ <span class="ridu-field-label">{runtime.i18n.t("account:expiration")}</span>
334
+ <DateValueControl
335
+ id="api-key-expiry"
336
+ appearance="dayAndTime"
337
+ value={apiKeyExpiry}
338
+ label={runtime.i18n.t("account:expiration")}
339
+ onValueChange={(next) => (apiKeyExpiry = next)}
340
+ />
341
+ </label>
342
+ <Button type="submit" disabled={operation !== undefined}>
343
+ {runtime.i18n.t("account:createKey")}
344
+ </Button>
345
+ </form>
346
+ {#if createdAPIKey !== undefined}
347
+ <Banner>
348
+ <div class="min-w-0 flex-1">
349
+ <p class="font-medium">{runtime.i18n.t("account:copyAPIKeyNow")}</p>
350
+ <code class="mt-2 block overflow-x-auto font-mono text-[11px]">
351
+ {createdAPIKey.key}
352
+ </code>
353
+ </div>
354
+ <div class="flex shrink-0 gap-2">
355
+ <Button variant="outline" onclick={copyAPIKey}>
356
+ {runtime.i18n.t("general:copy")}
357
+ </Button><Button variant="ghost" onclick={controller.dismissCreatedAPIKey}>
358
+ {runtime.i18n.t("general:done")}
359
+ </Button>
360
+ </div>
361
+ </Banner>
362
+ {/if}
363
+ <div class="overflow-hidden rounded-[4px] border border-control-border bg-control">
364
+ {#if apiKeys.length === 0}
365
+ <p class="px-5 py-6 text-[13px] text-foreground-muted">
366
+ {runtime.i18n.t("account:noAPIKeys")}
367
+ </p>
368
+ {:else}
369
+ {#each apiKeys as key (key.id)}
370
+ <div
371
+ class="flex items-center gap-3 border-b border-control-border px-5 py-4 last:border-b-0"
372
+ >
373
+ <div class="min-w-0 flex-1">
374
+ <p class="truncate text-[13px] text-foreground">{key.name}</p>
375
+ <p class="mt-0.5 text-[10.5px] text-foreground-faint">
376
+ {runtime.i18n.t("account:created", { date: formatDate(key.createdAt) })}
377
+ · {runtime.i18n.t("account:expires", { date: formatDate(key.expiresAt) })}
378
+ </p>
379
+ </div>
380
+ <Button
381
+ variant="ghost"
382
+ onclick={() => (destructiveAction = { kind: "api-key", id: key.id })}
383
+ disabled={operation !== undefined}
384
+ >
385
+ {runtime.i18n.t("account:revoke")}
386
+ </Button>
387
+ </div>
388
+ {/each}
389
+ {/if}
390
+ </div>
391
+ </section>
392
+ {/if}
393
+ {#if runtime.manifest !== undefined}
394
+ {#each afterComponents as component (component.key)}
395
+ <component.component
396
+ manifest={runtime.manifest}
397
+ user={runtime.session?.user}
398
+ surface="security"
399
+ {defaultView}
400
+ {host}
401
+ i18n={runtime.i18n}
402
+ />
403
+ {/each}
404
+ {/if}
405
+ </div>
406
+ {/snippet}
407
+
408
+ {#if runtime.manifest !== undefined && replacement !== undefined}
409
+ <replacement.component
410
+ manifest={runtime.manifest}
411
+ user={runtime.session?.user}
412
+ surface="security"
413
+ {defaultView}
414
+ {host}
415
+ i18n={runtime.i18n}
416
+ />
417
+ {:else}
418
+ {@render defaultView()}
419
+ {/if}
420
+
421
+ <Dialog
422
+ bind:open={
423
+ () => destructiveAction !== undefined,
424
+ (open) => {
425
+ if (!open) destructiveAction = undefined;
426
+ }
427
+ }
428
+ >
429
+ <DialogContent>
430
+ <DialogHeader>
431
+ <p class="font-mono text-[10px] tracking-[0.13em] text-destructive uppercase">
432
+ {runtime.i18n.t("account:securityAction")}
433
+ </p>
434
+ <DialogTitle class="text-[20px] leading-tight font-semibold">
435
+ {destructiveAction?.kind === "logout-all"
436
+ ? runtime.i18n.t("account:confirmSignOutAll")
437
+ : destructiveAction?.kind === "api-key"
438
+ ? runtime.i18n.t("account:confirmRevokeAPIKey")
439
+ : runtime.i18n.t("account:confirmRevokeSession")}
440
+ </DialogTitle>
441
+ <DialogDescription class="mt-1 text-[13.5px] leading-5 text-foreground-muted">
442
+ {destructiveAction?.kind === "logout-all"
443
+ ? runtime.i18n.t("account:signOutAllDescription")
444
+ : runtime.i18n.t("account:revokeCredentialDescription")}
445
+ </DialogDescription>
446
+ </DialogHeader>
447
+ <DialogFooter>
448
+ <Button variant="outline" onclick={() => (destructiveAction = undefined)}>
449
+ {runtime.i18n.t("general:cancel")}
450
+ </Button>
451
+ <Button
452
+ variant="destructive"
453
+ onclick={confirmDestructiveAction}
454
+ disabled={operation !== undefined}
455
+ >
456
+ {runtime.i18n.t("general:confirm")}
457
+ </Button>
458
+ </DialogFooter>
459
+ </DialogContent>
460
+ </Dialog>
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import RiduLogo from "@admin/components/brand/ridu-logo.svelte";
3
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
4
+
5
+ const runtime = getAdminRuntime();
6
+ const applicationName = $derived(
7
+ runtime.manifest?.application.name ?? runtime.i18n.t("general:riduApplication")
8
+ );
9
+ </script>
10
+
11
+ <div
12
+ class="inline-flex text-foreground"
13
+ role="img"
14
+ aria-label={runtime.i18n.t("auth:adminBrand", { application: applicationName })}
15
+ >
16
+ <RiduLogo class="h-7" />
17
+ </div>
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ let {
5
+ brand,
6
+ children,
7
+ wide = false,
8
+ }: { brand?: Snippet; children: Snippet; wide?: boolean } = $props();
9
+ </script>
10
+
11
+ <main class="grid min-h-screen place-items-center bg-background px-5 py-12 sm:px-8">
12
+ <section class={["w-full", wide ? "max-w-180" : "max-w-120"]}>
13
+ {#if brand !== undefined}
14
+ <header class="mb-10 flex items-center">{@render brand()}</header>
15
+ {/if}
16
+ {@render children()}
17
+ </section>
18
+ </main>