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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -8,22 +8,30 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  // @navette-js/panels: Panel, layouts, pages, resources, navigation, authentication and session.
10
10
  import { AsyncLocalStorage } from 'node:async_hooks';
11
- import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
12
- import { checkVerificationUrl, consoleMailer, readToken, signToken, signVerificationUrl } from "./auth.js";
13
- export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, verifyPassword } from "./auth.js";
11
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
12
+ import { Throttle } from "./throttle.js";
13
+ export { Throttle } from "./throttle.js";
14
+ import { checkVerificationUrl, consoleMailer, fingerprint, readToken, signToken, signVerificationUrl } from "./auth.js";
15
+ export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, multiFactorAuthenticationSchema, verifyPassword } from "./auth.js";
14
16
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
17
  import { join } from 'node:path';
16
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
19
  import express from 'express';
18
20
  import { Action, ActionHalt, useColorManager, ActionGroup } from '@navette-js/actions';
19
21
  import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, loadSchemaRelationships, saveSchemaRelationships, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
20
- import { Actions, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, StatefulGroup } from '@navette-js/schemas';
22
+ import { Actions, childSchema, Component as SchemaComponent, EmbeddedSchema, Form, Group, Html, RenderHook, Schema, Section, StatefulGroup } from '@navette-js/schemas';
21
23
  import { WireError } from '@navette-js/wire';
22
24
  import { useColorManager as useInfolistsColorManager, useHtmlSanitizer } from '@navette-js/infolists';
23
25
  import { Notification, Notifications, publish, runInRequest } from "./notifications.js";
24
26
  export { Notification, Notifications } from "./notifications.js";
27
+ import { getDefaultCacheStore, MultiFactorChallenge, SecurityKeyAuthentication } from "./multi-factor.js";
28
+ export { AppAuthentication, EmailAuthentication, EmailCodeInput, decryptSecret, encryptSecret, MemoryCacheStore, MultiFactorChallenge, SecurityKeyAuthentication, SecurityKeyInput, useMultiFactorCacheStore } from "./multi-factor.js";
29
+ export { base32Decode, base32Encode, generateTotpSecret, hotp, totpCode, totpTimestep, totpUri, verifyTotp } from "./totp.js";
30
+ export { qrCodeDataUri, qrCodeMatrix, qrCodeSvg } from "./qr-code.js";
31
+ export { authenticationOptions, decodeCbor, generateChallenge, registrationOptions, verifyAuthentication, verifyRegistration, WebAuthnError } from "./webauthn.js";
25
32
  export { MemorySessionStore } from "./session-store.js";
26
- export { csvCell, csvLine, DownloadStore, ExportAction, ExportBulkAction, ExportColumn, Exporter, ImportAction, ImportColumn, Importer, parseCsv } from "./import-export.js";
33
+ export { csvCell, csvLine, DownloadStore, ExportAction, ExportBulkAction, ExportColumn, Exporter, ImportAction, ImportColumn, Importer, importExportSchema, knexImportExportHistory, parseCsv, readSpreadsheet, runInBackground, useBackgroundRunner } from "./import-export.js";
34
+ export { buildXlsx, readXlsx } from "./xlsx.js";
27
35
  import { DownloadStore } from "./import-export.js";
28
36
  import { MemorySessionStore } from "./session-store.js";
29
37
  import { addDays, addMonths, CALENDAR_LABELS, CALENDAR_VIEWS, isDate, renderCalendar, visibleRange } from "./calendar.js";
