@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,220 @@
1
+ import type { SchemaCollection } from "@riducms/protocol";
2
+ import { RiduError } from "@riducms/sdk";
3
+
4
+ import { FormController, FormValidationError } from "@admin/core/forms/form-controller.svelte";
5
+ import { initialFormValues } from "@admin/core/forms/form-schema";
6
+ import type { NotificationCenter } from "@admin/core/notifications/notification-center.svelte";
7
+ import { adminRoutePatterns } from "@admin/core/routing/admin-paths";
8
+ import type { AdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
9
+
10
+ type Navigate = (to: string, options?: { replace?: boolean }) => void;
11
+
12
+ interface CreateFirstUserControllerOptions {
13
+ runtime: AdminRuntime;
14
+ notifications: NotificationCenter;
15
+ navigate: Navigate;
16
+ }
17
+
18
+ export class CreateFirstUserController {
19
+ readonly form: FormController;
20
+ password = $state("");
21
+ passwordConfirmation = $state("");
22
+ pending = $state(false);
23
+ error = $state<string>();
24
+ credentialIssue = $state<string>();
25
+ #request?: AbortController;
26
+ #generation = 0;
27
+
28
+ constructor(readonly options: CreateFirstUserControllerOptions) {
29
+ this.form = new FormController({}, options.runtime.i18n);
30
+ this.form.reset(initialFormValues(this.fields));
31
+ this.form.setAccess(undefined, "create");
32
+ if (this.collection !== undefined) {
33
+ this.form.setResource({ collection: this.collection.slug });
34
+ }
35
+ }
36
+
37
+ get collection(): SchemaCollection | undefined {
38
+ return this.options.runtime.authCollection;
39
+ }
40
+
41
+ get fields() {
42
+ return this.collection?.fields ?? [];
43
+ }
44
+
45
+ get passwordHelp() {
46
+ const minimum = this.collection?.authSettings?.passwordMinLength ?? 8;
47
+ const maximum = this.collection?.authSettings?.passwordMaxBytes;
48
+ return maximum === undefined
49
+ ? this.options.runtime.i18n.t("auth:passwordMinimum", {
50
+ minimum: this.options.runtime.i18n.formatNumber(minimum),
51
+ })
52
+ : this.options.runtime.i18n.t("auth:passwordRange", {
53
+ minimum: this.options.runtime.i18n.formatNumber(minimum),
54
+ maximum: this.options.runtime.i18n.formatNumber(maximum),
55
+ });
56
+ }
57
+
58
+ submit = async () => {
59
+ if (this.pending) return undefined;
60
+ const collection = this.collection;
61
+ if (collection === undefined) {
62
+ this.error = this.options.runtime.i18n.t("auth:configuredCollectionUnavailable");
63
+ return undefined;
64
+ }
65
+ this.credentialIssue = this.#validatePassword(collection);
66
+ if (this.credentialIssue !== undefined) return "password";
67
+
68
+ const generation = ++this.#generation;
69
+ const request = new AbortController();
70
+ this.#request?.abort();
71
+ this.#request = request;
72
+ this.pending = true;
73
+ this.error = undefined;
74
+ let accountCreated = false;
75
+ try {
76
+ await this.form.submit(this.fields, true, (values) =>
77
+ this.options.runtime.client.createAuthUser(collection.slug, values, this.password, {
78
+ signal: request.signal,
79
+ })
80
+ );
81
+ accountCreated = true;
82
+ if (!this.#current(generation, request)) return undefined;
83
+
84
+ const identity = this.form.get(collection.authSettings?.identityField ?? "email");
85
+ if (typeof identity !== "string" || identity.trim() === "") {
86
+ throw new Error(this.options.runtime.i18n.t("auth:firstAccountIdentityUnavailable"));
87
+ }
88
+ const session = await this.options.runtime.client.login(
89
+ collection.slug,
90
+ { email: identity, password: this.password },
91
+ { signal: request.signal }
92
+ );
93
+ if (!this.#current(generation, request)) return undefined;
94
+ const initialAccessAuthoritative = await this.options.runtime.refreshAccess();
95
+ if (!this.#current(generation, request)) return undefined;
96
+ if (
97
+ initialAccessAuthoritative &&
98
+ this.options.runtime.collectionOperations[collection.slug]?.admin !== true
99
+ ) {
100
+ try {
101
+ await this.options.runtime.client.logout({ signal: request.signal });
102
+ } catch {
103
+ // The setup route remains closed even if cookie cleanup fails.
104
+ }
105
+ throw new Error(this.options.runtime.i18n.t("auth:firstAccountNoAdminAccess"));
106
+ }
107
+
108
+ this.options.runtime.authBootstrapAvailable = false;
109
+ this.options.runtime.session = session;
110
+ await Promise.all([
111
+ this.options.runtime.loadTheme(),
112
+ this.options.runtime.loadContentLocale(),
113
+ this.options.runtime.i18n.loadPreferences(),
114
+ ]);
115
+ const preferredAccessAuthoritative = await this.options.runtime.refreshAccess(
116
+ this.options.runtime.contentLocale
117
+ );
118
+ if (
119
+ preferredAccessAuthoritative &&
120
+ this.options.runtime.collectionOperations[collection.slug]?.admin !== true
121
+ ) {
122
+ try {
123
+ await this.options.runtime.client.logout({ signal: request.signal });
124
+ } catch {
125
+ // The setup route remains closed even if cookie cleanup fails.
126
+ }
127
+ this.options.runtime.session = undefined;
128
+ throw new Error(this.options.runtime.i18n.t("auth:firstAccountNoAdminAccess"));
129
+ }
130
+ if (!this.#current(generation, request)) return undefined;
131
+ this.options.navigate(adminRoutePatterns.home, { replace: true });
132
+ return undefined;
133
+ } catch (cause) {
134
+ if (!this.#current(generation, request)) return undefined;
135
+ if (accountCreated) {
136
+ this.#continueToLogin(
137
+ this.options.runtime.i18n.t("auth:accountCreated"),
138
+ cause instanceof Error
139
+ ? this.options.runtime.i18n.t("auth:autoSignInFailed", { message: cause.message })
140
+ : this.options.runtime.i18n.t("auth:accountReady")
141
+ );
142
+ return undefined;
143
+ }
144
+ if (cause instanceof FormValidationError) {
145
+ this.error = cause.message;
146
+ return this.form.issues[0]?.path;
147
+ }
148
+ if (cause instanceof RiduError) {
149
+ const passwordIssue = cause.issues.find((issue) => issue.path === "password");
150
+ if (passwordIssue !== undefined) {
151
+ this.credentialIssue = passwordIssue.message;
152
+ this.form.issues = this.form.issues.filter((issue) => issue.path !== "password");
153
+ this.error = this.options.runtime.i18n.t("auth:correctInvalidFields");
154
+ return "password";
155
+ }
156
+ }
157
+
158
+ try {
159
+ const bootstrap = await this.options.runtime.client.authBootstrap(collection.slug, {
160
+ signal: request.signal,
161
+ });
162
+ if (!this.#current(generation, request)) return undefined;
163
+ if (!bootstrap.available) {
164
+ this.#continueToLogin(
165
+ this.options.runtime.i18n.t("auth:setupCompleted"),
166
+ this.options.runtime.i18n.t("auth:firstAccountExists")
167
+ );
168
+ return undefined;
169
+ }
170
+ } catch {
171
+ if (!this.#current(generation, request)) return undefined;
172
+ }
173
+ this.error =
174
+ cause instanceof Error
175
+ ? cause.message
176
+ : this.options.runtime.i18n.t("auth:firstAccountCreateFailed");
177
+ return this.form.issues[0]?.path;
178
+ } finally {
179
+ if (this.#current(generation, request)) {
180
+ this.pending = false;
181
+ this.#request = undefined;
182
+ }
183
+ }
184
+ };
185
+
186
+ destroy() {
187
+ this.#generation += 1;
188
+ this.#request?.abort();
189
+ this.#request = undefined;
190
+ }
191
+
192
+ #continueToLogin(title: string, message: string) {
193
+ this.options.notifications.error({ title, message });
194
+ this.options.navigate(adminRoutePatterns.login, { replace: true });
195
+ this.options.runtime.authBootstrapAvailable = false;
196
+ }
197
+
198
+ #current(generation: number, request: AbortController) {
199
+ return generation === this.#generation && !request.signal.aborted;
200
+ }
201
+
202
+ #validatePassword(collection: SchemaCollection) {
203
+ const settings = collection.authSettings;
204
+ if (this.password === "") return this.options.runtime.i18n.t("auth:enterPassword");
205
+ if (Array.from(this.password).length < (settings?.passwordMinLength ?? 8)) {
206
+ return this.options.runtime.i18n.t("auth:passwordTooShort", {
207
+ minimum: this.options.runtime.i18n.formatNumber(settings?.passwordMinLength ?? 8),
208
+ });
209
+ }
210
+ if (new TextEncoder().encode(this.password).length > (settings?.passwordMaxBytes ?? 72)) {
211
+ return this.options.runtime.i18n.t("auth:passwordTooLong", {
212
+ maximum: this.options.runtime.i18n.formatNumber(settings?.passwordMaxBytes ?? 72),
213
+ });
214
+ }
215
+ if (this.password !== this.passwordConfirmation) {
216
+ return this.options.runtime.i18n.t("auth:passwordMismatch");
217
+ }
218
+ return undefined;
219
+ }
220
+ }
@@ -0,0 +1,174 @@
1
+ <script lang="ts">
2
+ import { useNavigate } from "@hvniel/svelte-router";
3
+ import EyeIcon from "~icons/lucide/eye";
4
+ import EyeOffIcon from "~icons/lucide/eye-off";
5
+ import { tick } from "svelte";
6
+
7
+ import { Banner } from "@admin/components/ui/banner";
8
+ import { Button } from "@admin/components/ui/button";
9
+ import { Input } from "@admin/components/ui/input";
10
+ import { getNotificationCenter } from "@admin/core/notifications/notification-center.svelte";
11
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
12
+ import AuthBrand from "@admin/features/auth/auth-brand.svelte";
13
+ import AuthFrame from "@admin/features/auth/auth-frame.svelte";
14
+ import { CreateFirstUserController } from "@admin/features/auth/create-first-user-controller.svelte";
15
+ import FieldLayout from "@admin/fields/field-layout.svelte";
16
+
17
+ const runtime = getAdminRuntime();
18
+ const notifications = getNotificationCenter();
19
+ const navigate = useNavigate();
20
+ const controller = new CreateFirstUserController({ runtime, notifications, navigate });
21
+ const { form, fields } = $derived(controller);
22
+ const applicationName = $derived(
23
+ runtime.manifest?.application.name ?? runtime.i18n.t("general:riduApplication")
24
+ );
25
+ let passwordVisible = $state(false);
26
+ let confirmationVisible = $state(false);
27
+
28
+ $effect(() => () => controller.destroy());
29
+
30
+ async function handleSubmit(event: SubmitEvent) {
31
+ event.preventDefault();
32
+ const issuePath = await controller.submit();
33
+ if (issuePath === undefined) return;
34
+ await tick();
35
+ focusIssue(issuePath);
36
+ }
37
+
38
+ function focusIssue(path: string) {
39
+ if (path === "password") {
40
+ document.getElementById("ridu-first-user-password")?.focus();
41
+ return;
42
+ }
43
+ const segments = path.split(".");
44
+ while (segments.length > 0) {
45
+ const candidate = segments.join(".");
46
+ const container = document.querySelector<HTMLElement>(
47
+ `[data-field-path="${CSS.escape(candidate)}"]`
48
+ );
49
+ if (container !== null) {
50
+ container.scrollIntoView({ behavior: "smooth", block: "center" });
51
+ container
52
+ .querySelector<HTMLElement>("input, textarea, button, [tabindex]:not([tabindex='-1'])")
53
+ ?.focus({ preventScroll: true });
54
+ return;
55
+ }
56
+ segments.pop();
57
+ }
58
+ }
59
+ </script>
60
+
61
+ {#snippet brand()}
62
+ <AuthBrand />
63
+ {/snippet}
64
+
65
+ <AuthFrame {brand} wide>
66
+ <header class="grid gap-2">
67
+ <h1 class="text-[28px] leading-tight font-semibold tracking-[-0.015em] text-foreground">
68
+ {runtime.i18n.t("auth:welcome", { application: applicationName })}
69
+ </h1>
70
+ <p class="max-w-[590px] text-[13.5px] leading-5.5 text-foreground-muted">
71
+ {runtime.i18n.t("auth:firstUserDescription")}
72
+ </p>
73
+ </header>
74
+
75
+ <form class="mt-8 grid gap-7" onsubmit={handleSubmit}>
76
+ <fieldset class="contents" disabled={controller.pending}>
77
+ <FieldLayout {fields} {form} />
78
+
79
+ <fieldset class="grid gap-4 rounded-[4px] border border-control-border bg-control p-4.5">
80
+ <legend class="px-1 text-[13px] font-semibold text-foreground">
81
+ {runtime.i18n.t("auth:credentials")}
82
+ </legend>
83
+ <div class="grid gap-4 sm:grid-cols-2">
84
+ <div class="grid gap-2">
85
+ <label class="ridu-field-label" for="ridu-first-user-password">
86
+ {runtime.i18n.t("auth:password")}
87
+ <span class="ridu-field-required" aria-hidden="true">*</span>
88
+ </label>
89
+ <span class="relative block">
90
+ <Input
91
+ id="ridu-first-user-password"
92
+ class="pe-11"
93
+ type={passwordVisible ? "text" : "password"}
94
+ autocomplete="new-password"
95
+ required
96
+ aria-invalid={controller.credentialIssue !== undefined}
97
+ aria-describedby="ridu-first-user-password-help"
98
+ bind:value={controller.password}
99
+ oninput={() => (controller.credentialIssue = undefined)}
100
+ />
101
+ <Button
102
+ variant="ghost"
103
+ size="icon-sm"
104
+ class="absolute top-1/2 end-2.5 -translate-y-1/2"
105
+ onclick={() => (passwordVisible = !passwordVisible)}
106
+ aria-label={passwordVisible
107
+ ? runtime.i18n.t("auth:hidePassword")
108
+ : runtime.i18n.t("auth:showPassword")}
109
+ >
110
+ {#if passwordVisible}
111
+ <EyeOffIcon class="size-3.5" />
112
+ {:else}
113
+ <EyeIcon class="size-3.5" />
114
+ {/if}
115
+ </Button>
116
+ </span>
117
+ </div>
118
+
119
+ <div class="grid gap-2">
120
+ <label class="ridu-field-label" for="ridu-first-user-password-confirmation">
121
+ {runtime.i18n.t("auth:confirmPassword")}
122
+ <span class="ridu-field-required" aria-hidden="true">*</span>
123
+ </label>
124
+ <span class="relative block">
125
+ <Input
126
+ id="ridu-first-user-password-confirmation"
127
+ class="pe-11"
128
+ type={confirmationVisible ? "text" : "password"}
129
+ autocomplete="new-password"
130
+ required
131
+ aria-invalid={controller.credentialIssue !== undefined}
132
+ aria-describedby="ridu-first-user-password-help"
133
+ bind:value={controller.passwordConfirmation}
134
+ oninput={() => (controller.credentialIssue = undefined)}
135
+ />
136
+ <Button
137
+ variant="ghost"
138
+ size="icon-sm"
139
+ class="absolute top-1/2 end-2.5 -translate-y-1/2"
140
+ onclick={() => (confirmationVisible = !confirmationVisible)}
141
+ aria-label={confirmationVisible
142
+ ? runtime.i18n.t("auth:hidePasswordConfirmation")
143
+ : runtime.i18n.t("auth:showPasswordConfirmation")}
144
+ >
145
+ {#if confirmationVisible}
146
+ <EyeOffIcon class="size-3.5" />
147
+ {:else}
148
+ <EyeIcon class="size-3.5" />
149
+ {/if}
150
+ </Button>
151
+ </span>
152
+ </div>
153
+ </div>
154
+ <p
155
+ id="ridu-first-user-password-help"
156
+ class={controller.credentialIssue === undefined ? "ridu-field-help" : "ridu-field-error"}
157
+ role={controller.credentialIssue === undefined ? undefined : "alert"}
158
+ >
159
+ {controller.credentialIssue ?? controller.passwordHelp}
160
+ </p>
161
+ </fieldset>
162
+ </fieldset>
163
+
164
+ {#if controller.error !== undefined}
165
+ <Banner tone="destructive">{controller.error}</Banner>
166
+ {/if}
167
+
168
+ <Button class="h-10 w-full" type="submit" size="lg" disabled={controller.pending}>
169
+ {controller.pending
170
+ ? runtime.i18n.t("auth:creatingAccount")
171
+ : runtime.i18n.t("auth:createAccount")}
172
+ </Button>
173
+ </form>
174
+ </AuthFrame>
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import { Link } from "@hvniel/svelte-router";
3
+ import { RiduError } from "@riducms/sdk";
4
+
5
+ import { Banner } from "@admin/components/ui/banner";
6
+ import { Button } from "@admin/components/ui/button";
7
+ import { Input } from "@admin/components/ui/input";
8
+ import { adminRoutePatterns } from "@admin/core/routing/admin-paths";
9
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
10
+ import AuthFrame from "@admin/features/auth/auth-frame.svelte";
11
+
12
+ const runtime = getAdminRuntime();
13
+ const authCollection = $derived(runtime.authCollection);
14
+ let email = $state("");
15
+ let pending = $state(false);
16
+ let sent = $state(false);
17
+ let error = $state<string>();
18
+
19
+ async function submit(event: SubmitEvent) {
20
+ event.preventDefault();
21
+ if (authCollection === undefined) return;
22
+ pending = true;
23
+ error = undefined;
24
+ try {
25
+ await runtime.client.requestPasswordReset(authCollection.slug, email);
26
+ sent = true;
27
+ } catch (cause) {
28
+ error =
29
+ cause instanceof RiduError ? cause.message : runtime.i18n.t("auth:resetRequestFailed");
30
+ } finally {
31
+ pending = false;
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <AuthFrame>
37
+ {#if sent}
38
+ <div class="grid gap-5">
39
+ <h1 class="text-[32px] leading-tight font-medium tracking-[-0.025em] text-foreground">
40
+ {runtime.i18n.t("auth:emailSent")}
41
+ </h1>
42
+ <p class="max-w-112 text-[13.5px] leading-5.5 text-foreground-muted">
43
+ {runtime.i18n.t("auth:passwordResetSent")}
44
+ </p>
45
+ <Link
46
+ class="w-fit text-[12.5px] text-foreground underline underline-offset-2 hover:text-foreground-muted"
47
+ to={adminRoutePatterns.login}
48
+ >
49
+ {runtime.i18n.t("auth:backToLogin")}
50
+ </Link>
51
+ </div>
52
+ {:else}
53
+ <form class="grid gap-5" onsubmit={submit}>
54
+ <div>
55
+ <h1 class="text-[32px] leading-tight font-medium tracking-[-0.025em] text-foreground">
56
+ {runtime.i18n.t("auth:forgotPassword")}
57
+ </h1>
58
+ <p class="mt-2 text-[13.5px] leading-5.5 text-foreground-muted">
59
+ {runtime.i18n.t("auth:forgotPasswordDescription")}
60
+ </p>
61
+ </div>
62
+ <label class="grid gap-2" for="reset-email">
63
+ <span class="ridu-field-label">{runtime.i18n.t("auth:email")}</span>
64
+ <Input id="reset-email" type="email" autocomplete="email" bind:value={email} required />
65
+ </label>
66
+ {#if error !== undefined}<Banner tone="destructive">{error}</Banner>{/if}
67
+ <Button class="h-10 w-full" type="submit" size="lg" disabled={pending}>
68
+ {pending ? runtime.i18n.t("auth:sending") : runtime.i18n.t("auth:sendResetLink")}
69
+ </Button>
70
+ <Link
71
+ class="w-fit text-[12.5px] text-foreground underline underline-offset-2 hover:text-foreground-muted"
72
+ to={adminRoutePatterns.login}
73
+ >
74
+ {runtime.i18n.t("auth:backToLogin")}
75
+ </Link>
76
+ </form>
77
+ {/if}
78
+ </AuthFrame>
@@ -0,0 +1,221 @@
1
+ <script lang="ts">
2
+ import { Link, useLocation } from "@hvniel/svelte-router";
3
+ import { RiduError } from "@riducms/sdk";
4
+ import EyeIcon from "~icons/lucide/eye";
5
+ import EyeOffIcon from "~icons/lucide/eye-off";
6
+
7
+ import { Banner } from "@admin/components/ui/banner";
8
+ import { Button } from "@admin/components/ui/button";
9
+ import { Input } from "@admin/components/ui/input";
10
+ import { adminRedirectFromSearch, adminRoutePatterns } from "@admin/core/routing/admin-paths";
11
+ import { getNotificationCenter } from "@admin/core/notifications/notification-center.svelte";
12
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
13
+ import AuthBrand from "@admin/features/auth/auth-brand.svelte";
14
+ import AuthFrame from "@admin/features/auth/auth-frame.svelte";
15
+
16
+ const runtime = getAdminRuntime();
17
+ const notifications = getNotificationCenter();
18
+ const location = $derived(useLocation());
19
+ const postLoginPath = $derived(adminRedirectFromSearch(location.search));
20
+ let email = $state("");
21
+ let password = $state("");
22
+ let passwordVisible = $state(false);
23
+ let pending = $state(false);
24
+ let error = $state<string>();
25
+ const authCollection = $derived(runtime.authCollection);
26
+ const replacement = $derived(
27
+ runtime.loginComponents.find((component) => component.position === "replace")
28
+ );
29
+ const loginLogo = $derived(
30
+ runtime.brandComponents.find((component) => component.surface === "loginLogo")
31
+ );
32
+ const beforeComponents = $derived(
33
+ runtime.loginComponents.filter((component) => component.position === "before")
34
+ );
35
+ const afterComponents = $derived(
36
+ runtime.loginComponents.filter(
37
+ (component) => component.position === undefined || component.position === "after"
38
+ )
39
+ );
40
+ const host = {
41
+ login: signIn,
42
+ notify: (tone: "success" | "error", title: string, message?: string) =>
43
+ notifications[tone]({ title, message }),
44
+ };
45
+
46
+ async function signIn(credentials: { email: string; password: string }) {
47
+ if (authCollection === undefined) {
48
+ throw new Error(runtime.i18n.t("auth:configuredCollectionUnavailable"));
49
+ }
50
+ const session = await runtime.client.login(authCollection.slug, credentials);
51
+ const destinationLocale = runtime.contentLocaleForPath(postLoginPath);
52
+ const destinationAccessAuthoritative = await runtime.refreshAccess(
53
+ destinationLocale ?? runtime.contentLocale
54
+ );
55
+ if (
56
+ destinationAccessAuthoritative &&
57
+ runtime.collectionOperations[authCollection.slug]?.admin !== true
58
+ ) {
59
+ try {
60
+ await runtime.client.logout();
61
+ } catch {
62
+ // The shell remains denied even if server cleanup fails.
63
+ }
64
+ throw new Error(runtime.i18n.t("auth:adminAccessDenied"));
65
+ }
66
+ // Keep this route mounted until access and the destination are ready.
67
+ // Assigning the session before the await swaps the route tree and leaves
68
+ // this component's navigate function inactive.
69
+ runtime.session = session;
70
+ await Promise.all([
71
+ runtime.loadTheme(),
72
+ runtime.loadContentLocale(destinationLocale),
73
+ runtime.i18n.loadPreferences(),
74
+ ]);
75
+ const preferredAccessAuthoritative = await runtime.refreshAccess(runtime.contentLocale);
76
+ if (
77
+ preferredAccessAuthoritative &&
78
+ runtime.collectionOperations[authCollection.slug]?.admin !== true
79
+ ) {
80
+ try {
81
+ await runtime.client.logout();
82
+ } catch {
83
+ // The shell remains denied even if server cleanup fails.
84
+ }
85
+ runtime.session = undefined;
86
+ throw new Error(runtime.i18n.t("auth:adminAccessDenied"));
87
+ }
88
+ // Assigning the authenticated session swaps this route for the authenticated
89
+ // route tree. Its /login route owns the deferred redirect so this component
90
+ // never reads route-owned derived state after it has been destroyed.
91
+ }
92
+
93
+ async function login() {
94
+ pending = true;
95
+ error = undefined;
96
+ try {
97
+ await signIn({ email, password });
98
+ } catch (cause) {
99
+ error =
100
+ cause instanceof RiduError || cause instanceof Error
101
+ ? cause.message
102
+ : runtime.i18n.t("auth:loginFailed");
103
+ } finally {
104
+ pending = false;
105
+ }
106
+ }
107
+
108
+ async function handleSubmit(event: SubmitEvent) {
109
+ event.preventDefault();
110
+ await login();
111
+ }
112
+ </script>
113
+
114
+ {#snippet brand()}
115
+ {#if loginLogo !== undefined && runtime.manifest !== undefined}
116
+ <loginLogo.component
117
+ manifest={runtime.manifest}
118
+ user={runtime.session?.user}
119
+ surface="loginLogo"
120
+ i18n={runtime.i18n}
121
+ />
122
+ {:else}
123
+ <AuthBrand />
124
+ {/if}
125
+ {/snippet}
126
+
127
+ {#snippet defaultView()}
128
+ <AuthFrame {brand}>
129
+ {#if runtime.manifest !== undefined}
130
+ {#each beforeComponents as component (component.key)}
131
+ <component.component manifest={runtime.manifest} {defaultView} {host} i18n={runtime.i18n} />
132
+ {/each}
133
+ {/if}
134
+
135
+ <form class="grid gap-5" onsubmit={handleSubmit}>
136
+ <h1 class="sr-only">{runtime.i18n.t("auth:login")}</h1>
137
+
138
+ <label class="grid gap-2" for="ridu-login-email">
139
+ <span class="ridu-field-label text-foreground-body">{runtime.i18n.t("auth:email")}</span>
140
+ <Input
141
+ id="ridu-login-email"
142
+ type="email"
143
+ bind:value={email}
144
+ autocomplete="email"
145
+ placeholder={runtime.i18n.t("auth:emailPlaceholder")}
146
+ required
147
+ />
148
+ </label>
149
+
150
+ <div class="grid gap-2">
151
+ <label class="ridu-field-label text-foreground-body" for="ridu-login-password">
152
+ {runtime.i18n.t("auth:password")}
153
+ </label>
154
+ <span class="relative block">
155
+ <Input
156
+ id="ridu-login-password"
157
+ class="pe-11"
158
+ type={passwordVisible ? "text" : "password"}
159
+ bind:value={password}
160
+ autocomplete="current-password"
161
+ placeholder={runtime.i18n.t("auth:passwordPlaceholder")}
162
+ required
163
+ />
164
+ <Button
165
+ variant="ghost"
166
+ size="icon-sm"
167
+ class="absolute top-1/2 end-2.5 -translate-y-1/2"
168
+ onclick={() => (passwordVisible = !passwordVisible)}
169
+ aria-label={passwordVisible
170
+ ? runtime.i18n.t("auth:hidePassword")
171
+ : runtime.i18n.t("auth:showPassword")}
172
+ >
173
+ {#if passwordVisible}
174
+ <EyeOffIcon class="size-3.5" />
175
+ {:else}
176
+ <EyeIcon class="size-3.5" />
177
+ {/if}
178
+ </Button>
179
+ </span>
180
+ {#if authCollection?.authSettings?.passwordReset}
181
+ <Link
182
+ class="w-fit text-[12px] text-foreground underline underline-offset-2 hover:text-foreground-muted"
183
+ to={adminRoutePatterns.forgotPassword}
184
+ >
185
+ {runtime.i18n.t("auth:forgotPassword")}
186
+ </Link>
187
+ {/if}
188
+ </div>
189
+
190
+ {#if error !== undefined}
191
+ <Banner tone="destructive">
192
+ <span class="size-2 shrink-0 rounded-full bg-destructive" aria-hidden="true"></span>
193
+ {error}
194
+ </Banner>
195
+ {/if}
196
+
197
+ <Button class="mt-1 h-10 w-full" type="submit" size="lg" disabled={pending}>
198
+ {pending ? runtime.i18n.t("auth:signingIn") : runtime.i18n.t("auth:login")}
199
+ </Button>
200
+ {#if authCollection?.authSettings?.verifyEmail}
201
+ <Link
202
+ class="text-center text-[11.5px] text-foreground-muted hover:text-primary"
203
+ to={adminRoutePatterns.requestVerification}
204
+ >
205
+ {runtime.i18n.t("auth:requestVerification")}
206
+ </Link>
207
+ {/if}
208
+ </form>
209
+ {#if runtime.manifest !== undefined}
210
+ {#each afterComponents as component (component.key)}
211
+ <component.component manifest={runtime.manifest} {defaultView} {host} i18n={runtime.i18n} />
212
+ {/each}
213
+ {/if}
214
+ </AuthFrame>
215
+ {/snippet}
216
+
217
+ {#if runtime.manifest !== undefined && replacement !== undefined}
218
+ <replacement.component manifest={runtime.manifest} {defaultView} {host} i18n={runtime.i18n} />
219
+ {:else}
220
+ {@render defaultView()}
221
+ {/if}