@navette-js/panels 1.0.0-beta.3 → 1.0.0-beta.5

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 (41) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -1
  2. package/dist/navette/navette/calendar.css +5 -5
  3. package/dist/navette/navette/motion.css +21 -21
  4. package/dist/navette/navette/motion.js +6 -6
  5. package/dist/navette/navette/security-keys.css +11 -0
  6. package/dist/navette/navette/security-keys.js +72 -0
  7. package/lib/auth.d.ts +17 -0
  8. package/lib/auth.js +70 -0
  9. package/lib/error-pages.d.ts +9 -0
  10. package/lib/error-pages.js +173 -0
  11. package/lib/import-export.d.ts +185 -0
  12. package/lib/import-export.js +543 -0
  13. package/lib/index.d.ts +629 -79
  14. package/lib/index.js +1945 -333
  15. package/lib/multi-factor.d.ts +348 -0
  16. package/lib/multi-factor.js +928 -0
  17. package/lib/notifications.d.ts +20 -2
  18. package/lib/notifications.js +34 -4
  19. package/lib/qr-code.d.ts +16 -0
  20. package/lib/qr-code.js +255 -0
  21. package/lib/session-store.d.ts +21 -0
  22. package/lib/session-store.js +35 -0
  23. package/lib/throttle.d.ts +10 -0
  24. package/lib/throttle.js +25 -0
  25. package/lib/topbar.d.ts +3 -1
  26. package/lib/topbar.js +18 -1
  27. package/lib/totp.d.ts +38 -0
  28. package/lib/totp.js +104 -0
  29. package/lib/webauthn.d.ts +155 -0
  30. package/lib/webauthn.js +287 -0
  31. package/lib/xlsx.d.ts +7 -0
  32. package/lib/xlsx.js +171 -0
  33. package/package.json +9 -9
  34. package/views/components/tenant-menu.ejs +3 -3
  35. package/views/components/user-menu.ejs +3 -2
  36. package/views/errors/page.ejs +87 -0
  37. package/views/layout/base.ejs +9 -1
  38. package/views/livewire/sidebar.ejs +35 -20
  39. package/views/livewire/topbar.ejs +1 -1
  40. package/views/pages/page.ejs +53 -35
  41. package/views/pages/simple.ejs +4 -0
package/lib/index.js CHANGED
@@ -8,28 +8,41 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  // @navette-js/panels: Panel, layouts, pages, resources, navigation, authentication and session.
10
10
  import { AsyncLocalStorage } from 'node:async_hooks';
11
- import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
12
- import { checkVerificationUrl, consoleMailer, readToken, signToken, signVerificationUrl } from "./auth.js";
13
- export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, verifyPassword } from "./auth.js";
11
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
12
+ import { Throttle } from "./throttle.js";
13
+ export { Throttle } from "./throttle.js";
14
+ import { checkVerificationUrl, consoleMailer, fingerprint, readToken, signToken, signVerificationUrl } from "./auth.js";
15
+ export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, multiFactorAuthenticationSchema, verifyPassword } from "./auth.js";
14
16
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
17
  import { join } from 'node:path';
16
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
19
  import express from 'express';
18
- import { Action, useColorManager, ActionGroup } from '@navette-js/actions';
19
- import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
20
- import { Actions, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, StatefulGroup } from '@navette-js/schemas';
20
+ import { Action, ActionHalt, useColorManager, ActionGroup } from '@navette-js/actions';
21
+ import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, loadSchemaRelationships, saveSchemaRelationships, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
22
+ import { Actions, childSchema, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, Section, StatefulGroup } from '@navette-js/schemas';
21
23
  import { WireError } from '@navette-js/wire';
22
24
  import { useColorManager as useInfolistsColorManager, useHtmlSanitizer } from '@navette-js/infolists';
23
25
  import { Notification, Notifications, publish, runInRequest } from "./notifications.js";
24
26
  export { Notification, Notifications } from "./notifications.js";
27
+ import { getDefaultCacheStore, MultiFactorChallenge, SecurityKeyAuthentication } from "./multi-factor.js";
28
+ export { AppAuthentication, EmailAuthentication, EmailCodeInput, decryptSecret, encryptSecret, MemoryCacheStore, MultiFactorChallenge, SecurityKeyAuthentication, SecurityKeyInput, useMultiFactorCacheStore } from "./multi-factor.js";
29
+ export { base32Decode, base32Encode, generateTotpSecret, hotp, totpCode, totpTimestep, totpUri, verifyTotp } from "./totp.js";
30
+ export { qrCodeDataUri, qrCodeMatrix, qrCodeSvg } from "./qr-code.js";
31
+ export { authenticationOptions, decodeCbor, generateChallenge, registrationOptions, verifyAuthentication, verifyRegistration, WebAuthnError } from "./webauthn.js";
32
+ export { MemorySessionStore } from "./session-store.js";
33
+ export { csvCell, csvLine, DownloadStore, ExportAction, ExportBulkAction, ExportColumn, Exporter, ImportAction, ImportColumn, Importer, importExportSchema, knexImportExportHistory, parseCsv, readSpreadsheet, runInBackground, useBackgroundRunner } from "./import-export.js";
34
+ export { buildXlsx, readXlsx } from "./xlsx.js";
35
+ import { DownloadStore } from "./import-export.js";
36
+ import { MemorySessionStore } from "./session-store.js";
25
37
  import { addDays, addMonths, CALENDAR_LABELS, CALENDAR_VIEWS, isDate, renderCalendar, visibleRange } from "./calendar.js";
26
38
  export { addDays, addMonths, CALENDAR_LABELS, dayOf, daysBetween, eventsOn, renderCalendar, renderCalendarEvent, timeOf, visibleRange } from "./calendar.js";
27
39
  import { DatabaseNotifications, GlobalSearch, keyBindingSuffix } from "./topbar.js";
28
40
  export { DatabaseNotifications, GlobalSearch, knexNotificationStore, timeAgo } from "./topbar.js";
29
41
  import { Attributes, ColorManager, HtmlString, RenderHookRegistry, cls, convertToRgb, e, icon, js, loadingIndicator, renderHook, runWithRenderHooks, views } from '@navette-js/support';