@@ -31,8 +39,9 @@ export { addDays, addMonths, CALENDAR_LABELS, dayOf, daysBetween, eventsOn, rend
31
39
  import { DatabaseNotifications, GlobalSearch, keyBindingSuffix } from "./topbar.js";
32
40
  export { DatabaseNotifications, GlobalSearch, knexNotificationStore, timeAgo } from "./topbar.js";
33
41
  import { Attributes, ColorManager, HtmlString, RenderHookRegistry, cls, convertToRgb, e, icon, js, loadingIndicator, renderHook, runWithRenderHooks, views } from '@navette-js/support';
34
- import { t, tc } from '@navette-js/support/i18n';
35
- import { emptyState } from '@navette-js/support/ui';
42
+ import { getLocale, t, tc } from '@navette-js/support/i18n';
43
+ import { ERROR_PAGE_CSS, errorIllustration, errorPageScene } from "./error-pages.js";
44
+ import { badge as uiBadge, button as uiButton, emptyState, useColorManager as useUiColorManager } from '@navette-js/support/ui';
36
45
  import { REORDER_LIMIT, Table, TrashedFilter, filterSelectableKeys, getTableRecords, initializeTableGrouping, isTableFilterRemovable, updateTableColumnState as updateEditableColumnState, getTableQuery, renderTable, sanitizeColumnState, sortTable, useColorManager as useTableColorManager } from '@navette-js/tables';
37
46
  import { Component } from '@navette-js/wire';
38
47
  import { csrfToken, verifyCsrf, wireRouter, wireScripts, wireStyles } from '@navette-js/wire/express';
@@ -63,8 +72,14 @@ export class NavigationItem {
63
72
  _isActive = null;
64
73
  _badge = null;
65
74
  _badgeColor = 'primary';
75
+ _parentItem = null;
76
+ _childItems = [];
66
77
  constructor(label) { this.label = label; }
67
78
  static make(label) { return new NavigationItem(label); }
79
+ /** Shows the item below another item of its group, by that item's label. */
80
+ parentItem(label) { this._parentItem = label; return this; }
81
+ /** Items shown below this one, when it (or one of them) is active, or when it has no URL. */
82
+ childItems(items) { this._childItems = items; return this; }
68
83
  url(url) { this._url = url; return this; }
69
84
  icon(icon) { this._icon = icon; return this; }
70
85
  group(group) { this._group = group; return this; }
@@ -97,6 +112,10 @@ export class Panel {
97
112
  _downloads = null;
98
113
  /** Where files offered for download (exports, failed import rows) are kept: in memory by default. */
99
114
  downloads(store) { this._downloads = store; return this; }
115
+ _importExportHistory = null;
116
+ /** Records each import and export run (`knexImportExportHistory(db)`): who, which file, how many rows, failed import rows. */
117
+ importExportHistory(store) { this._importExportHistory = store; return this; }
118
+ getImportExportHistory() { return this._importExportHistory; }
100
119
  getDownloads() { return (this._downloads ??= new DownloadStore()); }
101
120
  getDownloadUrl(token) { return this.getBaseUrl(`downloads/${encodeURIComponent(token)}`); }
102
121
  _pages = [];
@@ -190,16 +209,53 @@ export class Panel {
190
209
  }
191
210
  /** Name of the session cookie: per guard (shared), or per panel. */
192
211
  getSessionCookieName() { return this._authGuard !== null ? `navette_${this._authGuard}_session` : `navette_${this._id}_session`; }
193
- pages(pages) { this._pages.push(...pages); return this; }
212
+ pages(pages) {
213
+ for (const page of pages) {
214
+ this.registerCluster(page);
215
+ this._pages.push(page);
216
+ }
217
+ return this;
218
+ }
194
219
  /** Registers resources: their pages and navigation items. */
195
220
  resources(resources) {
196
221
  for (const resource of resources) {
197
222
  resource.panel = this;
223
+ this.registerCluster(resource);
198
224
  this._resources.push(resource);
199
225
  this._pages.push(...Object.values(resource.getPages()));
200
226
  }
201
227
  return this;
202
228
  }
229
+ /** A page or resource in a cluster is served under the cluster's slug; the cluster page is registered with it. */
230
+ registerCluster(component) {
231
+ const cluster = component.cluster;
232
+ if (!cluster)
233
+ return;
234
+ if (!(cluster.prototype instanceof Cluster))
235
+ throw new Error(`${component.name}.cluster must be a Cluster subclass.`);
236
+ cluster.slug = cluster.getSlug();
237
+ if (!component.slug.startsWith(`${cluster.slug}/`))
238
+ component.slug = `${cluster.slug}/${component.slug}`;
239
+ if (!this._pages.includes(cluster))
240
+ this._pages.push(cluster);
241
+ }
242
+ /** Pages (resource list pages included) shown in a cluster's sub-navigation. */
243
+ getClusteredComponents(cluster) {
244
+ return this._pages.filter((page) => page !== cluster && page.shouldRegisterNavigation && clusterOf(page) === cluster);
245
+ }
246
+ /** Sub-navigation of a cluster, for one of its pages: the components the user can open, sorted, with their groups. */
247
+ async getClusterSubNavigation(cluster, current, user) {
248
+ const components = [];
249
+ for (const component of this.getClusteredComponents(cluster))
250
+ if (await this.canAccessPage(component, user))
251
+ components.push(component);
252
+ const items = [];
253
+ for (const component of components.sort((a, b) => a.navigationSort - b.navigationSort)) {
254
+ const badge = typeof component.getNavigationBadge === 'function' ? await component.getNavigationBadge() : component.navigationBadge;
255
+ items.push({ label: component.getNavigationLabel(), icon: component.navigationIcon, url: this.getUrl(component.slug), active: current instanceof component, group: component.navigationGroup, badge: badge ?? null, badgeColor: component.navigationBadgeColor, sort: component.navigationSort });
256
+ }
257
+ return items.map(({ sort: _sort, ...item }) => item);
258
+ }
203
259
  navigationItems(items) { this._navigationItems.push(...items); return this; }
204
260
  /** Order of navigation groups. */
205
261
  /** Order of the navigation groups: labels, or `NavigationGroup.make('Shop').icon('o-shopping-cart').collapsible(false)`. */
@@ -241,20 +297,38 @@ export class Panel {
241
297
  _authorizer = null;
242
298
  /** Plugs in your own permission system: consulted before any policy, for every ability, page and widget. */
243
299
  authorizeUsing(authorizer) { this._authorizer = authorizer; return this; }
244
- /** Authorization decision: the panel hook first, then the given rule. Always `false` without a user. */
300
+ _guestAccess = false;
301
+ /**
302
+ * Lets visitors who are not signed in use the panel's pages. Policies, `canAccess()` and the authorization hook then receive
303
+ * a `null` user: return `true` there for what guests may see. Not available with tenancy.
304
+ */
305
+ guestAccess(condition = true) { this._guestAccess = condition; return this; }
306
+ allowsGuests() { return this._guestAccess; }
307
+ /** Authorization decision: the panel hook first, then the given rule. Without a user: `false`, unless the panel allows guests. */
245
308
  async authorize(user, ability, subject = {}) {
246
- if (!user)
309
+ if (!user && !this._guestAccess)
247
310
  return false;
248
- const decided = await this._authorizer?.({ user, ability, resource: subject.resource ?? null, record: subject.record ?? null, page: subject.page ?? null, widget: subject.widget ?? null });
311
+ const decided = await this._authorizer?.({ user: user, ability, resource: subject.resource ?? null, record: subject.record ?? null, page: subject.page ?? null, widget: subject.widget ?? null });
249
312
  if (typeof decided === 'boolean')
250
313
  return decided;
314
+ if (!subject.rule && subject.resource && !subject.page && !subject.widget && this._strictAuthorization) {
315
+ throw new Error(`Strict authorization mode is enabled, but ${subject.resource.name ?? 'the resource'} has no "${ability}" rule in its policy.`);
316
+ }
251
317
  return subject.rule ? Boolean(await subject.rule()) : true;
252
318
  }
253
319
  /** Whether the user can open a page. List: `viewAny`; create: `create`; edit: checked against the record; custom page: `canAccess`. */
254
320
  async canAccessPage(PageClass, user) {
321
+ if (PageClass.prototype instanceof Cluster) {
322
+ if (!(await this.authorize(user, 'access', { page: PageClass, rule: () => PageClass.canAccess(user) })))
323
+ return false;
324
+ for (const component of this.getClusteredComponents(PageClass))
325
+ if (await this.canAccessPage(component, user))
326
+ return true;
327
+ return false;
328
+ }
255
329
  const resource = PageClass.resource;
256
330
  if (resource && PageClass.prototype instanceof EditRecord)
257
- return user !== null;
331
+ return user !== null || this._guestAccess;
258
332
  if (resource && PageClass.prototype instanceof CreateRecord)
259
333
  return resource.can('create', user);
260
334
  if (resource)
@@ -376,11 +450,47 @@ export class Panel {
376
450
  profile(page = EditProfile, isSimple = true) { this._profile = { page, isSimple }; return this; }
377
451
  hasProfile() { return this._profile !== null; }
378
452
  getProfile() { return this._profile; }
453
+ /** Whether the profile page uses the simple layout (`profile(page, false)`: the full layout, with the navigation). */
454
+ isProfileSimple() { return this._profile?.isSimple !== false; }
455
+ _multiFactor = null;
456
+ /**
457
+ * Multi-factor authentication: after the password, users with a provider set up must pass its challenge. They set providers
458
+ * up on their profile page (`profile()`). `isRequired`: users without any provider are sent to a setup page first.
459
+ */
460
+ multiFactorAuthentication(providers, options = {}) {
461
+ const ids = providers.map((provider) => provider.getId());
462
+ for (const id of ids)
463
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,40}$/.test(id))
464
+ throw new Error(`Invalid multi-factor provider id: ${id}`);
465
+ if (new Set(ids).size !== ids.length)
466
+ throw new Error('Two multi-factor providers share the same id: give one another id with id().');
467
+ this._multiFactor = providers.length ? { providers, isRequired: options.isRequired === true, setUpPage: options.setUpPage ?? SetUpRequiredMultiFactorAuthentication } : null;
468
+ return this;
469
+ }
470
+ hasMultiFactorAuthentication() { return this._multiFactor !== null; }
471
+ getMultiFactorAuthenticationProviders() { return this._multiFactor?.providers ?? []; }
472
+ isMultiFactorAuthenticationRequired() { return this._multiFactor?.isRequired === true; }
473
+ getSetUpRequiredMultiFactorAuthenticationPage() { return this._multiFactor?.isRequired ? this._multiFactor.setUpPage : null; }
474
+ getSetUpRequiredMultiFactorAuthenticationUrl() { return this.getBaseUrl(this.getAuthPaths().setUpRequiredMultiFactor); }
475
+ /** The login challenge of this panel (enabled providers, rate limiting). */
476
+ getMultiFactorChallenge() { return MultiFactorChallenge.make(this); }
477
+ /** Whether the user must set up multi-factor authentication before using the panel. */
478
+ async mustSetUpMultiFactorAuthentication(user) {
479
+ if (!user || !this.isMultiFactorAuthenticationRequired())
480
+ return false;
481
+ return !(await this.getMultiFactorChallenge().hasEnabledProviders(user));
482
+ }
483
+ usesSecurityKeys() { return this.getMultiFactorAuthenticationProviders().some((provider) => provider instanceof SecurityKeyAuthentication); }
379
484
  /** Mailer for password reset and verification emails. Defaults to the console. */
380
485
  mailer(mailer) { this._mailer = mailer; return this; }
381
486
  async sendMail(message) { await this._mailer.send(message); }
382
487
  getAuthKey() { return this._key; }
383
- _authSlugs = { login: 'login', registration: 'register', passwordResetPrefix: 'password-reset', passwordResetRequest: 'request', passwordReset: 'reset' };
488
+ _authSlugs = {
489
+ login: 'login', registration: 'register', passwordResetPrefix: 'password-reset', passwordResetRequest: 'request', passwordReset: 'reset', logout: 'logout',
490
+ emailVerificationPrefix: 'email-verification', emailVerificationPrompt: 'prompt', emailVerification: 'verify',
491
+ emailChangeVerificationPrefix: 'email-change-verification', emailChangeVerification: 'verify',
492
+ multiFactorPrefix: 'multi-factor-authentication', setUpRequiredMultiFactor: 'set-up',
493
+ };
384
494
  authSlug(slug) { if (!/^[a-z0-9][a-z0-9-]{0,60}$/.test(slug))
385
495
  throw new Error(`Invalid route slug: ${slug}`); return slug; }
386
496
  /** URL segment of the login page (`login` by default). */
@@ -392,12 +502,29 @@ export class Panel {
392
502
  passwordResetRequestRouteSlug(slug) { this._authSlugs.passwordResetRequest = this.authSlug(slug); return this; }
393
503
  passwordResetRouteSlug(slug) { this._authSlugs.passwordReset = this.authSlug(slug); return this; }
394
504
  /** Paths of the authentication pages, relative to the panel. */
505
+ /** URL segment of the logout route (`logout` by default). */
506
+ logoutRouteSlug(slug) { this._authSlugs.logout = this.authSlug(slug); return this; }
507
+ /** URL segments of email verification: `<prefix>/<prompt>` and `<prefix>/<verify>/…` (`email-verification/prompt`, `email-verification/verify`). */
508
+ emailVerificationRoutePrefix(prefix) { this._authSlugs.emailVerificationPrefix = this.authSlug(prefix); return this; }
509
+ emailVerificationPromptRouteSlug(slug) { this._authSlugs.emailVerificationPrompt = this.authSlug(slug); return this; }
510
+ emailVerificationRouteSlug(slug) { this._authSlugs.emailVerification = this.authSlug(slug); return this; }
511
+ /** URL segments of the email change links: `<prefix>/<verify>` and `<prefix>/block` (`email-change-verification/verify`). */
512
+ emailChangeVerificationRoutePrefix(prefix) { this._authSlugs.emailChangeVerificationPrefix = this.authSlug(prefix); return this; }
513
+ emailChangeVerificationRouteSlug(slug) { this._authSlugs.emailChangeVerification = this.authSlug(slug); return this; }
514
+ /** URL segments of the required multi-factor setup page (`multi-factor-authentication/set-up`). */
515
+ multiFactorAuthenticationRoutePrefix(prefix) { this._authSlugs.multiFactorPrefix = this.authSlug(prefix); return this; }
516
+ setUpRequiredMultiFactorAuthenticationRouteSlug(slug) { this._authSlugs.setUpRequiredMultiFactor = this.authSlug(slug); return this; }
395
517
  getAuthPaths() {
396
518
  const slugs = this._authSlugs;
397
- return { login: slugs.login, registration: slugs.registration, passwordResetRequest: `${slugs.passwordResetPrefix}/${slugs.passwordResetRequest}`, passwordReset: `${slugs.passwordResetPrefix}/${slugs.passwordReset}` };
519
+ return {
520
+ login: slugs.login, registration: slugs.registration, passwordResetRequest: `${slugs.passwordResetPrefix}/${slugs.passwordResetRequest}`, passwordReset: `${slugs.passwordResetPrefix}/${slugs.passwordReset}`, logout: slugs.logout,
521
+ emailVerificationPrompt: `${slugs.emailVerificationPrefix}/${slugs.emailVerificationPrompt}`, emailVerification: `${slugs.emailVerificationPrefix}/${slugs.emailVerification}`,
522
+ emailChangeVerification: `${slugs.emailChangeVerificationPrefix}/${slugs.emailChangeVerification}`, blockEmailChange: `${slugs.emailChangeVerificationPrefix}/block`,
523
+ setUpRequiredMultiFactor: `${slugs.multiFactorPrefix}/${slugs.setUpRequiredMultiFactor}`,
524
+ };
398
525
  }
399
526
  /** 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]); }
527
+ getReservedSegments() { const slugs = this._authSlugs; return new Set([...RESERVED_SEGMENTS, slugs.login, slugs.registration, slugs.passwordResetPrefix, slugs.logout, slugs.emailVerificationPrefix, slugs.emailChangeVerificationPrefix, slugs.multiFactorPrefix]); }
401
528
  getRegistrationUrl() { return this.getBaseUrl(this.getAuthPaths().registration); }
402
529
  getRequestPasswordResetUrl() { return this.getBaseUrl(this.getAuthPaths().passwordResetRequest); }
403
530
  getResetPasswordUrl(user, fingerprint) {
@@ -409,10 +536,28 @@ export class Panel {
409
536
  getVerifyEmailUrl(user) {
410
537
  const expires = Math.floor(Date.now() / 1000) + 60 * 60;
411
538
  const { hash, signature } = signVerificationUrl(`${this._key}.${this._id}`, user.id, user.email, expires);
412
- return `${this.getBaseUrl(`email-verification/verify/${encodeURIComponent(String(user.id))}/${hash}`)}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
539
+ return `${this.getBaseUrl(`${this.getAuthPaths().emailVerification}/${encodeURIComponent(String(user.id))}/${hash}`)}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
413
540
  }
414
541
  checkVerifyEmailUrl(id, hash, expires, signature) { return checkVerificationUrl(`${this._key}.${this._id}`, id, hash, expires, signature); }
415
- getEmailVerificationPromptUrl() { return this.getBaseUrl('email-verification/prompt'); }
542
+ getEmailVerificationPromptUrl() { return this.getBaseUrl(this.getAuthPaths().emailVerificationPrompt); }
543
+ _emailChangeVerification = false;
544
+ /**
545
+ * A new email address set on the profile page is only saved once confirmed: a link is sent to the new address, and a link
546
+ * to block the change to the old one. Both are valid for 60 minutes.
547
+ */
548
+ emailChangeVerification(condition = true) { this._emailChangeVerification = condition; return this; }
549
+ hasEmailChangeVerification() { return this._emailChangeVerification; }
550
+ /** Links of an email change request: to confirm the new address, and to block the change. */
551
+ getEmailChangeUrls(user, newEmail) {
552
+ const nonce = randomBytes(12).toString('base64url');
553
+ const verify = signToken(this._key, `${this._id}.email-change`, { uid: user.id, email: newEmail, from: fingerprint(user.email), nonce }, 60 * 60);
554
+ const block = signToken(this._key, `${this._id}.email-change-block`, { uid: user.id, email: newEmail, nonce }, 60 * 60);
555
+ return { verify: `${this.getBaseUrl(this.getAuthPaths().emailChangeVerification)}?token=${encodeURIComponent(verify)}`, block: `${this.getBaseUrl(this.getAuthPaths().blockEmailChange)}?token=${encodeURIComponent(block)}` };
556
+ }
557
+ readEmailChangeToken(kind, token) {
558
+ const payload = readToken(this._key, `${this._id}.${kind === 'verify' ? 'email-change' : 'email-change-block'}`, token);
559
+ return payload && typeof payload.email === 'string' && typeof payload.nonce === 'string' ? payload : null;
560
+ }
416
561
  getProfileUrl() { return this._profile ? this.getBaseUrl('profile') : null; }
417
562
  /** Whether the user must still verify their email before using the panel. */
418
563
  async mustVerifyEmail(user) {
@@ -446,6 +591,84 @@ export class Panel {
446
591
  getTenantProfilePage() { return this._tenantProfile; }
447
592
  tenantMenu(condition = true) { this._tenantMenu = condition; return this; }
448
593
  hasTenantMenu() { return this._tenantMenu; }
594
+ _searchableTenantMenu = null;
595
+ /** A search field above the tenants of the menu. By default (`null`), it is shown from ten tenants to switch to. */
596
+ searchableTenantMenu(condition = true) { this._searchableTenantMenu = condition; return this; }
597
+ isTenantMenuSearchable() { return this._searchableTenantMenu; }
598
+ _tenantRoutePrefix = null;
599
+ /** A segment before the tenant key in URLs: `tenantRoutePrefix('team')` serves `/app/team/1/projects`. */
600
+ tenantRoutePrefix(prefix) {
601
+ if (prefix !== null && !/^[a-z0-9][a-z0-9-]*$/.test(prefix))
602
+ throw new Error(`Invalid tenant route prefix: ${prefix}`);
603
+ this._tenantRoutePrefix = prefix;
604
+ return this;
605
+ }
606
+ getTenantRoutePrefix() { return this._tenantRoutePrefix; }
607
+ _tenantDomain = null;
608
+ /**
609
+ * Identifies the tenant by the host instead of the path: `tenantDomain('{tenant}.example.com')` serves the tenant `acme` at
610
+ * `acme.example.com/app/projects`. A port may follow (`{tenant}.localhost:3000`).
611
+ */
612
+ tenantDomain(pattern) {
613
+ if (pattern !== null && !pattern.includes('{tenant}'))
614
+ throw new Error('tenantDomain(): the pattern must contain {tenant}.');
615
+ this._tenantDomain = pattern;
616
+ return this;
617
+ }
618
+ getTenantDomain() { return this._tenantDomain; }
619
+ /** Domain of a tenant, with its port if the pattern has one. */
620
+ getTenantDomainFor(tenant) { return this._tenantDomain.replace('{tenant}', encodeURIComponent(this.getTenantUrlKey(tenant)).toLowerCase()); }
621
+ /** Key of the tenant designated by a request (its path, or its host with `tenantDomain()`), unchecked; `null` if none. */
622
+ getTenantKeyFromRequest(path, hostname) {
623
+ if (this._tenantDomain) {
624
+ const host = this._tenantDomain.replace(/:\d+$/, '').toLowerCase();
625
+ const [before, after] = host.split('{tenant}').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
626
+ const match = new RegExp(`^${before}([^.]+)${after}$`).exec(hostname.toLowerCase());
627
+ if (!match)
628
+ return null;
629
+ try {
630
+ return decodeURIComponent(match[1]);
631
+ }
632
+ catch {
633
+ return null;
634
+ }
635
+ }
636
+ const base = this.getBaseUrl();
637
+ const prefix = base === '/' ? '/' : `${base}/`;
638
+ if (!path.startsWith(prefix))
639
+ return null;
640
+ const segments = path.slice(prefix.length).split('/');
641
+ if (this._tenantRoutePrefix !== null && segments.shift() !== this._tenantRoutePrefix)
642
+ return null;
643
+ let key;
644
+ try {
645
+ key = decodeURIComponent(segments[0] ?? '');
646
+ }
647
+ catch {
648
+ return null;
649
+ }
650
+ if (!key || key.length > 200)
651
+ return null;
652
+ if (this._tenantRoutePrefix === null && (this.getReservedSegments().has(key) || key === this._tenantRegistration?.slug))
653
+ return null;
654
+ return key;
655
+ }
656
+ _tenantBilling = null;
657
+ _tenantBillingRouteSlug = 'billing';
658
+ _requiresTenantSubscription = false;
659
+ /** Adds a "Manage subscription" link to the tenant menu, served by the provider at `/<tenant>/billing`. */
660
+ tenantBillingProvider(provider) { this._tenantBilling = provider; return this; }
661
+ getTenantBillingProvider() { return this._tenantBilling; }
662
+ hasTenantBilling() { return this._tenantBilling !== null; }
663
+ tenantBillingRouteSlug(slug) { this._tenantBillingRouteSlug = slug.replace(/^\/|\/$/g, ''); return this; }
664
+ getTenantBillingRouteSlug() { return this._tenantBillingRouteSlug; }
665
+ getTenantBillingUrl(tenant = this.getTenant()) { return this._tenantBilling && tenant ? this.getUrl(this._tenantBillingRouteSlug, tenant) : null; }
666
+ /** The pages of a tenant without an active subscription (`isSubscribed()`) redirect to its billing page. */
667
+ requiresTenantSubscription(condition = true) { this._requiresTenantSubscription = condition; return this; }
668
+ isTenantSubscriptionRequired() { return this._requiresTenantSubscription && this._tenantBilling !== null; }
669
+ _sessionCookieDomain = null;
670
+ /** Domain of the session cookie (`.example.com`): with `tenantDomain()`, a sign-in then holds on every tenant's host. */
671
+ sessionCookieDomain(domain) { this._sessionCookieDomain = domain; return this; }
449
672
  tenantMenuItems(items) {
450
673
  for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
451
674
  this._tenantMenuItems[key] = item;
@@ -468,14 +691,20 @@ export class Panel {
468
691
  getMaxContentWidth() { return this._maxContentWidth; }
469
692
  /** URL inside the panel; in a multi-tenant panel, prefixed with the current (or given) tenant. */
470
693
  getUrl(sub = '', tenant = this.getTenant()) {
694
+ if (this._tenant && tenant && this._tenantDomain) {
695
+ // On the tenant's own host, a path; another tenant's pages are on its host.
696
+ const current = this.getTenant();
697
+ const path = this.getBaseUrl(sub);
698
+ return current && this.getTenantUrlKey(current) === this.getTenantUrlKey(tenant) ? path : `//${this.getTenantDomainFor(tenant)}${path}`;
699
+ }
471
700
  if (this._tenant && tenant)
472
- return '/' + [this._path, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
701
+ return '/' + [this._path, this._tenantRoutePrefix, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
473
702
  return this.getBaseUrl(sub);
474
703
  }
475
704
  /** URL outside any tenant (login, registration, profile, tenant registration). */
476
705
  getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
477
706
  getLoginUrl() { return this.getBaseUrl(this.getAuthPaths().login); }
478
- getLogoutUrl() { return this.getBaseUrl('logout'); }
707
+ getLogoutUrl() { return this.getBaseUrl(this.getAuthPaths().logout); }
479
708
  /** Prefix of this panel's Livewire endpoint (`/livewire` or `/<path>/livewire`): each panel has its own. */
480
709
  getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
481
710
  assetUrl(file) { return `/${file}?v=${ASSET_VERSION}`; }
@@ -486,9 +715,40 @@ export class Panel {
486
715
  /** Tenant avatar: from the tenant provider (`getAvatarUrl`), otherwise its initials. */
487
716
  getTenantAvatarUrl(tenant) { return this.getTenantProvider().getAvatarUrl?.(tenant) ?? this.getAvatarUrlFor(this.getTenantProvider().getName(tenant)); }
488
717
  _spa = false;
489
- /** SPA mode: navigate between panel pages without full reloads (`wire:navigate`). */
490
- spa(condition = true) { this._spa = condition; return this; }
718
+ _spaPrefetching = false;
719
+ /**
720
+ * SPA mode: navigate between panel pages without full reloads (`wire:navigate`). `{ prefetch: true }` loads a page as soon
721
+ * as its link is hovered (`wire:navigate.hover`).
722
+ */
723
+ spa(condition = true, options = {}) { this._spa = condition; this._spaPrefetching = options.prefetch === true; return this; }
491
724
  hasSpaMode() { return this._spa; }
725
+ hasSpaPrefetching() { return this._spa && this._spaPrefetching; }
726
+ _domains = [];
727
+ /** Serves the panel on this domain only (`admin.example.com`): requests for other hosts go to the next routes. */
728
+ domain(host) { this._domains = host ? [host] : []; return this; }
729
+ /** Serves the panel on these domains only. */
730
+ domains(hosts) { this._domains = [...hosts]; return this; }
731
+ getDomains() { return this._domains; }
732
+ _unsavedChangesAlerts = false;
733
+ /** Warns the user before leaving a create or edit page whose form has unsaved changes. */
734
+ unsavedChangesAlerts(condition = true) { this._unsavedChangesAlerts = condition; return this; }
735
+ hasUnsavedChangesAlerts() { return this._unsavedChangesAlerts; }
736
+ _databaseTransactions = false;
737
+ /**
738
+ * Wraps the creation and the saving of records (from `beforeCreate()` / `beforeSave()` to `afterCreate()` / `afterSave()`)
739
+ * in a database transaction, rolled back when anything throws. Pages can opt in or out with `static hasDatabaseTransactions`.
740
+ */
741
+ databaseTransactions(condition = true) { this._databaseTransactions = condition; return this; }
742
+ hasDatabaseTransactions() { return this._databaseTransactions; }
743
+ _strictAuthorization = false;
744
+ /** Throws when a resource is checked for an ability its policy does not define, instead of allowing it. */
745
+ strictAuthorization(condition = true) { this._strictAuthorization = condition; return this; }
746
+ isAuthorizationStrict() { return this._strictAuthorization; }
747
+ _bootCallbacks = [];
748
+ /** Runs on every request handled by the panel (its pages and their updates), before them. */
749
+ bootUsing(callback) { this._bootCallbacks.push(callback); return this; }
750
+ async boot(req) { for (const callback of this._bootCallbacks)
751
+ await callback(this, req); }
492
752
  getAvatarUrlFor(name) {
493
753
  const initials = name.trim().split(' ').map((s) => s.replace(/^[^\p{L}\p{N}]+/u, '').slice(0, 1)).join(' ');
494
754
  const background = '#' + convertToRgb(this.colors.getColor('gray')[950]).map((n) => n.toString(16).padStart(2, '0')).join('');
@@ -513,27 +773,35 @@ export class Panel {
513
773
  }
514
774
  getNavigation(path, badges = new Map(), hidden = new Set()) {
515
775
  const items = [
516
- ...this._pages.filter((p) => p.shouldRegisterNavigation && !hidden.has(p.slug)).map((p) => {
517
- const item = NavigationItem.make(p.getNavigationLabel()).url(this.getUrl(p.slug)).icon(p.navigationIcon).group(p.navigationGroup).sort(p.navigationSort).badge(badges.has(p.slug) ? badges.get(p.slug) : p.navigationBadge, p.navigationBadgeColor);
776
+ // The pages of a cluster are in its sub-navigation: the menu only shows the cluster.
777
+ ...this._pages.filter((p) => p.shouldRegisterNavigation && !hidden.has(p.slug) && clusterOf(p) === null).map((p) => {
778
+ const item = NavigationItem.make(p.getNavigationLabel()).url(this.getUrl(p.slug)).icon(p.navigationIcon).group(p.navigationGroup).sort(p.navigationSort).badge(badges.has(p.slug) ? badges.get(p.slug) : p.navigationBadge, p.navigationBadgeColor).parentItem(p.navigationParentItem);
518
779
  const base = this.getUrl(p.slug);
519
- return p.resource ? item.isActiveWhen((current) => current === base || current.startsWith(base + '/')) : item;
780
+ return p.resource || p.prototype instanceof Cluster ? item.isActiveWhen((current) => current === base || current.startsWith(base + '/')) : item;
520
781
  }),
521
782
  ...this._navigationItems,
522
783
  ].sort((a, b) => a._sort - b._sort);
784
+ const view = (item, children) => {
785
+ const childItems = children.map((child) => view(child, child._childItems));
786
+ const activeChildItems = childItems.some((child) => child.isActive || child.activeChildItems);
787
+ return { label: item.label, url: item._url, icon: item._icon, isActive: !activeChildItems && item.isActive(path), badge: item._badge, badgeColorClasses: this.colors.getComponentClasses('badge', item._badgeColor), childItems, activeChildItems };
788
+ };
523
789
  const order = [null, ...this._navigationGroups, ...items.map((i) => i._group).filter((g) => g !== null && !this._navigationGroups.includes(g))];
524
790
  return [...new Set(order)].map((label) => {
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) }));
791
+ const inGroup = items.filter((i) => i._group === label);
792
+ // An item with a parent item is shown below it (an unknown parent hides it, as in Filament).
793
+ const own = inGroup.filter((i) => i._parentItem === null).map((i) => view(i, [...i._childItems, ...inGroup.filter((child) => child._parentItem === i.label)]));
526
794
  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 };
795
+ return { label, itemsId: label ? `fi-sidebar-group-items-${slug(label)}-${createHash('md5').update(label).digest('hex').slice(0, 8)}` : '', isActive: own.some((i) => i.isActive || i.activeChildItems), items: options?.icon ? own.map((i) => ({ ...i, icon: null })) : own, icon: options?.icon ?? null, collapsible: options?.collapsible ?? true };
528
796
  }).filter((g) => g.items.length);
529
797
  }
530
798
  // --- session: an HMAC-signed cookie holding the user id
531
799
  sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
532
800
  logIn(res, user, remember) {
533
801
  const value = Buffer.from(JSON.stringify({ uid: user.id })).toString('base64url');
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 } : {}) });
802
+ res.cookie(this.getSessionCookieName(), `${value}.${this.sign(value)}`, { httpOnly: true, sameSite: 'lax', secure: res.req?.secure === true, ...(this._sessionCookieDomain ? { domain: this._sessionCookieDomain } : {}), ...(remember ? { maxAge: 1000 * 60 * 60 * 24 * 30 } : {}) });
535
803
  }
536
- logOut(res) { res.clearCookie(this.getSessionCookieName()); }
804
+ logOut(res) { res.clearCookie(this.getSessionCookieName(), this._sessionCookieDomain ? { domain: this._sessionCookieDomain } : {}); }
537
805
  async user(req) {
538
806
  const raw = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`${this.getSessionCookieName()}=`))?.split('=')[1];
539
807
  if (!raw)
@@ -668,99 +936,61 @@ class BasePage extends Component {
668
936
  const resource = this.constructor.resource;
669
937
  return resource ? [this.constructor, resource] : [this.constructor];
670
938
  }
671
- }
672
- /** A panel page, in the full layout (sidebar, header). */
673
- export class Page extends BasePage {
674
- /** Methods the browser may call. A subclass adds its own: `static callable = ['approve']`. */
675
- static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
676
- static slug = '';
677
- static title = null;
678
- static navigationLabel = null;
679
- static navigationIcon = null;
680
- static navigationGroup = null;
681
- static navigationSort = 0;
682
- static navigationBadge = null;
683
- static navigationBadgeColor = 'primary';
684
- static shouldRegisterNavigation = true;
685
- /** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
686
- static canAccess(_user) { return true; }
687
- static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
688
- static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
689
- /** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
690
- async boot() {
691
- const ctx = this._ctx;
692
- if (!ctx?.user)
693
- throw new WireError(401, 'Unauthenticated.');
694
- // In a multi-tenant panel, every page belongs to a tenant the user is a member of (checked by the router).
695
- if (ctx.panel.hasTenancy() && !ctx.tenant)
696
- throw new WireError(404, 'Tenant not found.');
697
- if (await ctx.panel.mustVerifyEmail(ctx.user))
698
- throw new WireError(403, 'Email address not verified.');
699
- // Checked on every request, not only on display: a forged request to a forbidden page is denied.
700
- if (!(await this.isAccessAuthorized(ctx)))
701
- throw new WireError(403, 'Forbidden.');
702
- }
703
- isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
704
- getTitle() { return this.constructor.getTitle(); }
705
- getHeading() { return this.getTitle(); }
706
- getBreadcrumbs() { return []; }
939
+ /** Actions of the page header (full layout). */
707
940
  getHeaderActions() { return []; }
708
- getPageClasses() { return []; }
709
- /** Page sub-navigation; empty by default. */
710
- getSubNavigation() { return []; }
711
- /** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
712
- static subNavigationPosition = null;
713
- getSubNavigationPosition() {
714
- const own = this.constructor.subNavigationPosition;
715
- const resource = this.constructor.resource ?? this.constructor.parentResource;
716
- return own ?? resource?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
717
- }
718
- /** Widgets shown below the page content. */
719
- getFooterWidgets() { return []; }
720
- getFooterWidgetsColumns() { return 2; }
721
- /** Footer widget grid, in the page's `footerWidgets` schema. */
722
- async renderFooterWidgets() {
723
- const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
724
- if (!widgets.length)
725
- return '';
726
- const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
727
- return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'footerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getFooterWidgetsColumns(), 'footerWidgets')}${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_END)}</div>`;
728
- }
729
- /** Widgets shown above the page content, with the data passed to each (widget properties). */
730
- getHeaderWidgets() { return []; }
731
- getHeaderWidgetsColumns() { return 2; }
732
- getWidgetData() { return {}; }
733
- /** Header widget grid, in the page's `headerWidgets` schema. */
734
- async renderHeaderWidgets() {
735
- const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
736
- if (!widgets.length)
737
- return '';
738
- const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
739
- return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'headerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getHeaderWidgetsColumns(), 'headerWidgets')}${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_END)}</div>`;
740
- }
741
- /** Widgets the current user is allowed to see. */
742
- async filterVisibleWidgets(candidates) {
743
- const viewer = this._ctx.user;
744
- const widgets = [];
745
- for (const widget of candidates)
746
- if (await this.panel.authorize(viewer, 'view', { widget, rule: () => widget.canView(viewer) }))
747
- widgets.push(widget);
748
- return widgets;
941
+ // --- lazy loaded schemas (`lazyLoaded()`): loaded when they enter the viewport, then kept loaded
942
+ static callable = ['loadDeferredSchema'];
943
+ loadedDeferredSchemas = {};
944
+ /** Whether a lazy schema is rendered: loaded already, or holding a field with a validation error (loaded so it shows). */
945
+ async isDeferredSchemaLoaded(schema) {
946
+ const key = schema.getLazyKey();
947
+ if (this.loadedDeferredSchemas[key])
948
+ return true;
949
+ const errorKeys = Object.keys(this._errors ?? {});
950
+ if (!errorKeys.length)
951
+ return false;
952
+ for (const component of schema.getFlatComponents()) {
953
+ const path = component.hasStatePath?.() ? component.getStatePath() : '';
954
+ if (path && errorKeys.some((error) => error === path || error.startsWith(`${path}.`))) {
955
+ this.loadedDeferredSchemas[key] = true;
956
+ return true;
957
+ }
958
+ }
959
+ return false;
749
960
  }
750
- /** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
751
- async renderWidgetsGrid(widgets, columns, schemaKey) {
752
- const items = [];
753
- for (const [index, widget] of widgets.entries()) {
754
- const componentName = this._runtime.nameOf(widget);
755
- const key = `${schemaKey}.${componentName}-${index}`;
756
- const child = await this.mountChild(componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
757
- items.push(`<div class="fi-sc-liberated" wire:key="${e(`${this.id}.${key}`)}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})">${child}</div>`);
961
+ /** Called by the loading indicator of a lazy schema: renders only that schema. */
962
+ async loadDeferredSchema(schemaKey) {
963
+ if (typeof schemaKey !== 'string' || schemaKey.length > 300 || this.loadedDeferredSchemas[schemaKey]) {
964
+ this.skipRender();
965
+ return;
758
966
  }
759
- const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
760
- return `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div><div ${grid.toHtml()}>${items.join('')}</div></div></div></div>`;
967
+ const rootName = schemaKey.split('.')[0];
968
+ const mountedIndex = /^mountedActionSchema(\d+)$/.exec(rootName);
969
+ const root = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : /^[A-Za-z]+$/.test(rootName) && typeof this[`${rootName}Schema`] === 'function' ? this.getSchema(rootName) : null;
970
+ // Only a lazy schema reachable through visible components can be loaded.
971
+ const find = async (schema) => {
972
+ if (schema.getLazyKey() === schemaKey && (await schema.isLazyLoaded()))
973
+ return schema;
974
+ for (const component of schema.getComponents()) {
975
+ if (!(await component.isVisible()))
976
+ continue;
977
+ for (const child of (component.getChildSchemas?.() ?? [])) {
978
+ const found = await find(child);
979
+ if (found)
980
+ return found;
981
+ }
982
+ }
983
+ return null;
984
+ };
985
+ const schema = root ? await find(root) : null;
986
+ if (!schema) {
987
+ this.skipRender();
988
+ return;
989
+ }
990
+ this.loadedDeferredSchemas[schemaKey] = true;
991
+ const html = await schema.toHtml();
992
+ this.renderPartial(`schema.${schemaKey}`, html || `<div wire:partial="${e(`schema.${schemaKey}`)}" hidden></div>`);
761
993
  }
762
- /** Page content: its `content` schema by default. */
763
- async renderContent() { return (await this.getSchema('content')).toHtml(); }
764
994
  // --- mounted actions: the stack lives in the component state, modals are rendered server-side
765
995
  mountedActions = [];
766
996
  /** Files uploaded by rich editors (`componentFileAttachments.<state path>.<id>`), stored by the editor when it asks for their URL. */
@@ -871,7 +1101,8 @@ export class Page extends BasePage {
871
1101
  return;
872
1102
  const index = Number(m[1]);
873
1103
  const schema = await this.getMountedActionSchema(index);
874
- const field = schema?.getFlatComponents().find((c) => c instanceof Field && c.getStatePath() === `mountedActions.${index}.data.${m[2]}`);
1104
+ const path = `mountedActions.${index}.data.${m[2]}`;
1105
+ const field = schema?.getFlatComponents().find((c) => c instanceof Field && (c.getStatePath() === path || path.startsWith(`${c.getStatePath()}.`)));
875
1106
  if (!field?.hasAfterStateUpdated())
876
1107
  return;
877
1108
  const data = (this.mountedActions[index].data ??= {});
@@ -964,6 +1195,29 @@ export class Page extends BasePage {
964
1195
  this.renderPartial('action-modals', await this.renderActionModals());
965
1196
  this.syncActionModals();
966
1197
  }
1198
+ /**
1199
+ * Replaces the action being run with another action of the same component, opened in its place (to show a result, as
1200
+ * the new recovery codes). Call it from the running action; `name` must be mountable in the same context (it may be hidden).
1201
+ */
1202
+ async replaceMountedAction(name, args = {}) {
1203
+ const index = this.mountedActions.length - 1;
1204
+ if (index < 0)
1205
+ return;
1206
+ const context = this.mountedActions[index].context;
1207
+ const action = await this.findMountableAction(name, context);
1208
+ const mounted = { name, arguments: args, context };
1209
+ this.mountedActions[index] = mounted;
1210
+ this._errors = {};
1211
+ const schema = await this.getMountedActionSchema(index);
1212
+ if (schema) {
1213
+ const data = {};
1214
+ mounted.data = data;
1215
+ fillSchema(schema, data);
1216
+ }
1217
+ await action.runMountUsing({ ...(await this.actionContext(mounted)), data: (mounted.data ??= {}), action });
1218
+ this.renderPartial('action-modals', await this.renderActionModals());
1219
+ this.syncActionModals();
1220
+ }
967
1221
  unmountAction(_shouldCancelParentActions = true) {
968
1222
  this.cancelModalPartialRender();
969
1223
  this._errors = {};
@@ -1003,6 +1257,9 @@ export class Page extends BasePage {
1003
1257
  }
1004
1258
  return;
1005
1259
  }
1260
+ // The action replaced itself with another one (`replaceMountedAction()`): that one stays open.
1261
+ if (this.mountedActions[this.mountedActions.length - 1] !== mounted)
1262
+ return;
1006
1263
  // "… & create another": the modal stays open with a reset form (except the preserved fields).
1007
1264
  if (schema && _args && typeof _args === 'object' && _args.another === true) {
1008
1265
  const preserved = JSON.parse(JSON.stringify(action.getDataPreservedForAnother(callContext.data ?? {})));
@@ -1015,6 +1272,152 @@ export class Page extends BasePage {
1015
1272
  this.mountedActions.pop();
1016
1273
  this.syncActionModals();
1017
1274
  }
1275
+ }
1276
+ /** A navigation group for the topbar: in a dropdown, an item with children forms its own list with them. */
1277
+ function topNavigationGroup(group) {
1278
+ const colored = (item) => ({ ...item, colorClasses: item.isActive ? sharedColors.getComponentClasses('dropdown-item', 'primary') : [] });
1279
+ const lists = [];
1280
+ for (const item of group.items) {
1281
+ if (item.childItems.length) {
1282
+ lists.push([colored(item), ...item.childItems.map(colored)], []);
1283
+ continue;
1284
+ }
1285
+ if (!lists.length)
1286
+ lists.push([]);
1287
+ lists[lists.length - 1].push(colored(item));
1288
+ }
1289
+ if (lists.length && !lists[lists.length - 1].length)
1290
+ lists.pop();
1291
+ return { ...group, items: group.items.map(colored), lists };
1292
+ }
1293
+ /** A panel page, in the full layout (sidebar, header). */
1294
+ export class Page extends BasePage {
1295
+ /** Methods the browser may call. A subclass adds its own: `static callable = ['approve']`. */
1296
+ static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
1297
+ static slug = '';
1298
+ static title = null;
1299
+ static navigationLabel = null;
1300
+ static navigationIcon = null;
1301
+ static navigationGroup = null;
1302
+ static navigationSort = 0;
1303
+ static navigationBadge = null;
1304
+ static navigationBadgeColor = 'primary';
1305
+ /** Label of another menu item of the group, below which this page is shown. */
1306
+ static navigationParentItem = null;
1307
+ /** Cluster of the page: it is served under the cluster's URL and listed in its sub-navigation. */
1308
+ static cluster = null;
1309
+ static shouldRegisterNavigation = true;
1310
+ /** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
1311
+ static canAccess(_user) { return true; }
1312
+ /** Whether the page needs the tenant's subscription (`panel.requiresTenantSubscription()`); `false` keeps it open. */
1313
+ static isTenantSubscriptionRequired(panel) { return panel.isTenantSubscriptionRequired(); }
1314
+ static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
1315
+ static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
1316
+ /** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
1317
+ async boot() {
1318
+ const ctx = this._ctx;
1319
+ if (!ctx?.user && !ctx?.panel.allowsGuests())
1320
+ throw new WireError(401, 'Unauthenticated.');
1321
+ // In a multi-tenant panel, every page belongs to a tenant the user is a member of (checked by the router).
1322
+ if (ctx.panel.hasTenancy() && !ctx.tenant)
1323
+ throw new WireError(404, 'Tenant not found.');
1324
+ if (ctx.tenant && this.constructor.isTenantSubscriptionRequired(ctx.panel) && !(await ctx.panel.getTenantBillingProvider().isSubscribed(ctx.tenant)))
1325
+ throw new WireError(403, 'Subscription required.');
1326
+ if (await ctx.panel.mustVerifyEmail(ctx.user))
1327
+ throw new WireError(403, 'Email address not verified.');
1328
+ if (await ctx.panel.mustSetUpMultiFactorAuthentication(ctx.user))
1329
+ throw new WireError(403, 'Multi-factor authentication must be set up.');
1330
+ // Checked on every request, not only on display: a forged request to a forbidden page is denied.
1331
+ if (!(await this.isAccessAuthorized(ctx)))
1332
+ throw new WireError(403, 'Forbidden.');
1333
+ }
1334
+ isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
1335
+ getTitle() { return this.constructor.getTitle(); }
1336
+ getHeading() { return this.getTitle(); }
1337
+ getBreadcrumbs() { return []; }
1338
+ getHeaderActions() { return []; }
1339
+ getPageClasses() { return []; }
1340
+ /** Page sub-navigation: its cluster's pages, for a page in a cluster; empty otherwise. */
1341
+ getSubNavigation() {
1342
+ const cluster = this.constructor.cluster;
1343
+ return cluster?.shouldRegisterSubNavigation ? this.panel.getClusterSubNavigation(cluster, this, this._ctx.user) : [];
1344
+ }
1345
+ /** Breadcrumbs, preceded by the cluster's for a page in a cluster. */
1346
+ getBreadcrumbsWithCluster() {
1347
+ const breadcrumbs = this.getBreadcrumbs();
1348
+ const cluster = clusterOf(this.constructor);
1349
+ if (!cluster || this instanceof Cluster)
1350
+ return breadcrumbs;
1351
+ const crumb = cluster.getClusterBreadcrumb();
1352
+ return breadcrumbs[0]?.label === crumb ? breadcrumbs : [{ label: crumb, url: this.panel.getUrl(cluster.slug) }, ...breadcrumbs];
1353
+ }
1354
+ /** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
1355
+ static subNavigationPosition = null;
1356
+ getSubNavigationPosition() {
1357
+ const own = this.constructor.subNavigationPosition;
1358
+ const resource = this.constructor.resource ?? this.constructor.parentResource;
1359
+ return own ?? resource?.subNavigationPosition ?? clusterOf(this.constructor)?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
1360
+ }
1361
+ /** Widgets shown below the page content. */
1362
+ getFooterWidgets() { return []; }
1363
+ getFooterWidgetsColumns() { return 2; }
1364
+ /** Footer widget grid, in the page's `footerWidgets` schema. */
1365
+ async renderFooterWidgets() {
1366
+ const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
1367
+ if (!widgets.length)
1368
+ return '';
1369
+ const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
1370
+ return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'footerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getFooterWidgetsColumns(), 'footerWidgets')}${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_END)}</div>`;
1371
+ }
1372
+ /** Widgets shown above the page content, with the data passed to each (widget properties). */
1373
+ getHeaderWidgets() { return []; }
1374
+ getHeaderWidgetsColumns() { return 2; }
1375
+ getWidgetData() { return {}; }
1376
+ /** Header widget grid, in the page's `headerWidgets` schema. */
1377
+ async renderHeaderWidgets() {
1378
+ const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
1379
+ if (!widgets.length)
1380
+ return '';
1381
+ const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
1382
+ return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'headerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getHeaderWidgetsColumns(), 'headerWidgets')}${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_END)}</div>`;
1383
+ }
1384
+ /** Widgets the current user is allowed to see. */
1385
+ async filterVisibleWidgets(candidates) {
1386
+ const viewer = this._ctx.user;
1387
+ const widgets = [];
1388
+ for (const widget of candidates)
1389
+ if (await this.panel.authorize(viewer, 'view', { widget, rule: () => widget.canView(viewer) }))
1390
+ widgets.push(widget);
1391
+ return widgets;
1392
+ }
1393
+ /** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
1394
+ async renderWidgetsGrid(widgets, columns, schemaKey) {
1395
+ const items = [];
1396
+ for (const [index, widget] of widgets.entries()) {
1397
+ const componentName = this._runtime.nameOf(widget);
1398
+ const key = `${schemaKey}.${componentName}-${index}`;
1399
+ const child = await this.mountChild(componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
1400
+ items.push(`<div class="fi-sc-liberated" wire:key="${e(`${this.id}.${key}`)}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})">${child}</div>`);
1401
+ }
1402
+ const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
1403
+ return `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div><div ${grid.toHtml()}>${items.join('')}</div></div></div></div>`;
1404
+ }
1405
+ /** Page content: its `content` schema by default. */
1406
+ /**
1407
+ * Content rendered from an EJS view instead of the `content` schema: a namespaced view (`'app::pages.report'`), or a file
1408
+ * (path or `new URL('./report.ejs', import.meta.url)`). The view receives `page` (this page) and `getViewData()`, and may use
1409
+ * component tags (`<x-section>`, `<x-button>`…). Elements calling the server use `wire:click` on a method listed in `static callable`.
1410
+ */
1411
+ static view = null;
1412
+ /** Extra variables for the page view. */
1413
+ getViewData() { return {}; }
1414
+ async renderContent() {
1415
+ const view = this.constructor.view;
1416
+ if (view === null)
1417
+ return (await this.getSchema('content')).toHtml();
1418
+ const data = { page: this, livewireId: this.id, ...(await this.getViewData()) };
1419
+ return typeof view === 'string' && view.includes('::') ? views.render(view, data) : views.renderFile(view instanceof URL ? fileURLToPath(view) : view, data);
1420
+ }
1018
1421
  async render() {
1019
1422
  const ctx = { livewire: this, component: null, operation: null, record: this.getActionRecord(), get: () => undefined, state: undefined };
1020
1423
  const headerActions = [];
@@ -1023,12 +1426,34 @@ export class Page extends BasePage {
1023
1426
  headerActions.push(await action.toHtml(ctx));
1024
1427
  return views.render('filament-panels::pages.page', {
1025
1428
  heading: this.getHeading(), headerWidgets: await this.renderHeaderWidgets(), content: await this.renderContent(), footerWidgets: await this.renderFooterWidgets(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes(),
1026
- subNavigation: await this.getSubNavigation(), subNavigationPosition: this.getSubNavigationPosition(),
1027
- breadcrumbs: this.getBreadcrumbs(), headerActions, pageClasses: this.getPageClasses(), actionModals: this.rendersActionModalsInPage() ? await this.renderActionModals() : '',
1429
+ subNavigation: (await this.getSubNavigation()).map((item) => ({ ...item, badgeColorClasses: sharedColors.getComponentClasses('badge', item.badgeColor ?? 'primary') })), subNavigationPosition: this.getSubNavigationPosition(),
1430
+ breadcrumbs: this.getBreadcrumbsWithCluster(), headerActions, pageClasses: this.getPageClasses(), actionModals: this.rendersActionModalsInPage() ? await this.renderActionModals() : '',
1028
1431
  headerActionsKey: createHash('md5').update(this.getHeaderActions().map((a) => a.getName()).join(',')).digest('hex'),
1029
1432
  });
1030
1433
  }
1031
1434
  }
1435
+ /**
1436
+ * Cluster: a menu item gathering pages and resources (`static cluster = Settings`), listed in its sub-navigation and served
1437
+ * under its URL (`/settings/...`). Opening the cluster goes to its first page.
1438
+ */
1439
+ export class Cluster extends Page {
1440
+ /** Breadcrumb before those of its pages; defaults to the title. */
1441
+ static clusterBreadcrumb = null;
1442
+ /** `false`: the pages of the cluster have no sub-navigation. */
1443
+ static shouldRegisterSubNavigation = true;
1444
+ static getTitle() { return this.title ?? this.name.replace(/Cluster$/, '').replace(/([a-z])([A-Z])/g, '$1 $2'); }
1445
+ /** URL segment: `slug`, or the class name without "Cluster" in kebab case (`ProductsCluster` → `products`). */
1446
+ static getSlug() { return this.slug || this.name.replace(/Cluster$/, '').replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); }
1447
+ static getClusterBreadcrumb() { return this.clusterBreadcrumb ?? this.getTitle(); }
1448
+ async mount() {
1449
+ const [first] = await this.getSubNavigation();
1450
+ if (first)
1451
+ this.redirect(first.url);
1452
+ }
1453
+ getSubNavigation() {
1454
+ return this.panel.getClusterSubNavigation(this.constructor, this, this._ctx.user);
1455
+ }
1456
+ }
1032
1457
  /**
1033
1458
  * Dashboard: a widget grid, preceded by a filters form when the page defines `filtersFormSchema(schema)`.
1034
1459
  * The filters are passed to widgets as `pageFilters`.
@@ -1069,30 +1494,6 @@ export class Dashboard extends Page {
1069
1494
  return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${filters}${widgets}</div>`;
1070
1495
  }
1071
1496
  }
1072
- /** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
1073
- export class Throttle {
1074
- hits = new Map();
1075
- max;
1076
- windowMs;
1077
- constructor(max = 5, windowMs = 60_000) { this.max = max; this.windowMs = windowMs; }
1078
- /** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
1079
- hit(key, now = Date.now()) {
1080
- const recent = (this.hits.get(key) ?? []).filter((at) => at > now - this.windowMs);
1081
- if (recent.length >= this.max) {
1082
- this.hits.set(key, recent);
1083
- return Math.max(1, Math.ceil((recent[0] + this.windowMs - now) / 1000));
1084
- }
1085
- recent.push(now);
1086
- this.hits.set(key, recent);
1087
- // Keep the store from growing forever.
1088
- if (this.hits.size > 10_000)
1089
- for (const [k, v] of this.hits)
1090
- if (!v.some((at) => at > now - this.windowMs))
1091
- this.hits.delete(k);
1092
- return 0;
1093
- }
1094
- clear(key) { this.hits.delete(key); }
1095
- }
1096
1497
  const loginThrottle = new Throttle();
1097
1498
  /** Page with the simple layout (login, registration…). */
1098
1499
  export class SimplePage extends BasePage {
@@ -1101,7 +1502,7 @@ export class SimplePage extends BasePage {
1101
1502
  getSubheading() { return null; }
1102
1503
  hasLogo() { return true; }
1103
1504
  async render() {
1104
- return views.render('filament-panels::pages.simple', { brand: this.panel.getBrand(), heading: this.getHeading(), subheading: this.getSubheading(), hasLogo: this.hasLogo(), content: await this.getSchema('content').toHtml(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes() });
1505
+ return views.render('filament-panels::pages.simple', { brand: this.panel.getBrand(), heading: this.getHeading(), subheading: this.getSubheading(), hasLogo: this.hasLogo(), content: await this.getSchema('content').toHtml(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes(), actionModals: await this.renderActionModals() });
1105
1506
  }
1106
1507
  }
1107
1508
  /** Color manager read by the other packages and shared by all panels; each request sets its panel's colors on it (`Panel.withTheme`). */
@@ -1339,7 +1740,7 @@ export class EmailVerificationPrompt extends SimplePage {
1339
1740
  }
1340
1741
  /** User profile page: name, email, new password. The current password is required to change the email or password. */
1341
1742
  export class EditProfile extends SimplePage {
1342
- static callable = ['save', 'callSchemaComponentMethod'];
1743
+ static callable = ['save', 'mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
1343
1744
  static slug = 'profile';
1344
1745
  data = {};
1345
1746
  static getLabel() { return t('filament-panels::auth/pages/edit-profile.label'); }
@@ -1353,10 +1754,32 @@ export class EditProfile extends SimplePage {
1353
1754
  throw new WireError(401, 'Unauthenticated.'); }
1354
1755
  getTitle() { return this.constructor.getLabel(); }
1355
1756
  hasLogo() { return false; }
1757
+ /** Whether the page uses the full layout (`panel.profile(EditProfile, false)`): its form is then split into sections. */
1758
+ usesFullLayout() { return !this.panel.isProfileSimple(); }
1759
+ async render() {
1760
+ if (!this.usesFullLayout())
1761
+ return super.render();
1762
+ return views.render('filament-panels::pages.page', {
1763
+ heading: this.getTitle(), headerWidgets: '', content: await this.getSchema('content').toHtml(), footerWidgets: '', livewireId: this.id, renderHookScopes: this.getRenderHookScopes(),
1764
+ subNavigation: [], subNavigationPosition: 'top', breadcrumbs: [], headerActions: [], pageClasses: ['fi-auth-edit-profile-page'], actionModals: await this.renderActionModals(), headerActionsKey: '',
1765
+ });
1766
+ }
1356
1767
  formSchema(schema) {
1768
+ const fields = this.getFormComponents();
1769
+ if (!this.usesFullLayout())
1770
+ return schema.operation('edit').components(fields);
1771
+ const [name, email, ...password] = fields;
1772
+ const group = 'filament-panels::auth/pages/edit-profile.sections';
1773
+ return schema.operation('edit').components([
1774
+ Section.make(t(`${group}.information.heading`)).description(t(`${group}.information.description`)).schema([name, email]),
1775
+ Section.make(t(`${group}.password.heading`)).description(t(`${group}.password.description`)).schema(password),
1776
+ ]);
1777
+ }
1778
+ /** Fields of the form: name, email, new password, its confirmation, current password. */
1779
+ getFormComponents() {
1357
1780
  const { panel, user } = this._ctx;
1358
1781
  const auth = panel.getAuth();
1359
- return schema.operation('edit').components([
1782
+ return [
1360
1783
  TextInput.make('name').label(t('filament-panels::auth/pages/edit-profile.form.name.label')).required().maxLength(255).autofocus(),
1361
1784
  TextInput.make('email').label(t('filament-panels::auth/pages/edit-profile.form.email.label')).email().required().maxLength(255).live()
1362
1785
  .rule({ name: 'unique', passes: async (value) => { const other = await auth.findByEmail?.(String(value)); return !other || String(other.id) === String(user?.id); }, message: (attribute) => `The ${attribute} has already been taken.` }),
@@ -1367,13 +1790,44 @@ export class EditProfile extends SimplePage {
1367
1790
  .password().autocomplete('current-password').revealable().required()
1368
1791
  .rule({ name: 'current_password', passes: async (value) => Boolean(user && (await auth.attempt(user.email, String(value)))), message: () => 'The password is incorrect.' })
1369
1792
  .visible(({ get }) => !blankValue(get('password')) || get('email') !== user?.email).dehydrated(false),
1370
- ]);
1793
+ ];
1371
1794
  }
1372
1795
  contentSchema(schema) {
1373
- return schema.components(formContent('save', [
1374
- Action.make('save').label(t('filament-panels::auth/pages/edit-profile.form.actions.save.label')).submit('save').keyBindings(['mod+s']),
1375
- Action.make('back').label(t('filament-panels::auth/pages/edit-profile.actions.cancel.label')).color('gray').alpineClickHandler(`document.referrer ? window.history.back() : (window.location.href = ${JSON.stringify(this.panel.getUrl())})`),
1376
- ], { fullWidth: false, alignment: 'start' }));
1796
+ const { panel, user } = this._ctx;
1797
+ return schema.components([
1798
+ ...formContent('save', [
1799
+ Action.make('save').label(t('filament-panels::auth/pages/edit-profile.form.actions.save.label')).submit('save').keyBindings(['mod+s']),
1800
+ Action.make('back').label(t('filament-panels::auth/pages/edit-profile.actions.cancel.label')).color('gray').alpineClickHandler(`document.referrer ? window.history.back() : (window.location.href = ${JSON.stringify(this.panel.getUrl())})`),
1801
+ ], { fullWidth: false, alignment: 'start' }),
1802
+ ...(panel.hasMultiFactorAuthentication() && user ? [this.multiFactorAuthenticationSection(panel, user)] : []),
1803
+ ]);
1804
+ }
1805
+ /** Emails of an email change: a confirmation link to the new address, a link to block it to the old one. */
1806
+ async sendEmailChangeVerification(user, newEmail) {
1807
+ const { panel, req } = this._ctx;
1808
+ const urls = panel.getEmailChangeUrls(user, newEmail);
1809
+ await panel.sendMail({
1810
+ to: newEmail, subject: 'Verify Email Address',
1811
+ lines: ['Please click the button below to verify your new email address.'],
1812
+ action: { label: 'Verify Email Address', url: absoluteUrl(panel, req, urls.verify) },
1813
+ outro: ['This link will expire in 60 minutes.', 'If you did not request this change, no further action is required.'],
1814
+ });
1815
+ const notice = 'filament-panels::auth/notifications/notice-of-email-change-request';
1816
+ await panel.sendMail({
1817
+ to: user.email, subject: t(`${notice}.subject`),
1818
+ lines: [0, 1, 2].map((index) => t(`${notice}.lines.${index}`, { email: newEmail })),
1819
+ action: { label: t(`${notice}.action`), url: absoluteUrl(panel, req, urls.block) },
1820
+ outro: [t(`${notice}.lines.3`, { email: newEmail })],
1821
+ });
1822
+ Notification.make().success().title(t('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.title')).body(t('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.body', { email: newEmail })).send();
1823
+ }
1824
+ /** Multi-factor authentication settings: each provider's status and actions (set up, turn off…). */
1825
+ multiFactorAuthenticationSection(panel, user) {
1826
+ if (!this.usesFullLayout())
1827
+ return multiFactorManagementSection(panel, user);
1828
+ const group = 'filament-panels::auth/pages/edit-profile.multi_factor_authentication';
1829
+ return Section.make(t(`${group}.label`)).description(t(`${group}.description`)).divided()
1830
+ .schema(panel.getMultiFactorAuthenticationProviders().map((provider) => Group.make(provider.getManagementSchemaComponents(user, panel)).statePath(provider.getId())));
1377
1831
  }
1378
1832
  async save() {
1379
1833
  const { panel, user } = this._ctx;
@@ -1386,7 +1840,13 @@ export class EditProfile extends SimplePage {
1386
1840
  return;
1387
1841
  }
1388
1842
  const data = await getSchemaState(form);
1389
- await panel.getAuth().update(user, { name: String(data.name), email: String(data.email), password: blankValue(data.password) ? undefined : String(data.password) });
1843
+ const newEmail = String(data.email);
1844
+ const verifyEmailChange = panel.hasEmailChangeVerification() && newEmail !== user.email;
1845
+ await panel.getAuth().update(user, { name: String(data.name), email: verifyEmailChange ? user.email : newEmail, password: blankValue(data.password) ? undefined : String(data.password) });
1846
+ if (verifyEmailChange) {
1847
+ await this.sendEmailChangeVerification(user, newEmail);
1848
+ this.data = { ...this.data, email: user.email };
1849
+ }
1390
1850
  this.data = { ...this.data, password: null, passwordConfirmation: null, currentPassword: null };
1391
1851
  Notification.make().success().title(t('filament-panels::auth/pages/edit-profile.notifications.saved.title')).send();
1392
1852
  }
@@ -1461,15 +1921,41 @@ export class EditTenantProfile extends Page {
1461
1921
  }
1462
1922
  export class Login extends SimplePage {
1463
1923
  static callable = ['authenticate', 'callSchemaComponentMethod'];
1924
+ /** Signed token naming the user undertaking the multi-factor challenge: set by the server only. */
1925
+ static locked = ['userUndertakingMultiFactorAuthentication'];
1464
1926
  data = {};
1927
+ userUndertakingMultiFactorAuthentication = null;
1928
+ /** User of the pending challenge, resolved from the token on each request. */
1929
+ _multiFactorUser = null;
1930
+ _multiFactorProviders = [];
1465
1931
  mount() { fillSchema(this.getSchema('form'), this.data); }
1932
+ async boot() {
1933
+ const payload = this.readMultiFactorToken();
1934
+ const { panel } = this._ctx;
1935
+ this._multiFactorUser = payload ? await panel.getAuth().find(payload.uid) : null;
1936
+ const ids = Array.isArray(payload?.providers) ? payload.providers.map(String) : [];
1937
+ this._multiFactorProviders = this._multiFactorUser ? panel.getMultiFactorAuthenticationProviders().filter((provider) => ids.includes(provider.getId())) : [];
1938
+ }
1939
+ readMultiFactorToken() {
1940
+ const { panel } = this._ctx;
1941
+ return this.userUndertakingMultiFactorAuthentication ? readToken(panel.getAuthKey(), `${panel.getId()}.mfa-login`, this.userUndertakingMultiFactorAuthentication) : null;
1942
+ }
1943
+ /** Whether the multi-factor challenge is shown. */
1944
+ isUndertakingMultiFactorAuthentication() { return this._multiFactorUser !== null && this._multiFactorProviders.length > 0; }
1466
1945
  getTitle() { return t('filament-panels::auth/pages/login.title'); }
1467
- getHeading() { return t('filament-panels::auth/pages/login.heading'); }
1946
+ getHeading() { return this.isUndertakingMultiFactorAuthentication() ? t('filament-panels::auth/pages/login.multi_factor.heading') : t('filament-panels::auth/pages/login.heading'); }
1468
1947
  getSubheading() {
1948
+ if (this.isUndertakingMultiFactorAuthentication())
1949
+ return e(t('filament-panels::auth/pages/login.multi_factor.subheading'));
1469
1950
  if (!this.panel.hasRegistration())
1470
1951
  return null;
1471
1952
  return `${e(t('filament-panels::auth/pages/login.actions.register.before'))} ${authLink(this.panel.getRegistrationUrl(), t('filament-panels::auth/pages/login.actions.register.label'))}`;
1472
1953
  }
1954
+ getSchema(name) {
1955
+ if (name === 'multiFactorChallengeForm')
1956
+ return this.multiFactorChallengeFormSchema(Schema.make(this).key(name).statePath('data.multiFactor'));
1957
+ return super.getSchema(name);
1958
+ }
1473
1959
  formSchema(schema) {
1474
1960
  return schema.components([
1475
1961
  TextInput.make('email').label(t('filament-panels::auth/pages/login.form.email.label')).email().required().autocomplete().autofocus(),
@@ -1479,19 +1965,48 @@ export class Login extends SimplePage {
1479
1965
  Checkbox.make('remember').label(t('filament-panels::auth/pages/login.form.remember.label')),
1480
1966
  ]);
1481
1967
  }
1968
+ /** Challenge fields of the user's enabled providers (with a picker when there are several). */
1969
+ multiFactorChallengeFormSchema(schema) {
1970
+ if (!this.isUndertakingMultiFactorAuthentication())
1971
+ return schema.components([]);
1972
+ return schema.components(this.panel.getMultiFactorChallenge().getFormComponents(this._multiFactorUser, this._multiFactorProviders));
1973
+ }
1482
1974
  contentSchema(schema) {
1975
+ const challenging = this.isUndertakingMultiFactorAuthentication();
1483
1976
  return schema.components([
1484
1977
  RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_BEFORE),
1485
1978
  Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler('authenticate')
1486
- .footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')]),
1487
- // Multi-factor challenge form: hidden until MFA is implemented.
1488
- Form.make([]).id('multiFactorChallengeForm').hidden(),
1979
+ .footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')])
1980
+ .hidden(challenging),
1981
+ Form.make(challenging ? [EmbeddedSchema.make('multiFactorChallengeForm')] : []).id('multiFactorChallengeForm').livewireSubmitHandler('authenticate')
1982
+ .footer(challenging ? [Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.multi_factor.form.actions.authenticate.label')).submit('authenticate')]).fullWidth()] : [])
1983
+ .hidden(!challenging),
1489
1984
  RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_AFTER),
1490
1985
  ]);
1491
1986
  }
1987
+ /** Starts a challenge for this user if one of their providers is set up; returns whether it did. */
1988
+ async startMultiFactorChallenge(user) {
1989
+ const { panel } = this._ctx;
1990
+ if (!panel.hasMultiFactorAuthentication())
1991
+ return false;
1992
+ const challenge = panel.getMultiFactorChallenge();
1993
+ const enabled = await challenge.getEnabledProviders(user);
1994
+ if (!enabled.length)
1995
+ return false;
1996
+ // Ten minutes to answer; the password is checked again when the challenge is answered.
1997
+ this.userUndertakingMultiFactorAuthentication = signToken(panel.getAuthKey(), `${panel.getId()}.mfa-login`, { uid: user.id, providers: enabled.map((provider) => provider.getId()) }, 60 * 10);
1998
+ this._multiFactorUser = user;
1999
+ this._multiFactorProviders = enabled;
2000
+ await challenge.beforeChallenge(user, enabled[0]);
2001
+ const state = {};
2002
+ this.data = { ...this.data, multiFactor: state };
2003
+ fillSchema(this.getSchema('multiFactorChallengeForm'), state);
2004
+ return true;
2005
+ }
1492
2006
  async authenticate() {
1493
2007
  this._errors = await validateSchema(this.getSchema('form'));
1494
2008
  if (Object.keys(this._errors).length) {
2009
+ this.cancelMultiFactorChallenge();
1495
2010
  this.dispatch('form-validation-error', { livewireId: this.id });
1496
2011
  return;
1497
2012
  }
@@ -1502,16 +2017,88 @@ export class Login extends SimplePage {
1502
2017
  Notification.make().danger().title(t('filament-actions::notifications.throttled.title', { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t('filament-actions::notifications.throttled.body', { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
1503
2018
  return;
1504
2019
  }
2020
+ // The password is checked on every submission, the challenge's included: a password changed meanwhile ends it.
1505
2021
  const user = await panel.getAuth().attempt(String(this.data.email), String(this.data.password));
1506
2022
  // As in Filament: a valid account that may not use this panel gets the same message as wrong credentials.
1507
2023
  if (!user || !(await panel.userCanAccess(user))) {
2024
+ this.cancelMultiFactorChallenge();
1508
2025
  this.addError('data.email', t('filament-panels::auth/pages/login.messages.failed'));
1509
2026
  return;
1510
2027
  }
2028
+ if (this.isUndertakingMultiFactorAuthentication() && String(this._multiFactorUser.id) === String(user.id)) {
2029
+ const challenge = panel.getMultiFactorChallenge();
2030
+ const challengeWait = challenge.hitRateLimiter(user);
2031
+ if (challengeWait > 0) {
2032
+ Notification.make().danger().title(t('filament-actions::notifications.throttled.title', { seconds: challengeWait, minutes: Math.ceil(challengeWait / 60) })).body(t('filament-actions::notifications.throttled.body', { seconds: challengeWait, minutes: Math.ceil(challengeWait / 60) })).send();
2033
+ return;
2034
+ }
2035
+ const challengeForm = this.getSchema('multiFactorChallengeForm');
2036
+ this._errors = await validateSchema(challengeForm);
2037
+ if (Object.keys(this._errors).length) {
2038
+ this.dispatch('form-validation-error', { livewireId: this.id });
2039
+ return;
2040
+ }
2041
+ // Defense in depth: the answered provider is one of the user's, and at least one of its fields was validated.
2042
+ const state = (this.data.multiFactor ?? {});
2043
+ const picked = this._multiFactorProviders.length > 1 ? String(state.provider ?? '') : this._multiFactorProviders[0].getId();
2044
+ const answered = this._multiFactorProviders.some((provider) => provider.getId() === picked)
2045
+ && (await challengeForm.getVisibleFlatComponents()).some((component) => component instanceof Field && component.getStatePath().startsWith(`data.multiFactor.${picked}.`));
2046
+ if (!answered) {
2047
+ this.addError('data.multiFactor.provider', t('filament-panels::auth/pages/login.messages.failed'));
2048
+ return;
2049
+ }
2050
+ challenge.clearRateLimiter(user);
2051
+ }
2052
+ else if (await this.startMultiFactorChallenge(user)) {
2053
+ return;
2054
+ }
1511
2055
  loginThrottle.clear(`${panel.getId()}:${req.ip ?? ''}`);
1512
2056
  panel.logIn(res, user, Boolean(this.data.remember));
2057
+ this.userUndertakingMultiFactorAuthentication = null;
1513
2058
  this.redirect(panel.getUrl());
1514
2059
  }
2060
+ /** The user picked another provider in the challenge: run its `beforeChallenge` (an email code is sent). */
2061
+ async updatedData(value, key) {
2062
+ if (key !== 'multiFactor.provider' || !this.isUndertakingMultiFactorAuthentication())
2063
+ return;
2064
+ const provider = this._multiFactorProviders.find((candidate) => candidate.getId() === value);
2065
+ if (provider)
2066
+ await this.panel.getMultiFactorChallenge().beforeChallenge(this._multiFactorUser, provider);
2067
+ }
2068
+ cancelMultiFactorChallenge() {
2069
+ this.userUndertakingMultiFactorAuthentication = null;
2070
+ this._multiFactorUser = null;
2071
+ this._multiFactorProviders = [];
2072
+ }
2073
+ }
2074
+ /** Shown after sign-in when the panel requires multi-factor authentication and the user has not set any provider up. */
2075
+ export class SetUpRequiredMultiFactorAuthentication extends SimplePage {
2076
+ static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
2077
+ async boot() { if (!this._ctx?.user)
2078
+ throw new WireError(401, 'Unauthenticated.'); }
2079
+ async mount() {
2080
+ const { panel, user } = this._ctx;
2081
+ if (!panel.hasMultiFactorAuthentication() || (await panel.getMultiFactorChallenge().hasEnabledProviders(user)))
2082
+ this.redirect(panel.getUrl());
2083
+ }
2084
+ getTitle() { return t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.title'); }
2085
+ getHeading() { return t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.heading'); }
2086
+ getSubheading() { return e(t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.subheading')); }
2087
+ contentSchema(schema) {
2088
+ const { panel, user } = this._ctx;
2089
+ return schema.components([
2090
+ multiFactorManagementSection(panel, user),
2091
+ Actions.make([
2092
+ Action.make('continue').label(t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.actions.continue.label')).url(panel.getUrl())
2093
+ .visible(() => panel.getMultiFactorChallenge().hasEnabledProviders(user)),
2094
+ ]).fullWidth().key('footer-actions'),
2095
+ ]);
2096
+ }
2097
+ }
2098
+ /** Profile section listing the providers: each one's status and actions, under its id. */
2099
+ function multiFactorManagementSection(panel, user) {
2100
+ return Section.make().compact().divided().secondary().key('multi-factor-authentication')
2101
+ .schema(panel.getMultiFactorAuthenticationProviders().map((provider) => Group.make(provider.getManagementSchemaComponents(user, panel)).statePath(provider.getId())));
1515
2102
  }
1516
2103
  /** Whether the host component's user has this ability on the component's resource (or relation manager). Use it in your actions' `authorize()`. */
1517
2104
  export const can = (livewire, ability, record = null) => canOn(livewire, ability, record);
@@ -1531,6 +2118,12 @@ export class Resource {
1531
2118
  static navigationGroup = null;
1532
2119
  static navigationSort = 0;
1533
2120
  static navigationBadge = null;
2121
+ /** Menu label; defaults to the plural model label in title case. */
2122
+ static navigationLabel = null;
2123
+ /** Label of another menu item of the group, below which the resource is shown. */
2124
+ static navigationParentItem = null;
2125
+ /** Cluster of the resource: its pages are served under the cluster's URL, and its list is in the cluster's sub-navigation. */
2126
+ static cluster = null;
1534
2127
  static adapter;
1535
2128
  /** In a multi-tenant panel, whether records belong to each tenant (`true` by default). */
1536
2129
  static isScopedToTenant = true;
@@ -1554,8 +2147,9 @@ export class Resource {
1554
2147
  static widgets = [];
1555
2148
  /** Widgets shown above the table on the list page. */
1556
2149
  static headerWidgets = [];
1557
- /** Relation managers shown below the edit form. */
2150
+ /** Relation managers shown below the edit form (and the view page), or groups of them (`RelationGroup`). */
1558
2151
  static relations = [];
2152
+ static getRelations() { return this.relations; }
1559
2153
  /** Tabs of the list page. */
1560
2154
  static tabs() { return {}; }
1561
2155
  /** Simple resource: a single page, with create and edit in modals. */
@@ -1609,6 +2203,11 @@ export class Resource {
1609
2203
  static getGlobalSearchResultTitle(record) { return this.getRecordTitle(record); }
1610
2204
  /** Details shown under the title: `{ Author: 'Jane' }`. Empty values are skipped. */
1611
2205
  static getGlobalSearchResultDetails(_record) { return {}; }
2206
+ /**
2207
+ * Buttons under a result: links (`Action.make('edit').url(…)`) or Livewire events (`.dispatch('quickView', [id])`).
2208
+ * They are not mounted: an action needing a modal belongs on the record's pages.
2209
+ */
2210
+ static getGlobalSearchResultActions(_record) { return []; }
1612
2211
  /** URL of a result: the view page, otherwise the edit page; for a simple resource, the list with the action open. `null` if the user has no access. */
1613
2212
  static async getGlobalSearchResultUrl(record, user) {
1614
2213
  const key = this.adapter.getKey(record);
@@ -1632,7 +2231,7 @@ export class Resource {
1632
2231
  for (const [label, value] of Object.entries(await this.getGlobalSearchResultDetails(record)))
1633
2232
  if (value !== null && value !== undefined && value !== '')
1634
2233
  details[label] = String(value);
1635
- results.push({ title: this.getGlobalSearchResultTitle(record), url, details });
2234
+ results.push({ title: this.getGlobalSearchResultTitle(record), url, details, actions: await this.getGlobalSearchResultActions(record), record });
1636
2235
  }
1637
2236
  return results;
1638
2237
  }
@@ -1659,6 +2258,8 @@ export class Resource {
1659
2258
  static navigationGroup = resource.navigationGroup;
1660
2259
  static navigationSort = resource.navigationSort;
1661
2260
  static navigationBadge = resource.navigationBadge;
2261
+ static navigationParentItem = resource.navigationParentItem;
2262
+ static navigationLabel = resource.navigationLabel;
1662
2263
  static getNavigationBadge = typeof resource.getNavigationBadge === 'function' ? () => resource.getNavigationBadge() : undefined;
1663
2264
  };
1664
2265
  const Create = class extends (resource.pages.create ?? CreateRecord) {
@@ -1675,6 +2276,15 @@ export class Resource {
1675
2276
  return { index: List };
1676
2277
  const related = {};
1677
2278
  for (const [name, RelatedPage] of Object.entries(resource.relatedPages)) {
2279
+ // Another edit or view page of the record (`EditCustomerContact extends EditRecord`): served at `<slug>/{record}/<name>`.
2280
+ if (RelatedPage.prototype instanceof EditRecord || RelatedPage.prototype instanceof ViewRecord) {
2281
+ related[name] = class extends RelatedPage {
2282
+ static resource = resource;
2283
+ static slug = `${resource.slug}/{record}/${name}`;
2284
+ static shouldRegisterNavigation = false;
2285
+ };
2286
+ continue;
2287
+ }
1678
2288
  related[name] = class extends RelatedPage {
1679
2289
  static parentResource = resource;
1680
2290
  static slug = `${resource.slug}/{record}/${name}`;
@@ -1691,6 +2301,8 @@ export class Resource {
1691
2301
  return { index: List, create: Create, edit: Edit, view: View, ...related };
1692
2302
  }
1693
2303
  }
2304
+ /** The cluster a page belongs to: its own, or its resource's. */
2305
+ export const clusterOf = (page) => page?.cluster ?? page?.resource?.cluster ?? page?.parentResource?.cluster ?? null;
1694
2306
  /** Widget: a component displayed in a page's widget grid. */
1695
2307
  export class Widget extends Component {
1696
2308
  static componentName;
@@ -2215,40 +2827,105 @@ export class EmbeddedTable extends SchemaComponent {
2215
2827
  static make() { return new EmbeddedTable(); }
2216
2828
  async toHtml() { return this.livewire.renderTable(); }
2217
2829
  }
2218
- /** Relation manager tabs of an edit page: only the active tab's manager is mounted, as a lazy child component. */
2830
+ /**
2831
+ * Several relation managers under one tab of a record page: `RelationGroup.make('Contacts', [PhonesManager, EmailsManager])`.
2832
+ * The managers are shown one below the other.
2833
+ */
2834
+ export class RelationGroup {
2835
+ _label;
2836
+ _managers;
2837
+ _icon = null;
2838
+ _badge = null;
2839
+ _badgeColor = 'primary';
2840
+ constructor(label, managers) { this._label = label; this._managers = managers; }
2841
+ static make(label, managers) { return new RelationGroup(label, managers); }
2842
+ icon(name) { this._icon = name; return this; }
2843
+ badge(badge) { this._badge = badge; return this; }
2844
+ badgeColor(color) { this._badgeColor = color; return this; }
2845
+ getLabel() { return this._label; }
2846
+ getIcon() { return this._icon; }
2847
+ getManagers() { return this._managers; }
2848
+ async getBadge() { return typeof this._badge === 'function' ? this._badge() : this._badge; }
2849
+ getBadgeColor() { return this._badgeColor; }
2850
+ }
2851
+ /** Relation manager tabs of a record page: only the active tab's managers are mounted, as lazy child components. */
2219
2852
  class RelationManagerTabs extends SchemaComponent {
2220
- constructor() { super(); this._keyName = 'relationManagerTabs'; }
2853
+ content = [];
2854
+ constructor(content = []) { super(); this._keyName = 'relationManagerTabs'; this.content = content; }
2855
+ /** With content (the form or infolist), it is the first tab: `hasCombinedRelationManagerTabsWithContent()`. */
2856
+ getChildSchemas() { return this.content.length ? [childSchema(this, this.content)] : []; }
2221
2857
  getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
2222
2858
  getLazyLoadedTabBadges() { return {}; }
2223
2859
  getDeferredTabBadges() { return {}; }
2860
+ /** Tabs the user can see on this record: managers and groups whose `canViewForRecord()` allows it. */
2861
+ async tabs(page) {
2862
+ const owner = page._record;
2863
+ const PageClass = page.constructor;
2864
+ const visible = async (manager) => Boolean(await manager.canViewForRecord(owner, PageClass));
2865
+ const tabs = [];
2866
+ if (this.content.length)
2867
+ tabs.push({ key: '-1', label: page.getContentTabLabel(), icon: page.getContentTabIcon(), badge: null, badgeColor: 'primary', managers: [], isContent: true });
2868
+ for (const [index, entry] of page.getRelationManagers().entries()) {
2869
+ if (entry instanceof RelationGroup) {
2870
+ const managers = [];
2871
+ for (const manager of entry.getManagers())
2872
+ if (await visible(manager))
2873
+ managers.push(manager);
2874
+ if (managers.length)
2875
+ tabs.push({ key: String(index), label: entry.getLabel(), icon: entry.getIcon(), badge: await entry.getBadge(), badgeColor: entry.getBadgeColor(), managers, isContent: false });
2876
+ }
2877
+ else if (await visible(entry)) {
2878
+ tabs.push({ key: String(index), label: entry.getTitle(), icon: entry.getIcon(), badge: await entry.getBadge(owner, PageClass), badgeColor: entry.getBadgeColor(), managers: [entry], isContent: false });
2879
+ }
2880
+ }
2881
+ return tabs;
2882
+ }
2883
+ async isVisible() { return (await super.isVisible()) && (this.content.length > 0 || (await this.tabs(this.livewire)).length > 0); }
2224
2884
  async toHtml() {
2225
2885
  const page = this.livewire;
2226
- const managers = page.resource.relations;
2886
+ const tabs = await this.tabs(page);
2887
+ if (!tabs.length)
2888
+ return '';
2227
2889
  const key = this.getKey();
2228
2890
  const id = page.id;
2229
- const active = Math.min(Math.max(Math.floor(Number(page.activeRelationManager)) || 0, 0), managers.length - 1);
2230
- const tabId = (index) => `fi-sc-tab-${createHash('md5').update(`${key}.${index}`).digest('hex')}`;
2231
- const tabs = managers.map((manager, index) => `<button id="${tabId(index)}" role="tab" aria-controls="${key}.${index}" aria-selected="${index === active ? 'true' : 'false'}" x-bind:aria-selected="isTabSelected('${index}')" tabindex="${index === active ? '0' : '-1'}" x-bind:tabindex="getTabIndex('${index}')" data-tab-key="${index}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab('${index}', $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" type="button" wire:click="$set('activeRelationManager', '${index}')" class="${cls(['fi-tabs-item', { 'fi-active': index === active }])}"><span class="fi-tabs-item-label">${e(manager.getTitle())}</span></button>`);
2891
+ const active = tabs.find((tab) => tab.key === String(page.activeRelationManager)) ?? tabs[0];
2892
+ const tabId = (tab) => `fi-sc-tab-${createHash('md5').update(`${key}.${tab.key}`).digest('hex')}`;
2893
+ const header = tabs.map((tab) => {
2894
+ const isActive = tab === active;
2895
+ const iconHtml = tab.icon ? icon(tab.icon) : '';
2896
+ const badgeHtml = tab.badge !== null && tab.badge !== undefined && tab.badge !== '' ? uiBadge({ color: tab.badgeColor, size: 'sm' }, e(String(tab.badge))) : '';
2897
+ return `<button id="${tabId(tab)}" role="tab" aria-controls="${key}.${tab.key}" aria-selected="${isActive ? 'true' : 'false'}" x-bind:aria-selected="isTabSelected('${tab.key}')" tabindex="${isActive ? '0' : '-1'}" x-bind:tabindex="getTabIndex('${tab.key}')" data-tab-key="${tab.key}" x-bind:data-tab-available="Boolean(true).toString()" x-show="true" x-on:click.capture="if (! selectTab('${tab.key}', $event)) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-bind:class="{ 'fi-active': isTabSelected('${tab.key}') }" type="button" wire:click="$set('activeRelationManager', '${tab.key}')" class="${cls(['fi-tabs-item', { 'fi-active': isActive }])}">${iconHtml}<span class="fi-tabs-item-label">${e(tab.label)}</span>${badgeHtml}</button>`;
2898
+ });
2232
2899
  const panels = [];
2233
- for (const [index, manager] of managers.entries()) {
2900
+ for (const tab of tabs) {
2234
2901
  let content = '';
2235
- if (index === active) {
2236
- const componentName = page._runtime.nameOf(manager);
2237
- const childKey = `${key}.${index}.${componentName}`;
2238
- const child = await page.mountChild(componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
2239
- content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div wire:partial="schema-component::${e(childKey)}" x-data="filamentSchemaComponent(${e(js({ key: childKey, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${id}.${childKey}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${child}</div></div></div>`;
2902
+ if (tab.isContent) {
2903
+ // The form or infolist: always rendered, so that its state and actions stay in the page.
2904
+ content = (await this.getChildSchemas()[0]?.toHtml()) ?? '';
2905
+ }
2906
+ else if (tab === active) {
2907
+ const parts = [];
2908
+ for (const manager of tab.managers) {
2909
+ const componentName = page._runtime.nameOf(manager);
2910
+ const childKey = `${key}.${tab.key}.${componentName}`;
2911
+ const child = await page.mountChild(componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
2912
+ parts.push(`<div wire:partial="schema-component::${e(childKey)}" x-data="filamentSchemaComponent(${e(js({ key: childKey, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${id}.${childKey}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${child}</div></div>`);
2913
+ }
2914
+ content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${parts.join('')}</div>`;
2240
2915
  }
2241
- panels.push(`<div aria-labelledby="${tabId(index)}" id="${key}.${index}" role="tabpanel" tabindex="0" wire:key="${id}.${key}.${index}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" x-on:expand="revealTab('${index}')" x-data="filamentSchemaComponent(${e(js({ key: `${key}.${index}`, exposedMethods: [], path: '', containerPath: '' }))})" class="fi-sc-tabs-tab">${content}</div>`);
2916
+ const isActive = tab === active;
2917
+ panels.push(`<div aria-labelledby="${tabId(tab)}" id="${key}.${tab.key}" role="tabpanel" tabindex="0" wire:key="${id}.${key}.${tab.key}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected('${tab.key}') }" x-on:expand="revealTab('${tab.key}')" x-data="filamentSchemaComponent(${e(js({ key: `${key}.${tab.key}`, exposedMethods: [], path: '', containerPath: '' }))})" class="${cls(['fi-sc-tabs-tab', { 'fi-active': isActive && tab.isContent }])}">${content}</div>`);
2242
2918
  }
2919
+ const activeIndex = tabs.indexOf(active);
2243
2920
  return `<div x-data="tabsSchemaComponent({
2244
- activeTab: ${active + 1},
2921
+ activeTab: ${activeIndex + 1},
2245
2922
  hasTabPanels: true,
2246
2923
  isScrollable: true,
2247
2924
  isVertical: false,
2248
2925
  livewireId: ${e(js(id))},
2249
2926
  livewireProperty: 'activeRelationManager',
2250
- tab: '${active}',
2251
- })" x-load x-load-src="${e(page.panel.assetUrl('js/filament/schemas/components/tabs.js'))}" wire:ignore.self id="${key}" wire:key="${id}.${key}.container" class="fi-sc-tabs"><input type="hidden" x-ref="tabsData" data-active-tab="${active}" /><div x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(t('filament::components/tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeRelationManager" class="fi-sc-tabs-tablist">${tabs.join('')}</div></div>${panels.join('')}</div>`;
2927
+ tab: '${active.key}',
2928
+ })" x-load x-load-src="${e(page.panel.assetUrl('js/filament/schemas/components/tabs.js'))}" wire:ignore.self id="${key}" wire:key="${id}.${key}.container" class="fi-sc-tabs"><input type="hidden" x-ref="tabsData" data-active-tab="${active.key}" /><div x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(t('filament::components/tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeRelationManager" class="fi-sc-tabs-tablist">${header.join('')}</div></div>${panels.join('')}</div>`;
2252
2929
  }
2253
2930
  }
2254
2931
  /** List page tab: restricts the table query and can show a badge. */
@@ -2353,6 +3030,11 @@ class ResourceTabs extends SchemaComponent {
2353
3030
  export class ListRecords extends Page {
2354
3031
  static callable = ['updateTableColumnState', 'sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableFilters', 'resetTableFiltersForm', 'removeTableFilters', 'removeTableFilter', 'applyTableColumnManager', 'resetTableColumnManager', 'toggleTableReordering', 'reorderTable'];
2355
3032
  static resource;
3033
+ /** In a cluster, the list page shows the cluster's sub-navigation. */
3034
+ getSubNavigation() {
3035
+ const cluster = this.resource.cluster;
3036
+ return cluster?.shouldRegisterSubNavigation ? this.panel.getClusterSubNavigation(cluster, this, this._ctx.user) : [];
3037
+ }
2356
3038
  tableSearch = '';
2357
3039
  tableSort = null;
2358
3040
  tableColumnSearches = {};
@@ -2628,7 +3310,13 @@ export class ListRecords extends Page {
2628
3310
  return {};
2629
3311
  }
2630
3312
  async resolveActionRecord(context) {
2631
- return typeof context.recordKey === 'string' ? ((await this.resource.adapter.find(context.recordKey)) ?? null) : null;
3313
+ if (typeof context.recordKey !== 'string')
3314
+ return null;
3315
+ const adapter = this.resource.adapter;
3316
+ // The trashed filter shows deleted records: their row actions must find them, no other key is reachable this way.
3317
+ if (trashedFilterValue(this) !== null && adapter.findWithTrashed)
3318
+ return (await adapter.findWithTrashed(context.recordKey)) ?? null;
3319
+ return (await adapter.find(context.recordKey)) ?? null;
2632
3320
  }
2633
3321
  /**
2634
3322
  * Writes an editable cell. Everything comes from the browser: the column must exist and be editable and visible, the record
@@ -2814,6 +3502,19 @@ export class RelationManager extends ListRecords {
2814
3502
  static table(table) { return table; }
2815
3503
  static form(schema) { return schema; }
2816
3504
  static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
3505
+ /** Icon of the manager's tab. */
3506
+ static icon = null;
3507
+ static getIcon() { return this.icon; }
3508
+ /** Badge of the manager's tab (a count of related records…), and its color. */
3509
+ static badge = null;
3510
+ static badgeColor = 'primary';
3511
+ static getBadge(_ownerRecord, _pageClass) { return this.badge; }
3512
+ static getBadgeColor() { return this.badgeColor; }
3513
+ /**
3514
+ * Whether the manager is shown for this record and page (all by default). Hide it here rather than by denying `viewAny`,
3515
+ * which would forbid the whole page.
3516
+ */
3517
+ static canViewForRecord(_ownerRecord, _pageClass) { return true; }
2817
3518
  /** Owner record key: set on mount by the page, never writable from the browser. */
2818
3519
  ownerRecord = '';
2819
3520
  static locked = ['ownerRecord'];
@@ -2827,7 +3528,13 @@ export class RelationManager extends ListRecords {
2827
3528
  getPluralModelLabel: () => manager.pluralModelLabel ?? plural(manager.modelLabel),
2828
3529
  getTitleCasePluralModelLabel: () => (manager.pluralModelLabel ?? plural(manager.modelLabel)).split(' ').map(ucfirst).join(' '),
2829
3530
  getRecordTitle: (record) => (manager.recordTitleAttribute ? String(record?.[manager.recordTitleAttribute] ?? '') : manager.modelLabel),
2830
- table: (table) => manager.table(table), form: (schema) => manager.form(schema.columns(2)), getUrl: () => '',
3531
+ table: (table) => manager.table(table), getUrl: () => '',
3532
+ form: (schema) => {
3533
+ const form = manager.form(schema.columns(2));
3534
+ const pivot = manager.pivotForm(form);
3535
+ return pivot.length ? form.components([...form.getComponents(), ...pivot]) : form;
3536
+ },
3537
+ pivotFieldNames: () => manager.pivotForm(Schema.make(this)).filter((component) => component instanceof Field).map((field) => field.getName()),
2831
3538
  });
2832
3539
  }
2833
3540
  getTable() {
@@ -2838,8 +3545,16 @@ export class RelationManager extends ListRecords {
2838
3545
  return hasEdit ? table.recordAction('edit') : table;
2839
3546
  }
2840
3547
  // --- many-to-many: to be defined by managers offering Attach / Detach
2841
- /** Attaches an existing record to the owner. */
2842
- static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach() must be defined.'); }
3548
+ /** Attaches an existing record to the owner; `pivot` holds the values of the `pivotForm()` fields. */
3549
+ static attach(_ownerKey, _recordKey, _pivot = {}) { throw new Error('RelationManager.attach() must be defined.'); }
3550
+ /**
3551
+ * Fields stored on the pivot table (a role, a quantity…): added to the attach modal and to the edit form of a row.
3552
+ * Their values go to `attach()` and `updatePivot()`, never to the related record. List the pivot columns in the
3553
+ * relation's query (`adapterFor()`) so that the table and the edit form can read them.
3554
+ */
3555
+ static pivotForm(_schema) { return []; }
3556
+ /** Saves the pivot fields of an attached record (edit form of a row). */
3557
+ static updatePivot(_ownerKey, _recordKey, _pivot) { throw new Error('RelationManager.updatePivot() must be defined when pivotForm() returns fields.'); }
2843
3558
  /** Detaches records from the owner without deleting them. */
2844
3559
  static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach() must be defined.'); }
2845
3560
  /** Attachable records matching the search: [key, label]. */
@@ -2986,6 +3701,34 @@ class ResourceFormPage extends Page {
2986
3701
  }
2987
3702
  static resource;
2988
3703
  data = {};
3704
+ /** Hash of the form data as last saved: the browser compares it with the current data (unsaved changes alert). */
3705
+ savedDataHash = '';
3706
+ static locked = ['savedDataHash'];
3707
+ /** `true` / `false` overrides the panel's `databaseTransactions()` for this page. */
3708
+ static hasDatabaseTransactions = null;
3709
+ /** `true` / `false` overrides the panel's `unsavedChangesAlerts()` for this page. */
3710
+ static hasUnsavedDataChangesAlert = null;
3711
+ hasDatabaseTransactions() { return this.constructor.hasDatabaseTransactions ?? this.panel.hasDatabaseTransactions(); }
3712
+ hasUnsavedDataChangesAlert() { return this.constructor.hasUnsavedDataChangesAlert ?? this.panel.hasUnsavedChangesAlerts(); }
3713
+ /** Runs `fn` in a database transaction of the resource's adapter, when the page uses them. */
3714
+ wrapInDatabaseTransaction(fn) {
3715
+ const adapter = this.getDataAdapter();
3716
+ return this.hasDatabaseTransactions() && typeof adapter.transaction === 'function' ? adapter.transaction(fn) : fn();
3717
+ }
3718
+ /** Remembers the form data as saved: leaving the page with other data asks for confirmation. */
3719
+ rememberData() {
3720
+ if (!this.hasUnsavedDataChangesAlert())
3721
+ return;
3722
+ this.savedDataHash = createHash('md5').update(JSON.stringify(this.data).replace(/\\/g, '')).digest('hex');
3723
+ }
3724
+ async render() {
3725
+ if (this.hasUnsavedDataChangesAlert()) {
3726
+ this.addScript('unsaved-data-changes-alert', this.panel.hasSpaMode()
3727
+ ? `setUpFilamentSpaModeUnsavedDataChangesAlert({ body: ${js(t('filament-panels::unsaved-changes-alert.body'))}, resolveLivewireComponentUsing: () => window.Livewire.find(${js(this.id)}), $wire })`
3728
+ : 'setUpFilamentUnsavedDataChangesAlert({ $wire })');
3729
+ }
3730
+ return super.render();
3731
+ }
2989
3732
  get resource() { return this.constructor.resource; }
2990
3733
  getDataAdapter() { return this.resource.adapter; }
2991
3734
  getRecordKey() { return null; }
@@ -3051,7 +3794,7 @@ class ResourceFormPage extends Page {
3051
3794
  /** Record creation page. */
3052
3795
  export class CreateRecord extends ResourceFormPage {
3053
3796
  static callable = ['create', 'createAnother'];
3054
- async mount() { await this.beforeFill(); fillSchema(this.getSchema('form'), this.data); await this.afterFill(); }
3797
+ async mount() { await this.beforeFill(); fillSchema(this.getSchema('form'), this.data); await this.afterFill(); this.rememberData(); }
3055
3798
  getOperation() { return 'create'; }
3056
3799
  getSubmitMethod() { return 'create'; }
3057
3800
  getTitle() { return t('filament-panels::resources/pages/create-record.title', { label: this.resource.modelLabel.split(' ').map(ucfirst).join(' ') }); }
@@ -3083,16 +3826,21 @@ export class CreateRecord extends ResourceFormPage {
3083
3826
  if (!(await this.validateForm()))
3084
3827
  return;
3085
3828
  const data = await this.mutateFormDataBeforeCreate(await getSchemaState(this.getSchema('form')));
3086
- await this.beforeCreate(data);
3087
- const record = await this.handleRecordCreation(data);
3088
- await saveSchemaRelationships(this.getSchema('form'), record);
3089
- await this.afterCreate(record);
3829
+ const record = await this.wrapInDatabaseTransaction(async () => {
3830
+ await this.beforeCreate(data);
3831
+ const created = await this.handleRecordCreation(data);
3832
+ await saveSchemaRelationships(this.getSchema('form'), created);
3833
+ await this.afterCreate(created);
3834
+ return created;
3835
+ });
3836
+ this.rememberData();
3090
3837
  const title = this.getCreatedNotificationTitle();
3091
3838
  if (title !== null)
3092
3839
  Notification.make().success().title(title).send();
3093
3840
  if (another === true) {
3094
3841
  this.data = {};
3095
3842
  fillSchema(this.getSchema('form'), this.data);
3843
+ this.rememberData();
3096
3844
  return;
3097
3845
  }
3098
3846
  this.redirect(this.getRedirectUrl(record), true);
@@ -3200,7 +3948,14 @@ export class EditAction {
3200
3948
  .fillForm(({ record }) => ({ ...record }))
3201
3949
  .action(async (ctx) => {
3202
3950
  const adapter = of(ctx.livewire).adapter;
3203
- const saved = await adapter.update(adapter.getKey(ctx.record), ctx.data);
3951
+ const pivotNames = of(ctx.livewire).pivotFieldNames?.() ?? [];
3952
+ let data = ctx.data;
3953
+ if (pivotNames.length) {
3954
+ const pivot = Object.fromEntries(Object.entries(data ?? {}).filter(([name]) => pivotNames.includes(name)));
3955
+ data = Object.fromEntries(Object.entries(data ?? {}).filter(([name]) => !pivotNames.includes(name)));
3956
+ await managerOf(ctx.livewire).updatePivot(ctx.livewire.ownerRecord, adapter.getKey(ctx.record), pivot);
3957
+ }
3958
+ const saved = Object.keys(data ?? {}).length ? await adapter.update(adapter.getKey(ctx.record), data) : ctx.record;
3204
3959
  await saveSchemaRelationships(ctx.schema, saved ?? ctx.record);
3205
3960
  await finishAction(ctx.action, ctx, t('filament-actions::edit.single.notifications.saved.title'));
3206
3961
  });
@@ -3232,10 +3987,12 @@ export class AttachAction {
3232
3987
  const livewire = schema.livewire;
3233
3988
  return [Select.make('recordId').label(t('filament-actions::attach.single.modal.fields.record_id.label')).hiddenLabel().required()
3234
3989
  .getSearchResultsUsing((search) => managerOf(livewire).searchAttachable(livewire.ownerRecord, search))
3235
- .getOptionLabelUsing((value) => managerOf(livewire).attachableLabel(livewire.ownerRecord, value))];
3990
+ .getOptionLabelUsing((value) => managerOf(livewire).attachableLabel(livewire.ownerRecord, value)),
3991
+ ...managerOf(livewire).pivotForm(schema)];
3236
3992
  })
3237
3993
  .action(async ({ livewire, data }) => {
3238
- await managerOf(livewire).attach(livewire.ownerRecord, String(data.recordId));
3994
+ const { recordId, ...pivot } = data ?? {};
3995
+ await managerOf(livewire).attach(livewire.ownerRecord, String(recordId), pivot);
3239
3996
  Notification.make().success().title(t('filament-actions::attach.single.notifications.attached.title')).send();
3240
3997
  });
3241
3998
  }
@@ -3321,17 +4078,25 @@ export class DissociateBulkAction {
3321
4078
  }
3322
4079
  }
3323
4080
  /** Restores a soft-deleted record. Only visible when the record is deleted. */
4081
+ /** Restores a soft-deleted record: the page's record, or a table row. Only visible when the record is deleted. */
3324
4082
  export class RestoreAction {
3325
4083
  static make() {
3326
- return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record))
3327
- .label(t('filament-actions::restore.single.label')).color('gray')
4084
+ return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record ?? livewire._record))
4085
+ .label(t('filament-actions::restore.single.label')).color('gray').tableIcon('c-arrow-uturn-left').groupedIcon('m-arrow-uturn-left')
3328
4086
  .requiresConfirmation().modalIcon('o-arrow-uturn-left')
3329
- .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
3330
- .modalHeading(({ livewire }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
4087
+ .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return !target || !livewire.getDataAdapter().isTrashed(target); })
4088
+ .modalHeading(({ livewire, record }) => t('filament-actions::restore.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
3331
4089
  .modalSubmitActionLabel(t('filament-actions::restore.single.modal.actions.restore.label'))
3332
4090
  .action(async (ctx) => {
3333
- await ctx.livewire.getDataAdapter().restore(ctx.livewire.recordKey);
3334
- await ctx.livewire.hydrate();
4091
+ const { livewire, record } = ctx;
4092
+ const adapter = livewire.getDataAdapter();
4093
+ if (typeof livewire.getTable === 'function') {
4094
+ await adapter.restore(adapter.getKey(record));
4095
+ }
4096
+ else {
4097
+ await adapter.restore(livewire.recordKey);
4098
+ await livewire.hydrate();
4099
+ }
3335
4100
  await finishAction(ctx.action, ctx, t('filament-actions::restore.single.notifications.restored.title'));
3336
4101
  });
3337
4102
  }
@@ -3339,15 +4104,18 @@ export class RestoreAction {
3339
4104
  /** Permanently deletes a soft-deleted record. Only visible when the record is deleted. */
3340
4105
  export class ForceDeleteAction {
3341
4106
  static make() {
3342
- return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record))
3343
- .label(t('filament-actions::force-delete.single.label')).color('danger')
4107
+ return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record ?? livewire._record))
4108
+ .label(t('filament-actions::force-delete.single.label')).color('danger').tableIcon('c-trash').groupedIcon('m-trash')
3344
4109
  .requiresConfirmation().modalIcon('o-trash')
3345
- .hidden(({ livewire }) => !livewire.getDataAdapter().isTrashed(livewire._record))
3346
- .modalHeading(({ livewire }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(livewire._record) }))
4110
+ .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return !target || !livewire.getDataAdapter().isTrashed(target); })
4111
+ .modalHeading(({ livewire, record }) => t('filament-actions::force-delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
3347
4112
  .modalSubmitActionLabel(t('filament-actions::force-delete.single.modal.actions.delete.label'))
3348
4113
  .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());
4114
+ const { livewire, record } = ctx;
4115
+ const adapter = livewire.getDataAdapter();
4116
+ const inTable = typeof livewire.getTable === 'function';
4117
+ await adapter.forceDelete(inTable ? adapter.getKey(record) : livewire.recordKey);
4118
+ await finishAction(ctx.action, ctx, t('filament-actions::force-delete.single.notifications.deleted.title'), inTable ? null : livewire.resource.getUrl());
3351
4119
  });
3352
4120
  }
3353
4121
  }
@@ -3444,21 +4212,66 @@ export class EditRecord extends ResourceFormPage {
3444
4212
  */
3445
4213
  previousUrl = '';
3446
4214
  getCancelUrl() { return this.previousUrl; }
4215
+ /** Relation managers of this page: the resource's by default (`static relationManagers` on the page replaces them). */
4216
+ getRelationManagers() { return this.constructor.relationManagers ?? this.resource.getRelations(); }
4217
+ /** `true`: the form becomes the first tab, next to the relation managers. */
4218
+ hasCombinedRelationManagerTabsWithContent() { return false; }
4219
+ getContentTabLabel() { return t('filament-panels::resources/pages/edit-record.content.tab.label'); }
4220
+ getContentTabIcon() { return null; }
4221
+ withRelationManagers(content) {
4222
+ const components = content.getComponents();
4223
+ if (!this.getRelationManagers().length)
4224
+ return content.components([...components, new RelationManagerTabs().key(null).hidden()]);
4225
+ if (this.hasCombinedRelationManagerTabsWithContent())
4226
+ return content.components([new RelationManagerTabs(components)]);
4227
+ return content.components([...components, new RelationManagerTabs()]);
4228
+ }
3447
4229
  contentSchema(schema) {
3448
- const content = super.contentSchema(schema);
3449
- // Always present: hidden (empty column) when the resource has no relation manager.
3450
- return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
4230
+ // The tabs are always present: hidden (empty column) when the page has no relation manager.
4231
+ return this.withRelationManagers(super.contentSchema(schema));
4232
+ }
4233
+ /**
4234
+ * Saves the fields of one component of the form (a section with its own "Save" action), after validating them only:
4235
+ * `livewire.saveFormComponentOnly('contact')` (the component's `key()`, or the component).
4236
+ */
4237
+ async saveFormComponentOnly(component, notificationTitle = null) {
4238
+ const form = this.getSchema('form');
4239
+ const wanted = typeof component === 'string' ? component : component.getKey();
4240
+ const target = (await form.getVisibleFlatComponents()).find((candidate) => { const key = candidate.getKey(); return key !== null && wanted !== null && (key === wanted || key.endsWith(`.${wanted}`)); });
4241
+ const part = target?.getChildSchemas()[0];
4242
+ if (!part)
4243
+ throw new Error(`saveFormComponentOnly(): no component ${String(wanted)} with fields in the form.`);
4244
+ const errors = await validateSchema(part);
4245
+ if (Object.keys(errors).length) {
4246
+ this._errors = errors;
4247
+ this.dispatch('form-validation-error', { livewireId: this.id });
4248
+ return;
4249
+ }
4250
+ this._errors = {};
4251
+ const data = await this.mutateFormDataBeforeSave(await getSchemaState(part));
4252
+ this._record = await this.wrapInDatabaseTransaction(async () => {
4253
+ const record = await this.handleRecordUpdate(this._record, data);
4254
+ await saveSchemaRelationships(part, record);
4255
+ return record;
4256
+ });
4257
+ const title = notificationTitle ?? this.getSavedNotificationTitle();
4258
+ if (title !== null)
4259
+ Notification.make().success().title(title).send();
3451
4260
  }
3452
4261
  static locked = ['recordKey', 'previousUrl'];
3453
4262
  _record = null;
3454
4263
  async mount(params) {
3455
4264
  this.recordKey = String(params.record ?? '');
3456
4265
  this.previousUrl = previousUrlOf(this._ctx?.req);
4266
+ // Combined tabs: the form's tab is open first.
4267
+ if (this.hasCombinedRelationManagerTabsWithContent())
4268
+ this.activeRelationManager = '-1';
3457
4269
  await this.hydrate();
3458
4270
  await this.beforeFill();
3459
4271
  fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
3460
4272
  await loadSchemaRelationships(this.getSchema('form'), this.data, this._record);
3461
4273
  await this.afterFill();
4274
+ this.rememberData();
3462
4275
  }
3463
4276
  /** The record is reloaded on every request (no route model binding). */
3464
4277
  async hydrate() {
@@ -3513,10 +4326,14 @@ export class EditRecord extends ResourceFormPage {
3513
4326
  if (!(await this.validateForm()))
3514
4327
  return;
3515
4328
  const data = await this.mutateFormDataBeforeSave(await getSchemaState(this.getSchema('form')));
3516
- await this.beforeSave(data);
3517
- this._record = await this.handleRecordUpdate(this._record, data);
3518
- await saveSchemaRelationships(this.getSchema('form'), this._record);
3519
- await this.afterSave(this._record);
4329
+ this._record = await this.wrapInDatabaseTransaction(async () => {
4330
+ await this.beforeSave(data);
4331
+ const record = await this.handleRecordUpdate(this._record, data);
4332
+ await saveSchemaRelationships(this.getSchema('form'), record);
4333
+ await this.afterSave(record);
4334
+ return record;
4335
+ });
4336
+ this.rememberData();
3520
4337
  const title = this.getSavedNotificationTitle();
3521
4338
  if (title !== null)
3522
4339
  Notification.make().success().title(title).send();
@@ -3537,6 +4354,8 @@ export class ViewRecord extends Page {
3537
4354
  getDataAdapter() { return this.resource.adapter; }
3538
4355
  async mount(params) {
3539
4356
  this.recordKey = String(params.record ?? '');
4357
+ if (this.hasCombinedRelationManagerTabsWithContent())
4358
+ this.activeRelationManager = '-1';
3540
4359
  await this.hydrate();
3541
4360
  }
3542
4361
  /** The record is reloaded on every request, as on the edit page. */
@@ -3551,10 +4370,22 @@ export class ViewRecord extends Page {
3551
4370
  getActionMountContext() { return { recordKey: this.recordKey }; }
3552
4371
  getActionRecord() { return this._record; }
3553
4372
  infolistSchema(schema) { return this.resource.infolist(schema.columns(2).operation('view').record(this._record)); }
4373
+ /** Relation managers of this page: the resource's by default (`static relationManagers` on the page replaces them). */
4374
+ getRelationManagers() { return this.constructor.relationManagers ?? this.resource.getRelations(); }
4375
+ /** `true`: the infolist becomes the first tab, next to the relation managers. */
4376
+ hasCombinedRelationManagerTabsWithContent() { return false; }
4377
+ getContentTabLabel() { return t('filament-panels::resources/pages/view-record.content.tab.label'); }
4378
+ getContentTabIcon() { return null; }
4379
+ withRelationManagers(content) {
4380
+ const components = content.getComponents();
4381
+ if (!this.getRelationManagers().length)
4382
+ return content.components([...components, new RelationManagerTabs().key(null).hidden()]);
4383
+ if (this.hasCombinedRelationManagerTabsWithContent())
4384
+ return content.components([new RelationManagerTabs(components)]);
4385
+ return content.components([...components, new RelationManagerTabs()]);
4386
+ }
3554
4387
  contentSchema(schema) {
3555
- const content = schema.components([EmbeddedSchema.make('infolist')]);
3556
- // Always present: hidden (empty column) when the resource has no relation manager.
3557
- return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
4388
+ return this.withRelationManagers(schema.components([EmbeddedSchema.make('infolist')]));
3558
4389
  }
3559
4390
  /** Defaults to a link to the edit page. */
3560
4391
  getHeaderActions() { return [this.editAction()]; }
@@ -3599,7 +4430,40 @@ async function renderUserMenu(component, position) {
3599
4430
  hasThemeSwitcher: panel.hasDarkMode() && !panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
3600
4431
  });
3601
4432
  }
3602
- /** Tenant menu: current tenant, other tenants, registration, profile. */
4433
+ /**
4434
+ * Answers with the panel's error page for a status (404, 403, 500…): the cable car in trouble, in the request's theme.
4435
+ * Requests that do not accept HTML get plain text. Use it in your own middleware, a maintenance mode answering 503 for example.
4436
+ */
4437
+ export function renderErrorPage(panel, req, res, status, options = {}) {
4438
+ res.status(status);
4439
+ if (!req.accepts('html'))
4440
+ return void res.type('text').send(`${status}`);
4441
+ const scene = errorPageScene(status);
4442
+ const text = (name) => t(`filament-panels::errors.${scene}.${name}`);
4443
+ // Inside the request's theme: the buttons' color classes depend on its palette.
4444
+ const html = panel.withTheme(req, () => {
4445
+ const home = uiButton({ tag: 'a', href: panel.getBaseUrl(), icon: 'm-home', color: 'primary' }, t('filament-panels::errors.actions.home'));
4446
+ const homeGray = uiButton({ tag: 'a', href: panel.getBaseUrl(), icon: 'm-home', color: 'gray' }, t('filament-panels::errors.actions.home'));
4447
+ const back = uiButton({ icon: 'm-arrow-left', color: 'gray', attributes: { onclick: 'history.back()' } }, t('filament-panels::errors.actions.back'));
4448
+ const retry = uiButton({ tag: 'a', href: req.method === 'GET' ? req.originalUrl : panel.getBaseUrl(), icon: 'm-arrow-path', color: 'primary' }, t('filament-panels::errors.actions.retry'));
4449
+ const login = panel.getLoginPage() ? uiButton({ tag: 'a', href: panel.getLoginUrl(), icon: 'm-arrow-right-end-on-rectangle', color: 'primary' }, t('filament-panels::errors.actions.login')) : home;
4450
+ const actions = scene === 401 ? (panel.getLoginPage() ? [login, homeGray] : [home]) : scene === 403 || scene === 404 ? [home, back] : scene === 503 ? [retry] : [retry, homeGray];
4451
+ const badgeColor = scene >= 500 ? 'danger' : scene === 419 || scene === 429 ? 'warning' : 'gray';
4452
+ const label = t('filament-panels::errors.label', { code: status });
4453
+ const error = options.error instanceof Error ? options.error : null;
4454
+ const theme = panel.resolveTheme(req);
4455
+ return views.render('filament-panels::errors.page', {
4456
+ status, locale: getLocale(), title: text('title'), heading: text('title'), description: options.description ?? text('description'),
4457
+ illustration: errorIllustration(status, `${label}: ${text('title')}`), badge: uiBadge({ color: badgeColor, size: 'lg' }, e(label)), actions, css: ERROR_PAGE_CSS,
4458
+ // The stack only helps while developing: never shown otherwise.
4459
+ details: error && process.env.NODE_ENV === 'development' ? (error.stack ?? String(error)) : null,
4460
+ brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), panelId: panel.getId(),
4461
+ font: theme?.font ? { ...panel.getFont(), ...fontOf(theme.font) } : panel.getFont(), themeCss: theme?.css ?? '', colorVariables: panel.colors.renderCssVariables(),
4462
+ darkMode: panel.hasDarkMode(), darkModeForced: panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
4463
+ });
4464
+ });
4465
+ res.type('html').send(html);
4466
+ }
3603
4467
  async function renderTenantMenu(component, options) {
3604
4468
  const { panel, user } = component._ctx;
3605
4469
  const tenant = panel.getTenant();
@@ -3612,6 +4476,9 @@ async function renderTenantMenu(component, options) {
3612
4476
  const ProfilePage = panel.getTenantProfilePage();
3613
4477
  if (ProfilePage && provider.update && !custom.profile)
3614
4478
  items.push(Action.make('profile').label(ProfilePage.getLabel()).icon('m-cog-6-tooth').url(panel.getUrl(ProfilePage.slug)).color('gray').sort(-1));
4479
+ const billingUrl = panel.getTenantBillingUrl(tenant);
4480
+ if (billingUrl && !custom.billing)
4481
+ items.push(Action.make('billing').label(t('filament-panels::layout.actions.billing.label')).icon('m-credit-card').url(billingUrl).color('gray').sort(-1));
3615
4482
  const RegistrationPage = panel.getTenantRegistrationPage();
3616
4483
  if (RegistrationPage && !custom.register && (await RegistrationPage.canView(panel, user)))
3617
4484
  items.push(Action.make('register').label(RegistrationPage.getLabel()).icon('m-plus').url(panel.getBaseUrl(RegistrationPage.slug)).color('gray').sort(Number.MAX_SAFE_INTEGER));
@@ -3630,6 +4497,7 @@ async function renderTenantMenu(component, options) {
3630
4497
  itemsBefore: canSwitch ? await html(visible.filter((item) => item.getSort() < 0)) : [],
3631
4498
  itemsAfter: await html(canSwitch ? visible.filter((item) => item.getSort() >= 0) : visible),
3632
4499
  switchable: others.map((other) => ({ name: provider.getName(other), url: panel.getUrl('', other), avatarUrl: panel.getTenantAvatarUrl(other) })),
4500
+ isSearchable: canSwitch && (panel.isTenantMenuSearchable() ?? others.length >= 10),
3633
4501
  });
3634
4502
  }
3635
4503
  /** Sidebar: logo, tenant menu, search, navigation and, without a topbar, the bell and user menu. */
@@ -3641,9 +4509,9 @@ export class Sidebar extends Component {
3641
4509
  /** A resource item stays active on its create and edit pages. */
3642
4510
  async render() {
3643
4511
  const { panel, user } = this._ctx;
3644
- if (!user)
4512
+ if (!user && !panel.allowsGuests())
3645
4513
  return '<div></div>';
3646
- const inSidebar = (position) => position === 'sidebar';
4514
+ const inSidebar = (position) => position === 'sidebar' && user !== null;
3647
4515
  return views.render('filament-panels::livewire.sidebar', {
3648
4516
  hasTopbar: panel.hasTopbar(), hasTopNavigation: panel.hasTopNavigation(), spa: panel.hasSpaMode(),
3649
4517
  isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
@@ -3678,9 +4546,9 @@ export class Topbar extends Component {
3678
4546
  mount(params) { this.currentPath = String(params.path ?? '/'); }
3679
4547
  async render() {
3680
4548
  const { panel, user } = this._ctx;
3681
- if (!user)
4549
+ if (!user && !panel.allowsGuests())
3682
4550
  return '<div></div>';
3683
- const inTopbar = (position) => position === 'topbar';
4551
+ const inTopbar = (position) => position === 'topbar' && user !== null;
3684
4552
  const hasNavigation = panel.hasNavigation();
3685
4553
  const showsNavigationHere = panel.hasTopNavigation() || !hasNavigation;
3686
4554
  const tenant = panel.getTenant();
@@ -3689,7 +4557,7 @@ export class Topbar extends Component {
3689
4557
  hasNavigation, spa: panel.hasSpaMode(), isSidebarCollapsibleOnDesktop: panel.isSidebarCollapsibleOnDesktop(), isSidebarFullyCollapsibleOnDesktop: panel.isSidebarFullyCollapsibleOnDesktop(),
3690
4558
  homeUrl: panel.getUrl(), brand: panel.getBrand(), livewireId: this.id,
3691
4559
  tenantMenu: showsNavigationHere ? (await renderTenantMenu(this, { teleport: true })) || null : null,
3692
- topNavigation: groups?.map((group) => ({ ...group, items: group.items.map((item) => ({ ...item, colorClasses: item.isActive ? sharedColors.getComponentClasses('dropdown-item', 'primary') : [] })) })) ?? null,
4560
+ topNavigation: groups?.map((group) => topNavigationGroup(group)) ?? null,
3693
4561
  persistKey: panel.hasTenancy() ? `topbar.end.panel-${panel.getId()}.tenant-${tenant ? panel.getTenantUrlKey(tenant) : ''}` : `topbar.end.panel-${panel.getId()}`,
3694
4562
  globalSearch: panel.hasGlobalSearch() && inTopbar(panel.getGlobalSearchPosition()) ? await this.mountChild(`navette.${panel.getId()}.global-search`, {}, `${this.id}.global-search`) : null,
3695
4563
  databaseNotifications: panel.getDatabaseNotifications() && inTopbar(panel.getDatabaseNotificationsPosition()) ? await this.mountChild(`navette.${panel.getId()}.database-notifications`, {}, `${this.id}.database-notifications`, { lazy: true }) : null,
@@ -3758,7 +4626,7 @@ export async function discoverPanels(directory) {
3758
4626
  /** A runtime serves a single panel: each panel has its own Livewire endpoint and components. */
3759
4627
  const runtimePanels = new WeakMap();
3760
4628
  /** Reserved segments right after a multi-tenant panel's path (they are not tenant keys). */
3761
- const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
4629
+ const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'multi-factor-authentication', 'profile', 'livewire']);
3762
4630
  /** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
3763
4631
  /** Panels sharing an auth guard: same key and same auth provider, otherwise a user id of one could be read as a user of the other. */
3764
4632
  const guardOwners = new Map();
@@ -3790,6 +4658,8 @@ export function panelRouter(panel, runtime) {
3790
4658
  useFormsColorManager(sharedColors);
3791
4659
  useTableColorManager(sharedColors);
3792
4660
  useInfolistsColorManager(sharedColors);
4661
+ // View components (<x-badge>, <x-button>…) use the same colors.
4662
+ useUiColorManager(sharedColors);
3793
4663
  // HTML rendered by an infolist entry is sanitized through the rich editor schema, which only keeps its own tags.
3794
4664
  useHtmlSanitizer((html) => richDocumentToHtml(richHtmlToDocument(html)) ?? '');
3795
4665
  views.share({ assetUrl: (file) => panel.assetUrl(file) });
@@ -3818,34 +4688,27 @@ export function panelRouter(panel, runtime) {
3818
4688
  const denied = user !== null && !(await panel.userCanAccess(user));
3819
4689
  return { req, res, panel, user: denied ? null : user, denied, mailbox: csrfToken(req, res), tenant: null };
3820
4690
  };
3821
- const forbidden = (res) => void res.status(403).send('Forbidden');
3822
- /** Tenant designated by a panel URL, if the user is a member. */
3823
- const tenantFromPath = async (path, user) => {
4691
+ const forbidden = (req, res) => renderErrorPage(panel, req, res, 403);
4692
+ /** Tenant designated by a panel URL (or its host, with `tenantDomain()`), if the user is a member. */
4693
+ const tenantFromPath = async (path, user, hostname = '') => {
3824
4694
  if (!panel.hasTenancy() || !user)
3825
4695
  return null;
3826
- const base = panel.getBaseUrl();
3827
- const prefix = base === '/' ? '/' : `${base}/`;
3828
- if (!path.startsWith(prefix))
3829
- return null;
3830
- let key;
3831
- try {
3832
- key = decodeURIComponent(path.slice(prefix.length).split('/')[0] ?? '');
3833
- }
3834
- catch {
3835
- return null;
3836
- }
3837
- const registration = panel.getTenantRegistrationPage();
3838
- if (!key || panel.getReservedSegments().has(key) || key === registration?.slug || key.length > 200)
4696
+ const key = panel.getTenantKeyFromRequest(path, hostname);
4697
+ if (key === null)
3839
4698
  return null;
3840
4699
  const provider = panel.getTenantProvider();
3841
4700
  const tenant = await provider.find(key);
3842
4701
  return tenant && (await provider.canAccess(user, tenant)) ? tenant : null;
3843
4702
  };
3844
- /** User's home: the panel root or, in a multi-tenant panel, their first tenant's root (otherwise tenant registration). */
4703
+ /** User's home: the panel root or, in a multi-tenant panel, their default tenant's root, their first one's, or tenant registration. */
3845
4704
  const homeUrl = async (user) => {
3846
4705
  if (!panel.hasTenancy())
3847
4706
  return panel.getUrl();
3848
- const [first] = await panel.getTenantProvider().getTenants(user);
4707
+ const provider = panel.getTenantProvider();
4708
+ const preferred = (await provider.getDefaultTenant?.(user)) ?? null;
4709
+ if (preferred && (await provider.canAccess(user, preferred)))
4710
+ return panel.getUrl('', preferred);
4711
+ const [first] = await provider.getTenants(user);
3849
4712
  if (first)
3850
4713
  return panel.getUrl('', first);
3851
4714
  const registration = panel.getTenantRegistrationPage();
@@ -3868,6 +4731,9 @@ export function panelRouter(panel, runtime) {
3868
4731
  const profile = panel.getProfile();
3869
4732
  if (profile)
3870
4733
  runtime.register(name('auth.profile'), profile.page);
4734
+ const MultiFactorSetUpPage = panel.getSetUpRequiredMultiFactorAuthenticationPage();
4735
+ if (MultiFactorSetUpPage)
4736
+ runtime.register(name('auth.multi-factor-authentication.set-up-required'), MultiFactorSetUpPage);
3871
4737
  const TenantRegistration = panel.getTenantRegistrationPage();
3872
4738
  if (TenantRegistration)
3873
4739
  runtime.register(name('tenancy.registration'), TenantRegistration);
@@ -3885,12 +4751,31 @@ export function panelRouter(panel, runtime) {
3885
4751
  for (const resource of panel.getResources())
3886
4752
  for (const [index, widget] of resource.widgets.entries())
3887
4753
  runtime.register(name(`widgets.${resource.slug.replace(/\//g, '.')}.${index}`), widget);
3888
- for (const resource of panel.getResources())
3889
- for (const manager of resource.relations)
4754
+ for (const resource of panel.getResources()) {
4755
+ const managers = new Set();
4756
+ const add = (entries) => { for (const entry of entries ?? [])
4757
+ for (const manager of entry instanceof RelationGroup ? entry.getManagers() : [entry])
4758
+ managers.add(manager); };
4759
+ add(resource.getRelations());
4760
+ for (const page of Object.values(resource.getPages()))
4761
+ add(page.relationManagers);
4762
+ for (const manager of managers)
3890
4763
  runtime.register(name(`relation-managers.${resource.slug.replace(/\//g, '.')}.${manager.relationship}`), manager);
4764
+ }
4765
+ // A panel on its own domains: other hosts go to the next routes (another panel, the application).
4766
+ const domains = panel.getDomains();
4767
+ if (domains.length)
4768
+ router.use((req, _res, next) => next(domains.includes(req.hostname) ? undefined : 'router'));
3891
4769
  router.use(express.static(dir('../dist/filament'), { maxAge: '1y' }));
3892
4770
  // Navette's own front-end files.
3893
4771
  router.use(express.static(dir('../dist/navette')));
4772
+ // `bootUsing()`: before every request of the panel (its pages, their updates).
4773
+ const panelBase = panel.getBaseUrl();
4774
+ const belongsToPanel = (path) => panelBase === '/' || path === panelBase || path.startsWith(`${panelBase}/`) || path.startsWith(`${livewire}/`);
4775
+ router.use((req, _res, next) => { if (!belongsToPanel(req.path))
4776
+ return next(); panel.boot(req).then(() => next(), next); });
4777
+ // SPA prefetching: links load their page as soon as they are hovered.
4778
+ const prefetch = (html) => (panel.hasSpaPrefetching() ? html.replace(/ wire:navigate(?=[\s>/])/g, ' wire:navigate.hover') : html);
3894
4779
  // An update's tenant is the one of the page sending it (from the signed snapshot URL), if the user is a member.
3895
4780
  const wireContext = async (req, res) => {
3896
4781
  const ctx = await context(req, res);
@@ -3901,12 +4786,21 @@ export function panelRouter(panel, runtime) {
3901
4786
  catch {
3902
4787
  path = '';
3903
4788
  }
3904
- ctx.tenant = await tenantFromPath(path, ctx.user);
4789
+ ctx.tenant = await tenantFromPath(path, ctx.user, req.hostname);
3905
4790
  return ctx;
3906
4791
  };
3907
4792
  // Notifications sent during the request go into a batch announced to the browser.
3908
4793
  router.use(wireRouter(runtime, livewire, wireContext, async (ctx, _req, res, handle) => {
3909
4794
  const { result, pending } = await runInRequest(ctx.mailbox, () => panel.withTheme(ctx.req, () => panel.withTenant(ctx.tenant ?? null, handle)));
4795
+ if (panel.hasSpaPrefetching()) {
4796
+ for (const component of result.components) {
4797
+ if (typeof component.effects.html === 'string')
4798
+ component.effects.html = prefetch(component.effects.html);
4799
+ for (const [key, html] of Object.entries(component.effects.partials ?? {}))
4800
+ if (typeof html === 'string')
4801
+ component.effects.partials[key] = prefetch(html);
4802
+ }
4803
+ }
3910
4804
  if (pending.length) {
3911
4805
  const batch = publish(ctx.mailbox, pending);
3912
4806
  result.filamentNotificationBatch = batch;
@@ -3947,12 +4841,12 @@ export function panelRouter(panel, runtime) {
3947
4841
  sidebar: hasNavigation ? (await runtime.mount(name('sidebar'), { path: req.path }, request)).replace(/^s*<div /, '<div wire:key="lw-1-1-1" ') : '',
3948
4842
  });
3949
4843
  const bodyClass = options.layout === 'index' ? cls([{ 'fi-body-has-navigation': hasNavigation, 'fi-body-has-sidebar-collapsible-on-desktop': panel.isSidebarCollapsibleOnDesktop(), 'fi-body-has-sidebar-fully-collapsible-on-desktop': panel.isSidebarFullyCollapsibleOnDesktop(), 'fi-body-has-topbar': panel.hasTopbar(), 'fi-body-has-top-navigation': panel.hasTopNavigation() }]) || null : null;
3950
- res.send(views.render('filament-panels::layout.base', {
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 ?? '',
4844
+ res.send(prefetch(views.render('filament-panels::layout.base', {
4845
+ renderHookScopes, title: options.title, brandName: panel.getBrandName(), favicon: panel.getFavicon(), theme: panel.getTheme(), motion: panel.hasMotion(), securityKeys: panel.usesSecurityKeys(), panelId: panel.getId(), font: panel.resolveTheme(req)?.font ? { ...panel.getFont(), ...fontOf(panel.resolveTheme(req).font) } : panel.getFont(), themeCss: panel.resolveTheme(req)?.css ?? '',
3952
4846
  bodyClass, body, spa: panel.hasSpaMode(), darkMode: panel.hasDarkMode(), darkModeForced: panel.hasDarkModeForced(), defaultThemeMode: panel.getDefaultThemeMode(),
3953
4847
  csrf: csrfToken(req, res), colorVariables: panel.colors.renderCssVariables(), wireStyles: wireStyles(), wireScripts: wireScripts(req, res, livewire),
3954
4848
  notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
3955
- }));
4849
+ })));
3956
4850
  };
3957
4851
  /** Page access errors: 404 and 403 are answered directly, anything else is rethrown. */
3958
4852
  const guarded = async (res, render) => {
@@ -3960,10 +4854,8 @@ export function panelRouter(panel, runtime) {
3960
4854
  await render();
3961
4855
  }
3962
4856
  catch (error) {
3963
- if (error instanceof WireError && error.status === 404)
3964
- return void res.status(404).send('Not Found');
3965
- if (error instanceof WireError && error.status === 403)
3966
- return void res.status(403).send('Forbidden');
4857
+ if (error instanceof WireError && (error.status === 404 || error.status === 403))
4858
+ return renderErrorPage(panel, res.req, res, error.status);
3967
4859
  throw error;
3968
4860
  }
3969
4861
  };
@@ -3972,20 +4864,26 @@ export function panelRouter(panel, runtime) {
3972
4864
  router.get(panel.getBaseUrl(path), async (req, res) => {
3973
4865
  const ctx = await context(req, res);
3974
4866
  if (ctx.denied)
3975
- return forbidden(res);
4867
+ return forbidden(req, res);
3976
4868
  if (ctx.user)
3977
4869
  return res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
3978
4870
  await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
3979
4871
  });
3980
4872
  };
3981
4873
  /** Simple page for a signed-in user, outside any tenant (profile, email verification, tenant registration). */
3982
- const userPage = (path, component, title, layout = 'simple') => {
4874
+ const userPage = (path, component, title, layout = 'simple', options = {}) => {
3983
4875
  router.get(panel.getBaseUrl(path), async (req, res) => {
3984
4876
  const ctx = await context(req, res);
3985
4877
  if (ctx.denied)
3986
- return forbidden(res);
4878
+ return forbidden(req, res);
3987
4879
  if (!ctx.user)
3988
4880
  return res.redirect(panel.getLoginUrl());
4881
+ // Required multi-factor authentication: nothing but its setup page (and email verification) until it is set up.
4882
+ if (!options.beforeMultiFactorSetUp && (await panel.mustSetUpMultiFactorAuthentication(ctx.user)))
4883
+ return res.redirect(panel.getSetUpRequiredMultiFactorAuthenticationUrl());
4884
+ // Full layout in a multi-tenant panel: the navigation links to the user's first tenant.
4885
+ if (layout === 'index' && panel.hasTenancy())
4886
+ ctx.tenant = (await panel.getTenantProvider().getTenants(ctx.user))[0] ?? null;
3989
4887
  await guarded(res, () => document(req, res, ctx, { title, component, layout }));
3990
4888
  });
3991
4889
  };
@@ -3993,10 +4891,10 @@ export function panelRouter(panel, runtime) {
3993
4891
  router.get(panel.getBaseUrl('downloads/:token'), async (req, res) => {
3994
4892
  const ctx = await context(req, res);
3995
4893
  if (!ctx.user || ctx.denied)
3996
- return forbidden(res);
4894
+ return forbidden(req, res);
3997
4895
  const file = panel.getDownloads().get(String(req.params.token), ctx.user.id);
3998
4896
  if (!file)
3999
- return void res.status(404).send('Not Found');
4897
+ return renderErrorPage(panel, req, res, 404);
4000
4898
  res.setHeader('Content-Type', file.type);
4001
4899
  res.setHeader('Content-Disposition', `attachment; filename="${file.name.replace(/[^A-Za-z0-9._-]/g, '_')}"`);
4002
4900
  res.setHeader('X-Content-Type-Options', 'nosniff');
@@ -4007,7 +4905,7 @@ export function panelRouter(panel, runtime) {
4007
4905
  guestPage(panel.getAuthPaths().login, name('auth.login'), t('filament-panels::auth/pages/login.title'));
4008
4906
  router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
4009
4907
  if (!verifyCsrf(req, req.body?._token))
4010
- return res.status(419).send('Invalid CSRF token.');
4908
+ return renderErrorPage(panel, req, res, 419);
4011
4909
  panel.logOut(res);
4012
4910
  res.redirect(panel.getLoginUrl());
4013
4911
  });
@@ -4019,34 +4917,86 @@ export function panelRouter(panel, runtime) {
4019
4917
  guestPage(panel.getAuthPaths().passwordReset, name('auth.password-reset.reset'), t('filament-panels::auth/pages/password-reset/reset-password.title'), (req) => ({ email: typeof req.query.email === 'string' ? req.query.email : null, token: typeof req.query.token === 'string' ? req.query.token : null }));
4020
4918
  }
4021
4919
  if (VerificationPrompt) {
4022
- userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
4920
+ userPage(panel.getAuthPaths().emailVerificationPrompt, name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'), 'simple', { beforeMultiFactorSetUp: true });
4023
4921
  // Emailed link: signed, time-limited, and only valid for the signed-in user it designates.
4024
- router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
4922
+ router.get(panel.getBaseUrl(`${panel.getAuthPaths().emailVerification}/:id/:hash`), async (req, res) => {
4025
4923
  const ctx = await context(req, res);
4026
4924
  if (ctx.denied)
4027
- return forbidden(res);
4925
+ return forbidden(req, res);
4028
4926
  if (!ctx.user)
4029
4927
  return res.redirect(panel.getLoginUrl());
4030
4928
  const id = String(req.params.id), hash = String(req.params.hash);
4031
4929
  const expected = createHash('sha1').update(ctx.user.email).digest('hex');
4032
4930
  if (String(ctx.user.id) !== id || hash !== expected || !panel.checkVerifyEmailUrl(id, hash, req.query.expires, req.query.signature))
4033
- return void res.status(403).send('Invalid signature.');
4931
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
4034
4932
  await panel.getAuth().markEmailAsVerified?.(ctx.user);
4035
4933
  res.redirect((await homeUrl(ctx.user)) ?? panel.getBaseUrl());
4036
4934
  });
4037
4935
  }
4038
4936
  if (profile)
4039
- userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple || panel.hasTenancy() ? 'simple' : 'index');
4937
+ userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple ? 'simple' : 'index');
4938
+ if (panel.hasEmailChangeVerification()) {
4939
+ const flash = async (ctx, build) => { const { pending } = await runInRequest(ctx.mailbox, async () => build()); if (pending.length)
4940
+ publish(ctx.mailbox, pending); };
4941
+ const group = 'filament-panels::auth/http/controllers';
4942
+ // Link sent to the new address: only for the signed-in user who asked, while the old address is still theirs.
4943
+ router.get(panel.getBaseUrl(panel.getAuthPaths().emailChangeVerification), async (req, res) => {
4944
+ const ctx = await context(req, res);
4945
+ if (ctx.denied)
4946
+ return forbidden(req, res);
4947
+ if (!ctx.user)
4948
+ return res.redirect(panel.getLoginUrl());
4949
+ const payload = panel.readEmailChangeToken('verify', req.query.token);
4950
+ if (!payload || String(payload.uid) !== String(ctx.user.id) || payload.from !== fingerprint(ctx.user.email))
4951
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
4952
+ const store = getDefaultCacheStore();
4953
+ if (await store.get(`navette:email-change:blocked:${payload.nonce}`))
4954
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.email_change_blocked') });
4955
+ const auth = panel.getAuth();
4956
+ const other = await auth.findByEmail?.(payload.email);
4957
+ if (other && String(other.id) !== String(ctx.user.id)) {
4958
+ await flash(ctx, () => Notification.make().danger().title(t(`${group}/email-change-verification-controller.notifications.unavailable.title`)).body(t(`${group}/email-change-verification-controller.notifications.unavailable.body`)).send());
4959
+ }
4960
+ else {
4961
+ const updated = await auth.update(ctx.user, { email: payload.email });
4962
+ // The new address was just proven by following the link.
4963
+ await auth.markEmailAsVerified?.(updated);
4964
+ await flash(ctx, () => Notification.make().success().title(t(`${group}/email-change-verification-controller.notifications.verified.title`)).body(t(`${group}/email-change-verification-controller.notifications.verified.body`, { email: payload.email })).send());
4965
+ }
4966
+ res.redirect(panel.getProfileUrl() ?? panel.getBaseUrl());
4967
+ });
4968
+ // Link sent to the old address: works without signing in, until the change is confirmed.
4969
+ router.get(panel.getBaseUrl(panel.getAuthPaths().blockEmailChange), async (req, res) => {
4970
+ const ctx = await context(req, res);
4971
+ const payload = panel.readEmailChangeToken('block', req.query.token);
4972
+ if (!payload)
4973
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
4974
+ const user = await panel.getAuth().find(payload.uid);
4975
+ if (!user)
4976
+ return renderErrorPage(panel, req, res, 403, { description: t('filament-panels::errors.403.invalid_link') });
4977
+ const changed = user.email === payload.email;
4978
+ if (!changed)
4979
+ await getDefaultCacheStore().put(`navette:email-change:blocked:${payload.nonce}`, true, 60 * 60 + 60);
4980
+ await flash(ctx, () => (changed
4981
+ ? Notification.make().danger().persistent().title(t(`${group}/block-email-change-verification-controller.notifications.failed.title`)).body(t(`${group}/block-email-change-verification-controller.notifications.failed.body`, { email: payload.email }))
4982
+ : Notification.make().success().persistent().title(t(`${group}/block-email-change-verification-controller.notifications.blocked.title`)).body(t(`${group}/block-email-change-verification-controller.notifications.blocked.body`, { email: payload.email }))).send());
4983
+ res.redirect(ctx.user ? (panel.getProfileUrl() ?? panel.getBaseUrl()) : panel.getLoginUrl());
4984
+ });
4985
+ }
4040
4986
  if (TenantRegistration)
4041
4987
  userPage(TenantRegistration.slug, name('tenancy.registration'), TenantRegistration.getLabel());
4988
+ if (MultiFactorSetUpPage)
4989
+ userPage(panel.getAuthPaths().setUpRequiredMultiFactor, name('auth.multi-factor-authentication.set-up-required'), t('filament-panels::auth/multi-factor/pages/set-up-required-multi-factor-authentication.title'), 'simple', { beforeMultiFactorSetUp: true });
4042
4990
  /** Context of a protected page: login, email verification, tenant. Returns `null` after responding. */
4043
4991
  const pageContext = async (req, res) => {
4044
4992
  const ctx = await context(req, res);
4045
4993
  if (ctx.denied) {
4046
- forbidden(res);
4994
+ forbidden(req, res);
4047
4995
  return null;
4048
4996
  }
4049
4997
  if (!ctx.user) {
4998
+ if (panel.allowsGuests())
4999
+ return ctx;
4050
5000
  res.redirect(panel.getLoginUrl());
4051
5001
  return null;
4052
5002
  }
@@ -4054,64 +5004,125 @@ export function panelRouter(panel, runtime) {
4054
5004
  res.redirect(panel.getEmailVerificationPromptUrl());
4055
5005
  return null;
4056
5006
  }
5007
+ if (await panel.mustSetUpMultiFactorAuthentication(ctx.user)) {
5008
+ res.redirect(panel.getSetUpRequiredMultiFactorAuthenticationUrl());
5009
+ return null;
5010
+ }
4057
5011
  if (panel.hasTenancy()) {
4058
- ctx.tenant = await tenantFromPath(req.path, ctx.user);
5012
+ ctx.tenant = await tenantFromPath(req.path, ctx.user, req.hostname);
4059
5013
  if (!ctx.tenant) {
4060
- res.status(404).send('Not Found');
5014
+ renderErrorPage(panel, req, res, 404);
4061
5015
  return null;
4062
5016
  }
4063
5017
  }
4064
5018
  return ctx;
4065
5019
  };
4066
- const tenantSegment = panel.hasTenancy() ? ':tenant/' : '';
4067
- if (panel.hasTenancy()) {
4068
- // Panel root: the user's first tenant, otherwise tenant registration.
5020
+ // The tenant's segment in page routes: none on tenant domains, `<prefix>/:tenant/` with a route prefix.
5021
+ const tenantRoot = !panel.hasTenancy() || panel.getTenantDomain() ? '' : [panel.getTenantRoutePrefix(), ':tenant'].filter(Boolean).join('/');
5022
+ const tenantSegment = tenantRoot ? `${tenantRoot}/` : '';
5023
+ /** Whether a `:tenant` route parameter is really another route's segment (`login`…), without a route prefix. */
5024
+ const notATenant = (req) => panel.hasTenancy() && tenantRoot !== '' && panel.getTenantRoutePrefix() === null && (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug);
5025
+ /** A tenant page whose tenant has no subscription goes to the billing page. */
5026
+ const requiresSubscription = async (PageClass, ctx) => Boolean(ctx.tenant) && PageClass.isTenantSubscriptionRequired(panel) && !(await panel.getTenantBillingProvider().isSubscribed(ctx.tenant));
5027
+ if (!panel.hasTenancy() && !panel.getPages().some((page) => page.slug === '')) {
5028
+ // Panel root without a dashboard: the first menu page, as in Filament.
4069
5029
  router.get(panel.getBaseUrl(), async (req, res) => {
5030
+ const ctx = await pageContext(req, res);
5031
+ if (!ctx)
5032
+ return;
5033
+ const url = panel.getNavigation(req.path, new Map(), await panel.resolveInaccessiblePages(ctx.user))[0]?.items[0]?.url ?? null;
5034
+ if (!url)
5035
+ return renderErrorPage(panel, req, res, 404);
5036
+ res.redirect(url);
5037
+ });
5038
+ }
5039
+ if (panel.hasTenancy()) {
5040
+ // Panel root: the user's first tenant, otherwise tenant registration (on a tenant's own host: its pages).
5041
+ router.get(panel.getBaseUrl(), async (req, res, next) => {
5042
+ if (panel.getTenantDomain() && panel.getTenantKeyFromRequest(req.path, req.hostname) !== null)
5043
+ return next();
4070
5044
  const ctx = await context(req, res);
4071
5045
  if (ctx.denied)
4072
- return forbidden(res);
5046
+ return forbidden(req, res);
4073
5047
  if (!ctx.user)
4074
5048
  return res.redirect(panel.getLoginUrl());
4075
5049
  if (await panel.mustVerifyEmail(ctx.user))
4076
5050
  return res.redirect(panel.getEmailVerificationPromptUrl());
5051
+ if (await panel.mustSetUpMultiFactorAuthentication(ctx.user))
5052
+ return res.redirect(panel.getSetUpRequiredMultiFactorAuthenticationUrl());
4077
5053
  const url = await homeUrl(ctx.user);
4078
5054
  if (!url)
4079
- return void res.status(404).send('Not Found');
5055
+ return renderErrorPage(panel, req, res, 404);
4080
5056
  res.redirect(url);
4081
5057
  });
4082
5058
  // Tenant root without a dashboard: the first menu page.
4083
5059
  if (!panel.getPages().some((page) => page.slug === '')) {
4084
- router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
4085
- if (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
5060
+ router.get(panel.getBaseUrl(tenantRoot), async (req, res, next) => {
5061
+ if (notATenant(req))
4086
5062
  return next();
4087
5063
  const ctx = await pageContext(req, res);
4088
5064
  if (!ctx)
4089
5065
  return;
4090
5066
  const url = await panel.withTenant(ctx.tenant, async () => panel.getNavigation(req.path, new Map(), await panel.resolveInaccessiblePages(ctx.user))[0]?.items[0]?.url ?? null);
4091
5067
  if (!url)
4092
- return void res.status(404).send('Not Found');
5068
+ return renderErrorPage(panel, req, res, 404);
4093
5069
  res.redirect(url);
4094
5070
  });
4095
5071
  }
4096
5072
  }
5073
+ // Billing page of a tenant, served by the billing provider.
5074
+ const billing = panel.getTenantBillingProvider();
5075
+ if (panel.hasTenancy() && billing) {
5076
+ if (panel.isTenantSubscriptionRequired() && typeof billing.isSubscribed !== 'function')
5077
+ throw new Error('requiresTenantSubscription(): the billing provider must implement isSubscribed().');
5078
+ router.get(panel.getBaseUrl(tenantSegment + panel.getTenantBillingRouteSlug()), async (req, res, next) => {
5079
+ if (notATenant(req))
5080
+ return next();
5081
+ const ctx = await pageContext(req, res);
5082
+ if (!ctx)
5083
+ return;
5084
+ await panel.withTenant(ctx.tenant, () => billing.handle(req, res, ctx.tenant));
5085
+ });
5086
+ }
4097
5087
  for (const PageClass of panel.getPages()) {
4098
5088
  const component = name(`pages.${(PageClass.slug || 'dashboard').replace(/[{}]/g, '')}`);
4099
5089
  runtime.register(component, PageClass);
4100
5090
  // `{record}` in the slug becomes a route parameter, passed to mount().
4101
5091
  router.get(panel.getBaseUrl(tenantSegment + PageClass.slug).replace(/\{(\w+)\}/g, ':$1'), async (req, res, next) => {
4102
- if (panel.hasTenancy() && (panel.getReservedSegments().has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
5092
+ if (notATenant(req))
4103
5093
  return next();
4104
5094
  const ctx = await pageContext(req, res);
4105
5095
  if (!ctx)
4106
5096
  return;
5097
+ if (await requiresSubscription(PageClass, ctx))
5098
+ return res.redirect(panel.getTenantBillingUrl(ctx.tenant));
4107
5099
  const { tenant: _tenant, ...params } = req.params;
4108
5100
  await guarded(res, () => document(req, res, ctx, { title: PageClass.getTitle(), component, layout: 'index', params }));
4109
5101
  });
4110
5102
  }
5103
+ // Any other URL of a panel with a path is unknown. A panel at the root leaves them to the application (`panelNotFound()`).
5104
+ if (panelBase !== '/')
5105
+ router.get([panelBase, `${panelBase}/*path`], (req, res) => renderErrorPage(panel, req, res, 404));
5106
+ // An error thrown while rendering a page of the panel: the 500 page. Component updates keep Livewire's error handling.
5107
+ router.use((error, req, res, next) => {
5108
+ if (res.headersSent || req.method !== 'GET' || !belongsToPanel(req.path) || req.path.startsWith(`${livewire}/`))
5109
+ return next(error);
5110
+ console.error(error);
5111
+ const status = typeof error?.status === 'number' && error.status >= 400 ? error.status : 500;
5112
+ renderErrorPage(panel, req, res, status, { error });
5113
+ });
4111
5114
  return router;
4112
5115
  }
5116
+ /** The panel's 404 page for URLs nothing else answered: mount it after your routes, for a panel served at the root. */
5117
+ export function panelNotFound(panel) {
5118
+ return (req, res) => renderErrorPage(panel, req, res, 404);
5119
+ }
4113
5120
  /** Each auth feature requires some auth provider methods: fail at startup rather than on first click. */
4114
5121
  function assertAuthCapabilities(panel) {
5122
+ if (panel.allowsGuests() && panel.hasTenancy())
5123
+ throw new Error('guestAccess() is not available in a multi-tenant panel: tenants belong to signed-in users.');
5124
+ for (const provider of panel.getMultiFactorAuthenticationProviders())
5125
+ provider.checkConfiguration?.(panel);
4115
5126
  if (!panel.hasRegistration() && !panel.hasPasswordReset() && !panel.hasEmailVerification() && !panel.hasProfile())
4116
5127
  return;
4117
5128
  const auth = panel.getAuth();
@@ -4128,4 +5139,6 @@ function assertAuthCapabilities(panel) {
4128
5139
  missing('Email verification', ['hasVerifiedEmail', 'markEmailAsVerified']);
4129
5140
  if (panel.hasProfile())
4130
5141
  missing('Profile', ['update']);
5142
+ if (panel.hasEmailChangeVerification())
5143
+ missing('Email change verification', ['update', 'findByEmail']);
4131
5144
  }