@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/THIRD-PARTY-NOTICES.md +1 -0
- package/dist/navette/navette/calendar.css +5 -5
- package/dist/navette/navette/motion.css +21 -21
- package/dist/navette/navette/motion.js +6 -6
- package/dist/navette/navette/security-keys.css +11 -0
- package/dist/navette/navette/security-keys.js +72 -0
- package/lib/auth.d.ts +17 -0
- package/lib/auth.js +70 -0
- package/lib/error-pages.d.ts +9 -0
- package/lib/error-pages.js +173 -0
- package/lib/import-export.d.ts +45 -2
- package/lib/import-export.js +235 -62
- package/lib/index.d.ts +447 -74
- package/lib/index.js +1280 -267
- package/lib/multi-factor.d.ts +348 -0
- package/lib/multi-factor.js +928 -0
- package/lib/qr-code.d.ts +16 -0
- package/lib/qr-code.js +255 -0
- package/lib/throttle.d.ts +10 -0
- package/lib/throttle.js +25 -0
- package/lib/topbar.d.ts +3 -1
- package/lib/topbar.js +18 -1
- package/lib/totp.d.ts +38 -0
- package/lib/totp.js +104 -0
- package/lib/webauthn.d.ts +155 -0
- package/lib/webauthn.js +287 -0
- package/lib/xlsx.d.ts +5 -0
- package/lib/xlsx.js +103 -1
- package/package.json +9 -9
- package/views/components/tenant-menu.ejs +3 -3
- package/views/errors/page.ejs +87 -0
- package/views/layout/base.ejs +2 -0
- package/views/livewire/sidebar.ejs +29 -15
- package/views/livewire/topbar.ejs +1 -1
- package/views/pages/page.ejs +53 -35
- package/views/pages/simple.ejs +4 -0
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 {
|
|
13
|
-
export {
|
|
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 {
|
|
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) {
|
|
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
|
-
|
|
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 = {
|
|
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 {
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
/**
|
|
751
|
-
async
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
760
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1488
|
-
Form.make([]).id('multiFactorChallengeForm').
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 =
|
|
2230
|
-
const tabId = (
|
|
2231
|
-
const
|
|
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
|
|
2900
|
+
for (const tab of tabs) {
|
|
2234
2901
|
let content = '';
|
|
2235
|
-
if (
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
|
|
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: ${
|
|
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">${
|
|
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
|
-
|
|
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),
|
|
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.
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
3334
|
-
|
|
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(
|
|
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
|
-
|
|
3350
|
-
|
|
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
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
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.
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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) => (
|
|
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) =>
|
|
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
|
|
3827
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
5014
|
+
renderErrorPage(panel, req, res, 404);
|
|
4061
5015
|
return null;
|
|
4062
5016
|
}
|
|
4063
5017
|
}
|
|
4064
5018
|
return ctx;
|
|
4065
5019
|
};
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
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
|
|
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(
|
|
4085
|
-
if (
|
|
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
|
|
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 (
|
|
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
|
}
|