30
- import { t, tc } from '@navette-js/support/i18n';
31
- import { emptyState } from '@navette-js/support/ui';
32
- import { REORDER_LIMIT, Table, initializeTableGrouping, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
42
+ import { getLocale, t, tc } from '@navette-js/support/i18n';
43
+ import { ERROR_PAGE_CSS, errorIllustration, errorPageScene } from "./error-pages.js";
44
+ import { badge as uiBadge, button as uiButton, emptyState, useColorManager as useUiColorManager } from '@navette-js/support/ui';
45
+ import { REORDER_LIMIT, Table, TrashedFilter, filterSelectableKeys, getTableRecords, initializeTableGrouping, isTableFilterRemovable, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
33
46
  import { Component } from '@navette-js/wire';
34
47
  import { csrfToken, verifyCsrf, wireRouter, wireScripts, wireStyles } from '@navette-js/wire/express';
35
48
  const dir = (p) => fileURLToPath(new URL(p, import.meta.url));
@@ -37,6 +50,19 @@ views.namespace('filament-panels', dir('../views'));
37
50
  views.namespace('filament', fileURLToPath(new URL('../views', import.meta.resolve('@navette-js/support'))));
38
51
  views.share({ t, tc });
39
52
  export const ASSET_VERSION = '5.11.2.0';
53
+ /** Navigation group: label, icon (then its items have none, as in Filament) and whether it can be collapsed. */
54
+ export class NavigationGroup {
55
+ _label;
56
+ _icon = null;
57
+ _collapsible = true;
58
+ constructor(label) { this._label = label; }
59
+ static make(label) { return new NavigationGroup(label); }
60
+ icon(name) { this._icon = name; return this; }
61
+ collapsible(condition = true) { this._collapsible = condition; return this; }
62
+ getLabel() { return this._label; }
63
+ getIcon() { return this._icon; }
64
+ isCollapsible() { return this._collapsible; }
65
+ }
40
66
  export class NavigationItem {
41
67
  label;
42
68
  _url = '#';
@@ -46,8 +72,14 @@ export class NavigationItem {
46
72
  _isActive = null;
47
73
  _badge = null;
48
74
  _badgeColor = 'primary';
75
+ _parentItem = null;
76
+ _childItems = [];
49
77
  constructor(label) { this.label = label; }
50
78
  static make(label) { return new NavigationItem(label); }
79
+ /** Shows the item below another item of its group, by that item's label. */
80
+ parentItem(label) { this._parentItem = label; return this; }
81
+ /** Items shown below this one, when it (or one of them) is active, or when it has no URL. */
82
+ childItems(items) { this._childItems = items; return this; }
51
83
  url(url) { this._url = url; return this; }
52
84
  icon(icon) { this._icon = icon; return this; }
53
85
  group(group) { this._group = group; return this; }
@@ -73,10 +105,24 @@ export class Panel {
73
105
  _login = null;
74
106
  _auth = null;
75
107
  _key = 'cle-de-developpement-a-remplacer';
108
+ _sessionStore = null;
109
+ /** Where per-browser data is kept (persisted table filters, search and sort): in memory by default. */
110
+ sessionStore(store) { this._sessionStore = store; return this; }
111
+ getSessionStore() { return (this._sessionStore ??= new MemorySessionStore()); }
112
+ _downloads = null;
113
+ /** Where files offered for download (exports, failed import rows) are kept: in memory by default. */
114
+ downloads(store) { this._downloads = store; return this; }
115
+ _importExportHistory = null;
116
+ /** Records each import and export run (`knexImportExportHistory(db)`): who, which file, how many rows, failed import rows. */
117
+ importExportHistory(store) { this._importExportHistory = store; return this; }
118
+ getImportExportHistory() { return this._importExportHistory; }
119
+ getDownloads() { return (this._downloads ??= new DownloadStore()); }
120
+ getDownloadUrl(token) { return this.getBaseUrl(`downloads/${encodeURIComponent(token)}`); }
76
121
  _pages = [];
77
122
  _resources = [];
78
123
  _navigationItems = [];
79
124
  _navigationGroups = [];
125
+ _navigationGroupOptions = new Map();
80
126
  _maxContentWidth = '7xl';
81
127
  colors = new ColorManager();
82
128
  static make() { return new Panel(); }
@@ -136,19 +182,99 @@ export class Panel {
136
182
  login(page = Login) { this._login = page; return this; }
137
183
  auth(provider) { this._auth = provider; return this; }
138
184
  key(key) { this._key = key; return this; }
139
- pages(pages) { this._pages.push(...pages); return this; }
185
+ _authGuard = 'web';
186
+ _canAccess = null;
187
+ /**
188
+ * Session shared with the other panels that use the same guard (`'web'` by default): signing into one signs into all of
189
+ * them, and `canAccess()` decides who may enter each one. `null` gives this panel a session of its own.
190
+ * Panels sharing a guard must use the same key and the same auth provider.
191
+ */
192
+ authGuard(guard) {
193
+ if (guard !== null && !/^[a-z0-9_-]{1,40}$/.test(guard))
194
+ throw new Error(`Invalid auth guard name: ${guard}`);
195
+ this._authGuard = guard;
196
+ return this;
197
+ }
198
+ getAuthGuard() { return this._authGuard; }
199
+ /** Who may use this panel, e.g. `panel.canAccess((user) => user.role === 'admin')`. Checked on every request and at login. */
200
+ canAccess(rule) { this._canAccess = rule; return this; }
201
+ /** True if this user may use this panel: the panel's rule and the provider's `canAccessPanel()`, when set, must both allow it. */
202
+ async userCanAccess(user) {
203
+ if (this._canAccess && !(await this._canAccess(user, this)))
204
+ return false;
205
+ const provider = this.getAuth();
206
+ if (provider.canAccessPanel && !(await provider.canAccessPanel(user, this)))
207
+ return false;
208
+ return true;
209
+ }
210
+ /** Name of the session cookie: per guard (shared), or per panel. */
211
+ getSessionCookieName() { return this._authGuard !== null ? `navette_${this._authGuard}_session` : `navette_${this._id}_session`; }
212
+ pages(pages) {
213
+ for (const page of pages) {
214
+ this.registerCluster(page);
215
+ this._pages.push(page);
216
+ }
217
+ return this;
218
+ }
140
219
  /** Registers resources: their pages and navigation items. */
141
220
  resources(resources) {
142
221
  for (const resource of resources) {
143
222
  resource.panel = this;
223
+ this.registerCluster(resource);
144
224
  this._resources.push(resource);
145
225
  this._pages.push(...Object.values(resource.getPages()));
146
226
  }
147
227
  return this;
148
228
  }
229
+ /** A page or resource in a cluster is served under the cluster's slug; the cluster page is registered with it. */
230
+ registerCluster(component) {
231
+ const cluster = component.cluster;
232
+ if (!cluster)
233
+ return;
234
+ if (!(cluster.prototype instanceof Cluster))
235
+ throw new Error(`${component.name}.cluster must be a Cluster subclass.`);
236
+ cluster.slug = cluster.getSlug();
237
+ if (!component.slug.startsWith(`${cluster.slug}/`))
238
+ component.slug = `${cluster.slug}/${component.slug}`;
239
+ if (!this._pages.includes(cluster))
240
+ this._pages.push(cluster);
241
+ }
242
+ /** Pages (resource list pages included) shown in a cluster's sub-navigation. */
243
+ getClusteredComponents(cluster) {
244
+ return this._pages.filter((page) => page !== cluster && page.shouldRegisterNavigation && clusterOf(page) === cluster);
245
+ }
246
+ /** Sub-navigation of a cluster, for one of its pages: the components the user can open, sorted, with their groups. */
247
+ async getClusterSubNavigation(cluster, current, user) {
248
+ const components = [];
249
+ for (const component of this.getClusteredComponents(cluster))
250
+ if (await this.canAccessPage(component, user))
251
+ components.push(component);
252
+ const items = [];
253
+ for (const component of components.sort((a, b) => a.navigationSort - b.navigationSort)) {
254
+ const badge = typeof component.getNavigationBadge === 'function' ? await component.getNavigationBadge() : component.navigationBadge;
255
+ items.push({ label: component.getNavigationLabel(), icon: component.navigationIcon, url: this.getUrl(component.slug), active: current instanceof component, group: component.navigationGroup, badge: badge ?? null, badgeColor: component.navigationBadgeColor, sort: component.navigationSort });
256
+ }
257
+ return items.map(({ sort: _sort, ...item }) => item);
258
+ }
149
259
  navigationItems(items) { this._navigationItems.push(...items); return this; }
150
260
  /** Order of navigation groups. */
151
- navigationGroups(groups) { this._navigationGroups = groups; return this; }
261
+ /** Order of the navigation groups: labels, or `NavigationGroup.make('Shop').icon('o-shopping-cart').collapsible(false)`. */
262
+ _darkMode = true;
263
+ _darkModeForced = false;
264
+ _defaultThemeMode = 'system';
265
+ /** `false`: light mode only (no switcher); `darkMode(true, true)`: dark mode only. */
266
+ darkMode(condition = true, isForced = false) { this._darkMode = condition; this._darkModeForced = condition && isForced; return this; }
267
+ /** Mode used until the user picks one in the switcher: `'system'` (default), `'light'` or `'dark'`. */
268
+ defaultThemeMode(mode) { if (!['light', 'dark', 'system'].includes(mode))
269
+ throw new Error(`Invalid theme mode: ${mode}`); this._defaultThemeMode = mode; return this; }
270
+ hasDarkMode() { return this._darkMode; }
271
+ hasDarkModeForced() { return this._darkModeForced; }
272
+ getDefaultThemeMode() { return this._defaultThemeMode; }
273
+ navigationGroups(groups) {
274
+ this._navigationGroups = groups.map((group) => (typeof group === 'string' ? group : group.getLabel()));
275
+ this._navigationGroupOptions = new Map(groups.filter((group) => typeof group !== 'string').map((group) => [group.getLabel(), { icon: group.getIcon(), collapsible: group.isCollapsible() }]));
276
+ return this;
277
+ }
152
278
  _plugins = [];
153
279
  /** Applies a plugin: an object that configures the panel (resources, pages, render hooks, themes…). */
154
280
  plugin(plugin) {
@@ -171,20 +297,38 @@ export class Panel {
171
297
  _authorizer = null;
172
298
  /** Plugs in your own permission system: consulted before any policy, for every ability, page and widget. */
173
299
  authorizeUsing(authorizer) { this._authorizer = authorizer; return this; }
174
- /** Authorization decision: the panel hook first, then the given rule. Always `false` without a user. */
300
+ _guestAccess = false;
301
+ /**
302
+ * Lets visitors who are not signed in use the panel's pages. Policies, `canAccess()` and the authorization hook then receive
303
+ * a `null` user: return `true` there for what guests may see. Not available with tenancy.
304
+ */
305
+ guestAccess(condition = true) { this._guestAccess = condition; return this; }
306
+ allowsGuests() { return this._guestAccess; }
307
+ /** Authorization decision: the panel hook first, then the given rule. Without a user: `false`, unless the panel allows guests. */
175
308
  async authorize(user, ability, subject = {}) {
176
- if (!user)
309
+ if (!user && !this._guestAccess)
177
310
  return false;
178
- const decided = await this._authorizer?.({ user, ability, resource: subject.resource ?? null, record: subject.record ?? null, page: subject.page ?? null, widget: subject.widget ?? null });
311
+ const decided = await this._authorizer?.({ user: user, ability, resource: subject.resource ?? null, record: subject.record ?? null, page: subject.page ?? null, widget: subject.widget ?? null });
179
312
  if (typeof decided === 'boolean')
180
313
  return decided;
314
+ if (!subject.rule && subject.resource && !subject.page && !subject.widget && this._strictAuthorization) {
315
+ throw new Error(`Strict authorization mode is enabled, but ${subject.resource.name ?? 'the resource'} has no "${ability}" rule in its policy.`);
316
+ }
181
317
  return subject.rule ? Boolean(await subject.rule()) : true;
182
318
  }
183
319
  /** Whether the user can open a page. List: `viewAny`; create: `create`; edit: checked against the record; custom page: `canAccess`. */
184
320
  async canAccessPage(PageClass, user) {
321
+ if (PageClass.prototype instanceof Cluster) {
322
+ if (!(await this.authorize(user, 'access', { page: PageClass, rule: () => PageClass.canAccess(user) })))
323
+ return false;
324
+ for (const component of this.getClusteredComponents(PageClass))
325
+ if (await this.canAccessPage(component, user))
326
+ return true;
327
+ return false;
328
+ }
185
329
  const resource = PageClass.resource;
186
330
  if (resource && PageClass.prototype instanceof EditRecord)
187
- return user !== null;
331
+ return user !== null || this._guestAccess;
188
332
  if (resource && PageClass.prototype instanceof CreateRecord)
189
333
  return resource.can('create', user);
190
334
  if (resource)
@@ -306,25 +450,114 @@ export class Panel {
306
450
  profile(page = EditProfile, isSimple = true) { this._profile = { page, isSimple }; return this; }
307
451
  hasProfile() { return this._profile !== null; }
308
452
  getProfile() { return this._profile; }
453
+ /** Whether the profile page uses the simple layout (`profile(page, false)`: the full layout, with the navigation). */
454
+ isProfileSimple() { return this._profile?.isSimple !== false; }
455
+ _multiFactor = null;
456
+ /**
457
+ * Multi-factor authentication: after the password, users with a provider set up must pass its challenge. They set providers
458
+ * up on their profile page (`profile()`). `isRequired`: users without any provider are sent to a setup page first.
459
+ */
460
+ multiFactorAuthentication(providers, options = {}) {
461
+ const ids = providers.map((provider) => provider.getId());
462
+ for (const id of ids)
463
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,40}$/.test(id))
464
+ throw new Error(`Invalid multi-factor provider id: ${id}`);
465
+ if (new Set(ids).size !== ids.length)
466
+ throw new Error('Two multi-factor providers share the same id: give one another id with id().');
467
+ this._multiFactor = providers.length ? { providers, isRequired: options.isRequired === true, setUpPage: options.setUpPage ?? SetUpRequiredMultiFactorAuthentication } : null;
468
+ return this;
469
+ }
470
+ hasMultiFactorAuthentication() { return this._multiFactor !== null; }
471
+ getMultiFactorAuthenticationProviders() { return this._multiFactor?.providers ?? []; }
472
+ isMultiFactorAuthenticationRequired() { return this._multiFactor?.isRequired === true; }
473
+ getSetUpRequiredMultiFactorAuthenticationPage() { return this._multiFactor?.isRequired ? this._multiFactor.setUpPage : null; }
474
+ getSetUpRequiredMultiFactorAuthenticationUrl() { return this.getBaseUrl(this.getAuthPaths().setUpRequiredMultiFactor); }
475
+ /** The login challenge of this panel (enabled providers, rate limiting). */
476
+ getMultiFactorChallenge() { return MultiFactorChallenge.make(this); }
477
+ /** Whether the user must set up multi-factor authentication before using the panel. */
478
+ async mustSetUpMultiFactorAuthentication(user) {
479
+ if (!user || !this.isMultiFactorAuthenticationRequired())
480
+ return false;
481
+ return !(await this.getMultiFactorChallenge().hasEnabledProviders(user));
482
+ }
483
+ usesSecurityKeys() { return this.getMultiFactorAuthenticationProviders().some((provider) => provider instanceof SecurityKeyAuthentication); }
309
484
  /** Mailer for password reset and verification emails. Defaults to the console. */
310
485
  mailer(mailer) { this._mailer = mailer; return this; }
311
486
  async sendMail(message) { await this._mailer.send(message); }
312
487
  getAuthKey() { return this._key; }
313
- getRegistrationUrl() { return this.getBaseUrl('register'); }
314
- getRequestPasswordResetUrl() { return this.getBaseUrl('password-reset/request'); }
488
+ _authSlugs = {
489
+ login: 'login', registration: 'register', passwordResetPrefix: 'password-reset', passwordResetRequest: 'request', passwordReset: 'reset', logout: 'logout',
490
+ emailVerificationPrefix: 'email-verification', emailVerificationPrompt: 'prompt', emailVerification: 'verify',
491
+ emailChangeVerificationPrefix: 'email-change-verification', emailChangeVerification: 'verify',
492
+ multiFactorPrefix: 'multi-factor-authentication', setUpRequiredMultiFactor: 'set-up',
493
+ };
494
+ authSlug(slug) { if (!/^[a-z0-9][a-z0-9-]{0,60}$/.test(slug))
495
+ throw new Error(`Invalid route slug: ${slug}`); return slug; }
496
+ /** URL segment of the login page (`login` by default). */
497
+ loginRouteSlug(slug) { this._authSlugs.login = this.authSlug(slug); return this; }
498
+ /** URL segment of the registration page (`register` by default). */
499
+ registrationRouteSlug(slug) { this._authSlugs.registration = this.authSlug(slug); return this; }
500
+ /** URL segments of the password reset pages: `<prefix>/<request>` and `<prefix>/<reset>` (`password-reset/request` and `password-reset/reset` by default). */
501
+ passwordResetRoutePrefix(prefix) { this._authSlugs.passwordResetPrefix = this.authSlug(prefix); return this; }
502
+ passwordResetRequestRouteSlug(slug) { this._authSlugs.passwordResetRequest = this.authSlug(slug); return this; }
503
+ passwordResetRouteSlug(slug) { this._authSlugs.passwordReset = this.authSlug(slug); return this; }
504
+ /** Paths of the authentication pages, relative to the panel. */
505
+ /** URL segment of the logout route (`logout` by default). */
506
+ logoutRouteSlug(slug) { this._authSlugs.logout = this.authSlug(slug); return this; }
507
+ /** URL segments of email verification: `<prefix>/<prompt>` and `<prefix>/<verify>/…` (`email-verification/prompt`, `email-verification/verify`). */
508
+ emailVerificationRoutePrefix(prefix) { this._authSlugs.emailVerificationPrefix = this.authSlug(prefix); return this; }
509
+ emailVerificationPromptRouteSlug(slug) { this._authSlugs.emailVerificationPrompt = this.authSlug(slug); return this; }
510
+ emailVerificationRouteSlug(slug) { this._authSlugs.emailVerification = this.authSlug(slug); return this; }
511
+ /** URL segments of the email change links: `<prefix>/<verify>` and `<prefix>/block` (`email-change-verification/verify`). */
512
+ emailChangeVerificationRoutePrefix(prefix) { this._authSlugs.emailChangeVerificationPrefix = this.authSlug(prefix); return this; }
513
+ emailChangeVerificationRouteSlug(slug) { this._authSlugs.emailChangeVerification = this.authSlug(slug); return this; }
514
+ /** URL segments of the required multi-factor setup page (`multi-factor-authentication/set-up`). */
515
+ multiFactorAuthenticationRoutePrefix(prefix) { this._authSlugs.multiFactorPrefix = this.authSlug(prefix); return this; }
516
+ setUpRequiredMultiFactorAuthenticationRouteSlug(slug) { this._authSlugs.setUpRequiredMultiFactor = this.authSlug(slug); return this; }
517
+ getAuthPaths() {
518
+ const slugs = this._authSlugs;
519
+ return {
520
+ login: slugs.login, registration: slugs.registration, passwordResetRequest: `${slugs.passwordResetPrefix}/${slugs.passwordResetRequest}`, passwordReset: `${slugs.passwordResetPrefix}/${slugs.passwordReset}`, logout: slugs.logout,
521
+ emailVerificationPrompt: `${slugs.emailVerificationPrefix}/${slugs.emailVerificationPrompt}`, emailVerification: `${slugs.emailVerificationPrefix}/${slugs.emailVerification}`,
522
+ emailChangeVerification: `${slugs.emailChangeVerificationPrefix}/${slugs.emailChangeVerification}`, blockEmailChange: `${slugs.emailChangeVerificationPrefix}/block`,
523
+ setUpRequiredMultiFactor: `${slugs.multiFactorPrefix}/${slugs.setUpRequiredMultiFactor}`,
524
+ };
525
+ }
526
+ /** First URL segments used by the panel itself (never page slugs or tenant keys). */
527
+ getReservedSegments() { const slugs = this._authSlugs; return new Set([...RESERVED_SEGMENTS, slugs.login, slugs.registration, slugs.passwordResetPrefix, slugs.logout, slugs.emailVerificationPrefix, slugs.emailChangeVerificationPrefix, slugs.multiFactorPrefix]); }
528
+ getRegistrationUrl() { return this.getBaseUrl(this.getAuthPaths().registration); }
529
+ getRequestPasswordResetUrl() { return this.getBaseUrl(this.getAuthPaths().passwordResetRequest); }
315
530
  getResetPasswordUrl(user, fingerprint) {
316
531
  const token = signToken(this._key, `${this._id}.password-reset`, { uid: user.id, fp: fingerprint }, 60 * 60);
317
- return `${this.getBaseUrl('password-reset/reset')}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
532
+ return `${this.getBaseUrl(this.getAuthPaths().passwordReset)}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
318
533
  }
319
534
  /** Payload of a password reset token valid for this panel, otherwise `null`. */
320
535
  readResetPasswordToken(token) { return readToken(this._key, `${this._id}.password-reset`, token); }
321
536
  getVerifyEmailUrl(user) {
322
537
  const expires = Math.floor(Date.now() / 1000) + 60 * 60;
323
538
  const { hash, signature } = signVerificationUrl(`${this._key}.${this._id}`, user.id, user.email, expires);
324
- return `${this.getBaseUrl(`email-verification/verify/${encodeURIComponent(String(user.id))}/${hash}`)}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
539
+ return `${this.getBaseUrl(`${this.getAuthPaths().emailVerification}/${encodeURIComponent(String(user.id))}/${hash}`)}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
325
540
  }
326
541
  checkVerifyEmailUrl(id, hash, expires, signature) { return checkVerificationUrl(`${this._key}.${this._id}`, id, hash, expires, signature); }
327
- getEmailVerificationPromptUrl() { return this.getBaseUrl('email-verification/prompt'); }
542
+ getEmailVerificationPromptUrl() { return this.getBaseUrl(this.getAuthPaths().emailVerificationPrompt); }
543
+ _emailChangeVerification = false;
544
+ /**
545
+ * A new email address set on the profile page is only saved once confirmed: a link is sent to the new address, and a link
546
+ * to block the change to the old one. Both are valid for 60 minutes.
547
+ */
548
+ emailChangeVerification(condition = true) { this._emailChangeVerification = condition; return this; }
549
+ hasEmailChangeVerification() { return this._emailChangeVerification; }
550
+ /** Links of an email change request: to confirm the new address, and to block the change. */
551
+ getEmailChangeUrls(user, newEmail) {
552
+ const nonce = randomBytes(12).toString('base64url');
553
+ const verify = signToken(this._key, `${this._id}.email-change`, { uid: user.id, email: newEmail, from: fingerprint(user.email), nonce }, 60 * 60);
554
+ const block = signToken(this._key, `${this._id}.email-change-block`, { uid: user.id, email: newEmail, nonce }, 60 * 60);
555
+ return { verify: `${this.getBaseUrl(this.getAuthPaths().emailChangeVerification)}?token=${encodeURIComponent(verify)}`, block: `${this.getBaseUrl(this.getAuthPaths().blockEmailChange)}?token=${encodeURIComponent(block)}` };
556
+ }
557
+ readEmailChangeToken(kind, token) {
558
+ const payload = readToken(this._key, `${this._id}.${kind === 'verify' ? 'email-change' : 'email-change-block'}`, token);
559
+ return payload && typeof payload.email === 'string' && typeof payload.nonce === 'string' ? payload : null;
560
+ }
328
561
  getProfileUrl() { return this._profile ? this.getBaseUrl('profile') : null; }
329
562
  /** Whether the user must still verify their email before using the panel. */
330
563
  async mustVerifyEmail(user) {
@@ -358,6 +591,84 @@ export class Panel {
358
591
  getTenantProfilePage() { return this._tenantProfile; }
359
592
  tenantMenu(condition = true) { this._tenantMenu = condition; return this; }
360
593
  hasTenantMenu() { return this._tenantMenu; }
594
+ _searchableTenantMenu = null;
595
+ /** A search field above the tenants of the menu. By default (`null`), it is shown from ten tenants to switch to. */
596
+ searchableTenantMenu(condition = true) { this._searchableTenantMenu = condition; return this; }
597
+ isTenantMenuSearchable() { return this._searchableTenantMenu; }
598
+ _tenantRoutePrefix = null;
599
+ /** A segment before the tenant key in URLs: `tenantRoutePrefix('team')` serves `/app/team/1/projects`. */
600
+ tenantRoutePrefix(prefix) {
601
+ if (prefix !== null && !/^[a-z0-9][a-z0-9-]*$/.test(prefix))
602
+ throw new Error(`Invalid tenant route prefix: ${prefix}`);
603
+ this._tenantRoutePrefix = prefix;
604
+ return this;
605
+ }
606
+ getTenantRoutePrefix() { return this._tenantRoutePrefix; }
607
+ _tenantDomain = null;
608
+ /**
609
+ * Identifies the tenant by the host instead of the path: `tenantDomain('{tenant}.example.com')` serves the tenant `acme` at
610
+ * `acme.example.com/app/projects`. A port may follow (`{tenant}.localhost:3000`).
611
+ */
612
+ tenantDomain(pattern) {
613
+ if (pattern !== null && !pattern.includes('{tenant}'))
614
+ throw new Error('tenantDomain(): the pattern must contain {tenant}.');
615
+ this._tenantDomain = pattern;
616
+ return this;
617
+ }
618
+ getTenantDomain() { return this._tenantDomain; }
619
+ /** Domain of a tenant, with its port if the pattern has one. */
620
+ getTenantDomainFor(tenant) { return this._tenantDomain.replace('{tenant}', encodeURIComponent(this.getTenantUrlKey(tenant)).toLowerCase()); }
621
+ /** Key of the tenant designated by a request (its path, or its host with `tenantDomain()`), unchecked; `null` if none. */
622
+ getTenantKeyFromRequest(path, hostname) {
623
+ if (this._tenantDomain) {
624
+ const host = this._tenantDomain.replace(/:\d+$/, '').toLowerCase();
625
+ const [before, after] = host.split('{tenant}').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
626
+ const match = new RegExp(`^${before}([^.]+)${after}$`).exec(hostname.toLowerCase());
627
+ if (!match)
628
+ return null;
629
+ try {
630
+ return decodeURIComponent(match[1]);
631
+ }
632
+ catch {
633
+ return null;
634
+ }
635
+ }
636
+ const base = this.getBaseUrl();
637
+ const prefix = base === '/' ? '/' : `${base}/`;
638
+ if (!path.startsWith(prefix))
639
+ return null;
640
+ const segments = path.slice(prefix.length).split('/');
641
+ if (this._tenantRoutePrefix !== null && segments.shift() !== this._tenantRoutePrefix)
642
+ return null;
643
+ let key;
644
+ try {
645
+ key = decodeURIComponent(segments[0] ?? '');
646
+ }
647
+ catch {
648
+ return null;
649
+ }
650
+ if (!key || key.length > 200)
651
+ return null;
652
+ if (this._tenantRoutePrefix === null && (this.getReservedSegments().has(key) || key === this._tenantRegistration?.slug))
653
+ return null;
654
+ return key;
655
+ }
656
+ _tenantBilling = null;
657
+ _tenantBillingRouteSlug = 'billing';
658
+ _requiresTenantSubscription = false;
659
+ /** Adds a "Manage subscription" link to the tenant menu, served by the provider at `/<tenant>/billing`. */
660
+ tenantBillingProvider(provider) { this._tenantBilling = provider; return this; }
661
+ getTenantBillingProvider() { return this._tenantBilling; }
662
+ hasTenantBilling() { return this._tenantBilling !== null; }
663
+ tenantBillingRouteSlug(slug) { this._tenantBillingRouteSlug = slug.replace(/^\/|\/$/g, ''); return this; }
664
+ getTenantBillingRouteSlug() { return this._tenantBillingRouteSlug; }
665
+ getTenantBillingUrl(tenant = this.getTenant()) { return this._tenantBilling && tenant ? this.getUrl(this._tenantBillingRouteSlug, tenant) : null; }
666
+ /** The pages of a tenant without an active subscription (`isSubscribed()`) redirect to its billing page. */
667
+ requiresTenantSubscription(condition = true) { this._requiresTenantSubscription = condition; return this; }
668
+ isTenantSubscriptionRequired() { return this._requiresTenantSubscription && this._tenantBilling !== null; }
669
+ _sessionCookieDomain = null;
670
+ /** Domain of the session cookie (`.example.com`): with `tenantDomain()`, a sign-in then holds on every tenant's host. */
671
+ sessionCookieDomain(domain) { this._sessionCookieDomain = domain; return this; }
361
672
  tenantMenuItems(items) {
362
673
  for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
363
674
  this._tenantMenuItems[key] = item;
@@ -380,14 +691,20 @@ export class Panel {
380
691
  getMaxContentWidth() { return this._maxContentWidth; }
381
692
  /** URL inside the panel; in a multi-tenant panel, prefixed with the current (or given) tenant. */
382
693
  getUrl(sub = '', tenant = this.getTenant()) {
694
+ if (this._tenant && tenant && this._tenantDomain) {
695
+ // On the tenant's own host, a path; another tenant's pages are on its host.
696
+ const current = this.getTenant();
697
+ const path = this.getBaseUrl(sub);
698
+ return current && this.getTenantUrlKey(current) === this.getTenantUrlKey(tenant) ? path : `//${this.getTenantDomainFor(tenant)}${path}`;
699
+ }
383
700
  if (this._tenant && tenant)
384
- return '/' + [this._path, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
701
+ return '/' + [this._path, this._tenantRoutePrefix, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
385
702
  return this.getBaseUrl(sub);
386
703
  }
387
704
  /** URL outside any tenant (login, registration, profile, tenant registration). */
388
705
  getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
389
- getLoginUrl() { return this.getBaseUrl('login'); }
390
- getLogoutUrl() { return this.getBaseUrl('logout'); }
706
+ getLoginUrl() { return this.getBaseUrl(this.getAuthPaths().login); }
707
+ getLogoutUrl() { return this.getBaseUrl(this.getAuthPaths().logout); }
391
708
  /** Prefix of this panel's Livewire endpoint (`/livewire` or `/<path>/livewire`): each panel has its own. */
392
709
  getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
393
710
  assetUrl(file) { return `/${file}?v=${ASSET_VERSION}`; }
@@ -398,9 +715,40 @@ export class Panel {
398
715
  /** Tenant avatar: from the tenant provider (`getAvatarUrl`), otherwise its initials. */
399
716
  getTenantAvatarUrl(tenant) { return this.getTenantProvider().getAvatarUrl?.(tenant) ?? this.getAvatarUrlFor(this.getTenantProvider().getName(tenant)); }
400
717
  _spa = false;
401
- /** SPA mode: navigate between panel pages without full reloads (`wire:navigate`). */
402
- spa(condition = true) { this._spa = condition; return this; }
718
+ _spaPrefetching = false;
719
+ /**
720
+ * SPA mode: navigate between panel pages without full reloads (`wire:navigate`). `{ prefetch: true }` loads a page as soon
721
+ * as its link is hovered (`wire:navigate.hover`).
722
+ */
723
+ spa(condition = true, options = {}) { this._spa = condition; this._spaPrefetching = options.prefetch === true; return this; }
403
724
  hasSpaMode() { return this._spa; }
725
+ hasSpaPrefetching() { return this._spa && this._spaPrefetching; }
726
+ _domains = [];
727
+ /** Serves the panel on this domain only (`admin.example.com`): requests for other hosts go to the next routes. */
728
+ domain(host) { this._domains = host ? [host] : []; return this; }
729
+ /** Serves the panel on these domains only. */
730
+ domains(hosts) { this._domains = [...hosts]; return this; }
731
+ getDomains() { return this._domains; }
732
+ _unsavedChangesAlerts = false;
733
+ /** Warns the user before leaving a create or edit page whose form has unsaved changes. */
734
+ unsavedChangesAlerts(condition = true) { this._unsavedChangesAlerts = condition; return this; }
735
+ hasUnsavedChangesAlerts() { return this._unsavedChangesAlerts; }
736
+ _databaseTransactions = false;
737
+ /**
738
+ * Wraps the creation and the saving of records (from `beforeCreate()` / `beforeSave()` to `afterCreate()` / `afterSave()`)
739
+ * in a database transaction, rolled back when anything throws. Pages can opt in or out with `static hasDatabaseTransactions`.
740
+ */
741
+ databaseTransactions(condition = true) { this._databaseTransactions = condition; return this; }
742
+ hasDatabaseTransactions() { return this._databaseTransactions; }
743
+ _strictAuthorization = false;
744
+ /** Throws when a resource is checked for an ability its policy does not define, instead of allowing it. */
745
+ strictAuthorization(condition = true) { this._strictAuthorization = condition; return this; }
746
+ isAuthorizationStrict() { return this._strictAuthorization; }
747
+ _bootCallbacks = [];
748
+ /** Runs on every request handled by the panel (its pages and their updates), before them. */
749
+ bootUsing(callback) { this._bootCallbacks.push(callback); return this; }
750
+ async boot(req) { for (const callback of this._bootCallbacks)
751
+ await callback(this, req); }
404
752
  getAvatarUrlFor(name) {
405
753
  const initials = name.trim().split(' ').map((s) => s.replace(/^[^\p{L}\p{N}]+/u, '').slice(0, 1)).join(' ');
406
754
  const background = '#' + convertToRgb(this.colors.getColor('gray')[950]).map((n) => n.toString(16).padStart(2, '0')).join('');
@@ -425,28 +773,37 @@ export class Panel {
425
773
  }
426
774
  getNavigation(path, badges = new Map(), hidden = new Set()) {
427
775
  const items = [
428
- ...this._pages.filter((p) => p.shouldRegisterNavigation && !hidden.has(p.slug)).map((p) => {
429
- const item = NavigationItem.make(p.getNavigationLabel()).url(this.getUrl(p.slug)).icon(p.navigationIcon).group(p.navigationGroup).sort(p.navigationSort).badge(badges.has(p.slug) ? badges.get(p.slug) : p.navigationBadge, p.navigationBadgeColor);
776
+ // The pages of a cluster are in its sub-navigation: the menu only shows the cluster.
777
+ ...this._pages.filter((p) => p.shouldRegisterNavigation && !hidden.has(p.slug) && clusterOf(p) === null).map((p) => {
778
+ const item = NavigationItem.make(p.getNavigationLabel()).url(this.getUrl(p.slug)).icon(p.navigationIcon).group(p.navigationGroup).sort(p.navigationSort).badge(badges.has(p.slug) ? badges.get(p.slug) : p.navigationBadge, p.navigationBadgeColor).parentItem(p.navigationParentItem);
430
779
  const base = this.getUrl(p.slug);
431
- return p.resource ? item.isActiveWhen((current) => current === base || current.startsWith(base + '/')) : item;
780
+ return p.resource || p.prototype instanceof Cluster ? item.isActiveWhen((current) => current === base || current.startsWith(base + '/')) : item;
432
781
  }),
433
782
  ...this._navigationItems,
434
783
  ].sort((a, b) => a._sort - b._sort);
784
+ const view = (item, children) => {
785
+ const childItems = children.map((child) => view(child, child._childItems));
786
+ const activeChildItems = childItems.some((child) => child.isActive || child.activeChildItems);
787
+ return { label: item.label, url: item._url, icon: item._icon, isActive: !activeChildItems && item.isActive(path), badge: item._badge, badgeColorClasses: this.colors.getComponentClasses('badge', item._badgeColor), childItems, activeChildItems };
788
+ };
435
789
  const order = [null, ...this._navigationGroups, ...items.map((i) => i._group).filter((g) => g !== null && !this._navigationGroups.includes(g))];
436
790
  return [...new Set(order)].map((label) => {
437
- const own = items.filter((i) => i._group === label).map((i) => ({ label: i.label, url: i._url, icon: i._icon, isActive: i.isActive(path), badge: i._badge, badgeColorClasses: this.colors.getComponentClasses('badge', i._badgeColor) }));
438
- return { label, itemsId: label ? `fi-sidebar-group-items-${slug(label)}-${createHash('md5').update(label).digest('hex').slice(0, 8)}` : '', isActive: own.some((i) => i.isActive), items: own };
791
+ const inGroup = items.filter((i) => i._group === label);
792
+ // An item with a parent item is shown below it (an unknown parent hides it, as in Filament).
793
+ const own = inGroup.filter((i) => i._parentItem === null).map((i) => view(i, [...i._childItems, ...inGroup.filter((child) => child._parentItem === i.label)]));
794
+ const options = label ? this._navigationGroupOptions.get(label) : undefined;
795
+ return { label, itemsId: label ? `fi-sidebar-group-items-${slug(label)}-${createHash('md5').update(label).digest('hex').slice(0, 8)}` : '', isActive: own.some((i) => i.isActive || i.activeChildItems), items: options?.icon ? own.map((i) => ({ ...i, icon: null })) : own, icon: options?.icon ?? null, collapsible: options?.collapsible ?? true };
439
796
  }).filter((g) => g.items.length);
440
797
  }
441
798
  // --- session: an HMAC-signed cookie holding the user id
442
799
  sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
443
800
  logIn(res, user, remember) {
444
801
  const value = Buffer.from(JSON.stringify({ uid: user.id })).toString('base64url');
445
- res.cookie(`navette_${this._id}_session`, `${value}.${this.sign(value)}`, { httpOnly: true, sameSite: 'lax', secure: res.req?.secure === true, ...(remember ? { maxAge: 1000 * 60 * 60 * 24 * 30 } : {}) });
802
+ res.cookie(this.getSessionCookieName(), `${value}.${this.sign(value)}`, { httpOnly: true, sameSite: 'lax', secure: res.req?.secure === true, ...(this._sessionCookieDomain ? { domain: this._sessionCookieDomain } : {}), ...(remember ? { maxAge: 1000 * 60 * 60 * 24 * 30 } : {}) });
446
803
  }
447
- logOut(res) { res.clearCookie(`navette_${this._id}_session`); }
804
+ logOut(res) { res.clearCookie(this.getSessionCookieName(), this._sessionCookieDomain ? { domain: this._sessionCookieDomain } : {}); }
448
805
  async user(req) {
449
- const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`navette_${this._id}_session=`))?.split('=')[1];
806
+ const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`${this.getSessionCookieName()}=`))?.split('=')[1];
450
807
  if (!raw)
451
808
  return null;
452
809
  const [value, mac] = raw.split('.');
@@ -579,113 +936,86 @@ class BasePage extends Component {
579
936
  const resource = this.constructor.resource;
580
937
  return resource ? [this.constructor, resource] : [this.constructor];
581
938
  }
582
- }
583
- /** A panel page, in the full layout (sidebar, header). */
584
- export class Page extends BasePage {
585
- /** Methods the browser may call. A subclass adds its own: `static callable = ['approve']`. */
586
- static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
587
- static slug = '';
588
- static title = null;
589
- static navigationLabel = null;
590
- static navigationIcon = null;
591
- static navigationGroup = null;
592
- static navigationSort = 0;
593
- static navigationBadge = null;
594
- static navigationBadgeColor = 'primary';
595
- static shouldRegisterNavigation = true;
596
- /** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
597
- static canAccess(_user) { return true; }
598
- static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
599
- static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
600
- /** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
601
- async boot() {
602
- const ctx = this._ctx;
603
- if (!ctx?.user)
604
- throw new WireError(401, 'Unauthenticated.');
605
- // In a multi-tenant panel, every page belongs to a tenant the user is a member of (checked by the router).
606
- if (ctx.panel.hasTenancy() && !ctx.tenant)
607
- throw new WireError(404, 'Tenant not found.');
608
- if (await ctx.panel.mustVerifyEmail(ctx.user))
609
- throw new WireError(403, 'Email address not verified.');
610
- // Checked on every request, not only on display: a forged request to a forbidden page is denied.
611
- if (!(await this.isAccessAuthorized(ctx)))
612
- throw new WireError(403, 'Forbidden.');
613
- }
614
- isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
615
- getTitle() { return this.constructor.getTitle(); }
616
- getHeading() { return this.getTitle(); }
617
- getBreadcrumbs() { return []; }
939
+ /** Actions of the page header (full layout). */
618
940
  getHeaderActions() { return []; }
619
- getPageClasses() { return []; }
620
- /** Page sub-navigation; empty by default. */
621
- getSubNavigation() { return []; }
622
- /** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
623
- static subNavigationPosition = null;
624
- getSubNavigationPosition() {
625
- const own = this.constructor.subNavigationPosition;
626
- const resource = this.constructor.resource ?? this.constructor.parentResource;
627
- return own ?? resource?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
628
- }
629
- /** Widgets shown below the page content. */
630
- getFooterWidgets() { return []; }
631
- getFooterWidgetsColumns() { return 2; }
632
- /** Footer widget grid, in the page's `footerWidgets` schema. */
633
- async renderFooterWidgets() {
634
- const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
635
- if (!widgets.length)
636
- return '';
637
- const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
638
- return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'footerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getFooterWidgetsColumns(), 'footerWidgets')}${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_END)}</div>`;
639
- }
640
- /** Widgets shown above the page content, with the data passed to each (widget properties). */
641
- getHeaderWidgets() { return []; }
642
- getHeaderWidgetsColumns() { return 2; }
643
- getWidgetData() { return {}; }
644
- /** Header widget grid, in the page's `headerWidgets` schema. */
645
- async renderHeaderWidgets() {
646
- const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
647
- if (!widgets.length)
648
- return '';
649
- const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
650
- return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'headerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getHeaderWidgetsColumns(), 'headerWidgets')}${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_END)}</div>`;
651
- }
652
- /** Widgets the current user is allowed to see. */
653
- async filterVisibleWidgets(candidates) {
654
- const viewer = this._ctx.user;
655
- const widgets = [];
656
- for (const widget of candidates)
657
- if (await this.panel.authorize(viewer, 'view', { widget, rule: () => widget.canView(viewer) }))
658
- widgets.push(widget);
659
- return widgets;
941
+ // --- lazy loaded schemas (`lazyLoaded()`): loaded when they enter the viewport, then kept loaded
942
+ static callable = ['loadDeferredSchema'];
943
+ loadedDeferredSchemas = {};
944
+ /** Whether a lazy schema is rendered: loaded already, or holding a field with a validation error (loaded so it shows). */
945
+ async isDeferredSchemaLoaded(schema) {
946
+ const key = schema.getLazyKey();
947
+ if (this.loadedDeferredSchemas[key])
948
+ return true;
949
+ const errorKeys = Object.keys(this._errors ?? {});
950
+ if (!errorKeys.length)
951
+ return false;
952
+ for (const component of schema.getFlatComponents()) {
953
+ const path = component.hasStatePath?.() ? component.getStatePath() : '';
954
+ if (path && errorKeys.some((error) => error === path || error.startsWith(`${path}.`))) {
955
+ this.loadedDeferredSchemas[key] = true;
956
+ return true;
957
+ }
958
+ }
959
+ return false;
660
960
  }
661
- /** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
662
- async renderWidgetsGrid(widgets, columns, schemaKey) {
663
- const items = [];
664
- for (const [index, widget] of widgets.entries()) {
665
- const componentName = this._runtime.nameOf(widget);
666
- const key = `${schemaKey}.${componentName}-${index}`;
667
- const child = await this.mountChild(componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
668
- items.push(`<div class="fi-sc-liberated" wire:key="${e(`${this.id}.${key}`)}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})">${child}</div>`);
961
+ /** Called by the loading indicator of a lazy schema: renders only that schema. */
962
+ async loadDeferredSchema(schemaKey) {
963
+ if (typeof schemaKey !== 'string' || schemaKey.length > 300 || this.loadedDeferredSchemas[schemaKey]) {
964
+ this.skipRender();
965
+ return;
669
966
  }
670
- const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
671
- return `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div><div ${grid.toHtml()}>${items.join('')}</div></div></div></div>`;
967
+ const rootName = schemaKey.split('.')[0];
968
+ const mountedIndex = /^mountedActionSchema(\d+)$/.exec(rootName);
969
+ const root = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : /^[A-Za-z]+$/.test(rootName) && typeof this[`${rootName}Schema`] === 'function' ? this.getSchema(rootName) : null;
970
+ // Only a lazy schema reachable through visible components can be loaded.
971
+ const find = async (schema) => {
972
+ if (schema.getLazyKey() === schemaKey && (await schema.isLazyLoaded()))
973
+ return schema;
974
+ for (const component of schema.getComponents()) {
975
+ if (!(await component.isVisible()))
976
+ continue;
977
+ for (const child of (component.getChildSchemas?.() ?? [])) {
978
+ const found = await find(child);
979
+ if (found)
980
+ return found;
981
+ }
982
+ }
983
+ return null;
984
+ };
985
+ const schema = root ? await find(root) : null;
986
+ if (!schema) {
987
+ this.skipRender();
988
+ return;
989
+ }
990
+ this.loadedDeferredSchemas[schemaKey] = true;
991
+ const html = await schema.toHtml();
992
+ this.renderPartial(`schema.${schemaKey}`, html || `<div wire:partial="${e(`schema.${schemaKey}`)}" hidden></div>`);
672
993
  }
673
- /** Page content: its `content` schema by default. */
674
- async renderContent() { return (await this.getSchema('content')).toHtml(); }
675
994
  // --- mounted actions: the stack lives in the component state, modals are rendered server-side
676
995
  mountedActions = [];
996
+ /** Files uploaded by rich editors (`componentFileAttachments.<state path>.<id>`), stored by the editor when it asks for their URL. */
997
+ componentFileAttachments = {};
677
998
  getActionMountContext() { return {}; }
678
999
  getActionRecord() { return null; }
679
1000
  /** Record targeted by an action mounted in a given context (table row…). */
680
1001
  async resolveActionRecord(_context) { return this.getActionRecord(); }
681
1002
  /** Actions that can be mounted in a given context (page header by default). */
682
1003
  getMountableActions(context) {
683
- return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : this.getHeaderActions();
1004
+ return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : flattenActions(this.getHeaderActions());
684
1005
  }
685
1006
  /**
686
1007
  * Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
687
1008
  * The key comes from the browser: it must match a real component of the schema, or the schema itself.
688
1009
  */
1010
+ /** Visible component of a schema of this page (or of a mounted action's form) with this key. */
1011
+ async findSchemaComponent(key) {
1012
+ const name = key.split('.')[0];
1013
+ const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
1014
+ if (!mountedIndex && (!/^[A-Za-z]+$/.test(name) || typeof this[`${name}Schema`] !== 'function'))
1015
+ return null;
1016
+ const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : this.getSchema(name);
1017
+ return schema ? (await schema.getVisibleFlatComponents()).find((c) => c.getKey() === key) ?? null : null;
1018
+ }
689
1019
  async getSchemaComponentActions(key) {
690
1020
  const name = key.split('.')[0];
691
1021
  // The component may belong to the form of an already mounted action (rich editor in a modal).
@@ -708,12 +1038,42 @@ export class Page extends BasePage {
708
1038
  return typeof component === 'string' && component.length <= 200 ? { ...this.getActionMountContext(), schemaComponent: component } : this.getActionMountContext();
709
1039
  }
710
1040
  async findMountableAction(name, context = this.getActionMountContext()) {
1041
+ // An action of a modal footer: found among the extra footer actions of its (mounted) parent action.
1042
+ if (typeof context.parentAction === 'number') {
1043
+ const parent = this.mountedActions[context.parentAction];
1044
+ const owner = parent ? await this.findMountableAction(parent.name, parent.context) : null;
1045
+ const child = owner?.getExtraModalFooterActions().find((a) => a.getName() === name && a.isMountable());
1046
+ if (!child)
1047
+ throw new WireError(404, `Action not found: ${String(name)}`);
1048
+ return child;
1049
+ }
711
1050
  const action = (await this.getMountableActions(context)).find((a) => a.getName() === name && a.isMountable());
712
1051
  if (!action)
713
1052
  throw new WireError(404, `Action not found: ${String(name)}`);
714
1053
  return action;
715
1054
  }
716
- async actionContext(mounted) { return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}) }; }
1055
+ async actionContext(mounted) {
1056
+ const parentIndex = mounted.context.parentAction;
1057
+ const parent = typeof parentIndex === 'number' ? this.mountedActions[parentIndex] : undefined;
1058
+ const parentData = parent ? (parent.data ??= {}) : undefined;
1059
+ // An action of a form field (affix action): its state, and access to the other fields of the same form.
1060
+ let fieldAccess = {};
1061
+ if (typeof mounted.context.schemaComponent === 'string') {
1062
+ const component = await this.findSchemaComponent(mounted.context.schemaComponent);
1063
+ const path = typeof component?.getStatePath === 'function' && component.hasStatePath() ? String(component.getStatePath()) : null;
1064
+ if (path) {
1065
+ const container = path.split('.').slice(0, -1);
1066
+ const nodeOf = () => container.reduce((node, segment) => (node && typeof node === 'object' ? node[segment] : undefined), this);
1067
+ fieldAccess = {
1068
+ state: path.split('.').reduce((node, segment) => (node && typeof node === 'object' ? node[segment] : undefined), this),
1069
+ get: (name) => nodeOf()?.[name],
1070
+ set: (name, value) => { const node = nodeOf(); if (node && typeof node === 'object' && /^[A-Za-z0-9_]+$/.test(name))
1071
+ node[name] = value; },
1072
+ };
1073
+ }
1074
+ }
1075
+ return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}), ...(parentData ? { parentData } : {}), ...fieldAccess };
1076
+ }
717
1077
  /** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
718
1078
  async getMountedActionSchema(index) {
719
1079
  const mounted = this.mountedActions[index];
@@ -724,7 +1084,7 @@ export class Page extends BasePage {
724
1084
  }
725
1085
  async renderActionModal(index) {
726
1086
  const mounted = this.mountedActions[index];
727
- return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null);
1087
+ return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null, mounted.context);
728
1088
  }
729
1089
  /** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
730
1090
  cancelModalPartialRender() {
@@ -741,7 +1101,8 @@ export class Page extends BasePage {
741
1101
  return;
742
1102
  const index = Number(m[1]);
743
1103
  const schema = await this.getMountedActionSchema(index);
744
- const field = schema?.getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === `mountedActions.${index}.data.${m[2]}`);
1104
+ const path = `mountedActions.${index}.data.${m[2]}`;
1105
+ const field = schema?.getFlatComponents().find((c) => c instanceof Field && (c.getStatePath() === path || path.startsWith(`${c.getStatePath()}.`)));
745
1106
  if (!field?.hasAfterStateUpdated())
746
1107
  return;
747
1108
  const data = (this.mountedActions[index].data ??= {});
@@ -797,12 +1158,23 @@ export class Page extends BasePage {
797
1158
  /** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
798
1159
  async mountAction(name, args = {}, _context = {}) {
799
1160
  this.cancelModalPartialRender();
800
- const context = this.resolveMountContext(_context);
1161
+ // A modal footer action: its context is its parent's (kept by the server), plus the parent's index.
1162
+ const parentIndex = _context?.parentAction;
1163
+ const context = Number.isInteger(parentIndex) && parentIndex >= 0 && parentIndex < this.mountedActions.length
1164
+ ? { ...this.mountedActions[parentIndex].context, parentAction: parentIndex }
1165
+ : this.resolveMountContext(_context);
801
1166
  const action = await this.findMountableAction(name, context);
802
1167
  await this.assertActionVisible(action, context);
803
1168
  const mounted = { name: String(name), arguments: args && typeof args === 'object' ? args : {}, context };
804
- if (!action.hasModal()) {
805
- await action.call(await this.actionContext(mounted));
1169
+ if (!action.hasModal() || (await action.isModalHidden(await this.actionContext(mounted)))) {
1170
+ // Without a modal, `halt()` and `cancel()` simply stop the action.
1171
+ try {
1172
+ await action.call(await this.actionContext(mounted));
1173
+ }
1174
+ catch (error) {
1175
+ if (!(error instanceof ActionHalt))
1176
+ throw error;
1177
+ }
806
1178
  return;
807
1179
  }
808
1180
  this.mountedActions.push(mounted);
@@ -812,10 +1184,37 @@ export class Page extends BasePage {
812
1184
  const data = {};
813
1185
  mounted.data = data;
814
1186
  fillSchema(schema, data);
815
- const fill = await action.getFormFill(await this.actionContext(mounted));
816
- if (fill)
1187
+ const fillContext = await this.actionContext(mounted);
1188
+ const fill = await action.getFormFill(fillContext);
1189
+ if (fill) {
817
1190
  fillSchemaFromRecord(schema, data, fill);
1191
+ await loadSchemaRelationships(schema, data, fillContext.record);
1192
+ }
818
1193
  }
1194
+ await action.runMountUsing({ ...(await this.actionContext(mounted)), data: (mounted.data ??= {}), action });
1195
+ this.renderPartial('action-modals', await this.renderActionModals());
1196
+ this.syncActionModals();
1197
+ }
1198
+ /**
1199
+ * Replaces the action being run with another action of the same component, opened in its place (to show a result, as
1200
+ * the new recovery codes). Call it from the running action; `name` must be mountable in the same context (it may be hidden).
1201
+ */
1202
+ async replaceMountedAction(name, args = {}) {
1203
+ const index = this.mountedActions.length - 1;
1204
+ if (index < 0)
1205
+ return;
1206
+ const context = this.mountedActions[index].context;
1207
+ const action = await this.findMountableAction(name, context);
1208
+ const mounted = { name, arguments: args, context };
1209
+ this.mountedActions[index] = mounted;
1210
+ this._errors = {};
1211
+ const schema = await this.getMountedActionSchema(index);
1212
+ if (schema) {
1213
+ const data = {};
1214
+ mounted.data = data;
1215
+ fillSchema(schema, data);
1216
+ }
1217
+ await action.runMountUsing({ ...(await this.actionContext(mounted)), data: (mounted.data ??= {}), action });
819
1218
  this.renderPartial('action-modals', await this.renderActionModals());
820
1219
  this.syncActionModals();
821
1220
  }
@@ -841,19 +1240,184 @@ export class Page extends BasePage {
841
1240
  }
842
1241
  }
843
1242
  const callContext = await this.actionContext(mounted);
844
- if (schema)
1243
+ if (schema) {
845
1244
  callContext.data = await getSchemaState(schema);
846
- await action.call(callContext);
847
- // "… & create another": the modal stays open with a reset form.
1245
+ callContext.schema = schema;
1246
+ }
1247
+ try {
1248
+ await action.call(callContext);
1249
+ }
1250
+ catch (error) {
1251
+ // `action.halt()`: the modal stays open; `action.cancel()`: it closes.
1252
+ if (!(error instanceof ActionHalt))
1253
+ throw error;
1254
+ if (error.closesModal) {
1255
+ this.mountedActions.pop();
1256
+ this.syncActionModals();
1257
+ }
1258
+ return;
1259
+ }
1260
+ // The action replaced itself with another one (`replaceMountedAction()`): that one stays open.
1261
+ if (this.mountedActions[this.mountedActions.length - 1] !== mounted)
1262
+ return;
1263
+ // "… & create another": the modal stays open with a reset form (except the preserved fields).
848
1264
  if (schema && _args && typeof _args === 'object' && _args.another === true) {
1265
+ const preserved = JSON.parse(JSON.stringify(action.getDataPreservedForAnother(callContext.data ?? {})));
849
1266
  const data = {};
850
1267
  mounted.data = data;
851
1268
  fillSchema(schema, data);
1269
+ Object.assign(data, preserved);
852
1270
  return;
853
1271
  }
854
1272
  this.mountedActions.pop();
855
1273
  this.syncActionModals();
856
1274
  }
1275
+ }
1276
+ /** A navigation group for the topbar: in a dropdown, an item with children forms its own list with them. */
1277
+ function topNavigationGroup(group) {
1278
+ const colored = (item) => ({ ...item, colorClasses: item.isActive ? sharedColors.getComponentClasses('dropdown-item', 'primary') : [] });
1279
+ const lists = [];
1280
+ for (const item of group.items) {
1281
+ if (item.childItems.length) {
1282
+ lists.push([colored(item), ...item.childItems.map(colored)], []);
1283
+ continue;
1284
+ }
1285
+ if (!lists.length)
1286
+ lists.push([]);
1287
+ lists[lists.length - 1].push(colored(item));
1288
+ }
1289
+ if (lists.length && !lists[lists.length - 1].length)
1290
+ lists.pop();
1291
+ return { ...group, items: group.items.map(colored), lists };
1292
+ }
1293
+ /** A panel page, in the full layout (sidebar, header). */
1294
+ export class Page extends BasePage {
1295
+ /** Methods the browser may call. A subclass adds its own: `static callable = ['approve']`. */
1296
+ static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
1297
+ static slug = '';
1298
+ static title = null;
1299
+ static navigationLabel = null;
1300
+ static navigationIcon = null;
1301
+ static navigationGroup = null;
1302
+ static navigationSort = 0;
1303
+ static navigationBadge = null;
1304
+ static navigationBadgeColor = 'primary';
1305
+ /** Label of another menu item of the group, below which this page is shown. */
1306
+ static navigationParentItem = null;
1307
+ /** Cluster of the page: it is served under the cluster's URL and listed in its sub-navigation. */
1308
+ static cluster = null;
1309
+ static shouldRegisterNavigation = true;
1310
+ /** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
1311
+ static canAccess(_user) { return true; }
1312
+ /** Whether the page needs the tenant's subscription (`panel.requiresTenantSubscription()`); `false` keeps it open. */
1313
+ static isTenantSubscriptionRequired(panel) { return panel.isTenantSubscriptionRequired(); }
1314
+ static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
1315
+ static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
1316
+ /** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
1317
+ async boot() {
1318
+ const ctx = this._ctx;
1319
+ if (!ctx?.user && !ctx?.panel.allowsGuests())
1320
+ throw new WireError(401, 'Unauthenticated.');
1321
+ // In a multi-tenant panel, every page belongs to a tenant the user is a member of (checked by the router).
1322
+ if (ctx.panel.hasTenancy() && !ctx.tenant)
1323
+ throw new WireError(404, 'Tenant not found.');
1324
+ if (ctx.tenant && this.constructor.isTenantSubscriptionRequired(ctx.panel) && !(await ctx.panel.getTenantBillingProvider().isSubscribed(ctx.tenant)))
1325
+ throw new WireError(403, 'Subscription required.');
1326
+ if (await ctx.panel.mustVerifyEmail(ctx.user))
1327
+ throw new WireError(403, 'Email address not verified.');
1328
+ if (await ctx.panel.mustSetUpMultiFactorAuthentication(ctx.user))
1329
+ throw new WireError(403, 'Multi-factor authentication must be set up.');
1330
+ // Checked on every request, not only on display: a forged request to a forbidden page is denied.
1331
+ if (!(await this.isAccessAuthorized(ctx)))
1332
+ throw new WireError(403, 'Forbidden.');
1333
+ }
1334
+ isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
1335
+ getTitle() { return this.constructor.getTitle(); }
1336
+ getHeading() { return this.getTitle(); }
1337
+ getBreadcrumbs() { return []; }
1338
+ getHeaderActions() { return []; }
1339
+ getPageClasses() { return []; }
1340
+ /** Page sub-navigation: its cluster's pages, for a page in a cluster; empty otherwise. */
1341
+ getSubNavigation() {
1342
+ const cluster = this.constructor.cluster;
1343
+ return cluster?.shouldRegisterSubNavigation ? this.panel.getClusterSubNavigation(cluster, this, this._ctx.user) : [];
1344
+ }
1345
+ /** Breadcrumbs, preceded by the cluster's for a page in a cluster. */
1346
+ getBreadcrumbsWithCluster() {
1347
+ const breadcrumbs = this.getBreadcrumbs();
1348
+ const cluster = clusterOf(this.constructor);
1349
+ if (!cluster || this instanceof Cluster)
1350
+ return breadcrumbs;
1351
+ const crumb = cluster.getClusterBreadcrumb();
1352
+ return breadcrumbs[0]?.label === crumb ? breadcrumbs : [{ label: crumb, url: this.panel.getUrl(cluster.slug) }, ...breadcrumbs];
1353
+ }
1354
+ /** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
1355
+ static subNavigationPosition = null;
1356
+ getSubNavigationPosition() {
1357
+ const own = this.constructor.subNavigationPosition;
1358
+ const resource = this.constructor.resource ?? this.constructor.parentResource;
1359
+ return own ?? resource?.subNavigationPosition ?? clusterOf(this.constructor)?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
1360
+ }
1361
+ /** Widgets shown below the page content. */
1362
+ getFooterWidgets() { return []; }
1363
+ getFooterWidgetsColumns() { return 2; }
1364
+ /** Footer widget grid, in the page's `footerWidgets` schema. */
1365
+ async renderFooterWidgets() {
1366
+ const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
1367
+ if (!widgets.length)
1368
+ return '';
1369
+ const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
1370
+ return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'footerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getFooterWidgetsColumns(), 'footerWidgets')}${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_END)}</div>`;
1371
+ }
1372
+ /** Widgets shown above the page content, with the data passed to each (widget properties). */
1373
+ getHeaderWidgets() { return []; }
1374
+ getHeaderWidgetsColumns() { return 2; }
1375
+ getWidgetData() { return {}; }
1376
+ /** Header widget grid, in the page's `headerWidgets` schema. */
1377
+ async renderHeaderWidgets() {
1378
+ const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
1379
+ if (!widgets.length)
1380
+ return '';
1381
+ const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
1382
+ return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'headerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getHeaderWidgetsColumns(), 'headerWidgets')}${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_END)}</div>`;
1383
+ }
1384
+ /** Widgets the current user is allowed to see. */
1385
+ async filterVisibleWidgets(candidates) {
1386
+ const viewer = this._ctx.user;
1387
+ const widgets = [];
1388
+ for (const widget of candidates)
1389
+ if (await this.panel.authorize(viewer, 'view', { widget, rule: () => widget.canView(viewer) }))
1390
+ widgets.push(widget);
1391
+ return widgets;
1392
+ }
1393
+ /** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
1394
+ async renderWidgetsGrid(widgets, columns, schemaKey) {
1395
+ const items = [];
1396
+ for (const [index, widget] of widgets.entries()) {
1397
+ const componentName = this._runtime.nameOf(widget);
1398
+ const key = `${schemaKey}.${componentName}-${index}`;
1399
+ const child = await this.mountChild(componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
1400
+ items.push(`<div class="fi-sc-liberated" wire:key="${e(`${this.id}.${key}`)}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})">${child}</div>`);
1401
+ }
1402
+ const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
1403
+ return `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div><div ${grid.toHtml()}>${items.join('')}</div></div></div></div>`;
1404
+ }
1405
+ /** Page content: its `content` schema by default. */
1406
+ /**
1407
+ * Content rendered from an EJS view instead of the `content` schema: a namespaced view (`'app::pages.report'`), or a file
1408
+ * (path or `new URL('./report.ejs', import.meta.url)`). The view receives `page` (this page) and `getViewData()`, and may use
1409
+ * component tags (`<x-section>`, `<x-button>`…). Elements calling the server use `wire:click` on a method listed in `static callable`.
1410
+ */
1411
+ static view = null;
1412
+ /** Extra variables for the page view. */
1413
+ getViewData() { return {}; }
1414
+ async renderContent() {
1415
+ const view = this.constructor.view;
1416
+ if (view === null)
1417
+ return (await this.getSchema('content')).toHtml();
1418
+ const data = { page: this, livewireId: this.id, ...(await this.getViewData()) };
1419
+ return typeof view === 'string' && view.includes('::') ? views.render(view, data) : views.renderFile(view instanceof URL ? fileURLToPath(view) : view, data);
1420
+ }
857
1421
  async render() {
858
1422
  const ctx = { livewire: this, component: null, operation: null, record: this.getActionRecord(), get: () => undefined, state: undefined };
859
1423
  const headerActions = [];
@@ -862,12 +1426,34 @@ export class Page extends BasePage {
862
1426
  headerActions.push(await action.toHtml(ctx));
863
1427
  return views.render('filament-panels::pages.page', {
864
1428
  heading: this.getHeading(), headerWidgets: await this.renderHeaderWidgets(), content: await this.renderContent(), footerWidgets: await this.renderFooterWidgets(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes(),
865
- subNavigation: await this.getSubNavigation(), subNavigationPosition: this.getSubNavigationPosition(),
866
- breadcrumbs: this.getBreadcrumbs(), headerActions, pageClasses: this.getPageClasses(), actionModals: this.rendersActionModalsInPage() ? await this.renderActionModals() : '',
1429
+ subNavigation: (await this.getSubNavigation()).map((item) => ({ ...item, badgeColorClasses: sharedColors.getComponentClasses('badge', item.badgeColor ?? 'primary') })), subNavigationPosition: this.getSubNavigationPosition(),
1430
+ breadcrumbs: this.getBreadcrumbsWithCluster(), headerActions, pageClasses: this.getPageClasses(), actionModals: this.rendersActionModalsInPage() ? await this.renderActionModals() : '',
867
1431
  headerActionsKey: createHash('md5').update(this.getHeaderActions().map((a) => a.getName()).join(',')).digest('hex'),
868
1432
  });
869
1433
  }
870
1434
  }
1435
+ /**
1436
+ * Cluster: a menu item gathering pages and resources (`static cluster = Settings`), listed in its sub-navigation and served
1437
+ * under its URL (`/settings/...`). Opening the cluster goes to its first page.
1438
+ */
1439
+ export class Cluster extends Page {
1440
+ /** Breadcrumb before those of its pages; defaults to the title. */
1441
+ static clusterBreadcrumb = null;
1442
+ /** `false`: the pages of the cluster have no sub-navigation. */
1443
+ static shouldRegisterSubNavigation = true;
1444
+ static getTitle() { return this.title ?? this.name.replace(/Cluster$/, '').replace(/([a-z])([A-Z])/g, '$1 $2'); }
1445
+ /** URL segment: `slug`, or the class name without "Cluster" in kebab case (`ProductsCluster` → `products`). */
1446
+ static getSlug() { return this.slug || this.name.replace(/Cluster$/, '').replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); }
1447
+ static getClusterBreadcrumb() { return this.clusterBreadcrumb ?? this.getTitle(); }
1448
+ async mount() {
1449
+ const [first] = await this.getSubNavigation();
1450
+ if (first)
1451
+ this.redirect(first.url);
1452
+ }
1453
+ getSubNavigation() {
1454
+ return this.panel.getClusterSubNavigation(this.constructor, this, this._ctx.user);
1455
+ }
1456
+ }
871
1457
  /**
872
1458
  * Dashboard: a widget grid, preceded by a filters form when the page defines `filtersFormSchema(schema)`.
873
1459
  * The filters are passed to widgets as `pageFilters`.
@@ -880,7 +1466,8 @@ export class Dashboard extends Page {
880
1466
  /** Filters form state. Stays `null` while the page has no filters. */
881
1467
  filters = null;
882
1468
  /** Dashboard widgets. Defaults to the panel's (`panel.widgets([...])`). */
883
- getWidgets() { return this.panel.getWidgets(); }
1469
+ /** The panel's widgets, ordered by their `sort` (stable). */
1470
+ getWidgets() { return [...this.panel.getWidgets()].map((widget, index) => ({ widget, index })).sort((a, b) => (a.widget.sort ?? Infinity) - (b.widget.sort ?? Infinity) || a.index - b.index).map(({ widget }) => widget); }
884
1471
  /** Grid columns: a number (at the `lg` breakpoint), or one number per breakpoint. */
885
1472
  getColumns() { return 2; }
886
1473
  hasFiltersForm() { return typeof this.filtersFormSchema === 'function'; }
@@ -907,30 +1494,6 @@ export class Dashboard extends Page {
907
1494
  return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${filters}${widgets}</div>`;
908
1495
  }
909
1496
  }
910
- /** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
911
- export class Throttle {
912
- hits = new Map();
913
- max;
914
- windowMs;
915
- constructor(max = 5, windowMs = 60_000) { this.max = max; this.windowMs = windowMs; }
916
- /** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
917
- hit(key, now = Date.now()) {
918
- const recent = (this.hits.get(key) ?? []).filter((at) => at > now - this.windowMs);
919
- if (recent.length >= this.max) {
920
- this.hits.set(key, recent);
921
- return Math.max(1, Math.ceil((recent[0] + this.windowMs - now) / 1000));
922
- }
923
- recent.push(now);
924
- this.hits.set(key, recent);
925
- // Keep the store from growing forever.
926
- if (this.hits.size > 10_000)
927
- for (const [k, v] of this.hits)
928
- if (!v.some((at) => at > now - this.windowMs))
929
- this.hits.delete(k);
930
- return 0;
931
- }
932
- clear(key) { this.hits.delete(key); }
933
- }
934
1497
  const loginThrottle = new Throttle();
935
1498
  /** Page with the simple layout (login, registration…). */
936
1499
  export class SimplePage extends BasePage {
@@ -939,7 +1502,7 @@ export class SimplePage extends BasePage {
939
1502
  getSubheading() { return null; }
940
1503
  hasLogo() { return true; }
941
1504
  async render() {
942
- return views.render('filament-panels::pages.simple', { brand: this.panel.getBrand(), heading: this.getHeading(), subheading: this.getSubheading(), hasLogo: this.hasLogo(), content: await this.getSchema('content').toHtml(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes() });
1505
+ return views.render('filament-panels::pages.simple', { brand: this.panel.getBrand(), heading: this.getHeading(), subheading: this.getSubheading(), hasLogo: this.hasLogo(), content: await this.getSchema('content').toHtml(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes(), actionModals: await this.renderActionModals() });
943
1506
  }
944
1507
  }
945
1508
  /** Color manager read by the other packages and shared by all panels; each request sets its panel's colors on it (`Panel.withTheme`). */
@@ -1177,7 +1740,7 @@ export class EmailVerificationPrompt extends SimplePage {
1177
1740
  }
1178
1741
  /** User profile page: name, email, new password. The current password is required to change the email or password. */
1179
1742
  export class EditProfile extends SimplePage {
1180
- static callable = ['save', 'callSchemaComponentMethod'];
1743
+ static callable = ['save', 'mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
1181
1744
  static slug = 'profile';
1182
1745
  data = {};
1183
1746
  static getLabel() { return t('filament-panels::auth/pages/edit-profile.label'); }
@@ -1191,10 +1754,32 @@ export class EditProfile extends SimplePage {
1191
1754
  throw new WireError(401, 'Unauthenticated.'); }
1192
1755
  getTitle() { return this.constructor.getLabel(); }
1193
1756
  hasLogo() { return false; }
1757
+ /** Whether the page uses the full layout (`panel.profile(EditProfile, false)`): its form is then split into sections. */
1758
+ usesFullLayout() { return !this.panel.isProfileSimple(); }
1759
+ async render() {
1760
+ if (!this.usesFullLayout())
1761
+ return super.render();
1762
+ return views.render('filament-panels::pages.page', {
1763
+ heading: this.getTitle(), headerWidgets: '', content: await this.getSchema('content').toHtml(), footerWidgets: '', livewireId: this.id, renderHookScopes: this.getRenderHookScopes(),
1764
+ subNavigation: [], subNavigationPosition: 'top', breadcrumbs: [], headerActions: [], pageClasses: ['fi-auth-edit-profile-page'], actionModals: await this.renderActionModals(), headerActionsKey: '',
1765
+ });
1766
+ }
1194
1767
  formSchema(schema) {
1768
+ const fields = this.getFormComponents();
1769
+ if (!this.usesFullLayout())
1770
+ return schema.operation('edit').components(fields);
1771
+ const [name, email, ...password] = fields;
1772
+ const group = 'filament-panels::auth/pages/edit-profile.sections';
1773
+ return schema.operation('edit').components([
1774
+ Section.make(t(`${group}.information.heading`)).description(t(`${group}.information.description`)).schema([name, email]),
1775
+ Section.make(t(`${group}.password.heading`)).description(t(`${group}.password.description`)).schema(password),
1776
+ ]);
1777
+ }
1778
+ /** Fields of the form: name, email, new password, its confirmation, current password. */
1779
+ getFormComponents() {
1195
1780
  const { panel, user } = this._ctx;
1196
1781
  const auth = panel.getAuth();
1197
- return schema.operation('edit').components([
1782
+ return [
1198
1783
  TextInput.make('name').label(t('filament-panels::auth/pages/edit-profile.form.name.label')).required().maxLength(255).autofocus(),
1199
1784
  TextInput.make('email').label(t('filament-panels::auth/pages/edit-profile.form.email.label')).email().required().maxLength(255).live()
1200
1785
  .rule({ name: 'unique', passes: async (value) => { const other = await auth.findByEmail?.(String(value)); return !other || String(other.id) === String(user?.id); }, message: (attribute) => `The ${attribute} has already been taken.` }),
@@ -1205,13 +1790,44 @@ export class EditProfile extends SimplePage {
1205
1790
  .password().autocomplete('current-password').revealable().required()
1206
1791
  .rule({ name: 'current_password', passes: async (value) => Boolean(user && (await auth.attempt(user.email, String(value)))), message: () => 'The password is incorrect.' })
1207
1792
  .visible(({ get }) => !blankValue(get('password')) || get('email') !== user?.email).dehydrated(false),
1208
- ]);
1793
+ ];
1209
1794
  }
1210
1795
  contentSchema(schema) {
1211
- return schema.components(formContent('save', [
1212
- Action.make('save').label(t('filament-panels::auth/pages/edit-profile.form.actions.save.label')).submit('save').keyBindings(['mod+s']),
1213
- Action.make('back').label(t('filament-panels::auth/pages/edit-profile.actions.cancel.label')).color('gray').alpineClickHandler(`document.referrer ? window.history.back() : (window.location.href = ${JSON.stringify(this.panel.getUrl())})`),
1214
- ], { fullWidth: false, alignment: 'start' }));
1796
+ const { panel, user } = this._ctx;
1797
+ return schema.components([
1798
+ ...formContent('save', [
1799
+ Action.make('save').label(t('filament-panels::auth/pages/edit-profile.form.actions.save.label')).submit('save').keyBindings(['mod+s']),
1800
+ Action.make('back').label(t('filament-panels::auth/pages/edit-profile.actions.cancel.label')).color('gray').alpineClickHandler(`document.referrer ? window.history.back() : (window.location.href = ${JSON.stringify(this.panel.getUrl())})`),
1801
+ ], { fullWidth: false, alignment: 'start' }),
1802
+ ...(panel.hasMultiFactorAuthentication() && user ? [this.multiFactorAuthenticationSection(panel, user)] : []),
1803
+ ]);
1804
+ }
1805
+ /** Emails of an email change: a confirmation link to the new address, a link to block it to the old one. */
1806
+ async sendEmailChangeVerification(user, newEmail) {
1807
+ const { panel, req } = this._ctx;
1808
+ const urls = panel.getEmailChangeUrls(user, newEmail);
1809
+ await panel.sendMail({
1810
+ to: newEmail, subject: 'Verify Email Address',
1811
+ lines: ['Please click the button below to verify your new email address.'],
1812
+ action: { label: 'Verify Email Address', url: absoluteUrl(panel, req, urls.verify) },
1813
+ outro: ['This link will expire in 60 minutes.', 'If you did not request this change, no further action is required.'],
1814
+ });
1815
+ const notice = 'filament-panels::auth/notifications/notice-of-email-change-request';
1816
+ await panel.sendMail({
1817
+ to: user.email, subject: t(`${notice}.subject`),
1818
+ lines: [0, 1, 2].map((index) => t(`${notice}.lines.${index}`, { email: newEmail })),
1819
+ action: { label: t(`${notice}.action`), url: absoluteUrl(panel, req, urls.block) },
1820
+ outro: [t(`${notice}.lines.3`, { email: newEmail })],
1821
+ });
1822
+ Notification.make().success().title(t('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.title')).body(t('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.body', { email: newEmail })).send();
1823
+ }
1824
+ /** Multi-factor authentication settings: each provider's status and actions (set up, turn off…). */
1825
+ multiFactorAuthenticationSection(panel, user) {
1826
+ if (!this.usesFullLayout())
1827
+ return multiFactorManagementSection(panel, user);
1828
+ const group = 'filament-panels::auth/pages/edit-profile.multi_factor_authentication';
1829
+ return Section.make(t(`${group}.label`)).description(t(`${group}.description`)).divided()
1830
+ .schema(panel.getMultiFactorAuthenticationProviders().map((provider) => Group.make(provider.getManagementSchemaComponents(user, panel)).statePath(provider.getId())));
1215
1831
  }
1216
1832
  async save() {
1217
1833
  const { panel, user } = this._ctx;
@@ -1224,7 +1840,13 @@ export class EditProfile extends SimplePage {
1224
1840
  return;
1225
1841
  }
1226
1842
  const data = await getSchemaState(form);
1227
- await panel.getAuth().update(user, { name: String(data.name), email: String(data.email), password: blankValue(data.password) ? undefined : String(data.password) });
1843
+ const newEmail = String(data.email);
1844
+ const verifyEmailChange = panel.hasEmailChangeVerification() && newEmail !== user.email;
1845
+ await panel.getAuth().update(user, { name: String(data.name), email: verifyEmailChange ? user.email : newEmail, password: blankValue(data.password) ? undefined : String(data.password) });
1846
+ if (verifyEmailChange) {
1847
+ await this.sendEmailChangeVerification(user, newEmail);
1848
+ this.data = { ...this.data, email: user.email };
1849
+ }
1228
1850
  this.data = { ...this.data, password: null, passwordConfirmation: null, currentPassword: null };
1229
1851
  Notification.make().success().title(t('filament-panels::auth/pages/edit-profile.notifications.saved.title')).send();
1230
1852
  }
@@ -1299,15 +1921,41 @@ export class EditTenantProfile extends Page {
1299
1921
  }
1300
1922
  export class Login extends SimplePage {
1301
1923
  static callable = ['authenticate', 'callSchemaComponentMethod'];
1924
+ /** Signed token naming the user undertaking the multi-factor challenge: set by the server only. */
1925
+ static locked = ['userUndertakingMultiFactorAuthentication'];
1302
1926
  data = {};
1927
+ userUndertakingMultiFactorAuthentication = null;
1928
+ /** User of the pending challenge, resolved from the token on each request. */
1929
+ _multiFactorUser = null;
1930
+ _multiFactorProviders = [];
1303
1931
  mount() { fillSchema(this.getSchema('form'), this.data); }
1932
+ async boot() {
1933
+ const payload = this.readMultiFactorToken();
1934
+ const { panel } = this._ctx;
1935
+ this._multiFactorUser = payload ? await panel.getAuth().find(payload.uid) : null;
1936
+ const ids = Array.isArray(payload?.providers) ? payload.providers.map(String) : [];
1937
+ this._multiFactorProviders = this._multiFactorUser ? panel.getMultiFactorAuthenticationProviders().filter((provider) => ids.includes(provider.getId())) : [];
1938
+ }
1939
+ readMultiFactorToken() {
1940
+ const { panel } = this._ctx;
1941
+ return this.userUndertakingMultiFactorAuthentication ? readToken(panel.getAuthKey(), `${panel.getId()}.mfa-login`, this.userUndertakingMultiFactorAuthentication) : null;
1942
+ }
1943
+ /** Whether the multi-factor challenge is shown. */
1944
+ isUndertakingMultiFactorAuthentication() { return this._multiFactorUser !== null && this._multiFactorProviders.length > 0; }
1304
1945
  getTitle() { return t('filament-panels::auth/pages/login.title'); }
1305
- getHeading() { return t('filament-panels::auth/pages/login.heading'); }
1946
+ getHeading() { return this.isUndertakingMultiFactorAuthentication() ? t('filament-panels::auth/pages/login.multi_factor.heading') : t('filament-panels::auth/pages/login.heading'); }
1306
1947
  getSubheading() {
1948
+ if (this.isUndertakingMultiFactorAuthentication())
1949
+ return e(t('filament-panels::auth/pages/login.multi_factor.subheading'));
1307
1950
  if (!this.panel.hasRegistration())
1308
1951
  return null;
1309
1952
  return `${e(t('filament-panels::auth/pages/login.actions.register.before'))} ${authLink(this.panel.getRegistrationUrl(), t('filament-panels::auth/pages/login.actions.register.label'))}`;
1310
1953
  }
1954
+ getSchema(name) {
1955
+ if (name === 'multiFactorChallengeForm')
1956
+ return this.multiFactorChallengeFormSchema(Schema.make(this).key(name).statePath('data.multiFactor'));
1957
+ return super.getSchema(name);
1958
+ }
1311
1959
  formSchema(schema) {
1312
1960
  return schema.components([
1313
1961
  TextInput.make('email').label(t('filament-panels::auth/pages/login.form.email.label')).email().required().autocomplete().autofocus(),
@@ -1317,19 +1965,48 @@ export class Login extends SimplePage {
1317
1965
  Checkbox.make('remember').label(t('filament-panels::auth/pages/login.form.remember.label')),
1318
1966
  ]);
1319
1967
  }
1968
+ /** Challenge fields of the user's enabled providers (with a picker when there are several). */
1969
+ multiFactorChallengeFormSchema(schema) {
1970
+ if (!this.isUndertakingMultiFactorAuthentication())
1971
+ return schema.components([]);
1972
+ return schema.components(this.panel.getMultiFactorChallenge().getFormComponents(this._multiFactorUser, this._multiFactorProviders));
1973
+ }
1320
1974
  contentSchema(schema) {
1975
+ const challenging = this.isUndertakingMultiFactorAuthentication();
1321
1976
  return schema.components([
1322
1977
  RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_BEFORE),
1323
1978
  Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler('authenticate')
1324
- .footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')]),
1325
- // Multi-factor challenge form: hidden until MFA is implemented.
1326
- Form.make([]).id('multiFactorChallengeForm').hidden(),
1979
+ .footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')])
1980
+ .hidden(challenging),
1981
+ Form.make(challenging ? [EmbeddedSchema.make('multiFactorChallengeForm')] : []).id('multiFactorChallengeForm').livewireSubmitHandler('authenticate')
1982
+ .footer(challenging ? [Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.multi_factor.form.actions.authenticate.label')).submit('authenticate')]).fullWidth()] : [])
1983
+ .hidden(!challenging),
1327
1984
  RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_AFTER),
1328
1985
  ]);
1329
1986
  }
1987
+ /** Starts a challenge for this user if one of their providers is set up; returns whether it did. */
1988
+ async startMultiFactorChallenge(user) {
1989
+ const { panel } = this._ctx;
1990
+ if (!panel.hasMultiFactorAuthentication())
1991
+ return false;
1992
+ const challenge = panel.getMultiFactorChallenge();
1993
+ const enabled = await challenge.getEnabledProviders(user);
1994
+ if (!enabled.length)
1995
+ return false;
1996
+ // Ten minutes to answer; the password is checked again when the challenge is answered.
1997
+ this.userUndertakingMultiFactorAuthentication = signToken(panel.getAuthKey(), `${panel.getId()}.mfa-login`, { uid: user.id, providers: enabled.map((provider) => provider.getId()) }, 60 * 10);
1998
+ this._multiFactorUser = user;
1999
+ this._multiFactorProviders = enabled;
2000
+ await challenge.beforeChallenge(user, enabled[0]);
2001
+ const state = {};
2002
+ this.data = { ...this.data, multiFactor: state };
2003
+ fillSchema(this.getSchema('multiFactorChallengeForm'), state);
2004
+ return true;
2005
+ }
1330
2006
  async authenticate() {
1331
2007
  this._errors = await validateSchema(this.getSchema('form'));
1332
2008
  if (Object.keys(this._errors).length) {
2009
+ this.cancelMultiFactorChallenge();
1333
2010
  this.dispatch('form-validation-error', { livewireId: this.id });
1334
2011
  return;
1335
2012
  }
@@ -1340,15 +2017,88 @@ export class Login extends SimplePage {
1340
2017
  Notification.make().danger().title(t('filament-actions::notifications.throttled.title', { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t('filament-actions::notifications.throttled.body', { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
1341
2018
  return;
1342
2019
  }
2020
+ // The password is checked on every submission, the challenge's included: a password changed meanwhile ends it.
1343
2021
  const user = await panel.getAuth().attempt(String(this.data.email), String(this.data.password));
1344
- if (!user) {
2022
+ // As in Filament: a valid account that may not use this panel gets the same message as wrong credentials.
2023
+ if (!user || !(await panel.userCanAccess(user))) {
2024
+ this.cancelMultiFactorChallenge();
1345
2025
  this.addError('data.email', t('filament-panels::auth/pages/login.messages.failed'));
1346
2026
  return;
1347
2027
  }
2028
+ if (this.isUndertakingMultiFactorAuthentication() && String(this._multiFactorUser.id) === String(user.id)) {
2029
+ const challenge = panel.getMultiFactorChallenge();
2030
+ const challengeWait = challenge.hitRateLimiter(user);
2031
+ if (challengeWait > 0) {
2032
+ Notification.make().danger().title(t('filament-actions::notifications.throttled.title', { seconds: challengeWait, minutes: Math.ceil(challengeWait / 60) })).body(t('filament-actions::notifications.throttled.body', { seconds: challengeWait, minutes: Math.ceil(challengeWait / 60) })).send();
2033
+ return;
2034
+ }
2035
+ const challengeForm = this.getSchema('multiFactorChallengeForm');
2036
+ this._errors = await validateSchema(challengeForm);
2037
+ if (Object.keys(this._errors).length) {
2038
+ this.dispatch('form-validation-error', { livewireId: this.id });
2039
+ return;
2040
+ }
2041
+ // Defense in depth: the answered provider is one of the user's, and at least one of its fields was validated.
2042
+ const state = (this.data.multiFactor ?? {});
2043
+ const picked = this._multiFactorProviders.length > 1 ? String(state.provider ?? '') : this._multiFactorProviders[0].getId();
2044
+ const answered = this._multiFactorProviders.some((provider) => provider.getId() === picked)
2045
+ && (await challengeForm.getVisibleFlatComponents()).some((component) => component instanceof Field && component.getStatePath().startsWith(`data.multiFactor.${picked}.`));
2046
+ if (!answered) {
2047
+ this.addError('data.multiFactor.provider', t('filament-panels::auth/pages/login.messages.failed'));
2048
+ return;
2049
+ }
2050
+ challenge.clearRateLimiter(user);
2051
+ }
2052
+ else if (await this.startMultiFactorChallenge(user)) {
2053
+ return;
2054
+ }
1348
2055
  loginThrottle.clear(`${panel.getId()}:${req.ip ?? ''}`);
1349
2056
  panel.logIn(res, user, Boolean(this.data.remember));
2057
+ this.userUndertakingMultiFactorAuthentication = null;
1350
2058
  this.redirect(panel.getUrl());
1351
2059
  }
2060
+ /** The user picked another provider in the challenge: run its `beforeChallenge` (an email code is sent). */
2061
+ async updatedData(value, key) {
2062
+ if (key !== 'multiFactor.provider' || !this.isUndertakingMultiFactorAuthentication())
2063
+ return;
2064
+ const provider = this._multiFactorProviders.find((candidate) => candidate.getId() === value);
2065
+ if (provider)
2066
+ await this.panel.getMultiFactorChallenge().beforeChallenge(this._multiFactorUser, provider);
2067
+ }
2068
+ cancelMultiFactorChallenge() {
2069
+ this.userUndertakingMultiFactorAuthentication = null;
2070
+ this._multiFactorUser = null;
2071
+ this._multiFactorProviders = [];
2072
+ }
2073
+ }
2074
+ /** Shown after sign-in when the panel requires multi-factor authentication and the user has not set any provider up. */
2075
+ export class SetUpRequiredMultiFactorAuthentication extends SimplePage {
2076
+ static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
2077
+ async boot() { if (!this._ctx?.user)
2078
+ throw new WireError(401, 'Unauthenticated.'); }
2079
+ async mount() {
2080
+ const { panel, user } = this._ctx;
2081
+ if (!panel.hasMultiFactorAuthentication() || (await panel.getMultiFactorChallenge().hasEnabledProviders(user)))
2082
+ this.redirect(panel.getUrl());
2083
+ }
2084
+ getTitle() { return t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.title'); }
2085
+ getHeading() { return t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.heading'); }
2086
+ getSubheading() { return e(t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.subheading')); }
2087
+ contentSchema(schema) {
2088
+ const { panel, user } = this._ctx;
2089
+ return schema.components([
2090
+ multiFactorManagementSection(panel, user),
2091
+ Actions.make([
2092
+ Action.make('continue').label(t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.actions.continue.label')).url(panel.getUrl())
2093
+ .visible(() => panel.getMultiFactorChallenge().hasEnabledProviders(user)),
2094
+ ]).fullWidth().key('footer-actions'),
2095
+ ]);
2096
+ }
2097
+ }
2098
+ /** Profile section listing the providers: each one's status and actions, under its id. */
2099
+ function multiFactorManagementSection(panel, user) {
2100
+ return Section.make().compact().divided().secondary().key('multi-factor-authentication')
2101
+ .schema(panel.getMultiFactorAuthenticationProviders().map((provider) => Group.make(provider.getManagementSchemaComponents(user, panel)).statePath(provider.getId())));
1352
2102
  }
1353
2103
  /** Whether the host component's user has this ability on the component's resource (or relation manager). Use it in your actions' `authorize()`. */
1354
2104
  export const can = (livewire, ability, record = null) => canOn(livewire, ability, record);
@@ -1368,6 +2118,12 @@ export class Resource {
1368
2118
  static navigationGroup = null;
1369
2119
  static navigationSort = 0;
1370
2120
  static navigationBadge = null;
2121
+ /** Menu label; defaults to the plural model label in title case. */
2122
+ static navigationLabel = null;
2123
+ /** Label of another menu item of the group, below which the resource is shown. */
2124
+ static navigationParentItem = null;
2125
+ /** Cluster of the resource: its pages are served under the cluster's URL, and its list is in the cluster's sub-navigation. */
2126
+ static cluster = null;
1371
2127
  static adapter;
1372
2128
  /** In a multi-tenant panel, whether records belong to each tenant (`true` by default). */
1373
2129
  static isScopedToTenant = true;
@@ -1391,8 +2147,9 @@ export class Resource {
1391
2147
  static widgets = [];
1392
2148
  /** Widgets shown above the table on the list page. */
1393
2149
  static headerWidgets = [];
1394
- /** Relation managers shown below the edit form. */
2150
+ /** Relation managers shown below the edit form (and the view page), or groups of them (`RelationGroup`). */
1395
2151
  static relations = [];
2152
+ static getRelations() { return this.relations; }
1396
2153
  /** Tabs of the list page. */
1397
2154
  static tabs() { return {}; }
1398
2155
  /** Simple resource: a single page, with create and edit in modals. */
@@ -1434,7 +2191,11 @@ export class Resource {
1434
2191
  /** Columns searched by global search; defaults to the title attribute. Empty array: the resource is not searchable. */
1435
2192
  static globallySearchableAttributes = null;
1436
2193
  static getGloballySearchableAttributes() { return this.globallySearchableAttributes ?? (this.recordTitleAttribute ? [this.recordTitleAttribute] : []); }
1437
- static canGloballySearch() { return this.getGloballySearchableAttributes().length > 0; }
2194
+ /** `false`: the resource is left out of global search. */
2195
+ static isGloballySearchable = true;
2196
+ /** `false`: the search term is matched as a whole instead of word by word. */
2197
+ static shouldSplitGlobalSearchTerms = true;
2198
+ static canGloballySearch() { return this.isGloballySearchable && this.getGloballySearchableAttributes().length > 0; }
1438
2199
  /** Position of this resource's group in the results; without one, after the others in registration order. */
1439
2200
  static globalSearchSort = null;
1440
2201
  /** Maximum results rendered per resource. */
@@ -1442,6 +2203,11 @@ export class Resource {
1442
2203
  static getGlobalSearchResultTitle(record) { return this.getRecordTitle(record); }
1443
2204
  /** Details shown under the title: `{ Author: 'Jane' }`. Empty values are skipped. */
1444
2205
  static getGlobalSearchResultDetails(_record) { return {}; }
2206
+ /**
2207
+ * Buttons under a result: links (`Action.make('edit').url(…)`) or Livewire events (`.dispatch('quickView', [id])`).
2208
+ * They are not mounted: an action needing a modal belongs on the record's pages.
2209
+ */
2210
+ static getGlobalSearchResultActions(_record) { return []; }
1445
2211
  /** URL of a result: the view page, otherwise the edit page; for a simple resource, the list with the action open. `null` if the user has no access. */
1446
2212
  static async getGlobalSearchResultUrl(record, user) {
1447
2213
  const key = this.adapter.getKey(record);
@@ -1455,7 +2221,7 @@ export class Resource {
1455
2221
  return action ? `${this.getUrl('index')}?tableAction=${action}&tableActionRecord=${encodeURIComponent(key)}` : null;
1456
2222
  }
1457
2223
  static async getGlobalSearchResults(search, user) {
1458
- const page = await this.adapter.paginate({ search, searchColumns: this.getGloballySearchableAttributes(), page: 1, perPage: Math.min(Math.max(this.globalSearchResultsLimit, 1), 200) });
2224
+ const page = await this.adapter.paginate({ search, searchColumns: this.getGloballySearchableAttributes(), splitSearchTerms: this.shouldSplitGlobalSearchTerms, page: 1, perPage: Math.min(Math.max(this.globalSearchResultsLimit, 1), 200) });
1459
2225
  const results = [];
1460
2226
  for (const record of page.records) {
1461
2227
  const url = await this.getGlobalSearchResultUrl(record, user);
@@ -1465,7 +2231,7 @@ export class Resource {
1465
2231
  for (const [label, value] of Object.entries(await this.getGlobalSearchResultDetails(record)))
1466
2232
  if (value !== null && value !== undefined && value !== '')
1467
2233
  details[label] = String(value);
1468
- results.push({ title: this.getGlobalSearchResultTitle(record), url, details });
2234
+ results.push({ title: this.getGlobalSearchResultTitle(record), url, details, actions: await this.getGlobalSearchResultActions(record), record });
1469
2235
  }
1470
2236
  return results;
1471
2237
  }
@@ -1492,6 +2258,8 @@ export class Resource {
1492
2258
  static navigationGroup = resource.navigationGroup;
1493
2259
  static navigationSort = resource.navigationSort;
1494
2260
  static navigationBadge = resource.navigationBadge;
2261
+ static navigationParentItem = resource.navigationParentItem;
2262
+ static navigationLabel = resource.navigationLabel;
1495
2263
  static getNavigationBadge = typeof resource.getNavigationBadge === 'function' ? () => resource.getNavigationBadge() : undefined;
1496
2264
  };
1497
2265
  const Create = class extends (resource.pages.create ?? CreateRecord) {
@@ -1508,6 +2276,15 @@ export class Resource {
1508
2276
  return { index: List };
1509
2277
  const related = {};
1510
2278
  for (const [name, RelatedPage] of Object.entries(resource.relatedPages)) {
2279
+ // Another edit or view page of the record (`EditCustomerContact extends EditRecord`): served at `<slug>/{record}/<name>`.
2280
+ if (RelatedPage.prototype instanceof EditRecord || RelatedPage.prototype instanceof ViewRecord) {
2281
+ related[name] = class extends RelatedPage {
2282
+ static resource = resource;
2283
+ static slug = `${resource.slug}/{record}/${name}`;
2284
+ static shouldRegisterNavigation = false;
2285
+ };
2286
+ continue;
2287
+ }
1511
2288
  related[name] = class extends RelatedPage {
1512
2289
  static parentResource = resource;
1513
2290
  static slug = `${resource.slug}/{record}/${name}`;
@@ -1524,11 +2301,15 @@ export class Resource {
1524
2301
  return { index: List, create: Create, edit: Edit, view: View, ...related };
1525
2302
  }
1526
2303
  }
2304
+ /** The cluster a page belongs to: its own, or its resource's. */
2305
+ export const clusterOf = (page) => page?.cluster ?? page?.resource?.cluster ?? page?.parentResource?.cluster ?? null;
1527
2306
  /** Widget: a component displayed in a page's widget grid. */
1528
2307
  export class Widget extends Component {
1529
2308
  static componentName;
1530
- /** Width in the widget grid: a number of columns, or `'full'`. */
2309
+ /** Width in the widget grid: a number of columns or `'full'` (from `lg`), or a span per breakpoint (`{ md: 2, xl: 3 }`). */
1531
2310
  static columnSpan = 1;
2311
+ /** Position on the dashboard (lower first); widgets without a sort keep their order, after the sorted ones. */
2312
+ static sort = null;
1532
2313
  /** Loaded when it becomes visible. */
1533
2314
  static isLazy = true;
1534
2315
  get panel() { return this._ctx.panel; }
@@ -1542,9 +2323,16 @@ export class Widget extends Component {
1542
2323
  if (!(await ctx.panel.authorize(ctx.user, 'view', { widget: WidgetClass, rule: () => WidgetClass.canView(ctx.user) })))
1543
2324
  throw new WireError(403, 'Forbidden.');
1544
2325
  }
1545
- /** Grid column classes and style, at the `lg` breakpoint. */
2326
+ /** Grid column classes and style: at the `lg` breakpoint, or per breakpoint. */
1546
2327
  gridColumn() {
1547
2328
  const span = this.constructor.columnSpan;
2329
+ if (typeof span === 'object') {
2330
+ const entries = Object.entries(span).filter(([breakpoint, value]) => /^(default|sm|md|lg|xl|2xl)$/.test(breakpoint) && (value === 'full' || (Number.isInteger(value) && value > 0)));
2331
+ return {
2332
+ classes: ['fi-grid-col', ...entries.map(([breakpoint]) => (breakpoint === 'default' ? 'fi-grid-col-span' : `${breakpoint}:fi-grid-col-span`))],
2333
+ style: entries.map(([breakpoint, value]) => `--col-span-${breakpoint}: ${value === 'full' ? '1 / -1' : `span ${value} / span ${value}`};`).join(' '),
2334
+ };
2335
+ }
1548
2336
  // The span is always written, even for a single column.
1549
2337
  return { classes: ['fi-grid-col', 'lg:fi-grid-col-span'], style: `--col-span-lg: ${span === 'full' ? '1 / -1' : `span ${Math.floor(span)} / span ${Math.floor(span)}`};` };
1550
2338
  }
@@ -1842,6 +2630,10 @@ export class StatsOverviewWidget extends Widget {
1842
2630
  static columnSpan = 'full';
1843
2631
  /** Polling interval, or `null` to disable polling. */
1844
2632
  static pollingInterval = '5s';
2633
+ /** Heading shown above the stats. */
2634
+ getHeading() { return null; }
2635
+ /** Description under the heading. */
2636
+ getDescription() { return null; }
1845
2637
  /** Row columns: three, or four when the number of stats fits better. */
1846
2638
  getColumns(count) { return count < 3 ? 3 : count % 3 !== 1 ? 3 : 4; }
1847
2639
  async render() {
@@ -1855,11 +2647,13 @@ export class StatsOverviewWidget extends Widget {
1855
2647
  const id = `stats-overview-stat-${index}`, key = `content.${id}`;
1856
2648
  return `<div wire:partial="schema-component::${key}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${this.id}.${key}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${stat.toHtml(this.panel, id)}</div></div>`;
1857
2649
  }).join('');
2650
+ const heading = this.getHeading(), description = this.getDescription();
2651
+ const header = heading !== null || description !== null ? `<header class="fi-section-header"><div class="fi-section-header-content fi-section-header-content-has-heading"><div class="fi-section-header-text-ctn">${heading !== null ? `<h3 class="fi-section-header-heading">${e(heading)}</h3>` : ''}${description !== null ? `<p class="fi-section-header-description">${e(description)}</p>` : ''}</div></div></header>` : '';
1858
2652
  const interval = this.constructor.pollingInterval;
1859
2653
  const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}` : '';
1860
2654
  return this.wrap(`<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div ${anonymous} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component fi-grid-ctn"><div${poll} class="fi-sc-section"><section x-data="{
1861
2655
  isCollapsed: false,
1862
- }" class="fi-section fi-section-not-contained"><div class="fi-section-content-ctn"><div class="fi-section-content fi-grid @xl:fi-grid-cols !@lg:fi-grid-cols fi-sc fi-sc-has-gap" style="--cols-cxl: repeat(${columns}, minmax(0, 1fr)); --cols-nclg: repeat(${columns}, minmax(0, 1fr)); --cols-default: repeat(1, minmax(0, 1fr));">${cells}</div></div></section></div></div></div></div>`, ['fi-wi-stats-overview']);
2656
+ }" class="fi-section fi-section-not-contained${header ? ' fi-section-has-header' : ''}">${header}<div class="fi-section-content-ctn"><div class="fi-section-content fi-grid @xl:fi-grid-cols !@lg:fi-grid-cols fi-sc fi-sc-has-gap" style="--cols-cxl: repeat(${columns}, minmax(0, 1fr)); --cols-nclg: repeat(${columns}, minmax(0, 1fr)); --cols-default: repeat(1, minmax(0, 1fr));">${cells}</div></div></section></div></div></div></div>`, ['fi-wi-stats-overview']);
1863
2657
  }
1864
2658
  }
1865
2659
  /**
@@ -1868,6 +2662,10 @@ export class StatsOverviewWidget extends Widget {
1868
2662
  * has changed (checksum).
1869
2663
  */
1870
2664
  export class ChartWidget extends Widget {
2665
+ /** Empty state shown when `getData()` returns nothing. */
2666
+ getEmptyStateHeading() { return t('filament-widgets::chart.empty.heading'); }
2667
+ getEmptyStateDescription() { return null; }
2668
+ getEmptyStateIcon() { return 'o-x-mark'; }
1871
2669
  static callable = ['updateChartData'];
1872
2670
  static locked = ['dataChecksum'];
1873
2671
  /** Polling interval, or `null` to disable polling. */
@@ -1933,7 +2731,7 @@ export class ChartWidget extends Widget {
1933
2731
  type: ${e(js(type))},
1934
2732
  })" class="${cls([...this.panel.colors.getComponentClasses('stat-chart', this.getColor()), 'fi-wi-chart-frame', 'fi-wi-chart-canvas-ctn', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"><canvas x-ref="canvas" aria-hidden="true" style="width: 100%; ${hasMaxHeight ? `max-height: ${e(maxHeight)}` : 'height: 100%; max-height: 100%'}"></canvas>${colors.map((name, index) => `<span aria-hidden="true" x-ref="${refs[index]}ColorElement" class="fi-wi-chart-${name}-color"></span>`).join('')}</div>`;
1935
2733
  const empty = isEmpty
1936
- ? `<div class="${cls(['fi-wi-chart-frame', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"${hasMaxHeight ? ` style="min-height: ${e(maxHeight)}"` : ''}>${emptyState({ heading: t('filament-widgets::chart.empty.heading'), icon: 'o-x-mark', iconColor: 'gray', contained: false })}</div>`
2734
+ ? `<div class="${cls(['fi-wi-chart-frame', { 'fi-wi-chart-frame-no-aspect-ratio': hasMaxHeight }])}"${hasMaxHeight ? ` style="min-height: ${e(maxHeight)}"` : ''}>${emptyState({ heading: this.getEmptyStateHeading(), description: this.getEmptyStateDescription() ?? undefined, icon: this.getEmptyStateIcon() ?? undefined, iconColor: 'gray', contained: false })}</div>`
1937
2735
  : '';
1938
2736
  const body = `<div${poll}${isEmpty ? ' style="display: none"' : ''}><div class="fi-wi-chart-image"${label ? ` role="img" aria-label="${e(label)}"` : ''}>${frame}</div>${assistive ? `<div class="fi-wi-chart-assistive-content fi-sr-only"><p>${e(assistive)}</p></div>` : ''}</div>${empty}`;
1939
2737
  return this.wrap(`<section x-data="{
@@ -2029,40 +2827,105 @@ export class EmbeddedTable extends SchemaComponent {
2029
2827
  static make() { return new EmbeddedTable(); }
2030
2828
  async toHtml() { return this.livewire.renderTable(); }
2031
2829
  }
2032
- /** Relation manager tabs of an edit page: only the active tab's manager is mounted, as a lazy child component. */
2830
+ /**
2831
+ * Several relation managers under one tab of a record page: `RelationGroup.make('Contacts', [PhonesManager, EmailsManager])`.
2832
+ * The managers are shown one below the other.
2833
+ */
2834
+ export class RelationGroup {
2835
+ _label;
2836
+ _managers;
2837
+ _icon = null;
2838
+ _badge = null;
2839
+ _badgeColor = 'primary';
2840
+ constructor(label, managers) { this._label = label; this._managers = managers; }
2841
+ static make(label, managers) { return new RelationGroup(label, managers); }
2842
+ icon(name) { this._icon = name; return this; }
2843
+ badge(badge) { this._badge = badge; return this; }
2844
+ badgeColor(color) { this._badgeColor = color; return this; }
2845
+ getLabel() { return this._label; }
2846
+ getIcon() { return this._icon; }
2847
+ getManagers() { return this._managers; }
2848
+ async getBadge() { return typeof this._badge === 'function' ? this._badge() : this._badge; }
2849
+ getBadgeColor() { return this._badgeColor; }
2850
+ }
2851
+ /** Relation manager tabs of a record page: only the active tab's managers are mounted, as lazy child components. */
2033
2852
  class RelationManagerTabs extends SchemaComponent {
2034
- constructor() { super(); this._keyName = 'relationManagerTabs'; }
2853
+ content = [];
2854
+ constructor(content = []) { super(); this._keyName = 'relationManagerTabs'; this.content = content; }
2855
+ /** With content (the form or infolist), it is the first tab: `hasCombinedRelationManagerTabsWithContent()`. */
2856
+ getChildSchemas() { return this.content.length ? [childSchema(this, this.content)] : []; }
2035
2857
  getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
2036
2858
  getLazyLoadedTabBadges() { return {}; }
2037
2859
  getDeferredTabBadges() { return {}; }
2860
+ /** Tabs the user can see on this record: managers and groups whose `canViewForRecord()` allows it. */
2861
+ async tabs(page) {
2862
+ const owner = page._record;
2863
+ const PageClass = page.constructor;
2864
+ const visible = async (manager) => Boolean(await manager.canViewForRecord(owner, PageClass));
2865
+ const tabs = [];
2866
+ if (this.content.length)
2867
+ tabs.push({ key: '-1', label: page.getContentTabLabel(), icon: page.getContentTabIcon(), badge: null, badgeColor: 'primary', managers: [], isContent: true });
2868
+ for (const [index, entry] of page.getRelationManagers().entries()) {
2869
+ if (entry instanceof RelationGroup) {
2870
+ const managers = [];
2871
+ for (const manager of entry.getManagers())
2872
+ if (await visible(manager))
2873
+ managers.push(manager);
2874
+ if (managers.length)
2875
+ tabs.push({ key: String(index), label: entry.getLabel(), icon: entry.getIcon(), badge: await entry.getBadge(), badgeColor: entry.getBadgeColor(), managers, isContent: false });
2876
+ }
2877
+ else if (await visible(entry)) {
2878
+ tabs.push({ key: String(index), label: entry.getTitle(), icon: entry.getIcon(), badge: await entry.getBadge(owner, PageClass), badgeColor: entry.getBadgeColor(), managers: [entry], isContent: false });
2879
+ }
2880
+ }
2881
+ return tabs;
2882
+ }
2883
+ async isVisible() { return (await super.isVisible()) && (this.content.length > 0 || (await this.tabs(this.livewire)).length > 0); }
2038
2884
  async toHtml() {
2039
2885
  const page = this.livewire;
2040
- const managers = page.resource.relations;
2886
+ const tabs = await this.tabs(page);
2887
+ if (!tabs.length)
2888
+ return '';
2041
2889
  const key = this.getKey();
2042
2890
  const id = page.id;
2043
- const active = Math.min(Math.max(Math.floor(Number(page.activeRelationManager)) || 0, 0), managers.length - 1);
2044
- const tabId = (index) => `fi-sc-tab-${createHash('md5').update(`${key}.${index}`).digest('hex')}`;
2045
- const tabs = managers.map((manager, index) => `<button id="${tabId(index)}" role="tab" aria-controls="${key}.${index}" aria-selected="${index === active ? 'true' : 'false'}" x-bind:aria-selected="isTabSelected('${index}')" tabindex="${index === active ? '0' : '-1'}" x-bind:tabindex="getTabIndex('${index}')" data-tab-key="${index}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab('${index}', $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" type="button" wire:click="$set('activeRelationManager', '${index}')" class="${cls(['fi-tabs-item', { 'fi-active': index === active }])}"><span class="fi-tabs-item-label">${e(manager.getTitle())}</span></button>`);
2891
+ const active = tabs.find((tab) => tab.key === String(page.activeRelationManager)) ?? tabs[0];
2892
+ const tabId = (tab) => `fi-sc-tab-${createHash('md5').update(`${key}.${tab.key}`).digest('hex')}`;
2893
+ const header = tabs.map((tab) => {
2894
+ const isActive = tab === active;
2895
+ const iconHtml = tab.icon ? icon(tab.icon) : '';
2896
+ const badgeHtml = tab.badge !== null && tab.badge !== undefined && tab.badge !== '' ? uiBadge({ color: tab.badgeColor, size: 'sm' }, e(String(tab.badge))) : '';
2897
+ return `<button id="${tabId(tab)}" role="tab" aria-controls="${key}.${tab.key}" aria-selected="${isActive ? 'true' : 'false'}" x-bind:aria-selected="isTabSelected('${tab.key}')" tabindex="${isActive ? '0' : '-1'}" x-bind:tabindex="getTabIndex('${tab.key}')" data-tab-key="${tab.key}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab('${tab.key}', $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected('${tab.key}') }" type="button" wire:click="$set('activeRelationManager', '${tab.key}')" class="${cls(['fi-tabs-item', { 'fi-active': isActive }])}">${iconHtml}<span class="fi-tabs-item-label">${e(tab.label)}</span>${badgeHtml}</button>`;
2898
+ });
2046
2899
  const panels = [];
2047
- for (const [index, manager] of managers.entries()) {
2900
+ for (const tab of tabs) {
2048
2901
  let content = '';
2049
- if (index === active) {
2050
- const componentName = page._runtime.nameOf(manager);
2051
- const childKey = `${key}.${index}.${componentName}`;
2052
- const child = await page.mountChild(componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
2053
- content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div wire:partial="schema-component::${e(childKey)}" x-data="filamentSchemaComponent(${e(js({ key: childKey, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${id}.${childKey}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${child}</div></div></div>`;
2902
+ if (tab.isContent) {
2903
+ // The form or infolist: always rendered, so that its state and actions stay in the page.
2904
+ content = (await this.getChildSchemas()[0]?.toHtml()) ?? '';
2905
+ }
2906
+ else if (tab === active) {
2907
+ const parts = [];
2908
+ for (const manager of tab.managers) {
2909
+ const componentName = page._runtime.nameOf(manager);
2910
+ const childKey = `${key}.${tab.key}.${componentName}`;
2911
+ const child = await page.mountChild(componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
2912
+ parts.push(`<div wire:partial="schema-component::${e(childKey)}" x-data="filamentSchemaComponent(${e(js({ key: childKey, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${id}.${childKey}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${child}</div></div>`);
2913
+ }
2914
+ content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${parts.join('')}</div>`;
2054
2915
  }
2055
- panels.push(`<div aria-labelledby="${tabId(index)}" id="${key}.${index}" role="tabpanel" tabindex="0" wire:key="${id}.${key}.${index}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" x-on:expand="revealTab('${index}')" x-data="filamentSchemaComponent(${e(js({ key: `${key}.${index}`, exposedMethods: [], path: '', containerPath: '' }))})" class="fi-sc-tabs-tab">${content}</div>`);
2916
+ const isActive = tab === active;
2917
+ panels.push(`<div aria-labelledby="${tabId(tab)}" id="${key}.${tab.key}" role="tabpanel" tabindex="0" wire:key="${id}.${key}.${tab.key}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected('${tab.key}') }" x-on:expand="revealTab('${tab.key}')" x-data="filamentSchemaComponent(${e(js({ key: `${key}.${tab.key}`, exposedMethods: [], path: '', containerPath: '' }))})" class="${cls(['fi-sc-tabs-tab', { 'fi-active': isActive && tab.isContent }])}">${content}</div>`);
2056
2918
  }
2919
+ const activeIndex = tabs.indexOf(active);
2057
2920
  return `<div x-data="tabsSchemaComponent({
2058
- activeTab: ${active + 1},
2921
+ activeTab: ${activeIndex + 1},
2059
2922
  hasTabPanels: true,
2060
2923
  isScrollable: true,
2061
2924
  isVertical: false,
2062
2925
  livewireId: ${e(js(id))},
2063
2926
  livewireProperty: 'activeRelationManager',
2064
- tab: '${active}',
2065
- })" x-load x-load-src="${e(page.panel.assetUrl('js/filament/schemas/components/tabs.js'))}" wire:ignore.self id="${key}" wire:key="${id}.${key}.container" class="fi-sc-tabs"><input type="hidden" x-ref="tabsData" data-active-tab="${active}" /><div x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(t('filament::components/tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeRelationManager" class="fi-sc-tabs-tablist">${tabs.join('')}</div></div>${panels.join('')}</div>`;
2927
+ tab: '${active.key}',
2928
+ })" x-load x-load-src="${e(page.panel.assetUrl('js/filament/schemas/components/tabs.js'))}" wire:ignore.self id="${key}" wire:key="${id}.${key}.container" class="fi-sc-tabs"><input type="hidden" x-ref="tabsData" data-active-tab="${active.key}" /><div x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(t('filament::components/tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeRelationManager" class="fi-sc-tabs-tablist">${header.join('')}</div></div>${panels.join('')}</div>`;
2066
2929
  }
2067
2930
  }
2068
2931
  /** List page tab: restricts the table query and can show a badge. */
@@ -2078,6 +2941,13 @@ export class Tab {
2078
2941
  badge(resolver) { this._badge = resolver; return this; }
2079
2942
  badgeColor(color) { this._badgeColor = color; this._hasBadgeColor = true; return this; }
2080
2943
  query(modifier) { this._query = modifier; return this; }
2944
+ _icon = null;
2945
+ _iconPosition = 'before';
2946
+ /** Icon of the tab, before its label (or after, with `iconPosition('after')`). */
2947
+ icon(name) { this._icon = name; return this; }
2948
+ iconPosition(position) { this._iconPosition = position; return this; }
2949
+ getIcon() { return this._icon; }
2950
+ getIconPosition() { return this._iconPosition; }
2081
2951
  getLabel(key) { return this._label ?? ucfirst(key.replace(/[_-]+/g, ' ')); }
2082
2952
  hasBadge() { return this._badge !== null; }
2083
2953
  async getBadge() { return this._badge ? this._badge() : null; }
@@ -2132,7 +3002,7 @@ class ResourceTabs extends SchemaComponent {
2132
3002
  ${d}?.badgeIconHtml &&
2133
3003
  ${d}?.badgeIconPosition === 'after'
2134
3004
  "><span x-html="${d}.badgeIconHtml"></span></template></span></template>` : '';
2135
- return `<button id="fi-sc-tab-${createHash('md5').update(`${key}.${k}`).digest('hex')}" aria-pressed="${k === active ? 'true' : 'false'}" x-bind:aria-pressed="isTabSelected(${q(k)})" tabindex="0" x-bind:tabindex="getTabIndex(${q(k)})" data-tab-key="${e(k)}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab(${q(k)}, $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected(${q(k)}) }" type="button" wire:click="$set('activeTab', ${k === '' ? 'null' : q(k)})" class="${cls(['fi-tabs-item', { 'fi-active': k === active }])}"><span class="fi-tabs-item-label">${e(tab.getLabel(k))}</span>${badge}</button>`;
3005
+ return `<button id="fi-sc-tab-${createHash('md5').update(`${key}.${k}`).digest('hex')}" aria-pressed="${k === active ? 'true' : 'false'}" x-bind:aria-pressed="isTabSelected(${q(k)})" tabindex="0" x-bind:tabindex="getTabIndex(${q(k)})" data-tab-key="${e(k)}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab(${q(k)}, $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected(${q(k)}) }" type="button" wire:click="$set('activeTab', ${k === '' ? 'null' : q(k)})" class="${cls(['fi-tabs-item', { 'fi-active': k === active }])}">${tab.getIcon() && tab.getIconPosition() === 'before' ? icon(tab.getIcon(), { attributes: new Attributes().class(['fi-tabs-item-icon']) }) : ''}<span class="fi-tabs-item-label">${e(tab.getLabel(k))}</span>${tab.getIcon() && tab.getIconPosition() === 'after' ? icon(tab.getIcon(), { attributes: new Attributes().class(['fi-tabs-item-icon']) }) : ''}${badge}</button>`;
2136
3006
  });
2137
3007
  return `<div x-data="tabsSchemaComponent({
2138
3008
  activeTab: ${Math.max(tabs.findIndex(([k]) => k === active), 0) + 1},
@@ -2160,6 +3030,11 @@ class ResourceTabs extends SchemaComponent {
2160
3030
  export class ListRecords extends Page {
2161
3031
  static callable = ['updateTableColumnState', 'sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableFilters', 'resetTableFiltersForm', 'removeTableFilters', 'removeTableFilter', 'applyTableColumnManager', 'resetTableColumnManager', 'toggleTableReordering', 'reorderTable'];
2162
3032
  static resource;
3033
+ /** In a cluster, the list page shows the cluster's sub-navigation. */
3034
+ getSubNavigation() {
3035
+ const cluster = this.resource.cluster;
3036
+ return cluster?.shouldRegisterSubNavigation ? this.panel.getClusterSubNavigation(cluster, this, this._ctx.user) : [];
3037
+ }
2163
3038
  tableSearch = '';
2164
3039
  tableSort = null;
2165
3040
  tableColumnSearches = {};
@@ -2194,10 +3069,73 @@ export class ListRecords extends Page {
2194
3069
  if (allowed.delete(key))
2195
3070
  await adapter.update(key, { [column]: ++rank });
2196
3071
  }
3072
+ /** Key of this table's persisted state: per page class (and resource or relationship). */
3073
+ tableSessionKey(part) {
3074
+ const page = this.constructor;
3075
+ return ['tables', page.name, page.resource?.slug ?? '', page.relationship ?? '', part].join(':');
3076
+ }
3077
+ /** Session of the persisted table state: the browser (its CSRF token) and the signed-in user. */
3078
+ tableSession() {
3079
+ const ctx = this._ctx;
3080
+ return ctx?.mailbox ? `${ctx.panel.getId()}:${ctx.user?.id ?? ''}:${ctx.mailbox}` : null;
3081
+ }
3082
+ async dehydrate() {
3083
+ await super.dehydrate?.();
3084
+ const table = this.getTable(), session = this.tableSession();
3085
+ if (!session)
3086
+ return;
3087
+ const store = this._ctx.panel.getSessionStore();
3088
+ if (table.persistsFiltersInSession())
3089
+ await store.set(session, this.tableSessionKey('filters'), this.tableFilters);
3090
+ if (table.persistsSearchInSession())
3091
+ await store.set(session, this.tableSessionKey('search'), this.tableSearch);
3092
+ if (table.persistsColumnSearchesInSession())
3093
+ await store.set(session, this.tableSessionKey('column-searches'), this.tableColumnSearches);
3094
+ if (table.persistsSortInSession())
3095
+ await store.set(session, this.tableSessionKey('sort'), this.tableSort);
3096
+ if (table.persistsGroupingInSession())
3097
+ await store.set(session, this.tableSessionKey('grouping'), this.tableGrouping);
3098
+ }
3099
+ /** Restores the persisted table state; every value is sanitized like browser input. */
3100
+ async restoreTableSession(table) {
3101
+ const session = this.tableSession();
3102
+ if (!session)
3103
+ return;
3104
+ const store = this._ctx.panel.getSessionStore();
3105
+ if (table.persistsFiltersInSession()) {
3106
+ const saved = await store.get(session, this.tableSessionKey('filters'));
3107
+ if (saved && typeof saved === 'object') {
3108
+ this.tableFilters = this.sanitizeTableFilters(saved);
3109
+ this.tableDeferredFilters = JSON.parse(JSON.stringify(this.tableFilters));
3110
+ }
3111
+ }
3112
+ if (table.persistsSearchInSession()) {
3113
+ const saved = await store.get(session, this.tableSessionKey('search'));
3114
+ if (typeof saved === 'string')
3115
+ this.tableSearch = saved.slice(0, 1000);
3116
+ }
3117
+ if (table.persistsColumnSearchesInSession()) {
3118
+ const saved = await store.get(session, this.tableSessionKey('column-searches'));
3119
+ if (saved && typeof saved === 'object')
3120
+ this.tableColumnSearches = Object.fromEntries(Object.entries(saved).filter(([name, value]) => typeof value === 'string' && table.getColumn(name)?.isIndividuallySearchable()).map(([name, value]) => [name, value.slice(0, 1000)]));
3121
+ }
3122
+ if (table.persistsSortInSession()) {
3123
+ const saved = await store.get(session, this.tableSessionKey('sort'));
3124
+ if (typeof saved === 'string' || saved === null)
3125
+ this.tableSort = saved;
3126
+ }
3127
+ // Only a group the table declares is kept: `getTableGrouping()` ignores anything else.
3128
+ if (table.persistsGroupingInSession()) {
3129
+ const saved = await store.get(session, this.tableSessionKey('grouping'));
3130
+ if (typeof saved === 'string' && saved.length <= 200)
3131
+ this.tableGrouping = saved;
3132
+ }
3133
+ }
2197
3134
  async mount(_params = {}) {
2198
3135
  const table = this.getTable();
2199
3136
  this.tableFilters = table.getDefaultFilterState();
2200
3137
  this.tableDeferredFilters = table.getDefaultFilterState();
3138
+ await this.restoreTableSession(table);
2201
3139
  this.tableColumns = table.getDefaultColumnState();
2202
3140
  // Preselect the default group.
2203
3141
  initializeTableGrouping(table, this);
@@ -2216,43 +3154,71 @@ export class ListRecords extends Page {
2216
3154
  /** Filters form: one group per filter, bound to `tableDeferredFilters.<filter>`. */
2217
3155
  tableFiltersFormSchema(schema) {
2218
3156
  const table = this.getTable();
2219
- // Above the content, filters spread over several columns; in the dropdown, a single one.
2220
- const columns = table.getFiltersLayout() === 'above-content-collapsible' ? { sm: 2, lg: 3, xl: 4, '2xl': 5 } : 1;
2221
- return schema.statePath('tableDeferredFilters').columns(columns).partiallyRendered().components(table.getFilters().map((filter) => {
3157
+ // Deferred filters edit a copy applied by the "Apply" button; otherwise the form edits the applied state, field by field.
3158
+ const deferred = table.hasDeferredFilters();
3159
+ const groups = table.getFilters().map((filter) => {
2222
3160
  // A filter may provide its own form (query builder); otherwise it is a select.
2223
3161
  const custom = filter.getFormComponents?.();
2224
3162
  if (custom)
2225
3163
  return StatefulGroup.make(filter.name, custom).columnSpan(filter.getColumnSpan?.() ?? { default: 1, lg: 1 });
2226
3164
  const definition = filter.getFormDefinition();
2227
3165
  return StatefulGroup.make(filter.name, [Select.make('value').label(filter.getLabel()).placeholder(definition.placeholder).options(filter.getFormOptions ? () => filter.getFormOptions() : definition.options)]).columnSpan({ default: 1, lg: 1 });
3166
+ });
3167
+ // `filtersFormSchema()`: the application arranges the filter groups (sections, columns...).
3168
+ const arrange = table.getFiltersFormSchema();
3169
+ const components = arrange ? arrange(Object.fromEntries(table.getFilters().map((filter, index) => [filter.name, groups[index]]))) : groups;
3170
+ const built = schema.statePath(deferred ? 'tableDeferredFilters' : 'tableFilters').columns(table.getFiltersFormColumns()).partiallyRendered().components(components);
3171
+ if (!deferred)
3172
+ for (const component of built.getFlatComponents())
3173
+ if (component instanceof Field)
3174
+ component.live();
3175
+ return built;
3176
+ }
3177
+ /** Applied state of every declared filter, sanitized from the given (browser) state. */
3178
+ sanitizeTableFilters(source) {
3179
+ return Object.fromEntries(this.getTable().getFilters().map((filter) => {
3180
+ const received = source?.[filter.name];
3181
+ if (filter.sanitizeState)
3182
+ return [filter.name, filter.sanitizeState(received)];
3183
+ return [filter.name, Object.fromEntries(Object.keys(filter.getDefaultState()).map((key) => [key, received?.[key] ?? null]))];
2228
3184
  }));
2229
3185
  }
3186
+ /** Live filters (`deferFilters(false)`): a changed field applies at once. */
3187
+ async updatedTableFilters(value, key) {
3188
+ if (this.getTable().hasDeferredFilters()) {
3189
+ this.tableFilters = this.sanitizeTableFilters(this.tableFilters);
3190
+ return;
3191
+ }
3192
+ await this.runFilterFieldHook('tableFilters', key);
3193
+ this.tableFilters = this.sanitizeTableFilters(this.tableFilters);
3194
+ this.tableDeferredFilters = JSON.parse(JSON.stringify(this.tableFilters));
3195
+ this.paginators.page = 1;
3196
+ this.dispatch('deselectAllTableRecords', [], { self: true });
3197
+ }
2230
3198
  applyTableFilters() {
2231
3199
  // Only filters declared by the table reach the applied state: each sanitizes its own state, or else only its known keys are kept.
2232
- this.tableFilters = Object.fromEntries(this.getTable().getFilters().map((filter) => {
2233
- const deferred = this.tableDeferredFilters?.[filter.name];
2234
- if (filter.sanitizeState)
2235
- return [filter.name, filter.sanitizeState(deferred)];
2236
- return [filter.name, Object.fromEntries(Object.keys(filter.getDefaultState()).map((key) => [key, deferred?.[key] ?? null]))];
2237
- }));
3200
+ this.tableFilters = this.sanitizeTableFilters(this.tableDeferredFilters);
2238
3201
  this.paginators.page = 1;
2239
3202
  }
2240
3203
  /** Removes an active filter indicator: the field is cleared from both the applied state and the form. */
2241
- removeTableFilter(filterName, field = null) {
2242
- const filter = this.getTable().getFilters().find((candidate) => candidate.name === filterName);
2243
- if (!filter)
3204
+ async removeTableFilter(filterName, field = null) {
3205
+ const table = this.getTable();
3206
+ const filter = table.getFilters().find((candidate) => candidate.name === filterName);
3207
+ // A filter showing a non-removable indicator cannot be removed by a forged call either.
3208
+ if (!filter || !(await isTableFilterRemovable(table, this, filter.name)))
2244
3209
  return;
2245
3210
  const current = this.tableFilters?.[filter.name];
2246
- const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : filter.getDefaultState();
3211
+ const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : (filter.getResetState?.() ?? filter.getDefaultState());
2247
3212
  this.tableFilters = { ...this.tableFilters, [filter.name]: next };
2248
3213
  this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(next)) };
2249
3214
  this.paginators.page = 1;
2250
3215
  }
2251
3216
  /** Live field in the filters form (e.g. a rule's operator changed): runs its `afterStateUpdated`. */
2252
- async updatedTableDeferredFilters(_value, key) {
3217
+ async updatedTableDeferredFilters(_value, key) { await this.runFilterFieldHook('tableDeferredFilters', key); }
3218
+ async runFilterFieldHook(property, key) {
2253
3219
  if (!key)
2254
3220
  return;
2255
- const path = `tableDeferredFilters.${key}`;
3221
+ const path = `${property}.${key}`;
2256
3222
  const field = this.getSchema('tableFiltersForm').getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === path);
2257
3223
  if (!field?.hasAfterStateUpdated())
2258
3224
  return;
@@ -2273,7 +3239,21 @@ export class ListRecords extends Page {
2273
3239
  this.paginators.page = 1;
2274
3240
  this.dispatch('deselectAllTableRecords', [], { self: true });
2275
3241
  }
2276
- removeTableFilters() { this.resetTableFiltersForm(); }
3242
+ /** "Remove all": resets every removable filter (to its empty state, not its default), the search and the column searches. */
3243
+ async removeTableFilters() {
3244
+ const table = this.getTable();
3245
+ for (const filter of table.getFilters()) {
3246
+ if (!(await isTableFilterRemovable(table, this, filter.name)))
3247
+ continue;
3248
+ const reset = filter.getResetState?.() ?? filter.getDefaultState();
3249
+ this.tableFilters = { ...this.tableFilters, [filter.name]: reset };
3250
+ this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(reset)) };
3251
+ }
3252
+ this.tableSearch = '';
3253
+ this.tableColumnSearches = {};
3254
+ this.paginators.page = 1;
3255
+ this.dispatch('deselectAllTableRecords', [], { self: true });
3256
+ }
2277
3257
  applyTableColumnManager(state = null, _wasReordered = false) { if (state)
2278
3258
  this.tableColumns = sanitizeColumnState(this.getTable(), state); }
2279
3259
  resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
@@ -2300,7 +3280,11 @@ export class ListRecords extends Page {
2300
3280
  rendersActionModalsInPage() { return false; }
2301
3281
  getBulkActions() { return this.getTable().getBulkActions() ?? [DeleteBulkAction.make()]; }
2302
3282
  /** Row actions declared by the table, with groups flattened. */
2303
- getRecordActions() { return this.getTable().getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])); }
3283
+ getRecordActions() {
3284
+ const table = this.getTable();
3285
+ // Column actions are record actions too: mounted with the record key, and checked the same way.
3286
+ return [...table.getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])), ...table.getColumnActions()];
3287
+ }
2304
3288
  getMountableActions(context) {
2305
3289
  if (typeof context.schemaComponent === 'string')
2306
3290
  return this.getSchemaComponentActions(context.schemaComponent);
@@ -2309,8 +3293,8 @@ export class ListRecords extends Page {
2309
3293
  if (context.table === true && typeof context.recordKey === 'string')
2310
3294
  return this.getRecordActions();
2311
3295
  if (context.table === true)
2312
- return this.getTable().getHeaderActions();
2313
- return this.getHeaderActions();
3296
+ return flattenActions([...this.getTable().getHeaderActions(), ...this.getTable().getToolbarActions(), ...this.getTable().getEmptyStateActions()]);
3297
+ return flattenActions(this.getHeaderActions());
2314
3298
  }
2315
3299
  /** Only known context shapes are kept; the record key is checked again in the database on mount. */
2316
3300
  resolveMountContext(clientContext) {
@@ -2326,7 +3310,13 @@ export class ListRecords extends Page {
2326
3310
  return {};
2327
3311
  }
2328
3312
  async resolveActionRecord(context) {
2329
- return typeof context.recordKey === 'string' ? ((await this.resource.adapter.find(context.recordKey)) ?? null) : null;
3313
+ if (typeof context.recordKey !== 'string')
3314
+ return null;
3315
+ const adapter = this.resource.adapter;
3316
+ // The trashed filter shows deleted records: their row actions must find them, no other key is reachable this way.
3317
+ if (trashedFilterValue(this) !== null && adapter.findWithTrashed)
3318
+ return (await adapter.findWithTrashed(context.recordKey)) ?? null;
3319
+ return (await adapter.find(context.recordKey)) ?? null;
2330
3320
  }
2331
3321
  /**
2332
3322
  * Writes an editable cell. Everything comes from the browser: the column must exist and be editable and visible, the record
@@ -2339,11 +3329,73 @@ export class ListRecords extends Page {
2339
3329
  /** Selected keys: the sent list, or, in "select all" mode, every record of the current query except deselected ones. */
2340
3330
  async getSelectedRecordKeys() {
2341
3331
  const clean = (list) => (Array.isArray(list) ? list.filter((k) => typeof k === 'string' || typeof k === 'number').map(String) : []);
3332
+ const table = this.getTable(), adapter = this.getDataAdapterForTable();
3333
+ let keys;
2342
3334
  // The selection comes from the browser: it is narrowed to records this table can actually display.
2343
3335
  if (!this.isTrackingDeselectedTableRecords)
2344
- return this.resource.adapter.keysInScope(clean(this.selectedTableRecords));
2345
- const excluded = new Set(clean(this.deselectedTableRecords));
2346
- return (await this.resource.adapter.listKeys(getTableQuery(this.getTable(), this))).filter((k) => !excluded.has(k));
3336
+ keys = await adapter.keysInScope(clean(this.selectedTableRecords));
3337
+ else {
3338
+ const excluded = new Set(clean(this.deselectedTableRecords));
3339
+ // "Select all" covers the whole query, or only the current page with `selectCurrentPageOnly()`.
3340
+ const all = table.selectsCurrentPageOnly() ? (await getTableRecords(table, this, adapter)).records.map((record) => adapter.getKey(record)) : await adapter.listKeys(getTableQuery(table, this));
3341
+ keys = all.filter((k) => !excluded.has(k));
3342
+ }
3343
+ // Records that cannot be selected, and anything beyond `maxSelectableRecords()`, are dropped whatever the browser sent.
3344
+ keys = await filterSelectableKeys(table, adapter, keys);
3345
+ const max = table.getMaxSelectableRecords();
3346
+ return max !== null ? keys.slice(0, max) : keys;
3347
+ }
3348
+ /**
3349
+ * Runs `process` for each selected record, loaded in chunks of `chunkSelectedRecords()`. Records failing the action's
3350
+ * `authorizeIndividualRecords()` are skipped; `process` reports a failure by returning `false` or throwing. Then sends the
3351
+ * action's success notification, or its failure notification with the number of records skipped and failed.
3352
+ */
3353
+ async processSelectedRecords(action, process) {
3354
+ const keys = await this.getSelectedRecordKeys();
3355
+ const adapter = this.getDataAdapterForTable();
3356
+ const authorization = action.getIndividualRecordsAuthorization();
3357
+ const ctx = { livewire: this, record: null, arguments: {}, data: {}, action };
3358
+ let success = 0, unauthorized = 0, failed = 0;
3359
+ const size = action.getSelectedRecordsChunkSize();
3360
+ for (let index = 0; index < keys.length; index += size) {
3361
+ const records = await Promise.all(keys.slice(index, index + size).map((key) => adapter.find(key)));
3362
+ for (const record of records) {
3363
+ if (!record) {
3364
+ failed++;
3365
+ continue;
3366
+ }
3367
+ const allowed = authorization === null ? true : typeof authorization === 'string' ? await canOn(this, authorization, record) : await authorization(record, { ...ctx, record });
3368
+ if (!allowed) {
3369
+ unauthorized++;
3370
+ continue;
3371
+ }
3372
+ try {
3373
+ if ((await process(record)) === false)
3374
+ failed++;
3375
+ else
3376
+ success++;
3377
+ }
3378
+ catch (error) {
3379
+ failed++;
3380
+ if (failed === 1)
3381
+ console.error(error);
3382
+ }
3383
+ }
3384
+ }
3385
+ const total = keys.length;
3386
+ if (unauthorized + failed === 0) {
3387
+ const title = action.getSuccessNotificationTitle();
3388
+ if (title)
3389
+ Notification.make().success().title(title).send();
3390
+ }
3391
+ else {
3392
+ const body = [action.getMissingBulkAuthorizationFailureNotificationMessage(unauthorized, total) && unauthorized ? action.getMissingBulkAuthorizationFailureNotificationMessage(unauthorized, total) : null, failed ? action.getMissingBulkProcessingFailureNotificationMessage(failed, total) : null].filter(Boolean).join(' ');
3393
+ const notification = Notification.make().title(action.getFailureNotificationTitle(success, total) ?? action.getSuccessNotificationTitle() ?? '');
3394
+ (success ? notification.warning() : notification.danger()).body(body || null).send();
3395
+ }
3396
+ if (action.shouldDeselectRecordsAfterCompletion())
3397
+ this.deselectAllTableRecords();
3398
+ return { total, success, unauthorized, failed };
2347
3399
  }
2348
3400
  deselectAllTableRecords() {
2349
3401
  this.selectedTableRecords = [];
@@ -2366,7 +3418,7 @@ export class ListRecords extends Page {
2366
3418
  bulkActions.push(await action.toGroupedHtml(ctx, { table: true, bulk: true }));
2367
3419
  const table = this.getTable();
2368
3420
  return renderTable(table, this, resource.adapter, {
2369
- bulkActions, headerActions: await this.renderTableHeaderActions(ctx), after: await this.renderActionModals(),
3421
+ bulkActions, headerActions: await this.renderTableHeaderActions(ctx), toolbarActions: await this.renderTableToolbarActions(ctx), emptyStateActions: await this.renderTableEmptyStateActions(ctx), after: await this.renderActionModals(),
2370
3422
  filtersForm: table.getFilters().length ? await this.getSchema('tableFiltersForm').toHtml() : null,
2371
3423
  rowActionsHtml: table.getRecordActions().length ? async (record) => {
2372
3424
  const key = resource.adapter.getKey(record);
@@ -2385,7 +3437,21 @@ export class ListRecords extends Page {
2385
3437
  rowActions: async (record) => resource.isSimple || table.getRecordActions().length || !(await canOn(this, 'update', record)) ? [] : [{ label: t('filament-actions::edit.single.label'), url: resource.getUrl('edit', resource.adapter.getKey(record)), icon: 'c-pencil-square', key: `edit.${createHash('md5').update(resource.adapter.getKey(record)).digest('hex')}` }],
2386
3438
  });
2387
3439
  }
3440
+ async renderTableEmptyStateActions(ctx) {
3441
+ const html = [];
3442
+ for (const action of this.getTable().getEmptyStateActions())
3443
+ if (await action.isVisible(ctx))
3444
+ html.push(await action.toHtml(ctx, { table: true }));
3445
+ return html;
3446
+ }
2388
3447
  /** Table header actions (none on a list page: they go in the page header). */
3448
+ async renderTableToolbarActions(ctx) {
3449
+ const html = [];
3450
+ for (const action of this.getTable().getToolbarActions())
3451
+ if (await action.isVisible(ctx))
3452
+ html.push(await action.toHtml(ctx, { table: true }));
3453
+ return html;
3454
+ }
2389
3455
  async renderTableHeaderActions(ctx) {
2390
3456
  const html = [];
2391
3457
  for (const action of this.getTable().getHeaderActions())
@@ -2436,6 +3502,19 @@ export class RelationManager extends ListRecords {
2436
3502
  static table(table) { return table; }
2437
3503
  static form(schema) { return schema; }
2438
3504
  static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
3505
+ /** Icon of the manager's tab. */
3506
+ static icon = null;
3507
+ static getIcon() { return this.icon; }
3508
+ /** Badge of the manager's tab (a count of related records…), and its color. */
3509
+ static badge = null;
3510
+ static badgeColor = 'primary';
3511
+ static getBadge(_ownerRecord, _pageClass) { return this.badge; }
3512
+ static getBadgeColor() { return this.badgeColor; }
3513
+ /**
3514
+ * Whether the manager is shown for this record and page (all by default). Hide it here rather than by denying `viewAny`,
3515
+ * which would forbid the whole page.
3516
+ */
3517
+ static canViewForRecord(_ownerRecord, _pageClass) { return true; }
2439
3518
  /** Owner record key: set on mount by the page, never writable from the browser. */
2440
3519
  ownerRecord = '';
2441
3520
  static locked = ['ownerRecord'];
@@ -2449,7 +3528,13 @@ export class RelationManager extends ListRecords {
2449
3528
  getPluralModelLabel: () => manager.pluralModelLabel ?? plural(manager.modelLabel),
2450
3529
  getTitleCasePluralModelLabel: () => (manager.pluralModelLabel ?? plural(manager.modelLabel)).split(' ').map(ucfirst).join(' '),
2451
3530
  getRecordTitle: (record) => (manager.recordTitleAttribute ? String(record?.[manager.recordTitleAttribute] ?? '') : manager.modelLabel),
2452
- table: (table) => manager.table(table), form: (schema) => manager.form(schema.columns(2)), getUrl: () => '',
3531
+ table: (table) => manager.table(table), getUrl: () => '',
3532
+ form: (schema) => {
3533
+ const form = manager.form(schema.columns(2));
3534
+ const pivot = manager.pivotForm(form);
3535
+ return pivot.length ? form.components([...form.getComponents(), ...pivot]) : form;
3536
+ },
3537
+ pivotFieldNames: () => manager.pivotForm(Schema.make(this)).filter((component) => component instanceof Field).map((field) => field.getName()),
2453
3538
  });
2454
3539
  }
2455
3540
  getTable() {
@@ -2460,14 +3545,31 @@ export class RelationManager extends ListRecords {
2460
3545
  return hasEdit ? table.recordAction('edit') : table;
2461
3546
  }
2462
3547
  // --- many-to-many: to be defined by managers offering Attach / Detach
2463
- /** Attaches an existing record to the owner. */
2464
- static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach() must be defined.'); }
3548
+ /** Attaches an existing record to the owner; `pivot` holds the values of the `pivotForm()` fields. */
3549
+ static attach(_ownerKey, _recordKey, _pivot = {}) { throw new Error('RelationManager.attach() must be defined.'); }
3550
+ /**
3551
+ * Fields stored on the pivot table (a role, a quantity…): added to the attach modal and to the edit form of a row.
3552
+ * Their values go to `attach()` and `updatePivot()`, never to the related record. List the pivot columns in the
3553
+ * relation's query (`adapterFor()`) so that the table and the edit form can read them.
3554
+ */
3555
+ static pivotForm(_schema) { return []; }
3556
+ /** Saves the pivot fields of an attached record (edit form of a row). */
3557
+ static updatePivot(_ownerKey, _recordKey, _pivot) { throw new Error('RelationManager.updatePivot() must be defined when pivotForm() returns fields.'); }
2465
3558
  /** Detaches records from the owner without deleting them. */
2466
3559
  static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach() must be defined.'); }
2467
3560
  /** Attachable records matching the search: [key, label]. */
2468
3561
  static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() must be defined.'); }
2469
3562
  /** Label of an attachable record, or `null` if it is not attachable (also used as validation). */
2470
3563
  static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() must be defined.'); }
3564
+ // --- one-to-many: to be defined by managers offering Associate / Dissociate
3565
+ /** Sets the owner of an existing record (its foreign key). */
3566
+ static associate(_ownerKey, _recordKey) { throw new Error('RelationManager.associate() must be defined.'); }
3567
+ /** Clears the owner of records (their foreign key), without deleting them. */
3568
+ static dissociate(_ownerKey, _recordKeys) { throw new Error('RelationManager.dissociate() must be defined.'); }
3569
+ /** Associable records matching the search: [key, label]. */
3570
+ static searchAssociable(_ownerKey, _search) { throw new Error('RelationManager.searchAssociable() must be defined.'); }
3571
+ /** Label of an associable record, or `null` if it is not associable (also used as validation). */
3572
+ static associableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.associableLabel() must be defined.'); }
2471
3573
  getHeaderActions() { return []; }
2472
3574
  placeholder() {
2473
3575
  return `<div role="status" aria-busy="true" style="height: 8rem; ;" class="fi-section fi-loading-section fi-grid-col"><span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
@@ -2599,6 +3701,34 @@ class ResourceFormPage extends Page {
2599
3701
  }
2600
3702
  static resource;
2601
3703
  data = {};
3704
+ /** Hash of the form data as last saved: the browser compares it with the current data (unsaved changes alert). */
3705
+ savedDataHash = '';
3706
+ static locked = ['savedDataHash'];
3707
+ /** `true` / `false` overrides the panel's `databaseTransactions()` for this page. */
3708
+ static hasDatabaseTransactions = null;
3709
+ /** `true` / `false` overrides the panel's `unsavedChangesAlerts()` for this page. */
3710
+ static hasUnsavedDataChangesAlert = null;
3711
+ hasDatabaseTransactions() { return this.constructor.hasDatabaseTransactions ?? this.panel.hasDatabaseTransactions(); }
3712
+ hasUnsavedDataChangesAlert() { return this.constructor.hasUnsavedDataChangesAlert ?? this.panel.hasUnsavedChangesAlerts(); }
3713
+ /** Runs `fn` in a database transaction of the resource's adapter, when the page uses them. */
3714
+ wrapInDatabaseTransaction(fn) {
3715
+ const adapter = this.getDataAdapter();
3716
+ return this.hasDatabaseTransactions() && typeof adapter.transaction === 'function' ? adapter.transaction(fn) : fn();
3717
+ }
3718
+ /** Remembers the form data as saved: leaving the page with other data asks for confirmation. */
3719
+ rememberData() {
3720
+ if (!this.hasUnsavedDataChangesAlert())
3721
+ return;
3722
+ this.savedDataHash = createHash('md5').update(JSON.stringify(this.data).replace(/\\/g, '')).digest('hex');
3723
+ }
3724
+ async render() {
3725
+ if (this.hasUnsavedDataChangesAlert()) {
3726
+ this.addScript('unsaved-data-changes-alert', this.panel.hasSpaMode()
3727
+ ? `setUpFilamentSpaModeUnsavedDataChangesAlert({ body: ${js(t('filament-panels::unsaved-changes-alert.body'))}, resolveLivewireComponentUsing: () => window.Livewire.find(${js(this.id)}), $wire })`
3728
+ : 'setUpFilamentUnsavedDataChangesAlert({ $wire })');
3729
+ }
3730
+ return super.render();
3731
+ }
2602
3732
  get resource() { return this.constructor.resource; }
2603
3733
  getDataAdapter() { return this.resource.adapter; }
2604
3734
  getRecordKey() { return null; }
@@ -2640,11 +3770,23 @@ class ResourceFormPage extends Page {
2640
3770
  return Action.make('cancel').label(t(`filament-panels::resources/pages/${this.getOperation()}-record.form.actions.cancel.label`)).color('gray')
2641
3771
  .alpineClickHandler(`document.referrer ? window.history.back() : Livewire.navigate(${js(this.getCancelUrl())})`);
2642
3772
  }
3773
+ // --- lifecycle hooks around filling and validation (to override; none can be called from the browser)
3774
+ /** Before the form is filled (with defaults, or with the record on the edit page). */
3775
+ beforeFill() { }
3776
+ /** After the form is filled. */
3777
+ afterFill() { }
3778
+ /** Before the form is validated, when it is submitted. */
3779
+ beforeValidate() { }
3780
+ /** After the form is validated successfully, before the data is transformed and saved. */
3781
+ afterValidate() { }
2643
3782
  /** Validates the form; on error, sets the errors on the component and notifies the browser. */
2644
3783
  async validateForm() {
3784
+ await this.beforeValidate();
2645
3785
  this._errors = await validateSchema(this.getSchema('form'));
2646
- if (!Object.keys(this._errors).length)
3786
+ if (!Object.keys(this._errors).length) {
3787
+ await this.afterValidate();
2647
3788
  return true;
3789
+ }
2648
3790
  this.dispatch('form-validation-error', { livewireId: this.id });
2649
3791
  return false;
2650
3792
  }
@@ -2652,7 +3794,7 @@ class ResourceFormPage extends Page {
2652
3794
  /** Record creation page. */
2653
3795
  export class CreateRecord extends ResourceFormPage {
2654
3796
  static callable = ['create', 'createAnother'];
2655
- mount() { fillSchema(this.getSchema('form'), this.data); }
3797
+ async mount() { await this.beforeFill(); fillSchema(this.getSchema('form'), this.data); await this.afterFill(); this.rememberData(); }
2656
3798
  getOperation() { return 'create'; }
2657
3799
  getSubmitMethod() { return 'create'; }
2658
3800
  getTitle() { return t('filament-panels::resources/pages/create-record.title', { label: this.resource.modelLabel.split(' ').map(ucfirst).join(' ') }); }
@@ -2684,15 +3826,21 @@ export class CreateRecord extends ResourceFormPage {
2684
3826
  if (!(await this.validateForm()))
2685
3827
  return;
2686
3828
  const data = await this.mutateFormDataBeforeCreate(await getSchemaState(this.getSchema('form')));
2687
- await this.beforeCreate(data);
2688
- const record = await this.handleRecordCreation(data);
2689
- await this.afterCreate(record);
3829
+ const record = await this.wrapInDatabaseTransaction(async () => {
3830
+ await this.beforeCreate(data);
3831
+ const created = await this.handleRecordCreation(data);
3832
+ await saveSchemaRelationships(this.getSchema('form'), created);
3833
+ await this.afterCreate(created);
3834
+ return created;
3835
+ });
3836
+ this.rememberData();
2690
3837
  const title = this.getCreatedNotificationTitle();
2691
3838
  if (title !== null)
2692
3839
  Notification.make().success().title(title).send();
2693
3840
  if (another === true) {
2694
3841
  this.data = {};
2695
3842
  fillSchema(this.getSchema('form'), this.data);
3843
+ this.rememberData();
2696
3844
  return;
2697
3845
  }
2698
3846
  this.redirect(this.getRedirectUrl(record), true);
@@ -2710,34 +3858,62 @@ export class DeleteAction {
2710
3858
  .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return target ? livewire.getDataAdapter().isTrashed(target) : false; })
2711
3859
  .modalHeading(({ livewire, record }) => t('filament-actions::delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
2712
3860
  .modalSubmitActionLabel(t('filament-actions::delete.single.modal.actions.delete.label'))
2713
- .action(async ({ livewire, record }) => {
3861
+ .action(async (ctx) => {
3862
+ const { livewire, record } = ctx;
2714
3863
  const adapter = livewire.getDataAdapter();
2715
3864
  const inTable = typeof livewire.getTable === 'function';
2716
3865
  await adapter.delete(inTable ? adapter.getKey(record) : livewire.recordKey);
2717
- Notification.make().success().title(t('filament-actions::delete.single.notifications.deleted.title')).send();
2718
- if (!inTable)
2719
- livewire.redirect(livewire.resource.getUrl(), true);
3866
+ await finishAction(ctx.action, ctx, t('filament-actions::delete.single.notifications.deleted.title'), inTable ? null : livewire.resource.getUrl());
2720
3867
  });
2721
3868
  }
2722
3869
  }
3870
+ /**
3871
+ * Success outcome of a prebuilt action: its notification (`successNotificationTitle()`, `successNotification()`), then the
3872
+ * redirect (`successRedirectUrl()`, or `fallbackUrl`). Only local or http(s) URLs are followed.
3873
+ */
3874
+ async function finishAction(action, ctx, defaultTitle, fallbackUrl = null) {
3875
+ let notification = Notification.make().success().title(action.getSuccessNotificationTitle() ?? defaultTitle);
3876
+ const customize = action.getSuccessNotificationCustomizer();
3877
+ if (customize)
3878
+ notification = customize(notification, ctx);
3879
+ notification?.send();
3880
+ const url = (await action.getSuccessRedirectUrl(ctx)) ?? fallbackUrl;
3881
+ if (url && /^(\/(?!\/)|https?:\/\/)/.test(url))
3882
+ ctx.livewire.redirect(url, true);
3883
+ }
2723
3884
  /** Duplicates a table row: the copy takes the record's attributes, except its key, timestamps and excluded ones. */
2724
3885
  export class ReplicateAction {
2725
3886
  static make() {
2726
3887
  let excluded = [];
3888
+ let beforeSaved = null, afterSaved = null;
2727
3889
  const action = Action.make('replicate').authorize(({ livewire, record }) => canOn(livewire, 'replicate', record))
2728
3890
  .label(t('filament-actions::replicate.single.label')).tableIcon('c-square-2-stack').groupedIcon('m-square-2-stack')
2729
3891
  .modalHeading(({ livewire, record }) => t('filament-actions::replicate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
2730
3892
  .modalSubmitActionLabel(t('filament-actions::replicate.single.modal.actions.replicate.label'))
2731
- .action(async ({ livewire, record }) => {
3893
+ // With a modal form (`schema()`), it is filled with the record's attributes; its data overrides them in the copy.
3894
+ .fillForm(({ record }) => {
3895
+ const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
3896
+ for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
3897
+ delete source[attribute];
3898
+ return source;
3899
+ })
3900
+ .action(async (ctx) => {
3901
+ const { livewire, record } = ctx;
2732
3902
  const adapter = livewire.getDataAdapter();
2733
3903
  const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
2734
3904
  // No primary key, no created/updated timestamps.
2735
3905
  for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
2736
3906
  delete source[attribute];
2737
- await adapter.create(source);
2738
- Notification.make().success().title(t('filament-actions::replicate.single.notifications.replicated.title')).send();
3907
+ const data = ctx.data ?? {};
3908
+ const replica = { ...source, ...data };
3909
+ await beforeSaved?.({ replica, data, record });
3910
+ const saved = await adapter.create(replica);
3911
+ await afterSaved?.({ replica: saved, data, record });
3912
+ await finishAction(ctx.action, { ...ctx, replica: saved }, t('filament-actions::replicate.single.notifications.replicated.title'));
2739
3913
  });
2740
3914
  action.excludeAttributes = (attributes) => { excluded = attributes; return action; };
3915
+ action.beforeReplicaSaved = (callback) => { beforeSaved = callback; return action; };
3916
+ action.afterReplicaSaved = (callback) => { afterSaved = callback; return action; };
2741
3917
  return action;
2742
3918
  }
2743
3919
  }
@@ -2752,9 +3928,10 @@ export class CreateAction {
2752
3928
  .modalSubmitActionLabel(t('filament-actions::create.single.modal.actions.create.label'))
2753
3929
  .extraModalFooterActions([Action.make('createAnother').label(t('filament-actions::create.single.modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
2754
3930
  .schema((schema) => of(schema.livewire).form(schema))
2755
- .action(async ({ livewire, data }) => {
2756
- await of(livewire).adapter.create(data);
2757
- Notification.make().success().title(t('filament-actions::create.single.notifications.created.title')).send();
3931
+ .action(async (ctx) => {
3932
+ const record = await of(ctx.livewire).adapter.create(ctx.data);
3933
+ await saveSchemaRelationships(ctx.schema, record);
3934
+ await finishAction(ctx.action, { ...ctx, record }, t('filament-actions::create.single.notifications.created.title'));
2758
3935
  });
2759
3936
  }
2760
3937
  }
@@ -2769,10 +3946,18 @@ export class EditAction {
2769
3946
  .modalSubmitActionLabel(t('filament-actions::edit.single.modal.actions.save.label'))
2770
3947
  .schema((schema) => of(schema.livewire).form(schema))
2771
3948
  .fillForm(({ record }) => ({ ...record }))
2772
- .action(async ({ livewire, record, data }) => {
2773
- const adapter = of(livewire).adapter;
2774
- await adapter.update(adapter.getKey(record), data);
2775
- Notification.make().success().title(t('filament-actions::edit.single.notifications.saved.title')).send();
3949
+ .action(async (ctx) => {
3950
+ const adapter = of(ctx.livewire).adapter;
3951
+ const pivotNames = of(ctx.livewire).pivotFieldNames?.() ?? [];
3952
+ let data = ctx.data;
3953
+ if (pivotNames.length) {
3954
+ const pivot = Object.fromEntries(Object.entries(data ?? {}).filter(([name]) => pivotNames.includes(name)));
3955
+ data = Object.fromEntries(Object.entries(data ?? {}).filter(([name]) => !pivotNames.includes(name)));
3956
+ await managerOf(ctx.livewire).updatePivot(ctx.livewire.ownerRecord, adapter.getKey(ctx.record), pivot);
3957
+ }
3958
+ const saved = Object.keys(data ?? {}).length ? await adapter.update(adapter.getKey(ctx.record), data) : ctx.record;
3959
+ await saveSchemaRelationships(ctx.schema, saved ?? ctx.record);
3960
+ await finishAction(ctx.action, ctx, t('filament-actions::edit.single.notifications.saved.title'));
2776
3961
  });
2777
3962
  }
2778
3963
  }
@@ -2802,10 +3987,12 @@ export class AttachAction {
2802
3987
  const livewire = schema.livewire;
2803
3988
  return [Select.make('recordId').label(t('filament-actions::attach.single.modal.fields.record_id.label')).hiddenLabel().required()
2804
3989
  .getSearchResultsUsing((search) => managerOf(livewire).searchAttachable(livewire.ownerRecord, search))
2805
- .getOptionLabelUsing((value) => managerOf(livewire).attachableLabel(livewire.ownerRecord, value))];
3990
+ .getOptionLabelUsing((value) => managerOf(livewire).attachableLabel(livewire.ownerRecord, value)),
3991
+ ...managerOf(livewire).pivotForm(schema)];
2806
3992
  })
2807
3993
  .action(async ({ livewire, data }) => {
2808
- await managerOf(livewire).attach(livewire.ownerRecord, String(data.recordId));
3994
+ const { recordId, ...pivot } = data ?? {};
3995
+ await managerOf(livewire).attach(livewire.ownerRecord, String(recordId), pivot);
2809
3996
  Notification.make().success().title(t('filament-actions::attach.single.notifications.attached.title')).send();
2810
3997
  });
2811
3998
  }
@@ -2840,53 +4027,179 @@ export class DetachBulkAction {
2840
4027
  });
2841
4028
  }
2842
4029
  }
4030
+ /** Gives an existing record to the owner of a one-to-many relation manager (its foreign key). */
4031
+ export class AssociateAction {
4032
+ static make() {
4033
+ return Action.make('associate')
4034
+ .label(t('filament-actions::associate.single.label')).color('gray').modalWidth('lg')
4035
+ .modalHeading(({ livewire }) => t('filament-actions::associate.single.modal.heading', { label: String(livewire.resource.modelLabel).split(' ').map(ucfirst).join(' ') }))
4036
+ .modalSubmitActionLabel(t('filament-actions::associate.single.modal.actions.associate.label'))
4037
+ .extraModalFooterActions([Action.make('associateAnother').label(t('filament-actions::associate.single.modal.actions.associate_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
4038
+ .schema((schema) => {
4039
+ const livewire = schema.livewire;
4040
+ return [Select.make('recordId').label(t('filament-actions::associate.single.modal.fields.record_id.label')).hiddenLabel().required()
4041
+ .getSearchResultsUsing((search) => managerOf(livewire).searchAssociable(livewire.ownerRecord, search))
4042
+ .getOptionLabelUsing((value) => managerOf(livewire).associableLabel(livewire.ownerRecord, value))];
4043
+ })
4044
+ .action(async ({ livewire, data }) => {
4045
+ await managerOf(livewire).associate(livewire.ownerRecord, String(data.recordId));
4046
+ Notification.make().success().title(t('filament-actions::associate.single.notifications.associated.title')).send();
4047
+ });
4048
+ }
4049
+ }
4050
+ /** Removes a row from the owner (clears its foreign key) without deleting it. */
4051
+ export class DissociateAction {
4052
+ static make() {
4053
+ return Action.make('dissociate')
4054
+ .label(t('filament-actions::dissociate.single.label')).color('danger').icon('c-x-mark').groupedIcon('m-x-mark')
4055
+ .requiresConfirmation().modalIcon('o-x-mark')
4056
+ .modalHeading(({ livewire, record }) => t('filament-actions::dissociate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
4057
+ .modalSubmitActionLabel(t('filament-actions::dissociate.single.modal.actions.dissociate.label'))
4058
+ .action(async ({ livewire, record }) => {
4059
+ await managerOf(livewire).dissociate(livewire.ownerRecord, [livewire.resource.adapter.getKey(record)]);
4060
+ Notification.make().success().title(t('filament-actions::dissociate.single.notifications.dissociated.title')).send();
4061
+ });
4062
+ }
4063
+ }
4064
+ /** Dissociates the selected rows. */
4065
+ export class DissociateBulkAction {
4066
+ static make() {
4067
+ return Action.make('dissociate')
4068
+ .label(t('filament-actions::dissociate.multiple.label')).color('danger').icon('m-x-mark')
4069
+ .requiresConfirmation().modalIcon('o-x-mark')
4070
+ .modalHeading(({ livewire }) => t('filament-actions::dissociate.multiple.modal.heading', { label: livewire.resource.getPluralModelLabel() }))
4071
+ .modalSubmitActionLabel(t('filament-actions::dissociate.multiple.modal.actions.dissociate.label'))
4072
+ .action(async ({ livewire }) => {
4073
+ // Selected keys come from the manager's current query, so they belong to the owner.
4074
+ await managerOf(livewire).dissociate(livewire.ownerRecord, await livewire.getSelectedRecordKeys());
4075
+ Notification.make().success().title(t('filament-actions::dissociate.multiple.notifications.dissociated.title')).send();
4076
+ livewire.deselectAllTableRecords();
4077
+ });
4078
+ }
4079
+ }
2843
4080
  /** Restores a soft-deleted record. Only visible when the record is deleted. */
4081
+ /** Restores a soft-deleted record: the page's record, or a table row. Only visible when the record is deleted. */
2844
4082
  export class RestoreAction {
2845
4083
  static make() {
2846
- return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record))
2847
- .label(t('filament-actions::restore.single.label')).color('gray')
4084
+ return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record ?? livewire._record))
4085
+ .label(t('filament-actions::restore.single.label')).color('gray').tableIcon('c-arrow-uturn-left').groupedIcon('m-arrow-uturn-left')
2848
4086
  .requiresConfirmation().modalIcon('o-arrow-uturn-left')
2849
- .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
2850
- .modalHeading(({ livewire }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
4087
+ .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return !target || !livewire.getDataAdapter().isTrashed(target); })
4088
+ .modalHeading(({ livewire, record }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
2851
4089
  .modalSubmitActionLabel(t('filament-actions::restore.single.modal.actions.restore.label'))
2852
- .action(async ({ livewire }) => {
2853
- await livewire.getDataAdapter().restore(livewire.recordKey);
2854
- await livewire.hydrate();
2855
- Notification.make().success().title(t('filament-actions::restore.single.notifications.restored.title')).send();
4090
+ .action(async (ctx) => {
4091
+ const { livewire, record } = ctx;
4092
+ const adapter = livewire.getDataAdapter();
4093
+ if (typeof livewire.getTable === 'function') {
4094
+ await adapter.restore(adapter.getKey(record));
4095
+ }
4096
+ else {
4097
+ await adapter.restore(livewire.recordKey);
4098
+ await livewire.hydrate();
4099
+ }
4100
+ await finishAction(ctx.action, ctx, t('filament-actions::restore.single.notifications.restored.title'));
2856
4101
  });
2857
4102
  }
2858
4103
  }
2859
4104
  /** Permanently deletes a soft-deleted record. Only visible when the record is deleted. */
2860
4105
  export class ForceDeleteAction {
2861
4106
  static make() {
2862
- return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record))
2863
- .label(t('filament-actions::force-delete.single.label')).color('danger')
4107
+ return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record ?? livewire._record))
4108
+ .label(t('filament-actions::force-delete.single.label')).color('danger').tableIcon('c-trash').groupedIcon('m-trash')
2864
4109
  .requiresConfirmation().modalIcon('o-trash')
2865
- .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
2866
- .modalHeading(({ livewire }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
4110
+ .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return !target || !livewire.getDataAdapter().isTrashed(target); })
4111
+ .modalHeading(({ livewire, record }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
2867
4112
  .modalSubmitActionLabel(t('filament-actions::force-delete.single.modal.actions.delete.label'))
2868
- .action(async ({ livewire }) => {
2869
- await livewire.getDataAdapter().forceDelete(livewire.recordKey);
2870
- Notification.make().success().title(t('filament-actions::force-delete.single.notifications.deleted.title')).send();
2871
- livewire.redirect(livewire.resource.getUrl(), true);
4113
+ .action(async (ctx) => {
4114
+ const { livewire, record } = ctx;
4115
+ const adapter = livewire.getDataAdapter();
4116
+ const inTable = typeof livewire.getTable === 'function';
4117
+ await adapter.forceDelete(inTable ? adapter.getKey(record) : livewire.recordKey);
4118
+ await finishAction(ctx.action, ctx, t('filament-actions::force-delete.single.notifications.deleted.title'), inTable ? null : livewire.resource.getUrl());
2872
4119
  });
2873
4120
  }
2874
4121
  }
2875
- /** Deletes the records selected in a table. */
4122
+ /** Actions of a list that may contain action groups: the groups' actions are mounted, never the groups. */
4123
+ const flattenActions = (actions) => actions.flatMap((action) => (action instanceof ActionGroup ? action.getActions() : [action]));
4124
+ /** Value of the table's trashed filter: `'1'` with deleted records, `'0'` only deleted records, `null` without. */
4125
+ const trashedFilterValue = (livewire) => {
4126
+ const filter = livewire.getTable?.().getFilters().find((candidate) => candidate instanceof TrashedFilter);
4127
+ const value = filter ? livewire.tableFilters?.[filter.name]?.value : null;
4128
+ return value === '1' || value === '0' ? value : null;
4129
+ };
4130
+ /**
4131
+ * Deletes the records selected in a table. Without `authorizeIndividualRecords()`, the selection is deleted in one query;
4132
+ * with it, record by record, and the notification reports the records that could not be deleted.
4133
+ */
2876
4134
  export class DeleteBulkAction {
2877
4135
  static make() {
2878
4136
  return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'deleteAny', record))
2879
4137
  .label(t('filament-actions::delete.multiple.label')).color('danger').icon('m-trash')
2880
4138
  .requiresConfirmation().modalIcon('o-trash')
4139
+ // Showing only deleted records: there is nothing left to delete.
4140
+ .hidden(({ livewire }) => trashedFilterValue(livewire) === '0')
2881
4141
  .modalHeading(({ livewire }) => t('filament-actions::delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
2882
4142
  .modalSubmitActionLabel(t('filament-actions::delete.multiple.modal.actions.delete.label'))
2883
- .action(async ({ livewire }) => {
2884
- await livewire.getDataAdapterForTable().deleteMany(await livewire.getSelectedRecordKeys());
2885
- Notification.make().success().title(t('filament-actions::delete.multiple.notifications.deleted.title')).send();
4143
+ .successNotificationTitle(t('filament-actions::delete.multiple.notifications.deleted.title'))
4144
+ .failureNotificationTitle((success, total) => (success ? tc('filament-actions::delete.multiple.notifications.deleted_partial.title', success, { count: success, total }) : tc('filament-actions::delete.multiple.notifications.deleted_none.title', total, { count: total, total })))
4145
+ .missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_authorization_failure_message`, count, { count }))
4146
+ .missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_processing_failure_message`, count, { count }))
4147
+ .deselectRecordsAfterCompletion()
4148
+ .action(async ({ livewire, action }) => {
4149
+ const adapter = livewire.getDataAdapterForTable();
4150
+ if (action.getIndividualRecordsAuthorization() !== null) {
4151
+ await livewire.processSelectedRecords(action, (record) => adapter.delete(adapter.getKey(record)));
4152
+ return;
4153
+ }
4154
+ await adapter.deleteMany(await livewire.getSelectedRecordKeys());
4155
+ Notification.make().success().title(action.getSuccessNotificationTitle() ?? t('filament-actions::delete.multiple.notifications.deleted.title')).send();
2886
4156
  livewire.deselectAllTableRecords();
2887
4157
  });
2888
4158
  }
2889
4159
  }
4160
+ /** Restores the soft-deleted records selected in a table, each one checked with the `restore` ability. */
4161
+ export class RestoreBulkAction {
4162
+ static make() {
4163
+ return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restoreAny', record))
4164
+ .label(t('filament-actions::restore.multiple.label')).color('gray').icon('m-arrow-uturn-left')
4165
+ .requiresConfirmation().modalIcon('o-arrow-uturn-left')
4166
+ .hidden(({ livewire }) => trashedFilterValue(livewire) === null)
4167
+ .modalHeading(({ livewire }) => t('filament-actions::restore.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
4168
+ .modalSubmitActionLabel(t('filament-actions::restore.multiple.modal.actions.restore.label'))
4169
+ .authorizeIndividualRecords('restore')
4170
+ .successNotificationTitle(t('filament-actions::restore.multiple.notifications.restored.title'))
4171
+ .failureNotificationTitle((success, total) => (success ? tc('filament-actions::restore.multiple.notifications.restored_partial.title', success, { count: success, total }) : tc('filament-actions::restore.multiple.notifications.restored_none.title', total, { count: total, total })))
4172
+ .missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::restore.multiple.notifications.${count === total ? 'restored_none' : 'restored_partial'}.missing_authorization_failure_message`, count, { count }))
4173
+ .missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::restore.multiple.notifications.${count === total ? 'restored_none' : 'restored_partial'}.missing_processing_failure_message`, count, { count }))
4174
+ .deselectRecordsAfterCompletion()
4175
+ .action(async ({ livewire, action }) => {
4176
+ const adapter = livewire.getDataAdapterForTable();
4177
+ // Only deleted records are restored; the others count as failures.
4178
+ await livewire.processSelectedRecords(action, (record) => adapter.isTrashed(record) && adapter.restore(adapter.getKey(record)));
4179
+ });
4180
+ }
4181
+ }
4182
+ /** Permanently deletes the soft-deleted records selected in a table, each one checked with the `forceDelete` ability. */
4183
+ export class ForceDeleteBulkAction {
4184
+ static make() {
4185
+ return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDeleteAny', record))
4186
+ .label(t('filament-actions::force-delete.multiple.label')).color('danger').icon('m-trash')
4187
+ .requiresConfirmation().modalIcon('o-trash')
4188
+ .hidden(({ livewire }) => trashedFilterValue(livewire) === null)
4189
+ .modalHeading(({ livewire }) => t('filament-actions::force-delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
4190
+ .modalSubmitActionLabel(t('filament-actions::force-delete.multiple.modal.actions.delete.label'))
4191
+ .authorizeIndividualRecords('forceDelete')
4192
+ .successNotificationTitle(t('filament-actions::force-delete.multiple.notifications.deleted.title'))
4193
+ .failureNotificationTitle((success, total) => (success ? tc('filament-actions::force-delete.multiple.notifications.deleted_partial.title', success, { count: success, total }) : tc('filament-actions::force-delete.multiple.notifications.deleted_none.title', total, { count: total, total })))
4194
+ .missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::force-delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_authorization_failure_message`, count, { count }))
4195
+ .missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::force-delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_processing_failure_message`, count, { count }))
4196
+ .deselectRecordsAfterCompletion()
4197
+ .action(async ({ livewire, action }) => {
4198
+ const adapter = livewire.getDataAdapterForTable();
4199
+ await livewire.processSelectedRecords(action, (record) => adapter.forceDelete(adapter.getKey(record)));
4200
+ });
4201
+ }
4202
+ }
2890
4203
  /** Record edit page. */
2891
4204
  export class EditRecord extends ResourceFormPage {
2892
4205
  static callable = ['save'];
@@ -2899,18 +4212,66 @@ export class EditRecord extends ResourceFormPage {
2899
4212
  */
2900
4213
  previousUrl = '';
2901
4214
  getCancelUrl() { return this.previousUrl; }
4215
+ /** Relation managers of this page: the resource's by default (`static relationManagers` on the page replaces them). */
4216
+ getRelationManagers() { return this.constructor.relationManagers ?? this.resource.getRelations(); }
4217
+ /** `true`: the form becomes the first tab, next to the relation managers. */
4218
+ hasCombinedRelationManagerTabsWithContent() { return false; }
4219
+ getContentTabLabel() { return t('filament-panels::resources/pages/edit-record.content.tab.label'); }
4220
+ getContentTabIcon() { return null; }
4221
+ withRelationManagers(content) {
4222
+ const components = content.getComponents();
4223
+ if (!this.getRelationManagers().length)
4224
+ return content.components([...components, new RelationManagerTabs().key(null).hidden()]);
4225
+ if (this.hasCombinedRelationManagerTabsWithContent())
4226
+ return content.components([new RelationManagerTabs(components)]);
4227
+ return content.components([...components, new RelationManagerTabs()]);
4228
+ }
2902
4229
  contentSchema(schema) {
2903
- const content = super.contentSchema(schema);
2904
- // Always present: hidden (empty column) when the resource has no relation manager.
2905
- return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
4230
+ // The tabs are always present: hidden (empty column) when the page has no relation manager.
4231
+ return this.withRelationManagers(super.contentSchema(schema));
4232
+ }
4233
+ /**
4234
+ * Saves the fields of one component of the form (a section with its own "Save" action), after validating them only:
4235
+ * `livewire.saveFormComponentOnly('contact')` (the component's `key()`, or the component).
4236
+ */
4237
+ async saveFormComponentOnly(component, notificationTitle = null) {
4238
+ const form = this.getSchema('form');
4239
+ const wanted = typeof component === 'string' ? component : component.getKey();
4240
+ const target = (await form.getVisibleFlatComponents()).find((candidate) => { const key = candidate.getKey(); return key !== null && wanted !== null && (key === wanted || key.endsWith(`.${wanted}`)); });
4241
+ const part = target?.getChildSchemas()[0];
4242
+ if (!part)
4243
+ throw new Error(`saveFormComponentOnly(): no component ${String(wanted)} with fields in the form.`);
4244
+ const errors = await validateSchema(part);
4245
+ if (Object.keys(errors).length) {
4246
+ this._errors = errors;
4247
+ this.dispatch('form-validation-error', { livewireId: this.id });
4248
+ return;
4249
+ }
4250
+ this._errors = {};
4251
+ const data = await this.mutateFormDataBeforeSave(await getSchemaState(part));
4252
+ this._record = await this.wrapInDatabaseTransaction(async () => {
4253
+ const record = await this.handleRecordUpdate(this._record, data);
4254
+ await saveSchemaRelationships(part, record);
4255
+ return record;
4256
+ });
4257
+ const title = notificationTitle ?? this.getSavedNotificationTitle();
4258
+ if (title !== null)
4259
+ Notification.make().success().title(title).send();
2906
4260
  }
2907
4261
  static locked = ['recordKey', 'previousUrl'];
2908
4262
  _record = null;
2909
4263
  async mount(params) {
2910
4264
  this.recordKey = String(params.record ?? '');
2911
4265
  this.previousUrl = previousUrlOf(this._ctx?.req);
4266
+ // Combined tabs: the form's tab is open first.
4267
+ if (this.hasCombinedRelationManagerTabsWithContent())
4268
+ this.activeRelationManager = '-1';
2912
4269
  await this.hydrate();
4270
+ await this.beforeFill();
2913
4271
  fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
4272
+ await loadSchemaRelationships(this.getSchema('form'), this.data, this._record);
4273
+ await this.afterFill();
4274
+ this.rememberData();
2914
4275
  }
2915
4276
  /** The record is reloaded on every request (no route model binding). */
2916
4277
  async hydrate() {
@@ -2965,9 +4326,14 @@ export class EditRecord extends ResourceFormPage {
2965
4326
  if (!(await this.validateForm()))
2966
4327
  return;
2967
4328
  const data = await this.mutateFormDataBeforeSave(await getSchemaState(this.getSchema('form')));
2968
- await this.beforeSave(data);
2969
- this._record = await this.handleRecordUpdate(this._record, data);
2970
- await this.afterSave(this._record);
4329
+ this._record = await this.wrapInDatabaseTransaction(async () => {
4330
+ await this.beforeSave(data);
4331
+ const record = await this.handleRecordUpdate(this._record, data);
4332
+ await saveSchemaRelationships(this.getSchema('form'), record);
4333
+ await this.afterSave(record);
4334
+ return record;
4335
+ });
4336
+ this.rememberData();
2971
4337
  const title = this.getSavedNotificationTitle();
2972
4338
  if (title !== null)
2973
4339
  Notification.make().success().title(title).send();
@@ -2988,6 +4354,8 @@ export class ViewRecord extends Page {
2988
4354
  getDataAdapter() { return this.resource.adapter; }
2989
4355
  async mount(params) {
2990
4356
  this.recordKey = String(params.record ?? '');
4357
+ if (this.hasCombinedRelationManagerTabsWithContent())
4358
+ this.activeRelationManager = '-1';
2991
4359
  await this.hydrate();
2992
4360
  }
2993
4361
  /** The record is reloaded on every request, as on the edit page. */
@@ -3002,10 +4370,22 @@ export class ViewRecord extends Page {
3002
4370
  getActionMountContext() { return { recordKey: this.recordKey }; }
3003
4371
  getActionRecord() { return this._record; }
3004
4372
  infolistSchema(schema) { return this.resource.infolist(schema.columns(2).operation('view').record(this._record)); }
4373
+ /** Relation managers of this page: the resource's by default (`static relationManagers` on the page replaces them). */
4374
+ getRelationManagers() { return this.constructor.relationManagers ?? this.resource.getRelations(); }
4375
+ /** `true`: the infolist becomes the first tab, next to the relation managers. */
4376
+ hasCombinedRelationManagerTabsWithContent() { return false; }
4377
+ getContentTabLabel() { return t('filament-panels::resources/pages/view-record.content.tab.label'); }
4378
+ getContentTabIcon() { return null; }
4379
+ withRelationManagers(content) {
4380
+ const components = content.getComponents();
4381
+ if (!this.getRelationManagers().length)
4382
+ return content.components([...components, new RelationManagerTabs().key(null).hidden()]);
4383
+ if (this.hasCombinedRelationManagerTabsWithContent())
4384
+ return content.components([new RelationManagerTabs(components)]);
4385
+ return content.components([...components, new RelationManagerTabs()]);
4386
+ }
3005
4387
  contentSchema(schema) {
3006
- const content = schema.components([EmbeddedSchema.make('infolist')]);
3007
- // Always present: hidden (empty column) when the resource has no relation manager.
3008
- return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
4388
+ return this.withRelationManagers(schema.components([EmbeddedSchema.make('infolist')]));
3009
4389
  }
3010
4390
  /** Defaults to a link to the edit page. */
3011
4391
  getHeaderActions() { return [this.editAction()]; }
@@ -3047,9 +4427,43 @@ async function renderUserMenu(component, position) {
3047
4427
  profileUrl: profileUrl && !custom.profile ? profileUrl : null,
3048
4428
  itemsBefore: await html(visible.filter((item) => item.getSort() < 0)), itemsAfter: await html(visible.filter((item) => item.getSort() >= 0)),
3049
4429
  themes: panel.getThemes().map((theme) => ({ id: theme.id, label: theme.label, swatch: swatchOf(theme, panel) })), activeTheme: panel.resolveTheme(req)?.id ?? null,
4430
+ hasThemeSwitcher: panel.hasDarkMode() && !panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
4431
+ });
4432
+ }
4433
+ /**
4434
+ * Answers with the panel's error page for a status (404, 403, 500…): the cable car in trouble, in the request's theme.
4435
+ * Requests that do not accept HTML get plain text. Use it in your own middleware, a maintenance mode answering 503 for example.
4436
+ */
4437
+ export function renderErrorPage(panel, req, res, status, options = {}) {
4438
+ res.status(status);
4439
+ if (!req.accepts('html'))
4440
+ return void res.type('text').send(`${status}`);
4441
+ const scene = errorPageScene(status);
4442
+ const text = (name) => t(`filament-panels::errors.${scene}.${name}`);
4443
+ // Inside the request's theme: the buttons' color classes depend on its palette.
4444
+ const html = panel.withTheme(req, () => {
4445
+ const home = uiButton({ tag: 'a', href: panel.getBaseUrl(), icon: 'm-home', color: 'primary' }, t('filament-panels::errors.actions.home'));
4446
+ const homeGray = uiButton({ tag: 'a', href: panel.getBaseUrl(), icon: 'm-home', color: 'gray' }, t('filament-panels::errors.actions.home'));
4447
+ const back = uiButton({ icon: 'm-arrow-left', color: 'gray', attributes: { onclick: 'history.back()' } }, t('filament-panels::errors.actions.back'));
4448
+ const retry = uiButton({ tag: 'a', href: req.method === 'GET' ? req.originalUrl : panel.getBaseUrl(), icon: 'm-arrow-path', color: 'primary' }, t('filament-panels::errors.actions.retry'));
4449
+ const login = panel.getLoginPage() ? uiButton({ tag: 'a', href: panel.getLoginUrl(), icon: 'm-arrow-right-end-on-rectangle', color: 'primary' }, t('filament-panels::errors.actions.login')) : home;
4450
+ const actions = scene === 401 ? (panel.getLoginPage() ? [login, homeGray] : [home]) : scene === 403 || scene === 404 ? [home, back] : scene === 503 ? [retry] : [retry, homeGray];
4451
+ const badgeColor = scene >= 500 ? 'danger' : scene === 419 || scene === 429 ? 'warning' : 'gray';
4452
+ const label = t('filament-panels::errors.label', { code: status });
4453
+ const error = options.error instanceof Error ? options.error : null;
4454
+ const theme = panel.resolveTheme(req);
4455
+ return views.render('filament-panels::errors.page', {
4456
+ status, locale: getLocale(), title: text('title'), heading: text('title'), description: options.description ?? text('description'),
4457
+ illustration: errorIllustration(status, `${label}: ${text('title')}`), badge: uiBadge({ color: badgeColor, size: 'lg' }, e(label)), actions, css: ERROR_PAGE_CSS,
4458
+ // The stack only helps while developing: never shown otherwise.
4459
+ details: error && process.env.NODE_ENV === 'development' ? (error.stack ?? String(error)) : null,
4460
+ brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), panelId: panel.getId(),
4461
+ font: theme?.font ? { ...panel.getFont(), ...fontOf(theme.font) } : panel.getFont(), themeCss: theme?.css ?? '', colorVariables: panel.colors.renderCssVariables(),
4462
+ darkMode: panel.hasDarkMode(), darkModeForced: panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
4463
+ });
3050
4464
  });
4465
+ res.type('html').send(html);
3051
4466
  }
3052
- /** Tenant menu: current tenant, other tenants, registration, profile. */
3053
4467
  async function renderTenantMenu(component, options) {
3054
4468
  const { panel, user } = component._ctx;
3055
4469
  const tenant = panel.getTenant();
@@ -3062,6 +4476,9 @@ async function renderTenantMenu(component, options) {
3062
4476
  const ProfilePage = panel.getTenantProfilePage();
3063
4477
  if (ProfilePage && provider.update && !custom.profile)
3064
4478
  items.push(Action.make('profile').label(ProfilePage.getLabel()).icon('m-cog-6-tooth').url(panel.getUrl(ProfilePage.slug)).color('gray').sort(-1));
4479
+ const billingUrl = panel.getTenantBillingUrl(tenant);
4480
+ if (billingUrl && !custom.billing)
4481
+ items.push(Action.make('billing').label(t('filament-panels::layout.actions.billing.label')).icon('m-credit-card').url(billingUrl).color('gray').sort(-1));
3065
4482
  const RegistrationPage = panel.getTenantRegistrationPage();
3066
4483
  if (RegistrationPage && !custom.register && (await RegistrationPage.canView(panel, user)))
3067
4484
  items.push(Action.make('register').label(RegistrationPage.getLabel()).icon('m-plus').url(panel.getBaseUrl(RegistrationPage.slug)).color('gray').sort(Number.MAX_SAFE_INTEGER));
@@ -3080,6 +4497,7 @@ async function renderTenantMenu(component, options) {
3080
4497
  itemsBefore: canSwitch ? await html(visible.filter((item) => item.getSort() < 0)) : [],
3081
4498
  itemsAfter: await html(canSwitch ? visible.filter((item) => item.getSort() >= 0) : visible),
3082
4499
  switchable: others.map((other) => ({ name: provider.getName(other), url: panel.getUrl('', other), avatarUrl: panel.getTenantAvatarUrl(other) })),
4500
+ isSearchable: canSwitch && (panel.isTenantMenuSearchable() ?? others.length >= 10),
3083
4501
  });
3084
4502
  }
3085
4503
  /** Sidebar: logo, tenant menu, search, navigation and, without a topbar, the bell and user menu. */
@@ -3091,9 +4509,9 @@ export class Sidebar extends Component {
3091
4509
  /** A resource item stays active on its create and edit pages. */
3092
4510
  async render() {
3093
4511
  const { panel, user } = this._ctx;
3094
- if (!user)
4512
+ if (!user && !panel.allowsGuests())
3095
4513
  return '<div></div>';
3096
- const inSidebar = (position) => position === 'sidebar';
4514
+ const inSidebar = (position) => position === 'sidebar' && user !== null;
3097
4515
  return views.render('filament-panels::livewire.sidebar', {
3098
4516
  hasTopbar: panel.hasTopbar(), hasTopNavigation: panel.hasTopNavigation(), spa: panel.hasSpaMode(),
3099
4517
  isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
@@ -3128,9 +4546,9 @@ export class Topbar extends Component {
3128
4546
  mount(params) { this.currentPath = String(params.path ?? '/'); }
3129
4547
  async render() {
3130
4548
  const { panel, user } = this._ctx;
3131
- if (!user)
4549
+ if (!user && !panel.allowsGuests())
3132
4550
  return '<div></div>';
3133
- const inTopbar = (position) => position === 'topbar';
4551
+ const inTopbar = (position) => position === 'topbar' && user !== null;
3134
4552
  const hasNavigation = panel.hasNavigation();
3135
4553
  const showsNavigationHere = panel.hasTopNavigation() || !hasNavigation;
3136
4554
  const tenant = panel.getTenant();
@@ -3139,7 +4557,7 @@ export class Topbar extends Component {
3139
4557
  hasNavigation, spa: panel.hasSpaMode(), isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
3140
4558
  homeUrl: panel.getUrl(), brand: panel.getBrand(), livewireId: this.id,
3141
4559
  tenantMenu: showsNavigationHere ? (await renderTenantMenu(this, { teleport: true })) || null : null,
3142
- topNavigation: groups?.map((group) => ({ ...group, items: group.items.map((item) => ({ ...item, colorClasses: item.isActive ? sharedColors.getComponentClasses('dropdown-item', 'primary') : [] })) })) ?? null,
4560
+ topNavigation: groups?.map((group) => topNavigationGroup(group)) ?? null,
3143
4561
  persistKey: panel.hasTenancy() ? `topbar.end.panel-${panel.getId()}.tenant-${tenant ? panel.getTenantUrlKey(tenant) : ''}` : `topbar.end.panel-${panel.getId()}`,
3144
4562
  globalSearch: panel.hasGlobalSearch() && inTopbar(panel.getGlobalSearchPosition()) ? await this.mountChild(`navette.${panel.getId()}.global-search`, {}, `${this.id}.global-search`) : null,
3145
4563
  databaseNotifications: panel.getDatabaseNotifications() && inTopbar(panel.getDatabaseNotificationsPosition()) ? await this.mountChild(`navette.${panel.getId()}.database-notifications`, {}, `${this.id}.database-notifications`, { lazy: true }) : null,
@@ -3208,9 +4626,26 @@ export async function discoverPanels(directory) {
3208
4626
  /** A runtime serves a single panel: each panel has its own Livewire endpoint and components. */
3209
4627
  const runtimePanels = new WeakMap();
3210
4628
  /** Reserved segments right after a multi-tenant panel's path (they are not tenant keys). */
3211
- const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
4629
+ const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'multi-factor-authentication', 'profile', 'livewire']);
3212
4630
  /** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
4631
+ /** Panels sharing an auth guard: same key and same auth provider, otherwise a user id of one could be read as a user of the other. */
4632
+ const guardOwners = new Map();
4633
+ function checkSharedGuard(panel) {
4634
+ const guard = panel.getAuthGuard();
4635
+ if (guard === null)
4636
+ return;
4637
+ const other = guardOwners.get(guard);
4638
+ if (!other || other === panel) {
4639
+ guardOwners.set(guard, panel);
4640
+ return;
4641
+ }
4642
+ if (other._key !== panel._key)
4643
+ throw new Error(`Panels "${other.getId()}" and "${panel.getId()}" share the auth guard "${guard}" but not the same key: give them the same key, or a different authGuard().`);
4644
+ if (other.getAuth() !== panel.getAuth())
4645
+ throw new Error(`Panels "${other.getId()}" and "${panel.getId()}" share the auth guard "${guard}" but not the same auth provider: share the provider, or give one of them authGuard(null) or another guard.`);
4646
+ }
3213
4647
  export function panelRouter(panel, runtime) {
4648
+ checkSharedGuard(panel);
3214
4649
  const owner = runtimePanels.get(runtime);
3215
4650
  if (owner && owner !== panel)
3216
4651
  throw new Error(`This WireRuntime already serves panel "${owner.getId()}": create one WireRuntime per panel.`);
@@ -3223,6 +4658,8 @@ export function panelRouter(panel, runtime) {
3223
4658
  useFormsColorManager(sharedColors);
3224
4659
  useTableColorManager(sharedColors);
3225
4660
  useInfolistsColorManager(sharedColors);
4661
+ // View components (<x-badge>, <x-button>…) use the same colors.
4662
+ useUiColorManager(sharedColors);
3226
4663
  // HTML rendered by an infolist entry is sanitized through the rich editor schema, which only keeps its own tags.
3227
4664
  useHtmlSanitizer((html) => richDocumentToHtml(richHtmlToDocument(html)) ?? '');
3228
4665
  views.share({ assetUrl: (file) => panel.assetUrl(file) });
@@ -3245,34 +4682,33 @@ export function panelRouter(panel, runtime) {
3245
4682
  const name = (suffix) => `navette.${panel.getId()}.${suffix}`;
3246
4683
  const livewire = panel.getLivewirePrefix();
3247
4684
  // The notifications mailbox is per browser: it is keyed by the CSRF token.
3248
- const context = async (req, res) => ({ req, res, panel, user: await panel.user(req), mailbox: csrfToken(req, res), tenant: null });
3249
- /** Tenant designated by a panel URL, if the user is a member. */
3250
- const tenantFromPath = async (path, user) => {
4685
+ const context = async (req, res) => {
4686
+ const user = await panel.user(req);
4687
+ // Signed in through a shared session, but not allowed into this panel: treated as a guest that gets 403 pages.
4688
+ const denied = user !== null && !(await panel.userCanAccess(user));
4689
+ return { req, res, panel, user: denied ? null : user, denied, mailbox: csrfToken(req, res), tenant: null };
4690
+ };
4691
+ const forbidden = (req, res) => renderErrorPage(panel, req, res, 403);
4692
+ /** Tenant designated by a panel URL (or its host, with `tenantDomain()`), if the user is a member. */
4693
+ const tenantFromPath = async (path, user, hostname = '') => {
3251
4694
  if (!panel.hasTenancy() || !user)
3252
4695
  return null;
3253
- const base = panel.getBaseUrl();
3254
- const prefix = base === '/' ? '/' : `${base}/`;
3255
- if (!path.startsWith(prefix))
3256
- return null;
3257
- let key;
3258
- try {
3259
- key = decodeURIComponent(path.slice(prefix.length).split('/')[0] ?? '');
3260
- }
3261
- catch {
3262
- return null;
3263
- }
3264
- const registration = panel.getTenantRegistrationPage();
3265
- if (!key || RESERVED_SEGMENTS.has(key) || key === registration?.slug || key.length > 200)
4696
+ const key = panel.getTenantKeyFromRequest(path, hostname);
4697
+ if (key === null)
3266
4698
  return null;
3267
4699
  const provider = panel.getTenantProvider();
3268
4700
  const tenant = await provider.find(key);
3269
4701
  return tenant && (await provider.canAccess(user, tenant)) ? tenant : null;
3270
4702
  };
3271
- /** User's home: the panel root or, in a multi-tenant panel, their first tenant's root (otherwise tenant registration). */
4703
+ /** User's home: the panel root or, in a multi-tenant panel, their default tenant's root, their first one's, or tenant registration. */
3272
4704
  const homeUrl = async (user) => {
3273
4705
  if (!panel.hasTenancy())
3274
4706
  return panel.getUrl();
3275
- const [first] = await panel.getTenantProvider().getTenants(user);
4707
+ const provider = panel.getTenantProvider();
4708
+ const preferred = (await provider.getDefaultTenant?.(user)) ?? null;
4709
+ if (preferred && (await provider.canAccess(user, preferred)))
4710
+ return panel.getUrl('', preferred);
4711
+ const [first] = await provider.getTenants(user);
3276
4712
  if (first)
3277
4713
  return panel.getUrl('', first);
3278
4714
  const registration = panel.getTenantRegistrationPage();
@@ -3295,6 +4731,9 @@ export function panelRouter(panel, runtime) {
3295
4731
  const profile = panel.getProfile();
3296
4732
  if (profile)
3297
4733
  runtime.register(name('auth.profile'), profile.page);
4734
+ const MultiFactorSetUpPage = panel.getSetUpRequiredMultiFactorAuthenticationPage();
4735
+ if (MultiFactorSetUpPage)
4736
+ runtime.register(name('auth.multi-factor-authentication.set-up-required'), MultiFactorSetUpPage);
3298
4737
  const TenantRegistration = panel.getTenantRegistrationPage();
3299
4738
  if (TenantRegistration)
3300
4739
  runtime.register(name('tenancy.registration'), TenantRegistration);
@@ -3312,12 +4751,31 @@ export function panelRouter(panel, runtime) {
3312
4751
  for (const resource of panel.getResources())
3313
4752
  for (const [index, widget] of resource.widgets.entries())
3314
4753
  runtime.register(name(`widgets.${resource.slug.replace(/\//g, '.')}.${index}`), widget);
3315
- for (const resource of panel.getResources())
3316
- for (const manager of resource.relations)
4754
+ for (const resource of panel.getResources()) {
4755
+ const managers = new Set();
4756
+ const add = (entries) => { for (const entry of entries ?? [])
4757
+ for (const manager of entry instanceof RelationGroup ? entry.getManagers() : [entry])
4758
+ managers.add(manager); };
4759
+ add(resource.getRelations());
4760
+ for (const page of Object.values(resource.getPages()))
4761
+ add(page.relationManagers);
4762
+ for (const manager of managers)
3317
4763
  runtime.register(name(`relation-managers.${resource.slug.replace(/\//g, '.')}.${manager.relationship}`), manager);
4764
+ }
4765
+ // A panel on its own domains: other hosts go to the next routes (another panel, the application).
4766
+ const domains = panel.getDomains();
4767
+ if (domains.length)
4768
+ router.use((req, _res, next) => next(domains.includes(req.hostname) ? undefined : 'router'));
3318
4769
  router.use(express.static(dir('../dist/filament'), { maxAge: '1y' }));
3319
4770
  // Navette's own front-end files.
3320
4771
  router.use(express.static(dir('../dist/navette')));
4772
+ // `bootUsing()`: before every request of the panel (its pages, their updates).
4773
+ const panelBase = panel.getBaseUrl();
4774
+ const belongsToPanel = (path) => panelBase === '/' || path === panelBase || path.startsWith(`${panelBase}/`) || path.startsWith(`${livewire}/`);
4775
+ router.use((req, _res, next) => { if (!belongsToPanel(req.path))
4776
+ return next(); panel.boot(req).then(() => next(), next); });
4777
+ // SPA prefetching: links load their page as soon as they are hovered.
4778
+ const prefetch = (html) => (panel.hasSpaPrefetching() ? html.replace(/ wire:navigate(?=[\s>/])/g, ' wire:navigate.hover') : html);
3321
4779
  // An update's tenant is the one of the page sending it (from the signed snapshot URL), if the user is a member.
3322
4780
  const wireContext = async (req, res) => {
3323
4781
  const ctx = await context(req, res);
@@ -3328,12 +4786,21 @@ export function panelRouter(panel, runtime) {
3328
4786
  catch {
3329
4787
  path = '';
3330
4788
  }
3331
- ctx.tenant = await tenantFromPath(path, ctx.user);
4789
+ ctx.tenant = await tenantFromPath(path, ctx.user, req.hostname);
3332
4790
  return ctx;
3333
4791
  };
3334
4792
  // Notifications sent during the request go into a batch announced to the browser.
3335
4793
  router.use(wireRouter(runtime, livewire, wireContext, async (ctx, _req, res, handle) => {
3336
4794
  const { result, pending } = await runInRequest(ctx.mailbox, () => panel.withTheme(ctx.req, () => panel.withTenant(ctx.tenant ?? null, handle)));
4795
+ if (panel.hasSpaPrefetching()) {
4796
+ for (const component of result.components) {
4797
+ if (typeof component.effects.html === 'string')
4798
+ component.effects.html = prefetch(component.effects.html);
4799
+ for (const [key, html] of Object.entries(component.effects.partials ?? {}))
4800
+ if (typeof html === 'string')
4801
+ component.effects.partials[key] = prefetch(html);
4802
+ }
4803
+ }
3337
4804
  if (pending.length) {
3338
4805
  const batch = publish(ctx.mailbox, pending);
3339
4806
  result.filamentNotificationBatch = batch;
@@ -3374,12 +4841,12 @@ export function panelRouter(panel, runtime) {
3374
4841
  sidebar: hasNavigation ? (await runtime.mount(name('sidebar'), { path: req.path }, request)).replace(/^s*<div /, '<div wire:key="lw-1-1-1" ') : '',
3375
4842
  });
3376
4843
  const bodyClass = options.layout === 'index' ? cls([{ 'fi-body-has-navigation': hasNavigation, 'fi-body-has-sidebar-collapsible-on-desktop': panel.isSidebarCollapsibleOnDesktop(), 'fi-body-has-sidebar-fully-collapsible-on-desktop': panel.isSidebarFullyCollapsibleOnDesktop(), 'fi-body-has-topbar': panel.hasTopbar(), 'fi-body-has-top-navigation': panel.hasTopNavigation() }]) || null : null;
3377
- res.send(views.render('filament-panels::layout.base', {
3378
- renderHookScopes, title: options.title, brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), motion: panel.hasMotion(), panelId: panel.getId(), font: panel.resolveTheme(req)?.font ? { ...panel.getFont(), ...fontOf(panel.resolveTheme(req).font) } : panel.getFont(), themeCss: panel.resolveTheme(req)?.css ?? '',
3379
- bodyClass, body, spa: panel.hasSpaMode(),
4844
+ res.send(prefetch(views.render('filament-panels::layout.base', {
4845
+ renderHookScopes, title: options.title, brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), motion: panel.hasMotion(), securityKeys: panel.usesSecurityKeys(), panelId: panel.getId(), font: panel.resolveTheme(req)?.font ? { ...panel.getFont(), ...fontOf(panel.resolveTheme(req).font) } : panel.getFont(), themeCss: panel.resolveTheme(req)?.css ?? '',
4846
+ bodyClass, body, spa: panel.hasSpaMode(), darkMode: panel.hasDarkMode(), darkModeForced: panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
3380
4847
  csrf: csrfToken(req, res), colorVariables: panel.colors.renderCssVariables(), wireStyles: wireStyles(), wireScripts: wireScripts(req, res, livewire),
3381
4848
  notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
3382
- }));
4849
+ })));
3383
4850
  };
3384
4851
  /** Page access errors: 404 and 403 are answered directly, anything else is rethrown. */
3385
4852
  const guarded = async (res, render) => {
@@ -3387,10 +4854,8 @@ export function panelRouter(panel, runtime) {
3387
4854
  await render();
3388
4855
  }
3389
4856
  catch (error) {
3390
- if (error instanceof WireError && error.status === 404)
3391
- return void res.status(404).send('Not Found');
3392
- if (error instanceof WireError && error.status === 403)
3393
- return void res.status(403).send('Forbidden');
4857
+ if (error instanceof WireError && (error.status === 404 || error.status === 403))
4858
+ return renderErrorPage(panel, res.req, res, error.status);
3394
4859
  throw error;
3395
4860
  }
3396
4861
  };
@@ -3398,58 +4863,140 @@ export function panelRouter(panel, runtime) {
3398
4863
  const guestPage = (path, component, title, params = () => ({})) => {
3399
4864
  router.get(panel.getBaseUrl(path), async (req, res) => {
3400
4865
  const ctx = await context(req, res);
4866
+ if (ctx.denied)
4867
+ return forbidden(req, res);
3401
4868
  if (ctx.user)
3402
4869
  return res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
3403
4870
  await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
3404
4871
  });
3405
4872
  };
3406
4873
  /** Simple page for a signed-in user, outside any tenant (profile, email verification, tenant registration). */
3407
- const userPage = (path, component, title, layout = 'simple') => {
4874
+ const userPage = (path, component, title, layout = 'simple', options = {}) => {
3408
4875
  router.get(panel.getBaseUrl(path), async (req, res) => {
3409
4876
  const ctx = await context(req, res);
4877
+ if (ctx.denied)
4878
+ return forbidden(req, res);
3410
4879
  if (!ctx.user)
3411
4880
  return res.redirect(panel.getLoginUrl());
4881
+ // Required multi-factor authentication: nothing but its setup page (and email verification) until it is set up.
4882
+ if (!options.beforeMultiFactorSetUp && (await panel.mustSetUpMultiFactorAuthentication(ctx.user)))
4883
+ return res.redirect(panel.getSetUpRequiredMultiFactorAuthenticationUrl());
4884
+ // Full layout in a multi-tenant panel: the navigation links to the user's first tenant.
4885
+ if (layout === 'index' && panel.hasTenancy())
4886
+ ctx.tenant = (await panel.getTenantProvider().getTenants(ctx.user))[0] ?? null;
3412
4887
  await guarded(res, () => document(req, res, ctx, { title, component, layout }));
3413
4888
  });
3414
4889
  };
4890
+ // Files offered to the signed-in user (exports, failed import rows): only the user who created them can download them.
4891
+ router.get(panel.getBaseUrl('downloads/:token'), async (req, res) => {
4892
+ const ctx = await context(req, res);
4893
+ if (!ctx.user || ctx.denied)
4894
+ return forbidden(req, res);
4895
+ const file = panel.getDownloads().get(String(req.params.token), ctx.user.id);
4896
+ if (!file)
4897
+ return renderErrorPage(panel, req, res, 404);
4898
+ res.setHeader('Content-Type', file.type);
4899
+ res.setHeader('Content-Disposition', `attachment; filename="${file.name.replace(/[^A-Za-z0-9._-]/g, '_')}"`);
4900
+ res.setHeader('X-Content-Type-Options', 'nosniff');
4901
+ res.setHeader('Cache-Control', 'private, no-store');
4902
+ res.send(file.content);
4903
+ });
3415
4904
  if (LoginPage) {
3416
- guestPage('login', name('auth.login'), t('filament-panels::auth/pages/login.title'));
4905
+ guestPage(panel.getAuthPaths().login, name('auth.login'), t('filament-panels::auth/pages/login.title'));
3417
4906
  router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
3418
4907
  if (!verifyCsrf(req, req.body?._token))
3419
- return res.status(419).send('Invalid CSRF token.');
4908
+ return renderErrorPage(panel, req, res, 419);
3420
4909
  panel.logOut(res);
3421
4910
  res.redirect(panel.getLoginUrl());
3422
4911
  });
3423
4912
  }
3424
4913
  if (RegistrationPage)
3425
- guestPage('register', name('auth.register'), t('filament-panels::auth/pages/register.title'));
4914
+ guestPage(panel.getAuthPaths().registration, name('auth.register'), t('filament-panels::auth/pages/register.title'));
3426
4915
  if (passwordReset) {
3427
- guestPage('password-reset/request', name('auth.password-reset.request'), t('filament-panels::auth/pages/password-reset/request-password-reset.title'));
3428
- guestPage('password-reset/reset', name('auth.password-reset.reset'), t('filament-panels::auth/pages/password-reset/reset-password.title'), (req) => ({ email: typeof req.query.email === 'string' ? req.query.email : null, token: typeof req.query.token === 'string' ? req.query.token : null }));
4916
+ guestPage(panel.getAuthPaths().passwordResetRequest, name('auth.password-reset.request'), t('filament-panels::auth/pages/password-reset/request-password-reset.title'));
4917
+ guestPage(panel.getAuthPaths().passwordReset, name('auth.password-reset.reset'), t('filament-panels::auth/pages/password-reset/reset-password.title'), (req) => ({ email: typeof req.query.email === 'string' ? req.query.email : null, token: typeof req.query.token === 'string' ? req.query.token : null }));
3429
4918
  }
3430
4919
  if (VerificationPrompt) {
3431
- userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
4920
+ userPage(panel.getAuthPaths().emailVerificationPrompt, name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'), 'simple', { beforeMultiFactorSetUp: true });
3432
4921
  // Emailed link: signed, time-limited, and only valid for the signed-in user it designates.
3433
- router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
4922
+ router.get(panel.getBaseUrl(`${panel.getAuthPaths().emailVerification}/:id/:hash`), async (req, res) => {
3434
4923
  const ctx = await context(req, res);
4924
+ if (ctx.denied)
4925
+ return forbidden(req, res);
3435
4926
  if (!ctx.user)
3436
4927
  return res.redirect(panel.getLoginUrl());
3437
4928
  const id = String(req.params.id), hash = String(req.params.hash);
3438
4929
  const expected = createHash('sha1').update(ctx.user.email).digest('hex');
3439
4930
  if (String(ctx.user.id) !== id || hash !== expected || !panel.checkVerifyEmailUrl(id, hash, req.query.expires, req.query.signature))
3440
- return void res.status(403).send('Invalid signature.');
4931
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
3441
4932
  await panel.getAuth().markEmailAsVerified?.(ctx.user);
3442
4933
  res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
3443
4934
  });
3444
4935
  }
3445
4936
  if (profile)
3446
- userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple || panel.hasTenancy() ? 'simple' : 'index');
4937
+ userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple ? 'simple' : 'index');
4938
+ if (panel.hasEmailChangeVerification()) {
4939
+ const flash = async (ctx, build) => { const { pending } = await runInRequest(ctx.mailbox, async () => build()); if (pending.length)
4940
+ publish(ctx.mailbox, pending); };
4941
+ const group = 'filament-panels::auth/http/controllers';
4942
+ // Link sent to the new address: only for the signed-in user who asked, while the old address is still theirs.
4943
+ router.get(panel.getBaseUrl(panel.getAuthPaths().emailChangeVerification), async (req, res) => {
4944
+ const ctx = await context(req, res);
4945
+ if (ctx.denied)
4946
+ return forbidden(req, res);
4947
+ if (!ctx.user)
4948
+ return res.redirect(panel.getLoginUrl());
4949
+ const payload = panel.readEmailChangeToken('verify', req.query.token);
4950
+ if (!payload || String(payload.uid) !== String(ctx.user.id) || payload.from !== fingerprint(ctx.user.email))
4951
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
4952
+ const store = getDefaultCacheStore();
4953
+ if (await store.get(`navette:email-change:blocked:${payload.nonce}`))
4954
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.email_change_blocked') });
4955
+ const auth = panel.getAuth();
4956
+ const other = await auth.findByEmail?.(payload.email);
4957
+ if (other && String(other.id) !== String(ctx.user.id)) {
4958
+ await flash(ctx, () => Notification.make().danger().title(t(`${group}/email-change-verification-controller.notifications.unavailable.title`)).body(t(`${group}/email-change-verification-controller.notifications.unavailable.body`)).send());
4959
+ }
4960
+ else {
4961
+ const updated = await auth.update(ctx.user, { email: payload.email });
4962
+ // The new address was just proven by following the link.
4963
+ await auth.markEmailAsVerified?.(updated);
4964
+ await flash(ctx, () => Notification.make().success().title(t(`${group}/email-change-verification-controller.notifications.verified.title`)).body(t(`${group}/email-change-verification-controller.notifications.verified.body`, { email: payload.email })).send());
4965
+ }
4966
+ res.redirect(panel.getProfileUrl() ?? panel.getBaseUrl());
4967
+ });
4968
+ // Link sent to the old address: works without signing in, until the change is confirmed.
4969
+ router.get(panel.getBaseUrl(panel.getAuthPaths().blockEmailChange), async (req, res) => {
4970
+ const ctx = await context(req, res);
4971
+ const payload = panel.readEmailChangeToken('block', req.query.token);
4972
+ if (!payload)
4973
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
4974
+ const user = await panel.getAuth().find(payload.uid);
4975
+ if (!user)
4976
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
4977
+ const changed = user.email === payload.email;
4978
+ if (!changed)
4979
+ await getDefaultCacheStore().put(`navette:email-change:blocked:${payload.nonce}`, true, 60 * 60 + 60);
4980
+ await flash(ctx, () => (changed
4981
+ ? Notification.make().danger().persistent().title(t(`${group}/block-email-change-verification-controller.notifications.failed.title`)).body(t(`${group}/block-email-change-verification-controller.notifications.failed.body`, { email: payload.email }))
4982
+ : Notification.make().success().persistent().title(t(`${group}/block-email-change-verification-controller.notifications.blocked.title`)).body(t(`${group}/block-email-change-verification-controller.notifications.blocked.body`, { email: payload.email }))).send());
4983
+ res.redirect(ctx.user ? (panel.getProfileUrl() ?? panel.getBaseUrl()) : panel.getLoginUrl());
4984
+ });
4985
+ }
3447
4986
  if (TenantRegistration)
3448
4987
  userPage(TenantRegistration.slug, name('tenancy.registration'), TenantRegistration.getLabel());
4988
+ if (MultiFactorSetUpPage)
4989
+ userPage(panel.getAuthPaths().setUpRequiredMultiFactor, name('auth.multi-factor-authentication.set-up-required'), t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.title'), 'simple', { beforeMultiFactorSetUp: true });
3449
4990
  /** Context of a protected page: login, email verification, tenant. Returns `null` after responding. */
3450
4991
  const pageContext = async (req, res) => {
3451
4992
  const ctx = await context(req, res);
4993
+ if (ctx.denied) {
4994
+ forbidden(req, res);
4995
+ return null;
4996
+ }
3452
4997
  if (!ctx.user) {
4998
+ if (panel.allowsGuests())
4999
+ return ctx;
3453
5000
  res.redirect(panel.getLoginUrl());
3454
5001
  return null;
3455
5002
  }
@@ -3457,62 +5004,125 @@ export function panelRouter(panel, runtime) {
3457
5004
  res.redirect(panel.getEmailVerificationPromptUrl());
3458
5005
  return null;
3459
5006
  }
5007
+ if (await panel.mustSetUpMultiFactorAuthentication(ctx.user)) {
5008
+ res.redirect(panel.getSetUpRequiredMultiFactorAuthenticationUrl());
5009
+ return null;
5010
+ }
3460
5011
  if (panel.hasTenancy()) {
3461
- ctx.tenant = await tenantFromPath(req.path, ctx.user);
5012
+ ctx.tenant = await tenantFromPath(req.path, ctx.user, req.hostname);
3462
5013
  if (!ctx.tenant) {
3463
- res.status(404).send('Not Found');
5014
+ renderErrorPage(panel, req, res, 404);
3464
5015
  return null;
3465
5016
  }
3466
5017
  }
3467
5018
  return ctx;
3468
5019
  };
3469
- const tenantSegment = panel.hasTenancy() ? ':tenant/' : '';
3470
- if (panel.hasTenancy()) {
3471
- // Panel root: the user's first tenant, otherwise tenant registration.
5020
+ // The tenant's segment in page routes: none on tenant domains, `<prefix>/:tenant/` with a route prefix.
5021
+ const tenantRoot = !panel.hasTenancy() || panel.getTenantDomain() ? '' : [panel.getTenantRoutePrefix(), ':tenant'].filter(Boolean).join('/');
5022
+ const tenantSegment = tenantRoot ? `${tenantRoot}/` : '';
5023
+ /** Whether a `:tenant` route parameter is really another route's segment (`login`…), without a route prefix. */
5024
+ const notATenant = (req) => panel.hasTenancy() && tenantRoot !== '' && panel.getTenantRoutePrefix() === null && (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug);
5025
+ /** A tenant page whose tenant has no subscription goes to the billing page. */
5026
+ const requiresSubscription = async (PageClass, ctx) => Boolean(ctx.tenant) && PageClass.isTenantSubscriptionRequired(panel) && !(await panel.getTenantBillingProvider().isSubscribed(ctx.tenant));
5027
+ if (!panel.hasTenancy() && !panel.getPages().some((page) => page.slug === '')) {
5028
+ // Panel root without a dashboard: the first menu page, as in Filament.
3472
5029
  router.get(panel.getBaseUrl(), async (req, res) => {
5030
+ const ctx = await pageContext(req, res);
5031
+ if (!ctx)
5032
+ return;
5033
+ const url = panel.getNavigation(req.path, new Map(), await panel.resolveInaccessiblePages(ctx.user))[0]?.items[0]?.url ?? null;
5034
+ if (!url)
5035
+ return renderErrorPage(panel, req, res, 404);
5036
+ res.redirect(url);
5037
+ });
5038
+ }
5039
+ if (panel.hasTenancy()) {
5040
+ // Panel root: the user's first tenant, otherwise tenant registration (on a tenant's own host: its pages).
5041
+ router.get(panel.getBaseUrl(), async (req, res, next) => {
5042
+ if (panel.getTenantDomain() && panel.getTenantKeyFromRequest(req.path, req.hostname) !== null)
5043
+ return next();
3473
5044
  const ctx = await context(req, res);
5045
+ if (ctx.denied)
5046
+ return forbidden(req, res);
3474
5047
  if (!ctx.user)
3475
5048
  return res.redirect(panel.getLoginUrl());
3476
5049
  if (await panel.mustVerifyEmail(ctx.user))
3477
5050
  return res.redirect(panel.getEmailVerificationPromptUrl());
5051
+ if (await panel.mustSetUpMultiFactorAuthentication(ctx.user))
5052
+ return res.redirect(panel.getSetUpRequiredMultiFactorAuthenticationUrl());
3478
5053
  const url = await homeUrl(ctx.user);
3479
5054
  if (!url)
3480
- return void res.status(404).send('Not Found');
5055
+ return renderErrorPage(panel, req, res, 404);
3481
5056
  res.redirect(url);
3482
5057
  });
3483
5058
  // Tenant root without a dashboard: the first menu page.
3484
5059
  if (!panel.getPages().some((page) => page.slug === '')) {
3485
- router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
3486
- if (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
5060
+ router.get(panel.getBaseUrl(tenantRoot), async (req, res, next) => {
5061
+ if (notATenant(req))
3487
5062
  return next();
3488
5063
  const ctx = await pageContext(req, res);
3489
5064
  if (!ctx)
3490
5065
  return;
3491
5066
  const url = await panel.withTenant(ctx.tenant, async () => panel.getNavigation(req.path, new Map(), await panel.resolveInaccessiblePages(ctx.user))[0]?.items[0]?.url ?? null);
3492
5067
  if (!url)
3493
- return void res.status(404).send('Not Found');
5068
+ return renderErrorPage(panel, req, res, 404);
3494
5069
  res.redirect(url);
3495
5070
  });
3496
5071
  }
3497
5072
  }
5073
+ // Billing page of a tenant, served by the billing provider.
5074
+ const billing = panel.getTenantBillingProvider();
5075
+ if (panel.hasTenancy() && billing) {
5076
+ if (panel.isTenantSubscriptionRequired() && typeof billing.isSubscribed !== 'function')
5077
+ throw new Error('requiresTenantSubscription(): the billing provider must implement isSubscribed().');
5078
+ router.get(panel.getBaseUrl(tenantSegment + panel.getTenantBillingRouteSlug()), async (req, res, next) => {
5079
+ if (notATenant(req))
5080
+ return next();
5081
+ const ctx = await pageContext(req, res);
5082
+ if (!ctx)
5083
+ return;
5084
+ await panel.withTenant(ctx.tenant, () => billing.handle(req, res, ctx.tenant));
5085
+ });
5086
+ }
3498
5087
  for (const PageClass of panel.getPages()) {
3499
5088
  const component = name(`pages.${(PageClass.slug || 'dashboard').replace(/[{}]/g, '')}`);
3500
5089
  runtime.register(component, PageClass);
3501
5090
  // `{record}` in the slug becomes a route parameter, passed to mount().
3502
5091
  router.get(panel.getBaseUrl(tenantSegment + PageClass.slug).replace(/\{(\w+)\}/g, ':$1'), async (req, res, next) => {
3503
- if (panel.hasTenancy() && (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
5092
+ if (notATenant(req))
3504
5093
  return next();
3505
5094
  const ctx = await pageContext(req, res);
3506
5095
  if (!ctx)
3507
5096
  return;
5097
+ if (await requiresSubscription(PageClass, ctx))
5098
+ return res.redirect(panel.getTenantBillingUrl(ctx.tenant));
3508
5099
  const { tenant: _tenant, ...params } = req.params;
3509
5100
  await guarded(res, () => document(req, res, ctx, { title: PageClass.getTitle(), component, layout: 'index', params }));
3510
5101
  });
3511
5102
  }
5103
+ // Any other URL of a panel with a path is unknown. A panel at the root leaves them to the application (`panelNotFound()`).
5104
+ if (panelBase !== '/')
5105
+ router.get([panelBase, `${panelBase}/*path`], (req, res) => renderErrorPage(panel, req, res, 404));
5106
+ // An error thrown while rendering a page of the panel: the 500 page. Component updates keep Livewire's error handling.
5107
+ router.use((error, req, res, next) => {
5108
+ if (res.headersSent || req.method !== 'GET' || !belongsToPanel(req.path) || req.path.startsWith(`${livewire}/`))
5109
+ return next(error);
5110
+ console.error(error);
5111
+ const status = typeof error?.status === 'number' && error.status >= 400 ? error.status : 500;
5112
+ renderErrorPage(panel, req, res, status, { error });
5113
+ });
3512
5114
  return router;
3513
5115
  }
5116
+ /** The panel's 404 page for URLs nothing else answered: mount it after your routes, for a panel served at the root. */
5117
+ export function panelNotFound(panel) {
5118
+ return (req, res) => renderErrorPage(panel, req, res, 404);
5119
+ }
3514
5120
  /** Each auth feature requires some auth provider methods: fail at startup rather than on first click. */
3515
5121
  function assertAuthCapabilities(panel) {
5122
+ if (panel.allowsGuests() && panel.hasTenancy())
5123
+ throw new Error('guestAccess() is not available in a multi-tenant panel: tenants belong to signed-in users.');
5124
+ for (const provider of panel.getMultiFactorAuthenticationProviders())
5125
+ provider.checkConfiguration?.(panel);
3516
5126
  if (!panel.hasRegistration() && !panel.hasPasswordReset() && !panel.hasEmailVerification() && !panel.hasProfile())
3517
5127
  return;
3518
5128
  const auth = panel.getAuth();
@@ -3529,4 +5139,6 @@ function assertAuthCapabilities(panel) {
3529
5139
  missing('Email verification', ['hasVerifiedEmail', 'markEmailAsVerified']);
3530
5140
  if (panel.hasProfile())
3531
5141
  missing('Profile', ['update']);
5142
+ if (panel.hasEmailChangeVerification())
5143
+ missing('Email change verification', ['update', 'findByEmail']);
3532
5144
  }