@navette-js/panels 1.0.0-beta.2 → 1.0.0-beta.4

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.
package/lib/index.js CHANGED
@@ -15,13 +15,17 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
17
  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';
18
+ import { Action, ActionHalt, useColorManager, ActionGroup } from '@navette-js/actions';
19
+ import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, loadSchemaRelationships, saveSchemaRelationships, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
20
20
  import { Actions, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, StatefulGroup } from '@navette-js/schemas';
21
21
  import { WireError } from '@navette-js/wire';
22
22
  import { useColorManager as useInfolistsColorManager, useHtmlSanitizer } from '@navette-js/infolists';
23
23
  import { Notification, Notifications, publish, runInRequest } from "./notifications.js";
24
24
  export { Notification, Notifications } from "./notifications.js";
25
+ export { MemorySessionStore } from "./session-store.js";
26
+ export { csvCell, csvLine, DownloadStore, ExportAction, ExportBulkAction, ExportColumn, Exporter, ImportAction, ImportColumn, Importer, parseCsv } from "./import-export.js";
27
+ import { DownloadStore } from "./import-export.js";
28
+ import { MemorySessionStore } from "./session-store.js";
25
29
  import { addDays, addMonths, CALENDAR_LABELS, CALENDAR_VIEWS, isDate, renderCalendar, visibleRange } from "./calendar.js";
26
30
  export { addDays, addMonths, CALENDAR_LABELS, dayOf, daysBetween, eventsOn, renderCalendar, renderCalendarEvent, timeOf, visibleRange } from "./calendar.js";
27
31
  import { DatabaseNotifications, GlobalSearch, keyBindingSuffix } from "./topbar.js";
@@ -29,7 +33,7 @@ export { DatabaseNotifications, GlobalSearch, knexNotificationStore, timeAgo } f
29
33
  import { Attributes, ColorManager, HtmlString, RenderHookRegistry, cls, convertToRgb, e, icon, js, loadingIndicator, renderHook, runWithRenderHooks, views } from '@navette-js/support';
30
34
  import { t, tc } from '@navette-js/support/i18n';
31
35
  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';
36
+ import { REORDER_LIMIT, Table, TrashedFilter, filterSelectableKeys, getTableRecords, initializeTableGrouping, isTableFilterRemovable, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
33
37
  import { Component } from '@navette-js/wire';
34
38
  import { csrfToken, verifyCsrf, wireRouter, wireScripts, wireStyles } from '@navette-js/wire/express';
35
39
  const dir = (p) => fileURLToPath(new URL(p, import.meta.url));
@@ -37,6 +41,19 @@ views.namespace('filament-panels', dir('../views'));
37
41
  views.namespace('filament', fileURLToPath(new URL('../views', import.meta.resolve('@navette-js/support'))));
38
42
  views.share({ t, tc });
39
43
  export const ASSET_VERSION = '5.11.2.0';
44
+ /** Navigation group: label, icon (then its items have none, as in Filament) and whether it can be collapsed. */
45
+ export class NavigationGroup {
46
+ _label;
47
+ _icon = null;
48
+ _collapsible = true;
49
+ constructor(label) { this._label = label; }
50
+ static make(label) { return new NavigationGroup(label); }
51
+ icon(name) { this._icon = name; return this; }
52
+ collapsible(condition = true) { this._collapsible = condition; return this; }
53
+ getLabel() { return this._label; }
54
+ getIcon() { return this._icon; }
55
+ isCollapsible() { return this._collapsible; }
56
+ }
40
57
  export class NavigationItem {
41
58
  label;
42
59
  _url = '#';
@@ -73,10 +90,20 @@ export class Panel {
73
90
  _login = null;
74
91
  _auth = null;
75
92
  _key = 'cle-de-developpement-a-remplacer';
93
+ _sessionStore = null;
94
+ /** Where per-browser data is kept (persisted table filters, search and sort): in memory by default. */
95
+ sessionStore(store) { this._sessionStore = store; return this; }
96
+ getSessionStore() { return (this._sessionStore ??= new MemorySessionStore()); }
97
+ _downloads = null;
98
+ /** Where files offered for download (exports, failed import rows) are kept: in memory by default. */
99
+ downloads(store) { this._downloads = store; return this; }
100
+ getDownloads() { return (this._downloads ??= new DownloadStore()); }
101
+ getDownloadUrl(token) { return this.getBaseUrl(`downloads/${encodeURIComponent(token)}`); }
76
102
  _pages = [];
77
103
  _resources = [];
78
104
  _navigationItems = [];
79
105
  _navigationGroups = [];
106
+ _navigationGroupOptions = new Map();
80
107
  _maxContentWidth = '7xl';
81
108
  colors = new ColorManager();
82
109
  static make() { return new Panel(); }
@@ -136,6 +163,33 @@ export class Panel {
136
163
  login(page = Login) { this._login = page; return this; }
137
164
  auth(provider) { this._auth = provider; return this; }
138
165
  key(key) { this._key = key; return this; }
166
+ _authGuard = 'web';
167
+ _canAccess = null;
168
+ /**
169
+ * Session shared with the other panels that use the same guard (`'web'` by default): signing into one signs into all of
170
+ * them, and `canAccess()` decides who may enter each one. `null` gives this panel a session of its own.
171
+ * Panels sharing a guard must use the same key and the same auth provider.
172
+ */
173
+ authGuard(guard) {
174
+ if (guard !== null && !/^[a-z0-9_-]{1,40}$/.test(guard))
175
+ throw new Error(`Invalid auth guard name: ${guard}`);
176
+ this._authGuard = guard;
177
+ return this;
178
+ }
179
+ getAuthGuard() { return this._authGuard; }
180
+ /** Who may use this panel, e.g. `panel.canAccess((user) => user.role === 'admin')`. Checked on every request and at login. */
181
+ canAccess(rule) { this._canAccess = rule; return this; }
182
+ /** True if this user may use this panel: the panel's rule and the provider's `canAccessPanel()`, when set, must both allow it. */
183
+ async userCanAccess(user) {
184
+ if (this._canAccess && !(await this._canAccess(user, this)))
185
+ return false;
186
+ const provider = this.getAuth();
187
+ if (provider.canAccessPanel && !(await provider.canAccessPanel(user, this)))
188
+ return false;
189
+ return true;
190
+ }
191
+ /** Name of the session cookie: per guard (shared), or per panel. */
192
+ getSessionCookieName() { return this._authGuard !== null ? `navette_${this._authGuard}_session` : `navette_${this._id}_session`; }
139
193
  pages(pages) { this._pages.push(...pages); return this; }
140
194
  /** Registers resources: their pages and navigation items. */
141
195
  resources(resources) {
@@ -148,7 +202,23 @@ export class Panel {
148
202
  }
149
203
  navigationItems(items) { this._navigationItems.push(...items); return this; }
150
204
  /** Order of navigation groups. */
151
- navigationGroups(groups) { this._navigationGroups = groups; return this; }
205
+ /** Order of the navigation groups: labels, or `NavigationGroup.make('Shop').icon('o-shopping-cart').collapsible(false)`. */
206
+ _darkMode = true;
207
+ _darkModeForced = false;
208
+ _defaultThemeMode = 'system';
209
+ /** `false`: light mode only (no switcher); `darkMode(true, true)`: dark mode only. */
210
+ darkMode(condition = true, isForced = false) { this._darkMode = condition; this._darkModeForced = condition && isForced; return this; }
211
+ /** Mode used until the user picks one in the switcher: `'system'` (default), `'light'` or `'dark'`. */
212
+ defaultThemeMode(mode) { if (!['light', 'dark', 'system'].includes(mode))
213
+ throw new Error(`Invalid theme mode: ${mode}`); this._defaultThemeMode = mode; return this; }
214
+ hasDarkMode() { return this._darkMode; }
215
+ hasDarkModeForced() { return this._darkModeForced; }
216
+ getDefaultThemeMode() { return this._defaultThemeMode; }
217
+ navigationGroups(groups) {
218
+ this._navigationGroups = groups.map((group) => (typeof group === 'string' ? group : group.getLabel()));
219
+ this._navigationGroupOptions = new Map(groups.filter((group) => typeof group !== 'string').map((group) => [group.getLabel(), { icon: group.getIcon(), collapsible: group.isCollapsible() }]));
220
+ return this;
221
+ }
152
222
  _plugins = [];
153
223
  /** Applies a plugin: an object that configures the panel (resources, pages, render hooks, themes…). */
154
224
  plugin(plugin) {
@@ -310,11 +380,29 @@ export class Panel {
310
380
  mailer(mailer) { this._mailer = mailer; return this; }
311
381
  async sendMail(message) { await this._mailer.send(message); }
312
382
  getAuthKey() { return this._key; }
313
- getRegistrationUrl() { return this.getBaseUrl('register'); }
314
- getRequestPasswordResetUrl() { return this.getBaseUrl('password-reset/request'); }
383
+ _authSlugs = { login: 'login', registration: 'register', passwordResetPrefix: 'password-reset', passwordResetRequest: 'request', passwordReset: 'reset' };
384
+ authSlug(slug) { if (!/^[a-z0-9][a-z0-9-]{0,60}$/.test(slug))
385
+ throw new Error(`Invalid route slug: ${slug}`); return slug; }
386
+ /** URL segment of the login page (`login` by default). */
387
+ loginRouteSlug(slug) { this._authSlugs.login = this.authSlug(slug); return this; }
388
+ /** URL segment of the registration page (`register` by default). */
389
+ registrationRouteSlug(slug) { this._authSlugs.registration = this.authSlug(slug); return this; }
390
+ /** URL segments of the password reset pages: `<prefix>/<request>` and `<prefix>/<reset>` (`password-reset/request` and `password-reset/reset` by default). */
391
+ passwordResetRoutePrefix(prefix) { this._authSlugs.passwordResetPrefix = this.authSlug(prefix); return this; }
392
+ passwordResetRequestRouteSlug(slug) { this._authSlugs.passwordResetRequest = this.authSlug(slug); return this; }
393
+ passwordResetRouteSlug(slug) { this._authSlugs.passwordReset = this.authSlug(slug); return this; }
394
+ /** Paths of the authentication pages, relative to the panel. */
395
+ getAuthPaths() {
396
+ const slugs = this._authSlugs;
397
+ return { login: slugs.login, registration: slugs.registration, passwordResetRequest: `${slugs.passwordResetPrefix}/${slugs.passwordResetRequest}`, passwordReset: `${slugs.passwordResetPrefix}/${slugs.passwordReset}` };
398
+ }
399
+ /** First URL segments used by the panel itself (never page slugs or tenant keys). */
400
+ getReservedSegments() { return new Set([...RESERVED_SEGMENTS, this._authSlugs.login, this._authSlugs.registration, this._authSlugs.passwordResetPrefix]); }
401
+ getRegistrationUrl() { return this.getBaseUrl(this.getAuthPaths().registration); }
402
+ getRequestPasswordResetUrl() { return this.getBaseUrl(this.getAuthPaths().passwordResetRequest); }
315
403
  getResetPasswordUrl(user, fingerprint) {
316
404
  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)}`;
405
+ return `${this.getBaseUrl(this.getAuthPaths().passwordReset)}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
318
406
  }
319
407
  /** Payload of a password reset token valid for this panel, otherwise `null`. */
320
408
  readResetPasswordToken(token) { return readToken(this._key, `${this._id}.password-reset`, token); }
@@ -386,7 +474,7 @@ export class Panel {
386
474
  }
387
475
  /** URL outside any tenant (login, registration, profile, tenant registration). */
388
476
  getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
389
- getLoginUrl() { return this.getBaseUrl('login'); }
477
+ getLoginUrl() { return this.getBaseUrl(this.getAuthPaths().login); }
390
478
  getLogoutUrl() { return this.getBaseUrl('logout'); }
391
479
  /** Prefix of this panel's Livewire endpoint (`/livewire` or `/<path>/livewire`): each panel has its own. */
392
480
  getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
@@ -435,18 +523,19 @@ export class Panel {
435
523
  const order = [null, ...this._navigationGroups, ...items.map((i) => i._group).filter((g) => g !== null && !this._navigationGroups.includes(g))];
436
524
  return [...new Set(order)].map((label) => {
437
525
  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 };
526
+ const options = label ? this._navigationGroupOptions.get(label) : undefined;
527
+ 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: options?.icon ? own.map((i) => ({ ...i, icon: null })) : own, icon: options?.icon ?? null, collapsible: options?.collapsible ?? true };
439
528
  }).filter((g) => g.items.length);
440
529
  }
441
530
  // --- session: an HMAC-signed cookie holding the user id
442
531
  sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
443
532
  logIn(res, user, remember) {
444
533
  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 } : {}) });
