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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -1
  2. package/dist/navette/navette/calendar.css +5 -5
  3. package/dist/navette/navette/motion.css +21 -21
  4. package/dist/navette/navette/motion.js +6 -6
  5. package/dist/navette/navette/security-keys.css +11 -0
  6. package/dist/navette/navette/security-keys.js +72 -0
  7. package/lib/auth.d.ts +17 -0
  8. package/lib/auth.js +70 -0
  9. package/lib/error-pages.d.ts +9 -0
  10. package/lib/error-pages.js +173 -0
  11. package/lib/import-export.d.ts +185 -0
  12. package/lib/import-export.js +543 -0
  13. package/lib/index.d.ts +629 -79
  14. package/lib/index.js +1945 -333
  15. package/lib/multi-factor.d.ts +348 -0
  16. package/lib/multi-factor.js +928 -0
  17. package/lib/notifications.d.ts +20 -2
  18. package/lib/notifications.js +34 -4
  19. package/lib/qr-code.d.ts +16 -0
  20. package/lib/qr-code.js +255 -0
  21. package/lib/session-store.d.ts +21 -0
  22. package/lib/session-store.js +35 -0
  23. package/lib/throttle.d.ts +10 -0
  24. package/lib/throttle.js +25 -0
  25. package/lib/topbar.d.ts +3 -1
  26. package/lib/topbar.js +18 -1
  27. package/lib/totp.d.ts +38 -0
  28. package/lib/totp.js +104 -0
  29. package/lib/webauthn.d.ts +155 -0
  30. package/lib/webauthn.js +287 -0
  31. package/lib/xlsx.d.ts +7 -0
  32. package/lib/xlsx.js +171 -0
  33. package/package.json +9 -9
  34. package/views/components/tenant-menu.ejs +3 -3
  35. package/views/components/user-menu.ejs +3 -2
  36. package/views/errors/page.ejs +87 -0
  37. package/views/layout/base.ejs +9 -1
  38. package/views/livewire/sidebar.ejs +35 -20
  39. package/views/livewire/topbar.ejs +1 -1
  40. package/views/pages/page.ejs +53 -35
  41. package/views/pages/simple.ejs +4 -0
package/lib/index.d.ts CHANGED
@@ -1,10 +1,21 @@
1
+ export { Throttle } from './throttle.ts';
1
2
  import { type Mailer, type MailMessage } from './auth.ts';
2
- export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, verifyPassword, type Mailer, type MailMessage, type TableQuery } from './auth.ts';
3
+ export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, multiFactorAuthenticationSchema, verifyPassword, type Mailer, type MailMessage, type TableQuery } from './auth.ts';
3
4
  import { type Request, type Response, type Router } from 'express';
4
5
  import { Action } from '@navette-js/actions';
5
6
  import { Step, Wizard } from '@navette-js/forms';
6
7
  import { Component as SchemaComponent, Schema, type SchemaHost } from '@navette-js/schemas';
7
8
  export { Notification, Notifications } from './notifications.ts';
9
+ import { MultiFactorChallenge, type MultiFactorAuthenticationProvider, type SecurityKey } from './multi-factor.ts';
10
+ export { AppAuthentication, EmailAuthentication, EmailCodeInput, type EmailAuthenticationStore, decryptSecret, encryptSecret, MemoryCacheStore, MultiFactorChallenge, SecurityKeyAuthentication, SecurityKeyInput, useMultiFactorCacheStore, type AppAuthenticationStore, type MultiFactorAuthenticationProvider, type MultiFactorCacheStore, type SecurityKey, type SecurityKeyStore } from './multi-factor.ts';
11
+ export { base32Decode, base32Encode, generateTotpSecret, hotp, totpCode, totpTimestep, totpUri, verifyTotp, type TotpAlgorithm, type TotpOptions } from './totp.ts';
12
+ export { qrCodeDataUri, qrCodeMatrix, qrCodeSvg, type QrCodeSvgOptions, type QrErrorCorrection } from './qr-code.ts';
13
+ export { authenticationOptions, decodeCbor, generateChallenge, registrationOptions, verifyAuthentication, verifyRegistration, WebAuthnError, type AuthenticationResponseJSON, type RegistrationResponseJSON, type StoredCredential, type UserVerification } from './webauthn.ts';
14
+ export { MemorySessionStore, type SessionStore } from './session-store.ts';
15
+ export { csvCell, csvLine, DownloadStore, ExportAction, ExportBulkAction, ExportColumn, Exporter, ImportAction, ImportColumn, Importer, importExportSchema, knexImportExportHistory, parseCsv, readSpreadsheet, runInBackground, useBackgroundRunner, type BackgroundTask, type ExportActionInstance, type ImportActionInstance, type ImportExportHistory } from './import-export.ts';
16
+ export { buildXlsx, readXlsx } from './xlsx.ts';
17
+ import { DownloadStore, type ImportExportHistory } from './import-export.ts';
18
+ import { type SessionStore } from './session-store.ts';
8
19
  import { type CalendarEvent, type CalendarLabels, type CalendarView } from './calendar.ts';
9
20
  export { addDays, addMonths, CALENDAR_LABELS, dayOf, daysBetween, eventsOn, renderCalendar, renderCalendarEvent, timeOf, visibleRange, type CalendarEvent, type CalendarLabels, type CalendarModel, type CalendarView } from './calendar.ts';
10
21
  import { type DatabaseNotificationStore, type GlobalSearchCategory, type GlobalSearchResult } from './topbar.ts';
@@ -71,6 +82,29 @@ export interface AuthProvider {
71
82
  markEmailAsVerified?(user: User): MaybePromise<void>;
72
83
  /** A value that changes with the stored password, so a used reset link becomes invalid. */
73
84
  getPasswordFingerprint?(user: User): MaybePromise<string>;
85
+ /** Can this user use this panel? Checked on every request and at login (`false`: 403, or "credentials do not match" at login). */
86
+ canAccessPanel?(user: User, panel: Panel): MaybePromise<boolean>;
87
+ /** Stored authenticator app secret (encrypted by Navette), or `null`. */
88
+ getAppAuthenticationSecret?(user: User): MaybePromise<string | null>;
89
+ /** Saves the secret; `null` turns app authentication off. */
90
+ saveAppAuthenticationSecret?(user: User, secret: string | null): MaybePromise<void>;
91
+ /** Stored recovery codes (hashed by Navette), or `null`. */
92
+ getAppAuthenticationRecoveryCodes?(user: User): MaybePromise<string[] | null>;
93
+ saveAppAuthenticationRecoveryCodes?(user: User, codes: string[] | null): MaybePromise<void>;
94
+ /** Whether the user receives sign-in codes by email (`EmailAuthentication`). */
95
+ hasEmailAuthentication?(user: User): MaybePromise<boolean>;
96
+ saveEmailAuthentication?(user: User, enabled: boolean): MaybePromise<void>;
97
+ /** The user's security keys. */
98
+ getSecurityKeys?(user: User): MaybePromise<SecurityKey[]>;
99
+ createSecurityKey?(user: User, key: Omit<SecurityKey, 'id' | 'createdAt' | 'lastUsedAt'>): MaybePromise<void>;
100
+ /** Updates a key of this user (counter and last use after a sign-in). */
101
+ updateSecurityKey?(user: User, id: SecurityKey['id'], changes: {
102
+ signCount?: number;
103
+ lastUsedAt?: string;
104
+ name?: string;
105
+ }): MaybePromise<void>;
106
+ /** Deletes a key of this user. */
107
+ deleteSecurityKey?(user: User, id: SecurityKey['id']): MaybePromise<void>;
74
108
  }
75
109
  /**
76
110
  * Panel tenants (teams, organizations…). Every panel page then lives under
@@ -90,6 +124,15 @@ export interface TenantProvider<T = any> {
90
124
  /** Creates a tenant (tenant registration page); the user becomes a member. */
91
125
  create?(data: Record<string, unknown>, user: User): MaybePromise<T>;
92
126
  update?(tenant: T, data: Record<string, unknown>): MaybePromise<T>;
127
+ /** Tenant the user lands on after signing in (instead of their first one), or `null`. */
128
+ getDefaultTenant?(user: User): MaybePromise<T | null>;
129
+ }
130
+ /** Subscriptions of tenants (`panel.tenantBillingProvider()`): Stripe, Paddle, your own… */
131
+ export interface TenantBillingProvider<T = any> {
132
+ /** The billing page of a tenant (`/<tenant>/billing`): usually a redirect to the payment provider's customer portal. */
133
+ handle(req: Request, res: Response, tenant: T): void | Promise<void>;
134
+ /** Whether the tenant has an active subscription; required by `panel.requiresTenantSubscription()`. */
135
+ isSubscribed?(tenant: T): MaybePromise<boolean>;
93
136
  }
