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