534
+ res.cookie(this.getSessionCookieName(), `${value}.${this.sign(value)}`, { httpOnly: true, sameSite: 'lax', secure: res.req?.secure === true, ...(remember ? { maxAge: 1000 * 60 * 60 * 24 * 30 } : {}) });
446
535
  }
447
- logOut(res) { res.clearCookie(`navette_${this._id}_session`); }
536
+ logOut(res) { res.clearCookie(this.getSessionCookieName()); }
448
537
  async user(req) {
449
- const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`navette_${this._id}_session=`))?.split('=')[1];
538
+ const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`${this.getSessionCookieName()}=`))?.split('=')[1];
450
539
  if (!raw)
451
540
  return null;
452
541
  const [value, mac] = raw.split('.');
@@ -674,18 +763,29 @@ export class Page extends BasePage {
674
763
  async renderContent() { return (await this.getSchema('content')).toHtml(); }
675
764
  // --- mounted actions: the stack lives in the component state, modals are rendered server-side
676
765
  mountedActions = [];
766
+ /** Files uploaded by rich editors (`componentFileAttachments.<state path>.<id>`), stored by the editor when it asks for their URL. */
767
+ componentFileAttachments = {};
677
768
  getActionMountContext() { return {}; }
678
769
  getActionRecord() { return null; }
679
770
  /** Record targeted by an action mounted in a given context (table row…). */
680
771
  async resolveActionRecord(_context) { return this.getActionRecord(); }
681
772
  /** Actions that can be mounted in a given context (page header by default). */
682
773
  getMountableActions(context) {
683
- return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : this.getHeaderActions();
774
+ return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : flattenActions(this.getHeaderActions());
684
775
  }
685
776
  /**
686
777
  * Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
687
778
  * The key comes from the browser: it must match a real component of the schema, or the schema itself.
688
779
  */
780
+ /** Visible component of a schema of this page (or of a mounted action's form) with this key. */
781
+ async findSchemaComponent(key) {
782
+ const name = key.split('.')[0];
783
+ const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
784
+ if (!mountedIndex && (!/^[A-Za-z]+$/.test(name) || typeof this[`${name}Schema`] !== 'function'))
785
+ return null;
786
+ const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : this.getSchema(name);
787
+ return schema ? (await schema.getVisibleFlatComponents()).find((c) => c.getKey() === key) ?? null : null;
788
+ }
689
789
  async getSchemaComponentActions(key) {
690
790
  const name = key.split('.')[0];
691
791
  // The component may belong to the form of an already mounted action (rich editor in a modal).
@@ -708,12 +808,42 @@ export class Page extends BasePage {
708
808
  return typeof component === 'string' && component.length <= 200 ? { ...this.getActionMountContext(), schemaComponent: component } : this.getActionMountContext();
709
809
  }
710
810
  async findMountableAction(name, context = this.getActionMountContext()) {
811
+ // An action of a modal footer: found among the extra footer actions of its (mounted) parent action.
812
+ if (typeof context.parentAction === 'number') {
813
+ const parent = this.mountedActions[context.parentAction];
814
+ const owner = parent ? await this.findMountableAction(parent.name, parent.context) : null;
815
+ const child = owner?.getExtraModalFooterActions().find((a) => a.getName() === name && a.isMountable());
816
+ if (!child)
817
+ throw new WireError(404, `Action not found: ${String(name)}`);
818
+ return child;
819
+ }
711
820
  const action = (await this.getMountableActions(context)).find((a) => a.getName() === name && a.isMountable());
712
821
  if (!action)
713
822
  throw new WireError(404, `Action not found: ${String(name)}`);
714
823
  return action;
715
824
  }
716
- async actionContext(mounted) { return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}) }; }
825
+ async actionContext(mounted) {
826
+ const parentIndex = mounted.context.parentAction;
827
+ const parent = typeof parentIndex === 'number' ? this.mountedActions[parentIndex] : undefined;
828
+ const parentData = parent ? (parent.data ??= {}) : undefined;
829
+ // An action of a form field (affix action): its state, and access to the other fields of the same form.
830
+ let fieldAccess = {};
831
+ if (typeof mounted.context.schemaComponent === 'string') {
832
+ const component = await this.findSchemaComponent(mounted.context.schemaComponent);
833
+ const path = typeof component?.getStatePath === 'function' && component.hasStatePath() ? String(component.getStatePath()) : null;
834
+ if (path) {
835
+ const container = path.split('.').slice(0, -1);
836
+ const nodeOf = () => container.reduce((node, segment) => (node && typeof node === 'object' ? node[segment] : undefined), this);
837
+ fieldAccess = {
838
+ state: path.split('.').reduce((node, segment) => (node && typeof node === 'object' ? node[segment] : undefined), this),
839
+ get: (name) => nodeOf()?.[name],
840
+ set: (name, value) => { const node = nodeOf(); if (node && typeof node === 'object' && /^[A-Za-z0-9_]+$/.test(name))
841
+ node[name] = value; },
842
+ };
843
+ }
844
+ }
845
+ return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}), ...(parentData ? { parentData } : {}), ...fieldAccess };
846
+ }
717
847
  /** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
718
848
  async getMountedActionSchema(index) {
719
849
  const mounted = this.mountedActions[index];
@@ -724,7 +854,7 @@ export class Page extends BasePage {
724
854
  }
725
855
  async renderActionModal(index) {
726
856
  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);
857
+ return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null, mounted.context);
728
858
  }
729
859
  /** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
730
860
  cancelModalPartialRender() {
@@ -797,12 +927,23 @@ export class Page extends BasePage {
797
927
  /** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
798
928
  async mountAction(name, args = {}, _context = {}) {
799
929
  this.cancelModalPartialRender();
800
- const context = this.resolveMountContext(_context);
930
+ // A modal footer action: its context is its parent's (kept by the server), plus the parent's index.
931
+ const parentIndex = _context?.parentAction;
932
+ const context = Number.isInteger(parentIndex) && parentIndex >= 0 && parentIndex < this.mountedActions.length
933
+ ? { ...this.mountedActions[parentIndex].context, parentAction: parentIndex }
934
+ : this.resolveMountContext(_context);
801
935
  const action = await this.findMountableAction(name, context);
802
936
  await this.assertActionVisible(action, context);
803
937
  const mounted = { name: String(name), arguments: args && typeof args === 'object' ? args : {}, context };
804
- if (!action.hasModal()) {
805
- await action.call(await this.actionContext(mounted));
938
+ if (!action.hasModal() || (await action.isModalHidden(await this.actionContext(mounted)))) {
939
+ // Without a modal, `halt()` and `cancel()` simply stop the action.
940
+ try {
941
+ await action.call(await this.actionContext(mounted));
942
+ }
943
+ catch (error) {
944
+ if (!(error instanceof ActionHalt))
945
+ throw error;
946
+ }
806
947
  return;
807
948
  }
808
949
  this.mountedActions.push(mounted);
@@ -812,10 +953,14 @@ export class Page extends BasePage {
812
953
  const data = {};
813
954
  mounted.data = data;
814
955
  fillSchema(schema, data);
815
- const fill = await action.getFormFill(await this.actionContext(mounted));
816
- if (fill)
956
+ const fillContext = await this.actionContext(mounted);
957
+ const fill = await action.getFormFill(fillContext);
958
+ if (fill) {
817
959
  fillSchemaFromRecord(schema, data, fill);
960
+ await loadSchemaRelationships(schema, data, fillContext.record);
961
+ }
818
962
  }
963
+ await action.runMountUsing({ ...(await this.actionContext(mounted)), data: (mounted.data ??= {}), action });
819
964
  this.renderPartial('action-modals', await this.renderActionModals());
820
965
  this.syncActionModals();
821
966
  }
@@ -841,14 +986,30 @@ export class Page extends BasePage {
841
986
  }
842
987
  }
843
988
  const callContext = await this.actionContext(mounted);
844
- if (schema)
989
+ if (schema) {
845
990
  callContext.data = await getSchemaState(schema);
846
- await action.call(callContext);
847
- // "… & create another": the modal stays open with a reset form.
991
+ callContext.schema = schema;
992
+ }
993
+ try {
994
+ await action.call(callContext);
995
+ }
996
+ catch (error) {
997
+ // `action.halt()`: the modal stays open; `action.cancel()`: it closes.
998
+ if (!(error instanceof ActionHalt))
999
+ throw error;
1000
+ if (error.closesModal) {
1001
+ this.mountedActions.pop();
1002
+ this.syncActionModals();
1003
+ }
1004
+ return;
1005
+ }
1006
+ // "… & create another": the modal stays open with a reset form (except the preserved fields).
848
1007
  if (schema && _args && typeof _args === 'object' && _args.another === true) {
1008
+ const preserved = JSON.parse(JSON.stringify(action.getDataPreservedForAnother(callContext.data ?? {})));
849
1009
  const data = {};
850
1010
  mounted.data = data;
851
1011
  fillSchema(schema, data);
1012
+ Object.assign(data, preserved);
852
1013
  return;
853
1014
  }
854
1015
  this.mountedActions.pop();
@@ -880,7 +1041,8 @@ export class Dashboard extends Page {
880
1041
  /** Filters form state. Stays `null` while the page has no filters. */