94
137
  export type PanelContext = {
95
138
  req: Request;
@@ -97,7 +140,8 @@ export type PanelContext = {
97
140
  panel: Panel;
98
141
  user: User | null;
99
142
  mailbox: string;
100
- tenant?: unknown;
143
+ tenant?: unknown; /** Signed in through the shared session, but not allowed into this panel. */
144
+ denied?: boolean;
101
145
  };
102
146
  /**
103
147
  * Predefined panel theme: a set of colors (palettes or plain colors), optionally a font and some CSS.
@@ -114,6 +158,19 @@ export type Theme = {
114
158
  /** CSS added to the page for this theme (written by the panel developer, inserted as-is). */
115
159
  css?: string;
116
160
  };
161
+ /** Navigation group: label, icon (then its items have none, as in Filament) and whether it can be collapsed. */
162
+ export declare class NavigationGroup {
163
+ private _label;
164
+ private _icon;
165
+ private _collapsible;
166
+ constructor(label: string);
167
+ static make(label: string): NavigationGroup;
168
+ icon(name: string | null): this;
169
+ collapsible(condition?: boolean): this;
170
+ getLabel(): string;
171
+ getIcon(): string | null;
172
+ isCollapsible(): boolean;
173
+ }
117
174
  export declare class NavigationItem {
118
175
  label: string;
119
176
  _url: string;
@@ -123,8 +180,14 @@ export declare class NavigationItem {
123
180
  _isActive: ((path: string) => boolean) | null;
124
181
  _badge: string | number | null;
125
182
  _badgeColor: string;
183
+ _parentItem: string | null;
184
+ _childItems: NavigationItem[];
126
185
  constructor(label: string);
127
186
  static make(label: string): NavigationItem;
187
+ /** Shows the item below another item of its group, by that item's label. */
188
+ parentItem(label: string | null): this;
189
+ /** Items shown below this one, when it (or one of them) is active, or when it has no URL. */
190
+ childItems(items: NavigationItem[]): this;
128
191
  url(url: string): this;
129
192
  icon(icon: string | null): this;
130
193
  group(group: string | null): this;
@@ -149,10 +212,24 @@ export declare class Panel {
149
212
  private _login;
150
213
  private _auth;
151
214
  private _key;
215
+ private _sessionStore;
216
+ /** Where per-browser data is kept (persisted table filters, search and sort): in memory by default. */
217
+ sessionStore(store: SessionStore): this;
218
+ getSessionStore(): SessionStore;
219
+ private _downloads;
220
+ /** Where files offered for download (exports, failed import rows) are kept: in memory by default. */
221
+ downloads(store: DownloadStore): this;
222
+ private _importExportHistory;
223
+ /** Records each import and export run (`knexImportExportHistory(db)`): who, which file, how many rows, failed import rows. */
224
+ importExportHistory(store: ImportExportHistory | null): this;
225
+ getImportExportHistory(): ImportExportHistory | null;
226
+ getDownloads(): DownloadStore;
227
+ getDownloadUrl(token: string): string;
152
228
  private _pages;
153
229
  private _resources;
154
230
  private _navigationItems;
155
231
  private _navigationGroups;
232
+ private _navigationGroupOptions;
156
233
  private _maxContentWidth;
157
234
  readonly colors: ColorManager;
158
235
  static make(): Panel;
@@ -191,12 +268,44 @@ export declare class Panel {
191
268
  login(page?: typeof Login): this;
192
269
  auth(provider: AuthProvider): this;
193
270
  key(key: string): this;
271
+ private _authGuard;
272
+ private _canAccess;
273
+ /**
274
+ * Session shared with the other panels that use the same guard (`'web'` by default): signing into one signs into all of
275
+ * them, and `canAccess()` decides who may enter each one. `null` gives this panel a session of its own.
276
+ * Panels sharing a guard must use the same key and the same auth provider.
277
+ */
278
+ authGuard(guard: string | null): this;
279
+ getAuthGuard(): string | null;
280
+ /** Who may use this panel, e.g. `panel.canAccess((user) => user.role === 'admin')`. Checked on every request and at login. */
281
+ canAccess(rule: ((user: User, panel: Panel) => MaybePromise<boolean>) | null): this;
282
+ /** True if this user may use this panel: the panel's rule and the provider's `canAccessPanel()`, when set, must both allow it. */
283
+ userCanAccess(user: User): Promise<boolean>;
284
+ /** Name of the session cookie: per guard (shared), or per panel. */
285
+ getSessionCookieName(): string;
194
286
  pages(pages: (typeof Page)[]): this;
195
287
  /** Registers resources: their pages and navigation items. */
196
288
  resources(resources: (typeof Resource)[]): this;
289
+ /** A page or resource in a cluster is served under the cluster's slug; the cluster page is registered with it. */
290
+ private registerCluster;
291
+ /** Pages (resource list pages included) shown in a cluster's sub-navigation. */
292
+ getClusteredComponents(cluster: typeof Cluster): (typeof Page)[];
293
+ /** Sub-navigation of a cluster, for one of its pages: the components the user can open, sorted, with their groups. */
294
+ getClusterSubNavigation(cluster: typeof Cluster, current: Page, user: User | null): Promise<SubNavigationItem[]>;
197
295
  navigationItems(items: NavigationItem[]): this;
198
296
  /** Order of navigation groups. */
199
- navigationGroups(groups: string[]): this;
297
+ /** Order of the navigation groups: labels, or `NavigationGroup.make('Shop').icon('o-shopping-cart').collapsible(false)`. */
298
+ private _darkMode;
299
+ private _darkModeForced;
300
+ private _defaultThemeMode;
301
+ /** `false`: light mode only (no switcher); `darkMode(true, true)`: dark mode only. */
302
+ darkMode(condition?: boolean, isForced?: boolean): this;
303
+ /** Mode used until the user picks one in the switcher: `'system'` (default), `'light'` or `'dark'`. */
304
+ defaultThemeMode(mode: 'light' | 'dark' | 'system'): this;
305
+ hasDarkMode(): boolean;
306
+ hasDarkModeForced(): boolean;
307
+ getDefaultThemeMode(): 'light' | 'dark' | 'system';
308
+ navigationGroups(groups: (string | NavigationGroup)[]): this;
200
309
  private _plugins;
201
310
  /** Applies a plugin: an object that configures the panel (resources, pages, render hooks, themes…). */
202
311
  plugin(plugin: Plugin): this;
@@ -212,7 +321,14 @@ export declare class Panel {
212
321
  private _authorizer;
213
322
  /** Plugs in your own permission system: consulted before any policy, for every ability, page and widget. */
214
323
  authorizeUsing(authorizer: Authorizer | null): this;
215
- /** Authorization decision: the panel hook first, then the given rule. Always `false` without a user. */
324
+ private _guestAccess;
325
+ /**
326
+ * Lets visitors who are not signed in use the panel's pages. Policies, `canAccess()` and the authorization hook then receive
327
+ * a `null` user: return `true` there for what guests may see. Not available with tenancy.
328
+ */
329
+ guestAccess(condition?: boolean): this;
330
+ allowsGuests(): boolean;
331
+ /** Authorization decision: the panel hook first, then the given rule. Without a user: `false`, unless the panel allows guests. */
216
332
  authorize(user: User | null, ability: string, subject?: {
217
333
  resource?: unknown;
218
334
  record?: unknown;
@@ -320,10 +436,68 @@ export declare class Panel {
320
436
  page: typeof EditProfile;
321
437
  isSimple: boolean;
322
438
  };
439
+ /** Whether the profile page uses the simple layout (`profile(page, false)`: the full layout, with the navigation). */
440
+ isProfileSimple(): boolean;
441
+ private _multiFactor;
442
+ /**
443
+ * Multi-factor authentication: after the password, users with a provider set up must pass its challenge. They set providers
444
+ * up on their profile page (`profile()`). `isRequired`: users without any provider are sent to a setup page first.
445
+ */
446
+ multiFactorAuthentication(providers: MultiFactorAuthenticationProvider[], options?: {
447
+ isRequired?: boolean;
448
+ setUpPage?: typeof SetUpRequiredMultiFactorAuthentication;
449
+ }): this;
450
+ hasMultiFactorAuthentication(): boolean;
451
+ getMultiFactorAuthenticationProviders(): MultiFactorAuthenticationProvider[];
452
+ isMultiFactorAuthenticationRequired(): boolean;
453
+ getSetUpRequiredMultiFactorAuthenticationPage(): typeof SetUpRequiredMultiFactorAuthentication | null;
454
+ getSetUpRequiredMultiFactorAuthenticationUrl(): string;
455
+ /** The login challenge of this panel (enabled providers, rate limiting). */
456
+ getMultiFactorChallenge(): MultiFactorChallenge;
457
+ /** Whether the user must set up multi-factor authentication before using the panel. */
458
+ mustSetUpMultiFactorAuthentication(user: User | null): Promise<boolean>;
459
+ usesSecurityKeys(): boolean;
323
460
  /** Mailer for password reset and verification emails. Defaults to the console. */
324
461
  mailer(mailer: Mailer): this;
325
462
  sendMail(message: MailMessage): Promise<void>;
326
463
  getAuthKey(): string;
464
+ private _authSlugs;
465
+ private authSlug;
466
+ /** URL segment of the login page (`login` by default). */
467
+ loginRouteSlug(slug: string): this;
468
+ /** URL segment of the registration page (`register` by default). */
469
+ registrationRouteSlug(slug: string): this;
470
+ /** URL segments of the password reset pages: `<prefix>/<request>` and `<prefix>/<reset>` (`password-reset/request` and `password-reset/reset` by default). */
471
+ passwordResetRoutePrefix(prefix: string): this;
472
+ passwordResetRequestRouteSlug(slug: string): this;
473
+ passwordResetRouteSlug(slug: string): this;
474
+ /** Paths of the authentication pages, relative to the panel. */
475
+ /** URL segment of the logout route (`logout` by default). */
476
+ logoutRouteSlug(slug: string): this;
477
+ /** URL segments of email verification: `<prefix>/<prompt>` and `<prefix>/<verify>/…` (`email-verification/prompt`, `email-verification/verify`). */
478
+ emailVerificationRoutePrefix(prefix: string): this;
479
+ emailVerificationPromptRouteSlug(slug: string): this;
480
+ emailVerificationRouteSlug(slug: string): this;
481
+ /** URL segments of the email change links: `<prefix>/<verify>` and `<prefix>/block` (`email-change-verification/verify`). */
482
+ emailChangeVerificationRoutePrefix(prefix: string): this;
483
+ emailChangeVerificationRouteSlug(slug: string): this;
484
+ /** URL segments of the required multi-factor setup page (`multi-factor-authentication/set-up`). */
485
+ multiFactorAuthenticationRoutePrefix(prefix: string): this;
486
+ setUpRequiredMultiFactorAuthenticationRouteSlug(slug: string): this;
487
+ getAuthPaths(): {
488
+ login: string;
489
+ registration: string;
490
+ passwordResetRequest: string;
491
+ passwordReset: string;
492
+ logout: string;
493
+ emailVerificationPrompt: string;
494
+ emailVerification: string;
495
+ emailChangeVerification: string;
496
+ blockEmailChange: string;
497
+ setUpRequiredMultiFactor: string;
498
+ };
499
+ /** First URL segments used by the panel itself (never page slugs or tenant keys). */
500
+ getReservedSegments(): Set<string>;
327
501
  getRegistrationUrl(): string;
328
502
  getRequestPasswordResetUrl(): string;
329
503
  getResetPasswordUrl(user: User, fingerprint: string): string;
@@ -335,6 +509,24 @@ export declare class Panel {
335
509
  getVerifyEmailUrl(user: User): string;
336
510
  checkVerifyEmailUrl(id: string, hash: string, expires: unknown, signature: unknown): boolean;
337
511
  getEmailVerificationPromptUrl(): string;
512
+ private _emailChangeVerification;
513
+ /**
514
+ * 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
515
+ * to block the change to the old one. Both are valid for 60 minutes.
516
+ */
517
+ emailChangeVerification(condition?: boolean): this;
518
+ hasEmailChangeVerification(): boolean;
519
+ /** Links of an email change request: to confirm the new address, and to block the change. */
520
+ getEmailChangeUrls(user: User, newEmail: string): {
521
+ verify: string;
522
+ block: string;
523
+ };
524
+ readEmailChangeToken(kind: 'verify' | 'block', token: unknown): {
525
+ uid: string | number;
526
+ email: string;
527
+ from?: string;
528
+ nonce: string;
529
+ } | null;
338
530
  getProfileUrl(): string | null;
339
531
  /** Whether the user must still verify their email before using the panel. */
340
532
  mustVerifyEmail(user: User | null): Promise<boolean>;
@@ -363,6 +555,41 @@ export declare class Panel {
363
555
  getTenantProfilePage(): typeof EditTenantProfile | null;
364
556
  tenantMenu(condition?: boolean): this;
365
557
  hasTenantMenu(): boolean;
558
+ private _searchableTenantMenu;
559
+ /** A search field above the tenants of the menu. By default (`null`), it is shown from ten tenants to switch to. */
560
+ searchableTenantMenu(condition?: boolean | null): this;
561
+ isTenantMenuSearchable(): boolean | null;
562
+ private _tenantRoutePrefix;
563
+ /** A segment before the tenant key in URLs: `tenantRoutePrefix('team')` serves `/app/team/1/projects`. */
564
+ tenantRoutePrefix(prefix: string | null): this;
565
+ getTenantRoutePrefix(): string | null;
566
+ private _tenantDomain;
567
+ /**
568
+ * Identifies the tenant by the host instead of the path: `tenantDomain('{tenant}.example.com')` serves the tenant `acme` at
569
+ * `acme.example.com/app/projects`. A port may follow (`{tenant}.localhost:3000`).
570
+ */
571
+ tenantDomain(pattern: string | null): this;
572
+ getTenantDomain(): string | null;
573
+ /** Domain of a tenant, with its port if the pattern has one. */
574
+ getTenantDomainFor(tenant: unknown): string;
575
+ /** Key of the tenant designated by a request (its path, or its host with `tenantDomain()`), unchecked; `null` if none. */
576
+ getTenantKeyFromRequest(path: string, hostname: string): string | null;
577
+ private _tenantBilling;
578
+ private _tenantBillingRouteSlug;
579
+ private _requiresTenantSubscription;
580
+ /** Adds a "Manage subscription" link to the tenant menu, served by the provider at `/<tenant>/billing`. */
581
+ tenantBillingProvider(provider: TenantBillingProvider | null): this;
582
+ getTenantBillingProvider(): TenantBillingProvider | null;
583
+ hasTenantBilling(): boolean;
584
+ tenantBillingRouteSlug(slug: string): this;
585
+ getTenantBillingRouteSlug(): string;
586
+ getTenantBillingUrl(tenant?: unknown): string | null;
587
+ /** The pages of a tenant without an active subscription (`isSubscribed()`) redirect to its billing page. */
588
+ requiresTenantSubscription(condition?: boolean): this;
589
+ isTenantSubscriptionRequired(): boolean;
590
+ private _sessionCookieDomain;
591
+ /** Domain of the session cookie (`.example.com`): with `tenantDomain()`, a sign-in then holds on every tenant's host. */
592
+ sessionCookieDomain(domain: string | null): this;
366
593
  tenantMenuItems(items: Record<string, Action> | Action[]): this;
367
594
  getTenantMenuItems(): Record<string, Action>;
368
595
  /** Runs `fn` for a tenant: generated URLs (`getUrl`) and resource queries are scoped to it. */
@@ -395,9 +622,41 @@ export declare class Panel {
395
622
  /** Tenant avatar: from the tenant provider (`getAvatarUrl`), otherwise its initials. */
396
623
  getTenantAvatarUrl(tenant: unknown): string;
397
624
  private _spa;
398
- /** SPA mode: navigate between panel pages without full reloads (`wire:navigate`). */
399
- spa(condition?: boolean): this;
625
+ private _spaPrefetching;
626
+ /**
627
+ * SPA mode: navigate between panel pages without full reloads (`wire:navigate`). `{ prefetch: true }` loads a page as soon
628
+ * as its link is hovered (`wire:navigate.hover`).
629
+ */
630
+ spa(condition?: boolean, options?: {
631
+ prefetch?: boolean;
632
+ }): this;
400
633
  hasSpaMode(): boolean;
634
+ hasSpaPrefetching(): boolean;
635
+ private _domains;
636
+ /** Serves the panel on this domain only (`admin.example.com`): requests for other hosts go to the next routes. */
637
+ domain(host: string | null): this;
638
+ /** Serves the panel on these domains only. */
639
+ domains(hosts: string[]): this;
640
+ getDomains(): string[];
641
+ private _unsavedChangesAlerts;
642
+ /** Warns the user before leaving a create or edit page whose form has unsaved changes. */
643
+ unsavedChangesAlerts(condition?: boolean): this;
644
+ hasUnsavedChangesAlerts(): boolean;
645
+ private _databaseTransactions;
646
+ /**
647
+ * Wraps the creation and the saving of records (from `beforeCreate()` / `beforeSave()` to `afterCreate()` / `afterSave()`)
648
+ * in a database transaction, rolled back when anything throws. Pages can opt in or out with `static hasDatabaseTransactions`.
649
+ */
650
+ databaseTransactions(condition?: boolean): this;
651
+ hasDatabaseTransactions(): boolean;
652
+ private _strictAuthorization;
653
+ /** Throws when a resource is checked for an ability its policy does not define, instead of allowing it. */
654
+ strictAuthorization(condition?: boolean): this;
655
+ isAuthorizationStrict(): boolean;
656
+ private _bootCallbacks;
657
+ /** Runs on every request handled by the panel (its pages and their updates), before them. */
658
+ bootUsing(callback: (panel: Panel, req: Request) => void | Promise<void>): this;
659
+ boot(req: Request): Promise<void>;
401
660
  getAvatarUrlFor(name: string): string;
402
661
  /** Navigation groups: registered pages plus custom items, sorted, grouped in the declared order. */
403
662
  /** Computed navigation badges: page URL → value. */
@@ -408,14 +667,9 @@ export declare class Panel {
408
667
  label: string | null;
409
668
  itemsId: string;
410
669
  isActive: boolean;
411
- items: {
412
- label: string;
413
- url: string;
414
- icon: string | null;
415
- isActive: boolean;
416
- badge: string | number | null;
417
- badgeColorClasses: string[];
418
- }[];
670
+ icon: string | null;
671
+ collapsible: boolean;
672
+ items: NavigationItemView[];
419
673
  }[];
420
674
  private sign;
421
675
  logIn(res: Response, user: User, remember: boolean): void;
@@ -527,6 +781,63 @@ declare abstract class BasePage extends Component implements SchemaHost {
527
781
  contentSchema(schema: Schema): Schema;
528
782
  /** Render hook scopes of this page: its class (and its resource, for a resource page). */
529
783
  getRenderHookScopes(): unknown[];
784
+ /** Actions of the page header (full layout). */
785
+ getHeaderActions(): Action[];
786
+ static callable: string[];
787
+ loadedDeferredSchemas: Record<string, boolean>;
788
+ /** Whether a lazy schema is rendered: loaded already, or holding a field with a validation error (loaded so it shows). */
789
+ isDeferredSchemaLoaded(schema: Schema): Promise<boolean>;
790
+ /** Called by the loading indicator of a lazy schema: renders only that schema. */
791
+ loadDeferredSchema(schemaKey: unknown): Promise<void>;
792
+ mountedActions: {
793
+ name: string;
794
+ arguments: Record<string, unknown>;
795
+ context: Record<string, unknown>;
796
+ data?: Record<string, unknown>;
797
+ }[];
798
+ /** Files uploaded by rich editors (`componentFileAttachments.<state path>.<id>`), stored by the editor when it asks for their URL. */
799
+ componentFileAttachments: Record<string, unknown>;
800
+ getActionMountContext(): Record<string, unknown>;
801
+ protected getActionRecord(): unknown;
802
+ /** Record targeted by an action mounted in a given context (table row…). */
803
+ protected resolveActionRecord(_context: Record<string, unknown>): Promise<unknown>;
804
+ /** Actions that can be mounted in a given context (page header by default). */
805
+ protected getMountableActions(context: Record<string, unknown>): Action[] | Promise<Action[]>;
806
+ /**
807
+ * Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
808
+ * The key comes from the browser: it must match a real component of the schema, or the schema itself.
809
+ */
810
+ /** Visible component of a schema of this page (or of a mounted action's form) with this key. */
811
+ protected findSchemaComponent(key: string): Promise<any | null>;
812
+ protected getSchemaComponentActions(key: string): Promise<Action[]>;
813
+ /** Context kept for a mounted action: the server's, never the browser's as-is. */
814
+ protected resolveMountContext(clientContext: unknown): Record<string, unknown>;
815
+ private findMountableAction;
816
+ private actionContext;
817
+ /** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
818
+ getMountedActionSchema(index: number): Promise<Schema | null>;
819
+ private renderActionModal;
820
+ /** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
821
+ private cancelModalPartialRender;
822
+ /** Live field in a mounted action's form: runs `afterStateUpdated`, then only returns the affected modal. */
823
+ updatedMountedActions(_value: unknown, key: string | null): Promise<void>;
824
+ /** Calls a method exposed by a field, from that field's JavaScript (e.g. Select search). Does not re-render. */
825
+ callSchemaComponentMethod(componentKey: unknown, method: unknown, args?: unknown): Promise<unknown>;
826
+ /** A hidden action can be neither mounted nor run, even through a call forged by the browser. */
827
+ private assertActionVisible;
828
+ private syncActionModals;
829
+ /** False for a table component: its modals are rendered inside the table. */
830
+ protected rendersActionModalsInPage(): boolean;
831
+ renderActionModals(): Promise<string>;
832
+ /** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
833
+ mountAction(name: unknown, args?: unknown, _context?: unknown): Promise<void>;
834
+ /**
835
+ * Replaces the action being run with another action of the same component, opened in its place (to show a result, as
836
+ * the new recovery codes). Call it from the running action; `name` must be mountable in the same context (it may be hidden).
837
+ */
838
+ replaceMountedAction(name: string, args?: Record<string, unknown>): Promise<void>;
839
+ unmountAction(_shouldCancelParentActions?: unknown): void;
840
+ callMountedAction(_args?: unknown): Promise<void>;
530
841
  }
531
842
  /** A panel page, in the full layout (sidebar, header). */
532
843
  export declare class Page extends BasePage {
@@ -540,9 +851,15 @@ export declare class Page extends BasePage {
540
851
  static navigationSort: number;
541
852
  static navigationBadge: string | number | null;
542
853
  static navigationBadgeColor: string;
854
+ /** Label of another menu item of the group, below which this page is shown. */
855
+ static navigationParentItem: string | null;
856
+ /** Cluster of the page: it is served under the cluster's URL and listed in its sub-navigation. */
857
+ static cluster: typeof Cluster | null;
543
858
  static shouldRegisterNavigation: boolean;
544
859
  /** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
545
860
  static canAccess(_user: User): boolean | Promise<boolean>;
861
+ /** Whether the page needs the tenant's subscription (`panel.requiresTenantSubscription()`); `false` keeps it open. */
862
+ static isTenantSubscriptionRequired(panel: Panel): boolean;
546
863
  static getTitle(): string;
547
864
  static getNavigationLabel(): string;
548
865
  /** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
@@ -556,8 +873,13 @@ export declare class Page extends BasePage {
556
873
  }[];
557
874
  getHeaderActions(): Action[];
558
875
  getPageClasses(): string[];
559
- /** Page sub-navigation; empty by default. */
876
+ /** Page sub-navigation: its cluster's pages, for a page in a cluster; empty otherwise. */
560
877
  getSubNavigation(): SubNavigationItem[] | Promise<SubNavigationItem[]>;
878
+ /** Breadcrumbs, preceded by the cluster's for a page in a cluster. */
879
+ protected getBreadcrumbsWithCluster(): {
880
+ label: string;
881
+ url: string | null;
882
+ }[];
561
883
  /** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
562
884
  static subNavigationPosition: 'start' | 'end' | 'top' | null;
563
885
  getSubNavigationPosition(): 'start' | 'end' | 'top';
@@ -577,49 +899,33 @@ export declare class Page extends BasePage {
577
899
  /** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
578
900
  protected renderWidgetsGrid(widgets: (typeof Widget)[], columns: number | Record<string, number>, schemaKey: string): Promise<string>;
579
901
  /** Page content: its `content` schema by default. */
580
- protected renderContent(): Promise<string>;
581
- mountedActions: {
582
- name: string;
583
- arguments: Record<string, unknown>;
584
- context: Record<string, unknown>;
585
- data?: Record<string, unknown>;
586
- }[];
587
- getActionMountContext(): Record<string, unknown>;
588
- protected getActionRecord(): unknown;
589
- /** Record targeted by an action mounted in a given context (table row…). */
590
- protected resolveActionRecord(_context: Record<string, unknown>): Promise<unknown>;
591
- /** Actions that can be mounted in a given context (page header by default). */
592
- protected getMountableActions(context: Record<string, unknown>): Action[] | Promise<Action[]>;
593
902
  /**
594
- * Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
595
- * The key comes from the browser: it must match a real component of the schema, or the schema itself.
903
+ * Content rendered from an EJS view instead of the `content` schema: a namespaced view (`'app::pages.report'`), or a file
904
+ * (path or `new URL('./report.ejs', import.meta.url)`). The view receives `page` (this page) and `getViewData()`, and may use
905
+ * component tags (`<x-section>`, `<x-button>`…). Elements calling the server use `wire:click` on a method listed in `static callable`.
596
906
  */
597
- protected getSchemaComponentActions(key: string): Promise<Action[]>;
598
- /** Context kept for a mounted action: the server's, never the browser's as-is. */
599
- protected resolveMountContext(clientContext: unknown): Record<string, unknown>;
600
- private findMountableAction;
601
- private actionContext;
602
- /** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
603
- getMountedActionSchema(index: number): Promise<Schema | null>;
604
- private renderActionModal;
605
- /** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
606
- private cancelModalPartialRender;
607
- /** Live field in a mounted action's form: runs `afterStateUpdated`, then only returns the affected modal. */
608
- updatedMountedActions(_value: unknown, key: string | null): Promise<void>;
609
- /** Calls a method exposed by a field, from that field's JavaScript (e.g. Select search). Does not re-render. */
610
- callSchemaComponentMethod(componentKey: unknown, method: unknown, args?: unknown): Promise<unknown>;
611
- /** A hidden action can be neither mounted nor run, even through a call forged by the browser. */
612
- private assertActionVisible;
613
- private syncActionModals;
614
- /** False for a table component: its modals are rendered inside the table. */
615
- protected rendersActionModalsInPage(): boolean;
616
- renderActionModals(): Promise<string>;
617
- /** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
618
- mountAction(name: unknown, args?: unknown, _context?: unknown): Promise<void>;
619
- unmountAction(_shouldCancelParentActions?: unknown): void;
620
- callMountedAction(_args?: unknown): Promise<void>;
907
+ static view: string | URL | null;
908
+ /** Extra variables for the page view. */
909
+ getViewData(): Record<string, unknown> | Promise<Record<string, unknown>>;
910
+ protected renderContent(): Promise<string>;
621
911
  render(): Promise<string>;
622
912
  }
913
+ /**
914
+ * Cluster: a menu item gathering pages and resources (`static cluster = Settings`), listed in its sub-navigation and served
915
+ * under its URL (`/settings/...`). Opening the cluster goes to its first page.
916
+ */
917
+ export declare class Cluster extends Page {
918
+ /** Breadcrumb before those of its pages; defaults to the title. */
919
+ static clusterBreadcrumb: string | null;
920
+ /** `false`: the pages of the cluster have no sub-navigation. */
921
+ static shouldRegisterSubNavigation: boolean;
922
+ static getTitle(): string;
923
+ /** URL segment: `slug`, or the class name without "Cluster" in kebab case (`ProductsCluster` → `products`). */
924
+ static getSlug(): string;
925
+ static getClusterBreadcrumb(): string;
926
+ mount(): Promise<void>;
927
+ getSubNavigation(): SubNavigationItem[] | Promise<SubNavigationItem[]>;
928
+ }
623
929
  /**
624
930
  * Dashboard: a widget grid, preceded by a filters form when the page defines `filtersFormSchema(schema)`.
625
931
  * The filters are passed to widgets as `pageFilters`.
@@ -632,6 +938,7 @@ export declare class Dashboard extends Page {
632
938
  /** Filters form state. Stays `null` while the page has no filters. */
633
939
  filters: Record<string, unknown> | null;
634
940
  /** Dashboard widgets. Defaults to the panel's (`panel.widgets([...])`). */
941
+ /** The panel's widgets, ordered by their `sort` (stable). */
635
942
  getWidgets(): (typeof Widget)[];
636
943
  /** Grid columns: a number (at the `lg` breakpoint), or one number per breakpoint. */
637
944
  getColumns(): number | Record<string, number>;
@@ -641,16 +948,6 @@ export declare class Dashboard extends Page {
641
948
  getWidgetData(): Record<string, unknown>;
642
949
  protected renderContent(): Promise<string>;
643
950
  }
644
- /** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
645
- export declare class Throttle {
646
- private hits;
647
- private max;
648
- private windowMs;
649
- constructor(max?: number, windowMs?: number);
650
- /** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
651
- hit(key: string, now?: number): number;
652
- clear(key: string): void;
653
- }
654
951
  /** Page with the simple layout (login, registration…). */
655
952
  export declare abstract class SimplePage extends BasePage {
656
953
  abstract getTitle(): string;
@@ -725,8 +1022,17 @@ export declare class EditProfile extends SimplePage {
725
1022
  boot(): Promise<void>;
726
1023
  getTitle(): string;
727
1024
  hasLogo(): boolean;
1025
+ /** Whether the page uses the full layout (`panel.profile(EditProfile, false)`): its form is then split into sections. */
1026
+ usesFullLayout(): boolean;
1027
+ render(): Promise<string>;
728
1028
  formSchema(schema: Schema): Schema;
1029
+ /** Fields of the form: name, email, new password, its confirmation, current password. */
1030
+ protected getFormComponents(): SchemaComponent[];
729
1031
  contentSchema(schema: Schema): Schema;
1032
+ /** Emails of an email change: a confirmation link to the new address, a link to block it to the old one. */
1033
+ protected sendEmailChangeVerification(user: User, newEmail: string): Promise<void>;
1034
+ /** Multi-factor authentication settings: each provider's status and actions (set up, turn off…). */
1035
+ protected multiFactorAuthenticationSection(panel: Panel, user: User): SchemaComponent;
730
1036
  save(): Promise<void>;
731
1037
  }
732
1038
  /** Tenant registration page: the subclass provides its label and form (`formSchema`). */
@@ -763,14 +1069,42 @@ export declare abstract class EditTenantProfile extends Page {
763
1069
  }
764
1070
  export declare class Login extends SimplePage {
765
1071
  static callable: string[];
1072
+ /** Signed token naming the user undertaking the multi-factor challenge: set by the server only. */
1073
+ static locked: string[];
766
1074
  data: Record<string, unknown>;
1075
+ userUndertakingMultiFactorAuthentication: string | null;
1076
+ /** User of the pending challenge, resolved from the token on each request. */
1077
+ protected _multiFactorUser: User | null;
1078
+ protected _multiFactorProviders: MultiFactorAuthenticationProvider[];
767
1079
  mount(): void;
1080
+ boot(): Promise<void>;
1081
+ protected readMultiFactorToken(): Record<string, unknown> | null;
1082
+ /** Whether the multi-factor challenge is shown. */
1083
+ isUndertakingMultiFactorAuthentication(): boolean;
768
1084
  getTitle(): string;
769
1085
  getHeading(): string;
770
1086
  getSubheading(): string | null;
1087
+ getSchema(name: string): Schema;
771
1088
  formSchema(schema: Schema): Schema;
1089
+ /** Challenge fields of the user's enabled providers (with a picker when there are several). */
1090
+ multiFactorChallengeFormSchema(schema: Schema): Schema;
772
1091
  contentSchema(schema: Schema): Schema;
1092
+ /** Starts a challenge for this user if one of their providers is set up; returns whether it did. */
1093
+ protected startMultiFactorChallenge(user: User): Promise<boolean>;
773
1094
  authenticate(): Promise<void>;
1095
+ /** The user picked another provider in the challenge: run its `beforeChallenge` (an email code is sent). */
1096
+ updatedData(value: unknown, key: string | null): Promise<void>;
1097
+ protected cancelMultiFactorChallenge(): void;
1098
+ }
1099
+ /** Shown after sign-in when the panel requires multi-factor authentication and the user has not set any provider up. */
1100
+ export declare class SetUpRequiredMultiFactorAuthentication extends SimplePage {
1101
+ static callable: string[];
1102
+ boot(): Promise<void>;
1103
+ mount(): Promise<void>;
1104
+ getTitle(): string;
1105
+ getHeading(): string;
1106
+ getSubheading(): string | null;
1107
+ contentSchema(schema: Schema): Schema;
774
1108
  }
775
1109
  /** Whether the host component's user has this ability on the component's resource (or relation manager). Use it in your actions' `authorize()`. */
776
1110
  export declare const can: (livewire: unknown, ability: string, record?: unknown) => Promise<boolean> | boolean;
@@ -784,6 +1118,12 @@ export declare abstract class Resource {
784
1118
  static navigationGroup: string | null;
785
1119
  static navigationSort: number;
786
1120
  static navigationBadge: string | number | null;
1121
+ /** Menu label; defaults to the plural model label in title case. */
1122
+ static navigationLabel: string | null;
1123
+ /** Label of another menu item of the group, below which the resource is shown. */
1124
+ static navigationParentItem: string | null;
1125
+ /** Cluster of the resource: its pages are served under the cluster's URL, and its list is in the cluster's sub-navigation. */
1126
+ static cluster: typeof Cluster | null;
787
1127
  static adapter: DataAdapter;
788
1128
  /** In a multi-tenant panel, whether records belong to each tenant (`true` by default). */
789
1129
  static isScopedToTenant: boolean;
@@ -809,8 +1149,9 @@ export declare abstract class Resource {
809
1149
  static widgets: (typeof Widget)[];
810
1150
  /** Widgets shown above the table on the list page. */
811
1151
  static headerWidgets: (typeof Widget)[];
812
- /** Relation managers shown below the edit form. */
813
- static relations: any[];
1152
+ /** Relation managers shown below the edit form (and the view page), or groups of them (`RelationGroup`). */
1153
+ static relations: (typeof RelationManager | RelationGroup)[];
1154
+ static getRelations(): (typeof RelationManager | RelationGroup)[];
814
1155
  /** Tabs of the list page. */
815
1156
  static tabs(): Record<string, Tab>;
816
1157
  /** Simple resource: a single page, with create and edit in modals. */
@@ -819,7 +1160,7 @@ export declare abstract class Resource {
819
1160
  static getTitleCasePluralModelLabel(): string;
820
1161
  static getUrl(page?: string, record?: string | number): string;
821
1162
  /** Related records pages: `{ comments: ManagePostComments }` is served at `<slug>/{record}/comments`. */
822
- static relatedPages: Record<string, typeof ManageRelatedRecords>;
1163
+ static relatedPages: Record<string, typeof ManageRelatedRecords | typeof EditRecord | typeof ViewRecord>;
823
1164
  /** Pages listed in a record's sub-navigation, in order: `['view', 'edit', 'comments']`. Empty: no sub-navigation. */
824
1165
  static recordSubNavigation: string[];
825
1166
  /** Record sub-navigation: one tab per declared page, the current page's tab active. */
@@ -837,6 +1178,10 @@ export declare abstract class Resource {
837
1178
  /** Columns searched by global search; defaults to the title attribute. Empty array: the resource is not searchable. */
838
1179
  static globallySearchableAttributes: string[] | null;
839
1180
  static getGloballySearchableAttributes(): string[];
1181
+ /** `false`: the resource is left out of global search. */
1182
+ static isGloballySearchable: boolean;
1183
+ /** `false`: the search term is matched as a whole instead of word by word. */
1184
+ static shouldSplitGlobalSearchTerms: boolean;
840
1185
  static canGloballySearch(): boolean;
841
1186
  /** Position of this resource's group in the results; without one, after the others in registration order. */
842
1187
  static globalSearchSort: number | null;
@@ -845,6 +1190,11 @@ export declare abstract class Resource {
845
1190
  static getGlobalSearchResultTitle(record: any): string;
846
1191
  /** Details shown under the title: `{ Author: 'Jane' }`. Empty values are skipped. */
847
1192
  static getGlobalSearchResultDetails(_record: any): Record<string, unknown> | Promise<Record<string, unknown>>;
1193
+ /**
1194
+ * Buttons under a result: links (`Action.make('edit').url(…)`) or Livewire events (`.dispatch('quickView', [id])`).
1195
+ * They are not mounted: an action needing a modal belongs on the record's pages.
1196
+ */
1197
+ static getGlobalSearchResultActions(_record: any): Action[] | Promise<Action[]>;
848
1198
  /** 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. */
849
1199
  static getGlobalSearchResultUrl(record: any, user: User | null): Promise<string | null>;
850
1200
  static getGlobalSearchResults(search: string, user: User | null): Promise<GlobalSearchResult[]>;
@@ -856,26 +1206,44 @@ export declare abstract class Resource {
856
1206
  static getPages(): Record<string, typeof Page>;
857
1207
  private static buildPages;
858
1208
  }
859
- /** Sub-navigation tab of a page. */
1209
+ /** Sub-navigation tab of a page; items with a `group` are gathered under its label. */
860
1210
  export type SubNavigationItem = {
861
1211
  label: string;
862
1212
  icon: string | null;
863
1213
  url: string;
864
1214
  active: boolean;
1215
+ group?: string | null;
1216
+ badge?: string | number | null;
1217
+ badgeColor?: string;
865
1218
  };
866
- type ColumnSpan = number | 'full';
1219
+ /** A sidebar item, as rendered. */
1220
+ export type NavigationItemView = {
1221
+ label: string;
1222
+ url: string;
1223
+ icon: string | null;
1224
+ isActive: boolean;
1225
+ badge: string | number | null;
1226
+ badgeColorClasses: string[];
1227
+ childItems: NavigationItemView[];
1228
+ activeChildItems: boolean;
1229
+ };
1230
+ /** The cluster a page belongs to: its own, or its resource's. */
1231
+ export declare const clusterOf: (page: unknown) => typeof Cluster | null;
1232
+ type ColumnSpan = number | 'full' | Record<string, number | 'full'>;
867
1233
  /** Widget: a component displayed in a page's widget grid. */
868
1234
  export declare abstract class Widget extends Component {
869
1235
  static componentName: string;
870
- /** Width in the widget grid: a number of columns, or `'full'`. */
1236
+ /** Width in the widget grid: a number of columns or `'full'` (from `lg`), or a span per breakpoint (`{ md: 2, xl: 3 }`). */
871
1237
  static columnSpan: ColumnSpan;
1238
+ /** Position on the dashboard (lower first); widgets without a sort keep their order, after the sorted ones. */
1239
+ static sort: number | null;
872
1240
  /** Loaded when it becomes visible. */
873
1241
  static isLazy: boolean;
874
1242
  get panel(): Panel;
875
1243
  /** Restricts the widget to some users: returning `false` removes it from the page and refuses to load it. */
876
1244
  static canView(_user: User): boolean | Promise<boolean>;
877
1245
  boot(): Promise<void>;
878
- /** Grid column classes and style, at the `lg` breakpoint. */
1246
+ /** Grid column classes and style: at the `lg` breakpoint, or per breakpoint. */
879
1247
  protected gridColumn(): {
880
1248
  classes: string[];
881
1249
  style: string;
@@ -1086,6 +1454,10 @@ export declare abstract class StatsOverviewWidget extends Widget {
1086
1454
  /** Polling interval, or `null` to disable polling. */
1087
1455
  static pollingInterval: string | null;
1088
1456
  abstract getStats(): Stat[] | Promise<Stat[]>;
1457
+ /** Heading shown above the stats. */
1458
+ protected getHeading(): string | null;
1459
+ /** Description under the heading. */
1460
+ protected getDescription(): string | null;
1089
1461
  /** Row columns: three, or four when the number of stats fits better. */
1090
1462
  protected getColumns(count: number): number;
1091
1463
  render(): Promise<string>;
@@ -1102,6 +1474,10 @@ export type ChartData = {
1102
1474
  * has changed (checksum).
1103
1475
  */
1104
1476
  export declare abstract class ChartWidget extends Widget {
1477
+ /** Empty state shown when `getData()` returns nothing. */
1478
+ protected getEmptyStateHeading(): string;
1479
+ protected getEmptyStateDescription(): string | null;
1480
+ protected getEmptyStateIcon(): string | null;
1105
1481
  static callable: string[];
1106
1482
  static locked: string[];
1107
1483
  /** Polling interval, or `null` to disable polling. */
@@ -1209,6 +1585,27 @@ export declare class EmbeddedTable extends SchemaComponent {
1209
1585
  static make(): EmbeddedTable;
1210
1586
  toHtml(): Promise<string>;
1211
1587
  }
1588
+ /**
1589
+ * Several relation managers under one tab of a record page: `RelationGroup.make('Contacts', [PhonesManager, EmailsManager])`.
1590
+ * The managers are shown one below the other.
1591
+ */
1592
+ export declare class RelationGroup {
1593
+ private _label;
1594
+ private _managers;
1595
+ private _icon;
1596
+ private _badge;
1597
+ private _badgeColor;
1598
+ constructor(label: string, managers: (typeof RelationManager)[]);
1599
+ static make(label: string, managers: (typeof RelationManager)[]): RelationGroup;
1600
+ icon(name: string | null): this;
1601
+ badge(badge: string | number | null | (() => string | number | null | Promise<string | number | null>)): this;
1602
+ badgeColor(color: string): this;
1603
+ getLabel(): string;
1604
+ getIcon(): string | null;
1605
+ getManagers(): (typeof RelationManager)[];
1606
+ getBadge(): Promise<string | number | null>;
1607
+ getBadgeColor(): string;
1608
+ }
1212
1609
  /** List page tab: restricts the table query and can show a badge. */
1213
1610
  export declare class Tab {
1214
1611
  private _label;
@@ -1222,6 +1619,13 @@ export declare class Tab {
1222
1619
  badge(resolver: () => number | string | null | Promise<number | string | null>): this;
1223
1620
  badgeColor(color: string): this;
1224
1621
  query(modifier: (query: any) => void): this;
1622
+ private _icon;
1623
+ private _iconPosition;
1624
+ /** Icon of the tab, before its label (or after, with `iconPosition('after')`). */
1625
+ icon(name: string | null): this;
1626
+ iconPosition(position: 'before' | 'after'): this;
1627
+ getIcon(): string | null;
1628
+ getIconPosition(): 'before' | 'after';
1225
1629
  getLabel(key: string): string;
1226
1630
  hasBadge(): boolean;
1227
1631
  getBadge(): Promise<number | string | null>;
@@ -1234,6 +1638,8 @@ export declare class Tab {
1234
1638
  export declare class ListRecords extends Page implements TableHost {
1235
1639
  static callable: string[];
1236
1640
  static resource: typeof Resource;
1641
+ /** In a cluster, the list page shows the cluster's sub-navigation. */
1642
+ getSubNavigation(): SubNavigationItem[] | Promise<SubNavigationItem[]>;
1237
1643
  tableSearch: string;
1238
1644
  tableSort: string | null;
1239
1645
  tableColumnSearches: Record<string, string>;
@@ -1253,16 +1659,29 @@ export declare class ListRecords extends Page implements TableHost {
1253
1659
  toggleTableReordering(): Promise<void>;
1254
1660
  /** Saves the new order: only keys belonging to the table are kept, each gets its position. */
1255
1661
  reorderTable(order: unknown, _draggedRecordKey?: unknown): Promise<void>;
1662
+ /** Key of this table's persisted state: per page class (and resource or relationship). */
1663
+ protected tableSessionKey(part: string): string;
1664
+ /** Session of the persisted table state: the browser (its CSRF token) and the signed-in user. */
1665
+ private tableSession;
1666
+ dehydrate(): Promise<void>;
1667
+ /** Restores the persisted table state; every value is sanitized like browser input. */
1668
+ private restoreTableSession;
1256
1669
  mount(_params?: Record<string, unknown>): Promise<void>;
1257
1670
  /** Filters form: one group per filter, bound to `tableDeferredFilters.<filter>`. */
1258
1671
  tableFiltersFormSchema(schema: Schema): Schema;
1672
+ /** Applied state of every declared filter, sanitized from the given (browser) state. */
1673
+ protected sanitizeTableFilters(source: Record<string, unknown> | undefined): Record<string, FilterState>;
1674
+ /** Live filters (`deferFilters(false)`): a changed field applies at once. */
1675
+ updatedTableFilters(value: unknown, key: string | null): Promise<void>;
1259
1676
  applyTableFilters(): void;
1260
1677
  /** Removes an active filter indicator: the field is cleared from both the applied state and the form. */
1261
- removeTableFilter(filterName: unknown, field?: unknown): void;
1678
+ removeTableFilter(filterName: unknown, field?: unknown): Promise<void>;
1262
1679
  /** Live field in the filters form (e.g. a rule's operator changed): runs its `afterStateUpdated`. */
1263
1680
  updatedTableDeferredFilters(_value: unknown, key: string | null): Promise<void>;
1681
+ private runFilterFieldHook;
1264
1682
  resetTableFiltersForm(): void;
1265
- removeTableFilters(): void;
1683
+ /** "Remove all": resets every removable filter (to its empty state, not its default), the search and the column searches. */
1684
+ removeTableFilters(): Promise<void>;
1266
1685
  applyTableColumnManager(state?: unknown, _wasReordered?: unknown): void;
1267
1686
  resetTableColumnManager(): void;
1268
1687
  activeTab: string | null;
@@ -1292,10 +1711,23 @@ export declare class ListRecords extends Page implements TableHost {
1292
1711
  updateTableColumnState(columnName: unknown, recordKey: unknown, input: unknown): Promise<unknown>;
1293
1712
  /** Selected keys: the sent list, or, in "select all" mode, every record of the current query except deselected ones. */
1294
1713
  getSelectedRecordKeys(): Promise<string[]>;
1714
+ /**
1715
+ * Runs `process` for each selected record, loaded in chunks of `chunkSelectedRecords()`. Records failing the action's
1716
+ * `authorizeIndividualRecords()` are skipped; `process` reports a failure by returning `false` or throwing. Then sends the
1717
+ * action's success notification, or its failure notification with the number of records skipped and failed.
1718
+ */
1719
+ processSelectedRecords(action: Action, process: (record: any) => unknown | Promise<unknown>): Promise<{
1720
+ total: number;
1721
+ success: number;
1722
+ unauthorized: number;
1723
+ failed: number;
1724
+ }>;
1295
1725
  deselectAllTableRecords(): void;
1296
1726
  getTable(): Table;
1297
1727
  renderTable(): Promise<string>;
1728
+ protected renderTableEmptyStateActions(ctx: any): Promise<string[]>;
1298
1729
  /** Table header actions (none on a list page: they go in the page header). */
1730
+ protected renderTableToolbarActions(ctx: any): Promise<string[]>;
1299
1731
  protected renderTableHeaderActions(ctx: any): Promise<string[]>;
1300
1732
  sortTable(column?: string | null, direction?: string | null): void;
1301
1733
  gotoPage(page: number, _pageName?: string): void;
@@ -1332,6 +1764,19 @@ export declare class RelationManager extends ListRecords {
1332
1764
  static table(table: Table): Table;
1333
1765
  static form(schema: Schema): Schema;
1334
1766
  static getTitle(): string;
1767
+ /** Icon of the manager's tab. */
1768
+ static icon: string | null;
1769
+ static getIcon(): string | null;
1770
+ /** Badge of the manager's tab (a count of related records…), and its color. */
1771
+ static badge: string | number | null;
1772
+ static badgeColor: string;
1773
+ static getBadge(_ownerRecord: any, _pageClass: typeof Page): string | number | null | Promise<string | number | null>;
1774
+ static getBadgeColor(): string;
1775
+ /**
1776
+ * Whether the manager is shown for this record and page (all by default). Hide it here rather than by denying `viewAny`,
1777
+ * which would forbid the whole page.
1778
+ */
1779
+ static canViewForRecord(_ownerRecord: any, _pageClass: typeof Page): boolean | Promise<boolean>;
1335
1780
  /** Owner record key: set on mount by the page, never writable from the browser. */
1336
1781
  ownerRecord: string;
1337
1782
  static locked: string[];
@@ -1339,14 +1784,30 @@ export declare class RelationManager extends ListRecords {
1339
1784
  /** What ListRecords expects from a resource, provided here by the relation manager. */
1340
1785
  get resource(): any;
1341
1786
  getTable(): Table;
1342
- /** Attaches an existing record to the owner. */
1343
- static attach(_ownerKey: string, _recordKey: string): Promise<void>;
1787
+ /** Attaches an existing record to the owner; `pivot` holds the values of the `pivotForm()` fields. */
1788
+ static attach(_ownerKey: string, _recordKey: string, _pivot?: Record<string, unknown>): Promise<void>;
1789
+ /**
1790
+ * Fields stored on the pivot table (a role, a quantity…): added to the attach modal and to the edit form of a row.
1791
+ * Their values go to `attach()` and `updatePivot()`, never to the related record. List the pivot columns in the
1792
+ * relation's query (`adapterFor()`) so that the table and the edit form can read them.
1793
+ */
1794
+ static pivotForm(_schema: Schema): SchemaComponent[];
1795
+ /** Saves the pivot fields of an attached record (edit form of a row). */
1796
+ static updatePivot(_ownerKey: string, _recordKey: string, _pivot: Record<string, unknown>): Promise<void>;
1344
1797
  /** Detaches records from the owner without deleting them. */
1345
1798
  static detach(_ownerKey: string, _recordKeys: string[]): Promise<void>;
1346
1799
  /** Attachable records matching the search: [key, label]. */
1347
1800
  static searchAttachable(_ownerKey: string, _search: string): Promise<[string, string][]>;
1348
1801
  /** Label of an attachable record, or `null` if it is not attachable (also used as validation). */
1349
1802
  static attachableLabel(_ownerKey: string, _recordKey: string): Promise<string | null>;
1803
+ /** Sets the owner of an existing record (its foreign key). */
1804
+ static associate(_ownerKey: string, _recordKey: string): Promise<void>;
1805
+ /** Clears the owner of records (their foreign key), without deleting them. */
1806
+ static dissociate(_ownerKey: string, _recordKeys: string[]): Promise<void>;
1807
+ /** Associable records matching the search: [key, label]. */
1808
+ static searchAssociable(_ownerKey: string, _search: string): Promise<[string, string][]>;
1809
+ /** Label of an associable record, or `null` if it is not associable (also used as validation). */
1810
+ static associableLabel(_ownerKey: string, _recordKey: string): Promise<string | null>;
1350
1811
  getHeaderActions(): Action[];
1351
1812
  placeholder(): string;
1352
1813
  /** A relation manager has its own render hook locations, not the list page's. */
@@ -1402,6 +1863,20 @@ declare abstract class ResourceFormPage extends Page {
1402
1863
  updatedData(_value: unknown, key: string | null): Promise<void>;
1403
1864
  static resource: typeof Resource;
1404
1865
  data: Record<string, unknown>;
1866
+ /** Hash of the form data as last saved: the browser compares it with the current data (unsaved changes alert). */
1867
+ savedDataHash: string;
1868
+ static locked: string[];
1869
+ /** `true` / `false` overrides the panel's `databaseTransactions()` for this page. */
1870
+ static hasDatabaseTransactions: boolean | null;
1871
+ /** `true` / `false` overrides the panel's `unsavedChangesAlerts()` for this page. */
1872
+ static hasUnsavedDataChangesAlert: boolean | null;
1873
+ protected hasDatabaseTransactions(): boolean;
1874
+ protected hasUnsavedDataChangesAlert(): boolean;
1875
+ /** Runs `fn` in a database transaction of the resource's adapter, when the page uses them. */
1876
+ protected wrapInDatabaseTransaction<T>(fn: () => Promise<T>): Promise<T>;
1877
+ /** Remembers the form data as saved: leaving the page with other data asks for confirmation. */
1878
+ protected rememberData(): void;
1879
+ render(): Promise<string>;
1405
1880
  get resource(): typeof Resource;
1406
1881
  getDataAdapter(): DataAdapter;
1407
1882
  getRecordKey(): string | null;
@@ -1425,13 +1900,21 @@ declare abstract class ResourceFormPage extends Page {
1425
1900
  /** Where "Cancel" goes when the browser has no history: the list page, unless the page decides otherwise. */
1426
1901
  protected getCancelUrl(): string;
1427
1902
  protected cancelAction(): Action;
1903
+ /** Before the form is filled (with defaults, or with the record on the edit page). */
1904
+ protected beforeFill(): void | Promise<void>;
1905
+ /** After the form is filled. */
1906
+ protected afterFill(): void | Promise<void>;
1907
+ /** Before the form is validated, when it is submitted. */
1908
+ protected beforeValidate(): void | Promise<void>;
1909
+ /** After the form is validated successfully, before the data is transformed and saved. */
1910
+ protected afterValidate(): void | Promise<void>;
1428
1911
  /** Validates the form; on error, sets the errors on the component and notifies the browser. */
1429
1912
  protected validateForm(): Promise<boolean>;
1430
1913
  }
1431
1914
  /** Record creation page. */
1432
1915
  export declare class CreateRecord extends ResourceFormPage {
1433
1916
  static callable: string[];
1434
- mount(): void;
1917
+ mount(): Promise<void>;
1435
1918
  getOperation(): 'create';
1436
1919
  getSubmitMethod(): string;
1437
1920
  getTitle(): string;
@@ -1461,9 +1944,21 @@ export declare class DeleteAction {
1461
1944
  /** On an edit page, deletes the page's record and returns to the list. On a table row, deletes the row. */
1462
1945
  static make(): Action;
1463
1946
  }
1464
- /** Replicate action: an `Action` with a list of attributes not to copy. */
1947
+ /** Replicate action: an `Action` with a list of attributes not to copy, and hooks around the saving of the copy. */
1465
1948
  export type ReplicateActionInstance = Action & {
1466
1949
  excludeAttributes(attributes: string[]): ReplicateActionInstance;
1950
+ /** Runs before the copy is saved, with its attributes (`replica`, changeable) and the modal form's data. */
1951
+ beforeReplicaSaved(callback: (ctx: {
1952
+ replica: Record<string, unknown>;
1953
+ data: Record<string, unknown>;
1954
+ record: unknown;
1955
+ }) => unknown): ReplicateActionInstance;
1956
+ /** Runs after the copy is saved, with the saved record. */
1957
+ afterReplicaSaved(callback: (ctx: {
1958
+ replica: unknown;
1959
+ data: Record<string, unknown>;
1960
+ record: unknown;
1961
+ }) => unknown): ReplicateActionInstance;
1467
1962
  };
1468
1963
  /** Duplicates a table row: the copy takes the record's attributes, except its key, timestamps and excluded ones. */
1469
1964
  export declare class ReplicateAction {
@@ -1495,7 +1990,20 @@ export declare class DetachAction {
1495
1990
  export declare class DetachBulkAction {
1496
1991
  static make(): Action;
1497
1992
  }
1993
+ /** Gives an existing record to the owner of a one-to-many relation manager (its foreign key). */
1994
+ export declare class AssociateAction {
1995
+ static make(): Action;
1996
+ }
1997
+ /** Removes a row from the owner (clears its foreign key) without deleting it. */
1998
+ export declare class DissociateAction {
1999
+ static make(): Action;
2000
+ }
2001
+ /** Dissociates the selected rows. */
2002
+ export declare class DissociateBulkAction {
2003
+ static make(): Action;
2004
+ }
1498
2005
  /** Restores a soft-deleted record. Only visible when the record is deleted. */
2006
+ /** Restores a soft-deleted record: the page's record, or a table row. Only visible when the record is deleted. */
1499
2007
  export declare class RestoreAction {
1500
2008
  static make(): Action;
1501
2009
  }
@@ -1503,10 +2011,21 @@ export declare class RestoreAction {
1503
2011
  export declare class ForceDeleteAction {
1504
2012
  static make(): Action;
1505
2013
  }
1506
- /** Deletes the records selected in a table. */
2014
+ /**
2015
+ * Deletes the records selected in a table. Without `authorizeIndividualRecords()`, the selection is deleted in one query;
2016
+ * with it, record by record, and the notification reports the records that could not be deleted.
2017
+ */
1507
2018
  export declare class DeleteBulkAction {
1508
2019
  static make(): Action;
1509
2020
  }
2021
+ /** Restores the soft-deleted records selected in a table, each one checked with the `restore` ability. */
2022
+ export declare class RestoreBulkAction {
2023
+ static make(): Action;
2024
+ }
2025
+ /** Permanently deletes the soft-deleted records selected in a table, each one checked with the `forceDelete` ability. */
2026
+ export declare class ForceDeleteBulkAction {
2027
+ static make(): Action;
2028
+ }
1510
2029
  /** Record edit page. */
1511
2030
  export declare class EditRecord extends ResourceFormPage {
1512
2031
  static callable: string[];
@@ -1519,7 +2038,19 @@ export declare class EditRecord extends ResourceFormPage {
1519
2038
  */
1520
2039
  previousUrl: string;
1521
2040
  protected getCancelUrl(): string;
2041
+ /** Relation managers of this page: the resource's by default (`static relationManagers` on the page replaces them). */
2042
+ getRelationManagers(): (typeof RelationManager | RelationGroup)[];
2043
+ /** `true`: the form becomes the first tab, next to the relation managers. */
2044
+ hasCombinedRelationManagerTabsWithContent(): boolean;
2045
+ getContentTabLabel(): string;
2046
+ getContentTabIcon(): string | null;
2047
+ protected withRelationManagers(content: Schema): Schema;
1522
2048
  contentSchema(schema: Schema): Schema;
2049
+ /**
2050
+ * Saves the fields of one component of the form (a section with its own "Save" action), after validating them only:
2051
+ * `livewire.saveFormComponentOnly('contact')` (the component's `key()`, or the component).
2052
+ */
2053
+ saveFormComponentOnly(component: SchemaComponent | string, notificationTitle?: string | null): Promise<void>;
1523
2054
  static locked: string[];
1524
2055
  _record: any;
1525
2056
  mount(params: Record<string, unknown>): Promise<void>;
@@ -1574,6 +2105,13 @@ export declare class ViewRecord extends Page {
1574
2105
  getActionMountContext(): Record<string, unknown>;
1575
2106
  protected getActionRecord(): unknown;
1576
2107
  infolistSchema(schema: Schema): Schema;
2108
+ /** Relation managers of this page: the resource's by default (`static relationManagers` on the page replaces them). */
2109
+ getRelationManagers(): (typeof RelationManager | RelationGroup)[];
2110
+ /** `true`: the infolist becomes the first tab, next to the relation managers. */
2111
+ hasCombinedRelationManagerTabsWithContent(): boolean;
2112
+ getContentTabLabel(): string;
2113
+ getContentTabIcon(): string | null;
2114
+ protected withRelationManagers(content: Schema): Schema;
1577
2115
  contentSchema(schema: Schema): Schema;
1578
2116
  /** Defaults to a link to the edit page. */
1579
2117
  getHeaderActions(): Action[];
@@ -1586,6 +2124,17 @@ export declare class ViewRecord extends Page {
1586
2124
  getSubNavigation(): SubNavigationItem[];
1587
2125
  getPageClasses(): string[];
1588
2126
  }
2127
+ /** Tenant menu: current tenant, other tenants, registration, profile. */
2128
+ /** Options of an error page: a description replacing the status's own, and the error (its stack is shown in development). */
2129
+ export type ErrorPageOptions = {
2130
+ description?: string;
2131
+ error?: unknown;
2132
+ };
2133
+ /**
2134
+ * Answers with the panel's error page for a status (404, 403, 500…): the cable car in trouble, in the request's theme.
2135
+ * Requests that do not accept HTML get plain text. Use it in your own middleware, a maintenance mode answering 503 for example.
2136
+ */
2137
+ export declare function renderErrorPage(panel: Panel, req: Request, res: Response, status: number, options?: ErrorPageOptions): void;
1589
2138
  /** Sidebar: logo, tenant menu, search, navigation and, without a topbar, the bell and user menu. */
1590
2139
  export declare class Sidebar extends Component {
1591
2140
  static callable: string[];
@@ -1620,5 +2169,6 @@ export declare const discoverPages: (directory: string | URL) => Promise<(typeof
1620
2169
  export declare const discoverWidgets: (directory: string | URL) => Promise<(typeof Widget)[]>;
1621
2170
  /** Panels default-exported (`export default Panel.make()…`) by a folder's modules. */
1622
2171
  export declare function discoverPanels(directory: string | URL): Promise<Panel[]>;
1623
- /** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
1624
2172
  export declare function panelRouter(panel: Panel, runtime: WireRuntime): Router;
2173
+ /** The panel's 404 page for URLs nothing else answered: mount it after your routes, for a panel served at the root. */
2174
+ export declare function panelNotFound(panel: Panel): (req: Request, res: Response) => void;