881
1042
  filters = null;
882
1043
  /** Dashboard widgets. Defaults to the panel's (`panel.widgets([...])`). */
883
- getWidgets() { return this.panel.getWidgets(); }
1044
+ /** The panel's widgets, ordered by their `sort` (stable). */
1045
+ 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
1046
  /** Grid columns: a number (at the `lg` breakpoint), or one number per breakpoint. */
885
1047
  getColumns() { return 2; }
886
1048
  hasFiltersForm() { return typeof this.filtersFormSchema === 'function'; }
@@ -1341,7 +1503,8 @@ export class Login extends SimplePage {
1341
1503
  return;
1342
1504
  }
1343
1505
  const user = await panel.getAuth().attempt(String(this.data.email), String(this.data.password));
1344
- if (!user) {
1506
+ // As in Filament: a valid account that may not use this panel gets the same message as wrong credentials.
1507
+ if (!user || !(await panel.userCanAccess(user))) {
1345
1508
  this.addError('data.email', t('filament-panels::auth/pages/login.messages.failed'));
1346
1509
  return;
1347
1510
  }
@@ -1434,7 +1597,11 @@ export class Resource {
1434
1597
  /** Columns searched by global search; defaults to the title attribute. Empty array: the resource is not searchable. */
1435
1598
  static globallySearchableAttributes = null;
1436
1599
  static getGloballySearchableAttributes() { return this.globallySearchableAttributes ?? (this.recordTitleAttribute ? [this.recordTitleAttribute] : []); }
1437
- static canGloballySearch() { return this.getGloballySearchableAttributes().length > 0; }
1600
+ /** `false`: the resource is left out of global search. */
1601
+ static isGloballySearchable = true;
1602
+ /** `false`: the search term is matched as a whole instead of word by word. */
1603
+ static shouldSplitGlobalSearchTerms = true;
1604
+ static canGloballySearch() { return this.isGloballySearchable && this.getGloballySearchableAttributes().length > 0; }
1438
1605
  /** Position of this resource's group in the results; without one, after the others in registration order. */
1439
1606
  static globalSearchSort = null;
1440
1607
  /** Maximum results rendered per resource. */
@@ -1455,7 +1622,7 @@ export class Resource {
1455
1622
  return action ? `${this.getUrl('index')}?tableAction=${action}&tableActionRecord=${encodeURIComponent(key)}` : null;
1456
1623
  }
1457
1624
  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) });
1625
+ 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
1626
  const results = [];
1460
1627
  for (const record of page.records) {
1461
1628
  const url = await this.getGlobalSearchResultUrl(record, user);
@@ -1527,8 +1694,10 @@ export class Resource {
1527
1694
  /** Widget: a component displayed in a page's widget grid. */
1528
1695
  export class Widget extends Component {
1529
1696
  static componentName;
1530
- /** Width in the widget grid: a number of columns, or `'full'`. */
1697
+ /** Width in the widget grid: a number of columns or `'full'` (from `lg`), or a span per breakpoint (`{ md: 2, xl: 3 }`). */
1531
1698
  static columnSpan = 1;
1699
+ /** Position on the dashboard (lower first); widgets without a sort keep their order, after the sorted ones. */
1700
+ static sort = null;
1532
1701
  /** Loaded when it becomes visible. */
1533
1702
  static isLazy = true;
1534
1703
  get panel() { return this._ctx.panel; }
@@ -1542,9 +1711,16 @@ export class Widget extends Component {
1542
1711
  if (!(await ctx.panel.authorize(ctx.user, 'view', { widget: WidgetClass, rule: () => WidgetClass.canView(ctx.user) })))
1543
1712
  throw new WireError(403, 'Forbidden.');
1544
1713
  }
1545
- /** Grid column classes and style, at the `lg` breakpoint. */
1714
+ /** Grid column classes and style: at the `lg` breakpoint, or per breakpoint. */
1546
1715
  gridColumn() {
1547
1716
  const span = this.constructor.columnSpan;
1717
+ if (typeof span === 'object') {
1718
+ const entries = Object.entries(span).filter(([breakpoint, value]) => /^(default|sm|md|lg|xl|2xl)$/.test(breakpoint) && (value === 'full' || (Number.isInteger(value) && value > 0)));
1719
+ return {
1720
+ classes: ['fi-grid-col', ...entries.map(([breakpoint]) => (breakpoint === 'default' ? 'fi-grid-col-span' : `${breakpoint}:fi-grid-col-span`))],
1721
+ style: entries.map(([breakpoint, value]) => `--col-span-${breakpoint}: ${value === 'full' ? '1 / -1' : `span ${value} / span ${value}`};`).join(' '),
1722
+ };
1723
+ }
1548
1724
  // The span is always written, even for a single column.
1549
1725
  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
1726
  }
@@ -1842,6 +2018,10 @@ export class StatsOverviewWidget extends Widget {
1842
2018
  static columnSpan = 'full';
1843
2019
  /** Polling interval, or `null` to disable polling. */
1844
2020
  static pollingInterval = '5s';
2021
+ /** Heading shown above the stats. */
2022
+ getHeading() { return null; }
2023
+ /** Description under the heading. */
2024
+ getDescription() { return null; }
1845
2025
  /** Row columns: three, or four when the number of stats fits better. */
1846
2026
  getColumns(count) { return count < 3 ? 3 : count % 3 !== 1 ? 3 : 4; }
1847
2027
  async render() {
@@ -1855,11 +2035,13 @@ export class StatsOverviewWidget extends Widget {
1855
2035
  const id = `stats-overview-stat-${index}`, key = `content.${id}`;
1856
2036
  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
2037
  }).join('');
2038
+ const heading = this.getHeading(), description = this.getDescription();
2039
+ 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
2040
  const interval = this.constructor.pollingInterval;
1859
2041
  const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}` : '';
1860
2042
  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
2043
  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']);
2044
+ }" 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
2045
  }
1864
2046
  }
1865
2047
  /**
@@ -1868,6 +2050,10 @@ export class StatsOverviewWidget extends Widget {
1868
2050
  * has changed (checksum).
1869
2051
  */
1870
2052
  export class ChartWidget extends Widget {
2053
+ /** Empty state shown when `getData()` returns nothing. */
2054
+ getEmptyStateHeading() { return t('filament-widgets::chart.empty.heading'); }
2055
+ getEmptyStateDescription() { return null; }
2056
+ getEmptyStateIcon() { return 'o-x-mark'; }
1871
2057
  static callable = ['updateChartData'];
1872
2058
  static locked = ['dataChecksum'];
1873
2059
  /** Polling interval, or `null` to disable polling. */
@@ -1933,7 +2119,7 @@ export class ChartWidget extends Widget {
1933
2119
  type: ${e(js(type))},
1934
2120
  })" 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
2121
  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>`
2122
+ ? `<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
2123
  : '';
1938
2124
  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
2125
  return this.wrap(`<section x-data="{
@@ -2078,6 +2264,13 @@ export class Tab {
2078
2264
  badge(resolver) { this._badge = resolver; return this; }
2079
2265
  badgeColor(color) { this._badgeColor = color; this._hasBadgeColor = true; return this; }
2080
2266
  query(modifier) { this._query = modifier; return this; }
2267
+ _icon = null;
2268
+ _iconPosition = 'before';
2269
+ /** Icon of the tab, before its label (or after, with `iconPosition('after')`). */
2270
+ icon(name) { this._icon = name; return this; }
2271
+ iconPosition(position) { this._iconPosition = position; return this; }
2272
+ getIcon() { return this._icon; }
2273
+ getIconPosition() { return this._iconPosition; }
2081
2274
  getLabel(key) { return this._label ?? ucfirst(key.replace(/[_-]+/g, ' ')); }
2082
2275
  hasBadge() { return this._badge !== null; }
2083
2276
  async getBadge() { return this._badge ? this._badge() : null; }
@@ -2132,7 +2325,7 @@ class ResourceTabs extends SchemaComponent {
2132
2325
  ${d}?.badgeIconHtml &&
2133
2326
  ${d}?.badgeIconPosition === 'after'
2134
2327
  "><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>`;
2328
+ 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
2329
  });
2137
2330
  return `<div x-data="tabsSchemaComponent({
2138
2331
  activeTab: ${Math.max(tabs.findIndex(([k]) => k === active), 0) + 1},
@@ -2194,10 +2387,73 @@ export class ListRecords extends Page {
2194
2387
  if (allowed.delete(key))
2195
2388
  await adapter.update(key, { [column]: ++rank });
2196
2389
  }
2390
+ /** Key of this table's persisted state: per page class (and resource or relationship). */
2391
+ tableSessionKey(part) {
2392
+ const page = this.constructor;
2393
+ return ['tables', page.name, page.resource?.slug ?? '', page.relationship ?? '', part].join(':');
2394
+ }
2395
+ /** Session of the persisted table state: the browser (its CSRF token) and the signed-in user. */
2396
+ tableSession() {
2397
+ const ctx = this._ctx;
2398
+ return ctx?.mailbox ? `${ctx.panel.getId()}:${ctx.user?.id ?? ''}:${ctx.mailbox}` : null;
2399
+ }
2400
+ async dehydrate() {
2401
+ await super.dehydrate?.();
2402
+ const table = this.getTable(), session = this.tableSession();
2403
+ if (!session)
2404
+ return;
2405
+ const store = this._ctx.panel.getSessionStore();
2406
+ if (table.persistsFiltersInSession())
2407
+ await store.set(session, this.tableSessionKey('filters'), this.tableFilters);
2408
+ if (table.persistsSearchInSession())
2409
+ await store.set(session, this.tableSessionKey('search'), this.tableSearch);
2410
+ if (table.persistsColumnSearchesInSession())
2411
+ await store.set(session, this.tableSessionKey('column-searches'), this.tableColumnSearches);
2412
+ if (table.persistsSortInSession())
2413
+ await store.set(session, this.tableSessionKey('sort'), this.tableSort);
2414
+ if (table.persistsGroupingInSession())
2415
+ await store.set(session, this.tableSessionKey('grouping'), this.tableGrouping);
2416
+ }
2417
+ /** Restores the persisted table state; every value is sanitized like browser input. */
2418
+ async restoreTableSession(table) {
2419
+ const session = this.tableSession();
2420
+ if (!session)
2421
+ return;
2422
+ const store = this._ctx.panel.getSessionStore();
2423
+ if (table.persistsFiltersInSession()) {
2424
+ const saved = await store.get(session, this.tableSessionKey('filters'));
2425
+ if (saved && typeof saved === 'object') {
2426
+ this.tableFilters = this.sanitizeTableFilters(saved);
2427
+ this.tableDeferredFilters = JSON.parse(JSON.stringify(this.tableFilters));
2428
+ }
2429
+ }
2430
+ if (table.persistsSearchInSession()) {
2431
+ const saved = await store.get(session, this.tableSessionKey('search'));
2432
+ if (typeof saved === 'string')
2433
+ this.tableSearch = saved.slice(0, 1000);
2434
+ }
2435
+ if (table.persistsColumnSearchesInSession()) {
2436
+ const saved = await store.get(session, this.tableSessionKey('column-searches'));
2437
+ if (saved && typeof saved === 'object')
2438
+ 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)]));
2439
+ }
2440
+ if (table.persistsSortInSession()) {
2441
+ const saved = await store.get(session, this.tableSessionKey('sort'));
2442
+ if (typeof saved === 'string' || saved === null)
2443
+ this.tableSort = saved;
2444
+ }
2445
+ // Only a group the table declares is kept: `getTableGrouping()` ignores anything else.
2446
+ if (table.persistsGroupingInSession()) {
2447
+ const saved = await store.get(session, this.tableSessionKey('grouping'));
2448
+ if (typeof saved === 'string' && saved.length <= 200)
2449
+ this.tableGrouping = saved;
2450
+ }
2451
+ }
2197
2452
  async mount(_params = {}) {
2198
2453
  const table = this.getTable();
2199
2454
  this.tableFilters = table.getDefaultFilterState();
2200
2455
  this.tableDeferredFilters = table.getDefaultFilterState();
2456
+ await this.restoreTableSession(table);
2201
2457
  this.tableColumns = table.getDefaultColumnState();
2202
2458
  // Preselect the default group.
2203
2459
  initializeTableGrouping(table, this);
@@ -2216,43 +2472,71 @@ export class ListRecords extends Page {
2216
2472
  /** Filters form: one group per filter, bound to `tableDeferredFilters.<filter>`. */
2217
2473
  tableFiltersFormSchema(schema) {
2218
2474
  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) => {
2475
+ // Deferred filters edit a copy applied by the "Apply" button; otherwise the form edits the applied state, field by field.
2476
+ const deferred = table.hasDeferredFilters();
2477
+ const groups = table.getFilters().map((filter) => {
2222
2478
  // A filter may provide its own form (query builder); otherwise it is a select.
2223
2479
  const custom = filter.getFormComponents?.();
2224
2480
  if (custom)
2225
2481
  return StatefulGroup.make(filter.name, custom).columnSpan(filter.getColumnSpan?.() ?? { default: 1, lg: 1 });
2226
2482
  const definition = filter.getFormDefinition();
2227
2483
  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 });
2484
+ });
2485
+ // `filtersFormSchema()`: the application arranges the filter groups (sections, columns...).
2486
+ const arrange = table.getFiltersFormSchema();
2487
+ const components = arrange ? arrange(Object.fromEntries(table.getFilters().map((filter, index) => [filter.name, groups[index]]))) : groups;
2488
+ const built = schema.statePath(deferred ? 'tableDeferredFilters' : 'tableFilters').columns(table.getFiltersFormColumns()).partiallyRendered().components(components);
2489
+ if (!deferred)
2490
+ for (const component of built.getFlatComponents())
2491
+ if (component instanceof Field)
2492
+ component.live();
2493
+ return built;
2494
+ }
2495
+ /** Applied state of every declared filter, sanitized from the given (browser) state. */
2496
+ sanitizeTableFilters(source) {
2497
+ return Object.fromEntries(this.getTable().getFilters().map((filter) => {
2498
+ const received = source?.[filter.name];
2499
+ if (filter.sanitizeState)
2500
+ return [filter.name, filter.sanitizeState(received)];
2501
+ return [filter.name, Object.fromEntries(Object.keys(filter.getDefaultState()).map((key) => [key, received?.[key] ?? null]))];
2228
2502
  }));
2229
2503
  }
2504
+ /** Live filters (`deferFilters(false)`): a changed field applies at once. */
2505
+ async updatedTableFilters(value, key) {
2506
+ if (this.getTable().hasDeferredFilters()) {
2507
+ this.tableFilters = this.sanitizeTableFilters(this.tableFilters);
2508
+ return;
2509
+ }
2510
+ await this.runFilterFieldHook('tableFilters', key);
2511
+ this.tableFilters = this.sanitizeTableFilters(this.tableFilters);
2512
+ this.tableDeferredFilters = JSON.parse(JSON.stringify(this.tableFilters));
2513
+ this.paginators.page = 1;
2514
+ this.dispatch('deselectAllTableRecords', [], { self: true });
2515
+ }
2230
2516
  applyTableFilters() {
2231
2517
  // 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
- }));
2518
+ this.tableFilters = this.sanitizeTableFilters(this.tableDeferredFilters);
2238
2519
  this.paginators.page = 1;
2239
2520
  }
2240
2521
  /** 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)
2522
+ async removeTableFilter(filterName, field = null) {
2523
+ const table = this.getTable();
2524
+ const filter = table.getFilters().find((candidate) => candidate.name === filterName);
2525
+ // A filter showing a non-removable indicator cannot be removed by a forged call either.
2526
+ if (!filter || !(await isTableFilterRemovable(table, this, filter.name)))
2244
2527
  return;
2245
2528
  const current = this.tableFilters?.[filter.name];
2246
- const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : filter.getDefaultState();
2529
+ const next = filter.removeIndicator && typeof field === 'string' && current && typeof current === 'object' ? filter.removeIndicator(current, field) : (filter.getResetState?.() ?? filter.getDefaultState());
2247
2530
  this.tableFilters = { ...this.tableFilters, [filter.name]: next };
2248
2531
  this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(next)) };
2249
2532
  this.paginators.page = 1;
2250
2533
  }
2251
2534
  /** Live field in the filters form (e.g. a rule's operator changed): runs its `afterStateUpdated`. */
2252
- async updatedTableDeferredFilters(_value, key) {
2535
+ async updatedTableDeferredFilters(_value, key) { await this.runFilterFieldHook('tableDeferredFilters', key); }
2536
+ async runFilterFieldHook(property, key) {
2253
2537
  if (!key)
2254
2538
  return;
2255
- const path = `tableDeferredFilters.${key}`;
2539
+ const path = `${property}.${key}`;
2256
2540
  const field = this.getSchema('tableFiltersForm').getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === path);
2257
2541
  if (!field?.hasAfterStateUpdated())
2258
2542
  return;
@@ -2273,7 +2557,21 @@ export class ListRecords extends Page {
2273
2557
  this.paginators.page = 1;
2274
2558
  this.dispatch('deselectAllTableRecords', [], { self: true });
2275
2559
  }
2276
- removeTableFilters() { this.resetTableFiltersForm(); }
2560
+ /** "Remove all": resets every removable filter (to its empty state, not its default), the search and the column searches. */
2561
+ async removeTableFilters() {
2562
+ const table = this.getTable();
2563
+ for (const filter of table.getFilters()) {
2564
+ if (!(await isTableFilterRemovable(table, this, filter.name)))
2565
+ continue;
2566
+ const reset = filter.getResetState?.() ?? filter.getDefaultState();
2567
+ this.tableFilters = { ...this.tableFilters, [filter.name]: reset };
2568
+ this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(reset)) };
2569
+ }
2570
+ this.tableSearch = '';
2571
+ this.tableColumnSearches = {};
2572
+ this.paginators.page = 1;
2573
+ this.dispatch('deselectAllTableRecords', [], { self: true });
2574
+ }
2277
2575
  applyTableColumnManager(state = null, _wasReordered = false) { if (state)
2278
2576
  this.tableColumns = sanitizeColumnState(this.getTable(), state); }
2279
2577
  resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
@@ -2300,7 +2598,11 @@ export class ListRecords extends Page {
2300
2598
  rendersActionModalsInPage() { return false; }
2301
2599
  getBulkActions() { return this.getTable().getBulkActions() ?? [DeleteBulkAction.make()]; }
2302
2600
  /** Row actions declared by the table, with groups flattened. */
2303
- getRecordActions() { return this.getTable().getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])); }
2601
+ getRecordActions() {
2602
+ const table = this.getTable();
2603
+ // Column actions are record actions too: mounted with the record key, and checked the same way.
2604
+ return [...table.getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])), ...table.getColumnActions()];
2605
+ }
2304
2606
  getMountableActions(context) {
2305
2607
  if (typeof context.schemaComponent === 'string')
2306
2608
  return this.getSchemaComponentActions(context.schemaComponent);
@@ -2309,8 +2611,8 @@ export class ListRecords extends Page {
2309
2611
  if (context.table === true && typeof context.recordKey === 'string')
2310
2612
  return this.getRecordActions();
2311
2613
  if (context.table === true)
2312
- return this.getTable().getHeaderActions();
2313
- return this.getHeaderActions();
2614
+ return flattenActions([...this.getTable().getHeaderActions(), ...this.getTable().getToolbarActions(), ...this.getTable().getEmptyStateActions()]);
2615
+ return flattenActions(this.getHeaderActions());
2314
2616
  }
2315
2617
  /** Only known context shapes are kept; the record key is checked again in the database on mount. */
2316
2618
  resolveMountContext(clientContext) {
@@ -2339,11 +2641,73 @@ export class ListRecords extends Page {
2339
2641
  /** Selected keys: the sent list, or, in "select all" mode, every record of the current query except deselected ones. */
2340
2642
  async getSelectedRecordKeys() {
2341
2643
  const clean = (list) => (Array.isArray(list) ? list.filter((k) => typeof k === 'string' || typeof k === 'number').map(String) : []);
2644
+ const table = this.getTable(), adapter = this.getDataAdapterForTable();
2645
+ let keys;
2342
2646
  // The selection comes from the browser: it is narrowed to records this table can actually display.
2343
2647
  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));
2648
+ keys = await adapter.keysInScope(clean(this.selectedTableRecords));
2649
+ else {
2650
+ const excluded = new Set(clean(this.deselectedTableRecords));
2651
+ // "Select all" covers the whole query, or only the current page with `selectCurrentPageOnly()`.
2652
+ const all = table.selectsCurrentPageOnly() ? (await getTableRecords(table, this, adapter)).records.map((record) => adapter.getKey(record)) : await adapter.listKeys(getTableQuery(table, this));
2653
+ keys = all.filter((k) => !excluded.has(k));
2654
+ }
2655
+ // Records that cannot be selected, and anything beyond `maxSelectableRecords()`, are dropped whatever the browser sent.
2656
+ keys = await filterSelectableKeys(table, adapter, keys);
2657
+ const max = table.getMaxSelectableRecords();
2658
+ return max !== null ? keys.slice(0, max) : keys;
2659
+ }
2660
+ /**
2661
+ * Runs `process` for each selected record, loaded in chunks of `chunkSelectedRecords()`. Records failing the action's
2662
+ * `authorizeIndividualRecords()` are skipped; `process` reports a failure by returning `false` or throwing. Then sends the
2663
+ * action's success notification, or its failure notification with the number of records skipped and failed.
2664
+ */
2665
+ async processSelectedRecords(action, process) {
2666
+ const keys = await this.getSelectedRecordKeys();
2667
+ const adapter = this.getDataAdapterForTable();
2668
+ const authorization = action.getIndividualRecordsAuthorization();
2669
+ const ctx = { livewire: this, record: null, arguments: {}, data: {}, action };
2670
+ let success = 0, unauthorized = 0, failed = 0;
2671
+ const size = action.getSelectedRecordsChunkSize();
2672
+ for (let index = 0; index < keys.length; index += size) {
2673
+ const records = await Promise.all(keys.slice(index, index + size).map((key) => adapter.find(key)));
2674
+ for (const record of records) {
2675
+ if (!record) {
2676
+ failed++;
2677
+ continue;
2678
+ }
2679
+ const allowed = authorization === null ? true : typeof authorization === 'string' ? await canOn(this, authorization, record) : await authorization(record, { ...ctx, record });
2680
+ if (!allowed) {
2681
+ unauthorized++;
2682
+ continue;
2683
+ }
2684
+ try {
2685
+ if ((await process(record)) === false)
2686
+ failed++;
2687
+ else
2688
+ success++;
2689
+ }
2690
+ catch (error) {
2691
+ failed++;
2692
+ if (failed === 1)
2693
+ console.error(error);
2694
+ }
2695
+ }
2696
+ }
2697
+ const total = keys.length;
2698
+ if (unauthorized + failed === 0) {
2699
+ const title = action.getSuccessNotificationTitle();
2700
+ if (title)
2701
+ Notification.make().success().title(title).send();
2702
+ }
2703
+ else {
2704
+ const body = [action.getMissingBulkAuthorizationFailureNotificationMessage(unauthorized, total) && unauthorized ? action.getMissingBulkAuthorizationFailureNotificationMessage(unauthorized, total) : null, failed ? action.getMissingBulkProcessingFailureNotificationMessage(failed, total) : null].filter(Boolean).join(' ');
2705
+ const notification = Notification.make().title(action.getFailureNotificationTitle(success, total) ?? action.getSuccessNotificationTitle() ?? '');
2706
+ (success ? notification.warning() : notification.danger()).body(body || null).send();
2707
+ }
2708
+ if (action.shouldDeselectRecordsAfterCompletion())
2709
+ this.deselectAllTableRecords();
2710
+ return { total, success, unauthorized, failed };
2347
2711
  }
2348
2712
  deselectAllTableRecords() {
2349
2713
  this.selectedTableRecords = [];
@@ -2366,7 +2730,7 @@ export class ListRecords extends Page {
2366
2730
  bulkActions.push(await action.toGroupedHtml(ctx, { table: true, bulk: true }));
2367
2731
  const table = this.getTable();
2368
2732
  return renderTable(table, this, resource.adapter, {
2369
- bulkActions, headerActions: await this.renderTableHeaderActions(ctx), after: await this.renderActionModals(),
2733
+ bulkActions, headerActions: await this.renderTableHeaderActions(ctx), toolbarActions: await this.renderTableToolbarActions(ctx), emptyStateActions: await this.renderTableEmptyStateActions(ctx), after: await this.renderActionModals(),
2370
2734
  filtersForm: table.getFilters().length ? await this.getSchema('tableFiltersForm').toHtml() : null,
2371
2735
  rowActionsHtml: table.getRecordActions().length ? async (record) => {
2372
2736
  const key = resource.adapter.getKey(record);
@@ -2385,7 +2749,21 @@ export class ListRecords extends Page {
2385
2749
  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
2750
  });
2387
2751
  }
2752
+ async renderTableEmptyStateActions(ctx) {
2753
+ const html = [];
2754
+ for (const action of this.getTable().getEmptyStateActions())
2755
+ if (await action.isVisible(ctx))
2756
+ html.push(await action.toHtml(ctx, { table: true }));
2757
+ return html;
2758
+ }
2388
2759
  /** Table header actions (none on a list page: they go in the page header). */
2760
+ async renderTableToolbarActions(ctx) {
2761
+ const html = [];
2762
+ for (const action of this.getTable().getToolbarActions())
2763
+ if (await action.isVisible(ctx))
2764
+ html.push(await action.toHtml(ctx, { table: true }));
2765
+ return html;
2766
+ }
2389
2767
  async renderTableHeaderActions(ctx) {
2390
2768
  const html = [];
2391
2769
  for (const action of this.getTable().getHeaderActions())
@@ -2468,6 +2846,15 @@ export class RelationManager extends ListRecords {
2468
2846
  static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() must be defined.'); }
2469
2847
  /** Label of an attachable record, or `null` if it is not attachable (also used as validation). */
2470
2848
  static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() must be defined.'); }
2849
+ // --- one-to-many: to be defined by managers offering Associate / Dissociate
2850
+ /** Sets the owner of an existing record (its foreign key). */
2851
+ static associate(_ownerKey, _recordKey) { throw new Error('RelationManager.associate() must be defined.'); }
2852
+ /** Clears the owner of records (their foreign key), without deleting them. */
2853
+ static dissociate(_ownerKey, _recordKeys) { throw new Error('RelationManager.dissociate() must be defined.'); }
2854
+ /** Associable records matching the search: [key, label]. */
2855
+ static searchAssociable(_ownerKey, _search) { throw new Error('RelationManager.searchAssociable() must be defined.'); }
2856
+ /** Label of an associable record, or `null` if it is not associable (also used as validation). */
2857
+ static associableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.associableLabel() must be defined.'); }
2471
2858
  getHeaderActions() { return []; }
2472
2859
  placeholder() {
2473
2860
  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>`;
@@ -2640,11 +3027,23 @@ class ResourceFormPage extends Page {
2640
3027
  return Action.make('cancel').label(t(`filament-panels::resources/pages/${this.getOperation()}-record.form.actions.cancel.label`)).color('gray')
2641
3028
  .alpineClickHandler(`document.referrer ? window.history.back() : Livewire.navigate(${js(this.getCancelUrl())})`);
2642
3029
  }
3030
+ // --- lifecycle hooks around filling and validation (to override; none can be called from the browser)
3031
+ /** Before the form is filled (with defaults, or with the record on the edit page). */
3032
+ beforeFill() { }
3033
+ /** After the form is filled. */
3034
+ afterFill() { }
3035
+ /** Before the form is validated, when it is submitted. */
3036
+ beforeValidate() { }
3037
+ /** After the form is validated successfully, before the data is transformed and saved. */
3038
+ afterValidate() { }
2643
3039
  /** Validates the form; on error, sets the errors on the component and notifies the browser. */
2644
3040
  async validateForm() {
3041
+ await this.beforeValidate();
2645
3042
  this._errors = await validateSchema(this.getSchema('form'));
2646
- if (!Object.keys(this._errors).length)
3043
+ if (!Object.keys(this._errors).length) {
3044
+ await this.afterValidate();
2647
3045
  return true;
3046
+ }
2648
3047
  this.dispatch('form-validation-error', { livewireId: this.id });
2649
3048
  return false;
2650
3049
  }
@@ -2652,7 +3051,7 @@ class ResourceFormPage extends Page {
2652
3051
  /** Record creation page. */
2653
3052
  export class CreateRecord extends ResourceFormPage {
2654
3053
  static callable = ['create', 'createAnother'];
2655
- mount() { fillSchema(this.getSchema('form'), this.data); }
3054
+ async mount() { await this.beforeFill(); fillSchema(this.getSchema('form'), this.data); await this.afterFill(); }
2656
3055
  getOperation() { return 'create'; }
2657
3056
  getSubmitMethod() { return 'create'; }
2658
3057
  getTitle() { return t('filament-panels::resources/pages/create-record.title', { label: this.resource.modelLabel.split(' ').map(ucfirst).join(' ') }); }
@@ -2686,6 +3085,7 @@ export class CreateRecord extends ResourceFormPage {
2686
3085
  const data = await this.mutateFormDataBeforeCreate(await getSchemaState(this.getSchema('form')));
2687
3086
  await this.beforeCreate(data);
2688
3087
  const record = await this.handleRecordCreation(data);
3088
+ await saveSchemaRelationships(this.getSchema('form'), record);
2689
3089
  await this.afterCreate(record);
2690
3090
  const title = this.getCreatedNotificationTitle();
2691
3091
  if (title !== null)
@@ -2710,34 +3110,62 @@ export class DeleteAction {
2710
3110
  .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return target ? livewire.getDataAdapter().isTrashed(target) : false; })
2711
3111
  .modalHeading(({ livewire, record }) => t('filament-actions::delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
2712
3112
  .modalSubmitActionLabel(t('filament-actions::delete.single.modal.actions.delete.label'))
2713
- .action(async ({ livewire, record }) => {
3113
+ .action(async (ctx) => {
3114
+ const { livewire, record } = ctx;
2714
3115
  const adapter = livewire.getDataAdapter();
2715
3116
  const inTable = typeof livewire.getTable === 'function';
2716
3117
  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);
3118
+ await finishAction(ctx.action, ctx, t('filament-actions::delete.single.notifications.deleted.title'), inTable ? null : livewire.resource.getUrl());
2720
3119
  });
2721
3120
  }
2722
3121
  }
3122
+ /**
3123
+ * Success outcome of a prebuilt action: its notification (`successNotificationTitle()`, `successNotification()`), then the
3124
+ * redirect (`successRedirectUrl()`, or `fallbackUrl`). Only local or http(s) URLs are followed.
3125
+ */
3126
+ async function finishAction(action, ctx, defaultTitle, fallbackUrl = null) {
3127
+ let notification = Notification.make().success().title(action.getSuccessNotificationTitle() ?? defaultTitle);
3128
+ const customize = action.getSuccessNotificationCustomizer();
3129
+ if (customize)
3130
+ notification = customize(notification, ctx);
3131
+ notification?.send();
3132
+ const url = (await action.getSuccessRedirectUrl(ctx)) ?? fallbackUrl;
3133
+ if (url && /^(\/(?!\/)|https?:\/\/)/.test(url))
3134
+ ctx.livewire.redirect(url, true);
3135
+ }
2723
3136
  /** Duplicates a table row: the copy takes the record's attributes, except its key, timestamps and excluded ones. */
2724
3137
  export class ReplicateAction {
2725
3138
  static make() {
2726
3139
  let excluded = [];
3140
+ let beforeSaved = null, afterSaved = null;
2727
3141
  const action = Action.make('replicate').authorize(({ livewire, record }) => canOn(livewire, 'replicate', record))
2728
3142
  .label(t('filament-actions::replicate.single.label')).tableIcon('c-square-2-stack').groupedIcon('m-square-2-stack')
2729
3143
  .modalHeading(({ livewire, record }) => t('filament-actions::replicate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
2730
3144
  .modalSubmitActionLabel(t('filament-actions::replicate.single.modal.actions.replicate.label'))
2731
- .action(async ({ livewire, record }) => {
3145
+ // With a modal form (`schema()`), it is filled with the record's attributes; its data overrides them in the copy.
3146
+ .fillForm(({ record }) => {
3147
+ const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
3148
+ for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
3149
+ delete source[attribute];
3150
+ return source;
3151
+ })
3152
+ .action(async (ctx) => {
3153
+ const { livewire, record } = ctx;
2732
3154
  const adapter = livewire.getDataAdapter();
2733
3155
  const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
2734
3156
  // No primary key, no created/updated timestamps.
2735
3157
  for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
2736
3158
  delete source[attribute];
2737
- await adapter.create(source);
2738
- Notification.make().success().title(t('filament-actions::replicate.single.notifications.replicated.title')).send();
3159
+ const data = ctx.data ?? {};
3160
+ const replica = { ...source, ...data };
3161
+ await beforeSaved?.({ replica, data, record });
3162
+ const saved = await adapter.create(replica);
3163
+ await afterSaved?.({ replica: saved, data, record });
3164
+ await finishAction(ctx.action, { ...ctx, replica: saved }, t('filament-actions::replicate.single.notifications.replicated.title'));
2739
3165
  });
2740
3166
  action.excludeAttributes = (attributes) => { excluded = attributes; return action; };
3167
+ action.beforeReplicaSaved = (callback) => { beforeSaved = callback; return action; };
3168
+ action.afterReplicaSaved = (callback) => { afterSaved = callback; return action; };
2741
3169
  return action;
2742
3170
  }
2743
3171
  }
@@ -2752,9 +3180,10 @@ export class CreateAction {
2752
3180
  .modalSubmitActionLabel(t('filament-actions::create.single.modal.actions.create.label'))
2753
3181
  .extraModalFooterActions([Action.make('createAnother').label(t('filament-actions::create.single.modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
2754
3182
  .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();
3183
+ .action(async (ctx) => {
3184
+ const record = await of(ctx.livewire).adapter.create(ctx.data);
3185
+ await saveSchemaRelationships(ctx.schema, record);
3186
+ await finishAction(ctx.action, { ...ctx, record }, t('filament-actions::create.single.notifications.created.title'));
2758
3187
  });
2759
3188
  }
2760
3189
  }
@@ -2769,10 +3198,11 @@ export class EditAction {
2769
3198
  .modalSubmitActionLabel(t('filament-actions::edit.single.modal.actions.save.label'))
2770
3199
  .schema((schema) => of(schema.livewire).form(schema))
2771
3200
  .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();
3201
+ .action(async (ctx) => {
3202
+ const adapter = of(ctx.livewire).adapter;
3203
+ const saved = await adapter.update(adapter.getKey(ctx.record), ctx.data);
3204
+ await saveSchemaRelationships(ctx.schema, saved ?? ctx.record);
3205
+ await finishAction(ctx.action, ctx, t('filament-actions::edit.single.notifications.saved.title'));
2776
3206
  });
2777
3207
  }
2778
3208
  }
@@ -2840,6 +3270,56 @@ export class DetachBulkAction {
2840
3270
  });
2841
3271
  }
2842
3272
  }
3273
+ /** Gives an existing record to the owner of a one-to-many relation manager (its foreign key). */
3274
+ export class AssociateAction {
3275
+ static make() {
3276
+ return Action.make('associate')
3277
+ .label(t('filament-actions::associate.single.label')).color('gray').modalWidth('lg')
3278
+ .modalHeading(({ livewire }) => t('filament-actions::associate.single.modal.heading', { label: String(livewire.resource.modelLabel).split(' ').map(ucfirst).join(' ') }))
3279
+ .modalSubmitActionLabel(t('filament-actions::associate.single.modal.actions.associate.label'))
3280
+ .extraModalFooterActions([Action.make('associateAnother').label(t('filament-actions::associate.single.modal.actions.associate_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)])
3281
+ .schema((schema) => {
3282
+ const livewire = schema.livewire;
3283
+ return [Select.make('recordId').label(t('filament-actions::associate.single.modal.fields.record_id.label')).hiddenLabel().required()
3284
+ .getSearchResultsUsing((search) => managerOf(livewire).searchAssociable(livewire.ownerRecord, search))
3285
+ .getOptionLabelUsing((value) => managerOf(livewire).associableLabel(livewire.ownerRecord, value))];
3286
+ })
3287
+ .action(async ({ livewire, data }) => {
3288
+ await managerOf(livewire).associate(livewire.ownerRecord, String(data.recordId));
3289
+ Notification.make().success().title(t('filament-actions::associate.single.notifications.associated.title')).send();
3290
+ });
3291
+ }
3292
+ }
3293
+ /** Removes a row from the owner (clears its foreign key) without deleting it. */
3294
+ export class DissociateAction {
3295
+ static make() {
3296
+ return Action.make('dissociate')
3297
+ .label(t('filament-actions::dissociate.single.label')).color('danger').icon('c-x-mark').groupedIcon('m-x-mark')
3298
+ .requiresConfirmation().modalIcon('o-x-mark')
3299
+ .modalHeading(({ livewire, record }) => t('filament-actions::dissociate.single.modal.heading', { label: livewire.resource.getRecordTitle(record) }))
3300
+ .modalSubmitActionLabel(t('filament-actions::dissociate.single.modal.actions.dissociate.label'))
3301
+ .action(async ({ livewire, record }) => {
3302
+ await managerOf(livewire).dissociate(livewire.ownerRecord, [livewire.resource.adapter.getKey(record)]);
3303
+ Notification.make().success().title(t('filament-actions::dissociate.single.notifications.dissociated.title')).send();
3304
+ });
3305
+ }
3306
+ }
3307
+ /** Dissociates the selected rows. */
3308
+ export class DissociateBulkAction {
3309
+ static make() {
3310
+ return Action.make('dissociate')
3311
+ .label(t('filament-actions::dissociate.multiple.label')).color('danger').icon('m-x-mark')
3312
+ .requiresConfirmation().modalIcon('o-x-mark')
3313
+ .modalHeading(({ livewire }) => t('filament-actions::dissociate.multiple.modal.heading', { label: livewire.resource.getPluralModelLabel() }))
3314
+ .modalSubmitActionLabel(t('filament-actions::dissociate.multiple.modal.actions.dissociate.label'))
3315
+ .action(async ({ livewire }) => {
3316
+ // Selected keys come from the manager's current query, so they belong to the owner.
3317
+ await managerOf(livewire).dissociate(livewire.ownerRecord, await livewire.getSelectedRecordKeys());
3318
+ Notification.make().success().title(t('filament-actions::dissociate.multiple.notifications.dissociated.title')).send();
3319
+ livewire.deselectAllTableRecords();
3320
+ });
3321
+ }
3322
+ }
2843
3323
  /** Restores a soft-deleted record. Only visible when the record is deleted. */
2844
3324
  export class RestoreAction {
2845
3325
  static make() {
@@ -2849,10 +3329,10 @@ export class RestoreAction {
2849
3329
  .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
2850
3330
  .modalHeading(({ livewire }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
2851
3331
  .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();
3332
+ .action(async (ctx) => {
3333
+ await ctx.livewire.getDataAdapter().restore(ctx.livewire.recordKey);
3334
+ await ctx.livewire.hydrate();
3335
+ await finishAction(ctx.action, ctx, t('filament-actions::restore.single.notifications.restored.title'));
2856
3336
  });
2857
3337
  }
2858
3338
  }
@@ -2865,28 +3345,93 @@ export class ForceDeleteAction {
2865
3345
  .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
2866
3346
  .modalHeading(({ livewire }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
2867
3347
  .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);
3348
+ .action(async (ctx) => {
3349
+ await ctx.livewire.getDataAdapter().forceDelete(ctx.livewire.recordKey);
3350
+ await finishAction(ctx.action, ctx, t('filament-actions::force-delete.single.notifications.deleted.title'), ctx.livewire.resource.getUrl());
2872
3351
  });
2873
3352
  }
2874
3353
  }
2875
- /** Deletes the records selected in a table. */
3354
+ /** Actions of a list that may contain action groups: the groups' actions are mounted, never the groups. */
3355
+ const flattenActions = (actions) => actions.flatMap((action) => (action instanceof ActionGroup ? action.getActions() : [action]));
3356
+ /** Value of the table's trashed filter: `'1'` with deleted records, `'0'` only deleted records, `null` without. */
3357
+ const trashedFilterValue = (livewire) => {
3358
+ const filter = livewire.getTable?.().getFilters().find((candidate) => candidate instanceof TrashedFilter);
3359
+ const value = filter ? livewire.tableFilters?.[filter.name]?.value : null;
3360
+ return value === '1' || value === '0' ? value : null;
3361
+ };
3362
+ /**
3363
+ * Deletes the records selected in a table. Without `authorizeIndividualRecords()`, the selection is deleted in one query;
3364
+ * with it, record by record, and the notification reports the records that could not be deleted.
3365
+ */
2876
3366
  export class DeleteBulkAction {
2877
3367
  static make() {
2878
3368
  return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'deleteAny', record))
2879
3369
  .label(t('filament-actions::delete.multiple.label')).color('danger').icon('m-trash')
2880
3370
  .requiresConfirmation().modalIcon('o-trash')
3371
+ // Showing only deleted records: there is nothing left to delete.
3372
+ .hidden(({ livewire }) => trashedFilterValue(livewire) === '0')
2881
3373
  .modalHeading(({ livewire }) => t('filament-actions::delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
2882
3374
  .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();
3375
+ .successNotificationTitle(t('filament-actions::delete.multiple.notifications.deleted.title'))
3376
+ .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 })))
3377
+ .missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_authorization_failure_message`, count, { count }))
3378
+ .missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_processing_failure_message`, count, { count }))
3379
+ .deselectRecordsAfterCompletion()
3380
+ .action(async ({ livewire, action }) => {
3381
+ const adapter = livewire.getDataAdapterForTable();
3382
+ if (action.getIndividualRecordsAuthorization() !== null) {
3383
+ await livewire.processSelectedRecords(action, (record) => adapter.delete(adapter.getKey(record)));
3384
+ return;
3385
+ }
3386
+ await adapter.deleteMany(await livewire.getSelectedRecordKeys());
3387
+ Notification.make().success().title(action.getSuccessNotificationTitle() ?? t('filament-actions::delete.multiple.notifications.deleted.title')).send();
2886
3388
  livewire.deselectAllTableRecords();
2887
3389
  });
2888
3390
  }
2889
3391
  }
3392
+ /** Restores the soft-deleted records selected in a table, each one checked with the `restore` ability. */
3393
+ export class RestoreBulkAction {
3394
+ static make() {
3395
+ return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restoreAny', record))
3396
+ .label(t('filament-actions::restore.multiple.label')).color('gray').icon('m-arrow-uturn-left')
3397
+ .requiresConfirmation().modalIcon('o-arrow-uturn-left')
3398
+ .hidden(({ livewire }) => trashedFilterValue(livewire) === null)
3399
+ .modalHeading(({ livewire }) => t('filament-actions::restore.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
3400
+ .modalSubmitActionLabel(t('filament-actions::restore.multiple.modal.actions.restore.label'))
3401
+ .authorizeIndividualRecords('restore')
3402
+ .successNotificationTitle(t('filament-actions::restore.multiple.notifications.restored.title'))
3403
+ .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 })))
3404
+ .missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::restore.multiple.notifications.${count === total ? 'restored_none' : 'restored_partial'}.missing_authorization_failure_message`, count, { count }))
3405
+ .missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::restore.multiple.notifications.${count === total ? 'restored_none' : 'restored_partial'}.missing_processing_failure_message`, count, { count }))
3406
+ .deselectRecordsAfterCompletion()
3407
+ .action(async ({ livewire, action }) => {
3408
+ const adapter = livewire.getDataAdapterForTable();
3409
+ // Only deleted records are restored; the others count as failures.
3410
+ await livewire.processSelectedRecords(action, (record) => adapter.isTrashed(record) && adapter.restore(adapter.getKey(record)));
3411
+ });
3412
+ }
3413
+ }
3414
+ /** Permanently deletes the soft-deleted records selected in a table, each one checked with the `forceDelete` ability. */
3415
+ export class ForceDeleteBulkAction {
3416
+ static make() {
3417
+ return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDeleteAny', record))
3418
+ .label(t('filament-actions::force-delete.multiple.label')).color('danger').icon('m-trash')
3419
+ .requiresConfirmation().modalIcon('o-trash')
3420
+ .hidden(({ livewire }) => trashedFilterValue(livewire) === null)
3421
+ .modalHeading(({ livewire }) => t('filament-actions::force-delete.multiple.modal.heading', { label: livewire.resource.getTitleCasePluralModelLabel() }))
3422
+ .modalSubmitActionLabel(t('filament-actions::force-delete.multiple.modal.actions.delete.label'))
3423
+ .authorizeIndividualRecords('forceDelete')
3424
+ .successNotificationTitle(t('filament-actions::force-delete.multiple.notifications.deleted.title'))
3425
+ .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 })))
3426
+ .missingBulkAuthorizationFailureNotificationMessage((count, total) => tc(`filament-actions::force-delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_authorization_failure_message`, count, { count }))
3427
+ .missingBulkProcessingFailureNotificationMessage((count, total) => tc(`filament-actions::force-delete.multiple.notifications.${count === total ? 'deleted_none' : 'deleted_partial'}.missing_processing_failure_message`, count, { count }))
3428
+ .deselectRecordsAfterCompletion()
3429
+ .action(async ({ livewire, action }) => {
3430
+ const adapter = livewire.getDataAdapterForTable();
3431
+ await livewire.processSelectedRecords(action, (record) => adapter.forceDelete(adapter.getKey(record)));
3432
+ });
3433
+ }
3434
+ }
2890
3435
  /** Record edit page. */
2891
3436
  export class EditRecord extends ResourceFormPage {
2892
3437
  static callable = ['save'];
@@ -2910,7 +3455,10 @@ export class EditRecord extends ResourceFormPage {
2910
3455
  this.recordKey = String(params.record ?? '');
2911
3456
  this.previousUrl = previousUrlOf(this._ctx?.req);
2912
3457
  await this.hydrate();
3458
+ await this.beforeFill();
2913
3459
  fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
3460
+ await loadSchemaRelationships(this.getSchema('form'), this.data, this._record);
3461
+ await this.afterFill();
2914
3462
  }
2915
3463
  /** The record is reloaded on every request (no route model binding). */
2916
3464
  async hydrate() {
@@ -2967,6 +3515,7 @@ export class EditRecord extends ResourceFormPage {
2967
3515
  const data = await this.mutateFormDataBeforeSave(await getSchemaState(this.getSchema('form')));
2968
3516
  await this.beforeSave(data);
2969
3517
  this._record = await this.handleRecordUpdate(this._record, data);
3518
+ await saveSchemaRelationships(this.getSchema('form'), this._record);
2970
3519
  await this.afterSave(this._record);
2971
3520
  const title = this.getSavedNotificationTitle();
2972
3521
  if (title !== null)
@@ -3047,6 +3596,7 @@ async function renderUserMenu(component, position) {
3047
3596
  profileUrl: profileUrl && !custom.profile ? profileUrl : null,
3048
3597
  itemsBefore: await html(visible.filter((item) => item.getSort() < 0)), itemsAfter: await html(visible.filter((item) => item.getSort() >= 0)),
3049
3598
  themes: panel.getThemes().map((theme) => ({ id: theme.id, label: theme.label, swatch: swatchOf(theme, panel) })), activeTheme: panel.resolveTheme(req)?.id ?? null,
3599
+ hasThemeSwitcher: panel.hasDarkMode() && !panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
3050
3600
  });
3051
3601
  }
3052
3602
  /** Tenant menu: current tenant, other tenants, registration, profile. */
@@ -3210,7 +3760,24 @@ const runtimePanels = new WeakMap();
3210
3760
  /** Reserved segments right after a multi-tenant panel's path (they are not tenant keys). */
3211
3761
  const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
3212
3762
  /** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
3763
+ /** 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. */
3764
+ const guardOwners = new Map();
3765
+ function checkSharedGuard(panel) {
3766
+ const guard = panel.getAuthGuard();
3767
+ if (guard === null)
3768
+ return;
3769
+ const other = guardOwners.get(guard);
3770
+ if (!other || other === panel) {
3771
+ guardOwners.set(guard, panel);
3772
+ return;
3773
+ }
3774
+ if (other._key !== panel._key)
3775
+ 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().`);
3776
+ if (other.getAuth() !== panel.getAuth())
3777
+ 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.`);
3778
+ }
3213
3779
  export function panelRouter(panel, runtime) {
3780
+ checkSharedGuard(panel);
3214
3781
  const owner = runtimePanels.get(runtime);
3215
3782
  if (owner && owner !== panel)
3216
3783
  throw new Error(`This WireRuntime already serves panel "${owner.getId()}": create one WireRuntime per panel.`);
@@ -3245,7 +3812,13 @@ export function panelRouter(panel, runtime) {
3245
3812
  const name = (suffix) => `navette.${panel.getId()}.${suffix}`;
3246
3813
  const livewire = panel.getLivewirePrefix();
3247
3814
  // 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 });
3815
+ const context = async (req, res) => {
3816
+ const user = await panel.user(req);
3817
+ // Signed in through a shared session, but not allowed into this panel: treated as a guest that gets 403 pages.
3818
+ const denied = user !== null && !(await panel.userCanAccess(user));
3819
+ return { req, res, panel, user: denied ? null : user, denied, mailbox: csrfToken(req, res), tenant: null };
3820
+ };
3821
+ const forbidden = (res) => void res.status(403).send('Forbidden');
3249
3822
  /** Tenant designated by a panel URL, if the user is a member. */
3250
3823
  const tenantFromPath = async (path, user) => {
3251
3824
  if (!panel.hasTenancy() || !user)
@@ -3262,7 +3835,7 @@ export function panelRouter(panel, runtime) {
3262
3835
  return null;
3263
3836
  }
3264
3837
  const registration = panel.getTenantRegistrationPage();
3265
- if (!key || RESERVED_SEGMENTS.has(key) || key === registration?.slug || key.length > 200)
3838
+ if (!key || panel.getReservedSegments().has(key) || key === registration?.slug || key.length > 200)
3266
3839
  return null;
3267
3840
  const provider = panel.getTenantProvider();
3268
3841
  const tenant = await provider.find(key);
@@ -3376,7 +3949,7 @@ export function panelRouter(panel, runtime) {
3376
3949
  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
3950
  res.send(views.render('filament-panels::layout.base', {
3378
3951
  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(),
3952
+ bodyClass, body, spa: panel.hasSpaMode(), darkMode: panel.hasDarkMode(), darkModeForced: panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
3380
3953
  csrf: csrfToken(req, res), colorVariables: panel.colors.renderCssVariables(), wireStyles: wireStyles(), wireScripts: wireScripts(req, res, livewire),
3381
3954
  notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
3382
3955
  }));
@@ -3398,6 +3971,8 @@ export function panelRouter(panel, runtime) {
3398
3971
  const guestPage = (path, component, title, params = () => ({})) => {
3399
3972
  router.get(panel.getBaseUrl(path), async (req, res) => {
3400
3973
  const ctx = await context(req, res);
3974
+ if (ctx.denied)
3975
+ return forbidden(res);
3401
3976
  if (ctx.user)
3402
3977
  return res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
3403
3978
  await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
@@ -3407,13 +3982,29 @@ export function panelRouter(panel, runtime) {
3407
3982
  const userPage = (path, component, title, layout = 'simple') => {
3408
3983
  router.get(panel.getBaseUrl(path), async (req, res) => {
3409
3984
  const ctx = await context(req, res);
3985
+ if (ctx.denied)
3986
+ return forbidden(res);
3410
3987
  if (!ctx.user)
3411
3988
  return res.redirect(panel.getLoginUrl());
3412
3989
  await guarded(res, () => document(req, res, ctx, { title, component, layout }));
3413
3990
  });
3414
3991
  };
3992
+ // Files offered to the signed-in user (exports, failed import rows): only the user who created them can download them.
3993
+ router.get(panel.getBaseUrl('downloads/:token'), async (req, res) => {
3994
+ const ctx = await context(req, res);
3995
+ if (!ctx.user || ctx.denied)
3996
+ return forbidden(res);
3997
+ const file = panel.getDownloads().get(String(req.params.token), ctx.user.id);
3998
+ if (!file)
3999
+ return void res.status(404).send('Not Found');
4000
+ res.setHeader('Content-Type', file.type);
4001
+ res.setHeader('Content-Disposition', `attachment; filename="${file.name.replace(/[^A-Za-z0-9._-]/g, '_')}"`);
4002
+ res.setHeader('X-Content-Type-Options', 'nosniff');
4003
+ res.setHeader('Cache-Control', 'private, no-store');
4004
+ res.send(file.content);
4005
+ });
3415
4006
  if (LoginPage) {
3416
- guestPage('login', name('auth.login'), t('filament-panels::auth/pages/login.title'));
4007
+ guestPage(panel.getAuthPaths().login, name('auth.login'), t('filament-panels::auth/pages/login.title'));
3417
4008
  router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
3418
4009
  if (!verifyCsrf(req, req.body?._token))
3419
4010
  return res.status(419).send('Invalid CSRF token.');
@@ -3422,16 +4013,18 @@ export function panelRouter(panel, runtime) {
3422
4013
  });
3423
4014
  }
3424
4015
  if (RegistrationPage)
3425
- guestPage('register', name('auth.register'), t('filament-panels::auth/pages/register.title'));
4016
+ guestPage(panel.getAuthPaths().registration, name('auth.register'), t('filament-panels::auth/pages/register.title'));
3426
4017
  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 }));
4018
+ guestPage(panel.getAuthPaths().passwordResetRequest, name('auth.password-reset.request'), t('filament-panels::auth/pages/password-reset/request-password-reset.title'));
4019
+ 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
4020
  }
3430
4021
  if (VerificationPrompt) {
3431
4022
  userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
3432
4023
  // Emailed link: signed, time-limited, and only valid for the signed-in user it designates.
3433
4024
  router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
3434
4025
  const ctx = await context(req, res);
4026
+ if (ctx.denied)
4027
+ return forbidden(res);
3435
4028
  if (!ctx.user)
3436
4029
  return res.redirect(panel.getLoginUrl());
3437
4030
  const id = String(req.params.id), hash = String(req.params.hash);
@@ -3449,6 +4042,10 @@ export function panelRouter(panel, runtime) {
3449
4042
  /** Context of a protected page: login, email verification, tenant. Returns `null` after responding. */
3450
4043
  const pageContext = async (req, res) => {
3451
4044
  const ctx = await context(req, res);
4045
+ if (ctx.denied) {
4046
+ forbidden(res);
4047
+ return null;
4048
+ }
3452
4049
  if (!ctx.user) {
3453
4050
  res.redirect(panel.getLoginUrl());
3454
4051
  return null;
@@ -3471,6 +4068,8 @@ export function panelRouter(panel, runtime) {
3471
4068
  // Panel root: the user's first tenant, otherwise tenant registration.
3472
4069
  router.get(panel.getBaseUrl(), async (req, res) => {
3473
4070
  const ctx = await context(req, res);
4071
+ if (ctx.denied)
4072
+ return forbidden(res);
3474
4073
  if (!ctx.user)
3475
4074
  return res.redirect(panel.getLoginUrl());
3476
4075
  if (await panel.mustVerifyEmail(ctx.user))
@@ -3483,7 +4082,7 @@ export function panelRouter(panel, runtime) {
3483
4082
  // Tenant root without a dashboard: the first menu page.
3484
4083
  if (!panel.getPages().some((page) => page.slug === '')) {
3485
4084
  router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
3486
- if (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
4085
+ if (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
3487
4086
  return next();
3488
4087
  const ctx = await pageContext(req, res);
3489
4088
  if (!ctx)
@@ -3500,7 +4099,7 @@ export function panelRouter(panel, runtime) {
3500
4099
  runtime.register(component, PageClass);
3501
4100
  // `{record}` in the slug becomes a route parameter, passed to mount().
3502
4101
  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))
4102
+ if (panel.hasTenancy() && (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
3504
4103
  return next();
3505
4104
  const ctx = await pageContext(req, res);
3506
4105
  if (!ctx)