@navette-js/panels 1.0.0-beta.4 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +1 -0
- package/dist/navette/navette/calendar.css +5 -5
- package/dist/navette/navette/motion.css +21 -21
- package/dist/navette/navette/motion.js +6 -6
- package/dist/navette/navette/security-keys.css +11 -0
- package/dist/navette/navette/security-keys.js +72 -0
- package/lib/auth.d.ts +17 -0
- package/lib/auth.js +70 -0
- package/lib/error-pages.d.ts +9 -0
- package/lib/error-pages.js +173 -0
- package/lib/import-export.d.ts +45 -2
- package/lib/import-export.js +235 -62
- package/lib/index.d.ts +447 -74
- package/lib/index.js +1280 -267
- package/lib/multi-factor.d.ts +348 -0
- package/lib/multi-factor.js +928 -0
- package/lib/qr-code.d.ts +16 -0
- package/lib/qr-code.js +255 -0
- package/lib/throttle.d.ts +10 -0
- package/lib/throttle.js +25 -0
- package/lib/topbar.d.ts +3 -1
- package/lib/topbar.js +18 -1
- package/lib/totp.d.ts +38 -0
- package/lib/totp.js +104 -0
- package/lib/webauthn.d.ts +155 -0
- package/lib/webauthn.js +287 -0
- package/lib/xlsx.d.ts +5 -0
- package/lib/xlsx.js +103 -1
- package/package.json +9 -9
- package/views/components/tenant-menu.ejs +3 -3
- package/views/errors/page.ejs +87 -0
- package/views/layout/base.ejs +2 -0
- package/views/livewire/sidebar.ejs +29 -15
- package/views/livewire/topbar.ejs +1 -1
- package/views/pages/page.ejs +53 -35
- package/views/pages/simple.ejs +4 -0
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -7,6 +7,7 @@ Navette is an independent project, not affiliated with or endorsed by the Filame
|
|
|
7
7
|
| [Filament](https://github.com/filamentphp/filament) © Filament | HTML markup derived from its Blade views, compiled CSS and JavaScript (`@navette-js/panels/dist/filament`), English translations (`@navette-js/support/data`), image editor icons (`@navette-js/support/icons/filament`) | MIT | `packages/panels/dist/FILAMENT-LICENSE.md`, `packages/support/icons/filament/LICENSE.md` |
|
|
8
8
|
| [Livewire](https://github.com/livewire/livewire) © Caleb Porzio | Client script `livewire.js` (`@navette-js/wire/vendor`); the server runtime follows its protocol | MIT | `packages/wire/vendor/LIVEWIRE-LICENSE.md` |
|
|
9
9
|
| [Heroicons](https://github.com/tailwindlabs/heroicons) | SVG icons (`@navette-js/support/icons`) | MIT | `packages/support/icons/HEROICONS-LICENSE.md` |
|
|
10
|
+
| [QR Code generator library](https://www.nayuki.io/page/qr-code-generator-library) © Project Nayuki | Structure of the QR Code encoder (`@navette-js/panels`, `qr-code.ts`) | MIT | header of `packages/panels/src/qr-code.ts` |
|
|
10
11
|
| [Inter](https://github.com/rsms/inter) © The Inter Project Authors | Default font files (`@navette-js/panels/dist/filament/fonts`) | SIL Open Font License 1.1 | `packages/panels/dist/filament/fonts/INTER-LICENSE.txt` |
|
|
11
12
|
|
|
12
13
|
Runtime dependencies installed from npm (Express, Knex, Objection, better-sqlite3, Tiptap, EJS, Busboy…) keep their own licenses.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Navette —
|
|
2
|
+
* Navette — calendar (CalendarWidget).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `getExtraClasses()`)
|
|
6
|
-
* `fi-color fi-color-<
|
|
4
|
+
* A Navette component. Everything goes through variables: redefine them on `.nv-cal` (or on a class added by
|
|
5
|
+
* `getExtraClasses()`) to change the height of the cells, the radii, the borders… Event colors come from Filament's
|
|
6
|
+
* `fi-color fi-color-<name>` classes: any color declared by the panel works.
|
|
7
7
|
*/
|
|
8
8
|
.nv-cal {
|
|
9
9
|
--nv-cal-border: var(--gray-200);
|
|
@@ -86,7 +86,7 @@ button.nv-cal-day-number:hover { background: var(--nv-cal-surface-muted); }
|
|
|
86
86
|
:where(.dark) :is(a, button).nv-cal-event:hover { background: color-mix(in oklab, var(--color-400) 22%, transparent); }
|
|
87
87
|
.nv-cal-event-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
88
88
|
.nv-cal-event-time { flex: none; font-variant-numeric: tabular-nums; opacity: 0.8; }
|
|
89
|
-
/*
|
|
89
|
+
/* An event over several days: its inner edges are straight and touch the next cell. */
|
|
90
90
|
.nv-cal-grid .nv-cal-event-continued { border-start-start-radius: 0; border-end-start-radius: 0; margin-inline-start: -0.375rem; }
|
|
91
91
|
.nv-cal-grid .nv-cal-event-continues { border-start-end-radius: 0; border-end-end-radius: 0; margin-inline-end: -0.375rem; }
|
|
92
92
|
.nv-cal-more { align-self: flex-start; padding: 0 0.375rem; border-radius: var(--nv-cal-event-radius); font-size: 0.75rem; font-weight: 500; color: var(--nv-cal-muted); }
|
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Navette —
|
|
2
|
+
* Navette — motion and loading indicators.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A deliberate difference from FilamentPHP (decided on 2026-11-12): this sheet only adds motion and indicators, without
|
|
5
|
+
* touching the markup or the dimensions. `Panel.motion(false)` turns it off, which renders Filament's interface as is.
|
|
6
|
+
* The curves follow the spirit of iOS: soft springs, quick exits, a slight scale on press.
|
|
7
|
+
* Everything is neutralized for people who ask for less motion (`prefers-reduced-motion`).
|
|
8
8
|
*/
|
|
9
9
|
:root {
|
|
10
|
-
--nv-spring: cubic-bezier(0.32, 0.72, 0, 1); /*
|
|
11
|
-
--nv-bounce: cubic-bezier(0.2, 0.9, 0.3, 1.25); /*
|
|
10
|
+
--nv-spring: cubic-bezier(0.32, 0.72, 0, 1); /* damped slide, without overshoot */
|
|
11
|
+
--nv-bounce: cubic-bezier(0.2, 0.9, 0.3, 1.25); /* slight overshoot on arrival */
|
|
12
12
|
--nv-out: cubic-bezier(0.4, 0, 0.2, 1);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
@media (prefers-reduced-motion: no-preference) {
|
|
16
|
-
/* ---
|
|
16
|
+
/* --- page arrival: the content rises a few pixels as it appears */
|
|
17
17
|
@keyframes nv-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
18
|
-
/* `backwards
|
|
18
|
+
/* `backwards`, not `both`: a `transform` animation still applied after its end makes the element the containing block of its `position: fixed` descendants; the backdrop of a modal then only covered the page, not the window. */
|
|
19
19
|
.fi-page { animation: nv-rise 0.42s var(--nv-spring) backwards; }
|
|
20
20
|
.fi-simple-main { animation: nv-rise 0.5s var(--nv-spring) backwards; }
|
|
21
21
|
|
|
22
|
-
/* ---
|
|
22
|
+
/* --- press: buttons, action links, navigation items */
|
|
23
23
|
.fi-btn, .fi-icon-btn, .fi-link, .fi-tabs-item, .fi-sidebar-item-btn, .fi-dropdown-list-item, .fi-pagination-item-btn {
|
|
24
24
|
transition: transform 0.18s var(--nv-spring), background-color 0.2s var(--nv-out), color 0.2s var(--nv-out), box-shadow 0.2s var(--nv-out), opacity 0.2s var(--nv-out);
|
|
25
25
|
}
|
|
26
26
|
.fi-btn:active:not(.fi-disabled, [disabled]), .fi-icon-btn:active:not(.fi-disabled, [disabled]) { transform: scale(0.96); }
|
|
27
27
|
.fi-link:active, .fi-tabs-item:active, .fi-sidebar-item-btn:active, .fi-dropdown-list-item:active:not(.fi-disabled), .fi-pagination-item-btn:active { transform: scale(0.97); }
|
|
28
28
|
|
|
29
|
-
/* ---
|
|
29
|
+
/* --- modals: the window arrives with a spring; the backdrop stays Filament's (dark veil, no blur) */
|
|
30
30
|
.fi-modal-window { transition-timing-function: var(--nv-spring) !important; transition-duration: 0.42s !important; }
|
|
31
31
|
.fi-modal-window.fi-transition-leave { transition-timing-function: var(--nv-out) !important; transition-duration: 0.2s !important; }
|
|
32
32
|
|
|
33
|
-
/* ---
|
|
33
|
+
/* --- dropdowns: they open from their trigger */
|
|
34
34
|
@keyframes nv-pop { from { opacity: 0; transform: scale(0.94) translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
35
35
|
.fi-dropdown-panel:not([style*="display: none"]) { animation: nv-pop 0.26s var(--nv-bounce); transform-origin: top; }
|
|
36
36
|
|
|
37
|
-
/* --- notifications
|
|
37
|
+
/* --- notifications: damped side entrance */
|
|
38
38
|
.fi-no-notification { transition-timing-function: var(--nv-spring) !important; transition-duration: 0.45s !important; }
|
|
39
39
|
|
|
40
40
|
/* --- surfaces et champs */
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
.fi-badge { transition: background-color 0.2s var(--nv-out), color 0.2s var(--nv-out); }
|
|
45
45
|
.fi-sidebar-group-items { transition: opacity 0.25s var(--nv-out); }
|
|
46
46
|
|
|
47
|
-
/* ---
|
|
47
|
+
/* --- loading indicator of deferred areas: a spinner, instead of an empty frame */
|
|
48
48
|
@keyframes nv-spin { to { transform: rotate(360deg); } }
|
|
49
49
|
@keyframes nv-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
|
50
50
|
.fi-loading-section { position: relative; animation: nv-breathe 1.6s ease-in-out infinite; }
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
animation: nv-spin 0.7s linear infinite;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/* ---
|
|
58
|
-
/*
|
|
57
|
+
/* --- elements that arrive after the page is displayed: they unfold instead of popping in */
|
|
58
|
+
/* tab counter (loaded afterwards): it widens from zero, and the tab stretches with it */
|
|
59
59
|
@keyframes nv-expand {
|
|
60
60
|
from { opacity: 0; max-width: 0; padding-inline: 0; margin-inline-start: -0.5rem; transform: scale(0.6); }
|
|
61
61
|
60% { opacity: 1; }
|
|
@@ -64,22 +64,22 @@
|
|
|
64
64
|
.fi-tabs-item > .fi-badge { overflow: hidden; white-space: nowrap; transform-origin: left center; animation: nv-expand 0.5s var(--nv-spring) both; }
|
|
65
65
|
/* roue d'attente du compteur : elle s'efface en douceur */
|
|
66
66
|
.fi-tabs-item-badge-placeholder { transition: opacity 0.2s var(--nv-out); }
|
|
67
|
-
/*
|
|
67
|
+
/* area loaded on demand (relation manager, widget): its content rises into place when it replaces the spinner */
|
|
68
68
|
.fi-resource-relation-manager, .fi-wi-widget { animation: nv-rise 0.45s var(--nv-spring) backwards; }
|
|
69
|
-
/*
|
|
69
|
+
/* rows of a table that was just reloaded, validation errors, items added to a Repeater */
|
|
70
70
|
@keyframes nv-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
71
71
|
@keyframes nv-unfold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
72
72
|
.fi-fo-field-wrp-error-message { animation: nv-unfold 0.3s var(--nv-spring) backwards; }
|
|
73
|
-
/* `backwards
|
|
73
|
+
/* `backwards`, not `both`: an opacity animation still applied keeps the row a stacking context; the list of a select in the row then went under the Repeater's add button. */
|
|
74
74
|
.fi-fo-table-repeater tbody tr { animation: nv-fade-in 0.3s var(--nv-out) backwards; }
|
|
75
75
|
.fi-ta-summary-row, .fi-ta-summary-header-row { animation: nv-fade-in 0.35s var(--nv-out) backwards; }
|
|
76
76
|
|
|
77
|
-
/* ---
|
|
77
|
+
/* --- content being reloaded: it fades slightly during the request */
|
|
78
78
|
.fi-ta-content-ctn { transition: opacity 0.2s var(--nv-out); }
|
|
79
79
|
html[data-nv-busy] .fi-ta-content-ctn { opacity: 0.6; }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/* ---
|
|
82
|
+
/* --- activity pill: visible while a request takes a while (added by motion.js) */
|
|
83
83
|
.nv-busy {
|
|
84
84
|
position: fixed; top: 0.75rem; left: 50%; z-index: 60; display: flex; align-items: center; gap: 0.5rem;
|
|
85
85
|
padding: 0.4rem 0.8rem; border-radius: 9999px; font-size: 0.8125rem; font-weight: 500; pointer-events: none;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Navette
|
|
2
|
-
//
|
|
1
|
+
// Navette activity pill: signals a request that takes a while, so that the screen never looks frozen.
|
|
2
|
+
// A deliberate difference from FilamentPHP, turned off with `Panel.motion(false)`. No dependency: it relies on Livewire's hooks.
|
|
3
3
|
(() => {
|
|
4
|
-
const DELAY = 350; //
|
|
4
|
+
const DELAY = 350; // below this, the response arrives before an indicator would help
|
|
5
5
|
let pending = 0;
|
|
6
6
|
let timer = null;
|
|
7
7
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
element.className = 'nv-busy';
|
|
13
13
|
element.setAttribute('role', 'status');
|
|
14
14
|
element.setAttribute('aria-live', 'polite');
|
|
15
|
-
//
|
|
15
|
+
// Kept from one page to the next by Livewire's navigation.
|
|
16
16
|
element.setAttribute('wire:ignore', '');
|
|
17
17
|
element.setAttribute('x-persist', 'nv-busy');
|
|
18
18
|
element.textContent = document.documentElement.lang?.startsWith('fr') ? 'Chargement…' : 'Loading…';
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
const end = () => { pending = Math.max(0, pending - 1); update(); };
|
|
35
35
|
|
|
36
36
|
document.addEventListener('livewire:init', () => {
|
|
37
|
-
//
|
|
37
|
+
// Every update request of a component.
|
|
38
38
|
window.Livewire.hook('commit', ({ succeed, fail }) => {
|
|
39
39
|
begin();
|
|
40
40
|
let done = false;
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
fail(finish);
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
|
-
// Navigation
|
|
46
|
+
// Navigation between pages.
|
|
47
47
|
document.addEventListener('livewire:navigate', begin);
|
|
48
48
|
document.addEventListener('livewire:navigated', () => { pending = 0; update(); });
|
|
49
49
|
})();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* Navette: SecurityKeyInput field and the list of registered security keys. */
|
|
2
|
+
.nv-security-key { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
|
3
|
+
.nv-security-key-status { font-size: 0.875rem; line-height: 1.25rem; color: var(--gray-600); }
|
|
4
|
+
.dark .nv-security-key-status { color: var(--gray-400); }
|
|
5
|
+
.nv-security-key-status-error { color: var(--danger-600); }
|
|
6
|
+
.dark .nv-security-key-status-error { color: var(--danger-400); }
|
|
7
|
+
.nv-security-keys { display: grid; gap: 0.25rem; font-size: 0.875rem; line-height: 1.25rem; }
|
|
8
|
+
.nv-security-key-name { font-weight: 500; color: var(--gray-950); }
|
|
9
|
+
.dark .nv-security-key-name { color: #fff; }
|
|
10
|
+
.nv-security-key-meta { color: var(--gray-500); }
|
|
11
|
+
.dark .nv-security-key-meta { color: var(--gray-400); }
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Navette: WebAuthn ceremonies of the SecurityKeyInput field (security keys and passkeys). No dependency.
|
|
2
|
+
document.addEventListener('alpine:init', () => {
|
|
3
|
+
const toBuffer = (value) => {
|
|
4
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
5
|
+
return Uint8Array.from(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')), (character) => character.charCodeAt(0)).buffer;
|
|
6
|
+
};
|
|
7
|
+
const toBase64url = (buffer) => {
|
|
8
|
+
let binary = '';
|
|
9
|
+
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte);
|
|
10
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
11
|
+
};
|
|
12
|
+
const withIds = (credentials) => (credentials ?? []).map((credential) => ({ ...credential, id: toBuffer(credential.id) }));
|
|
13
|
+
const creationOptions = (options) => (typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function'
|
|
14
|
+
? PublicKeyCredential.parseCreationOptionsFromJSON(options)
|
|
15
|
+
: { ...options, challenge: toBuffer(options.challenge), user: { ...options.user, id: toBuffer(options.user.id) }, excludeCredentials: withIds(options.excludeCredentials) });
|
|
16
|
+
const requestOptions = (options) => (typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function'
|
|
17
|
+
? PublicKeyCredential.parseRequestOptionsFromJSON(options)
|
|
18
|
+
: { ...options, challenge: toBuffer(options.challenge), allowCredentials: withIds(options.allowCredentials) });
|
|
19
|
+
const serialize = (credential) => {
|
|
20
|
+
try { if (typeof credential.toJSON === 'function') return credential.toJSON(); } catch (error) { /* some password managers return incomplete objects */ }
|
|
21
|
+
const response = credential.response;
|
|
22
|
+
const result = { id: credential.id, rawId: toBase64url(credential.rawId), type: credential.type, response: { clientDataJSON: toBase64url(response.clientDataJSON) } };
|
|
23
|
+
if (response.attestationObject) {
|
|
24
|
+
result.response.attestationObject = toBase64url(response.attestationObject);
|
|
25
|
+
result.response.transports = typeof response.getTransports === 'function' ? response.getTransports() : [];
|
|
26
|
+
} else {
|
|
27
|
+
result.response.authenticatorData = toBase64url(response.authenticatorData);
|
|
28
|
+
result.response.signature = toBase64url(response.signature);
|
|
29
|
+
result.response.userHandle = response.userHandle ? toBase64url(response.userHandle) : null;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
window.Alpine.data('navetteSecurityKey', ({ state, mode, key, submitOnSuccess, autoStart, messages }) => ({
|
|
35
|
+
state,
|
|
36
|
+
isWaiting: false,
|
|
37
|
+
isError: false,
|
|
38
|
+
message: null,
|
|
39
|
+
init() {
|
|
40
|
+
if (autoStart && !this.state) this.$nextTick(() => this.start(true));
|
|
41
|
+
},
|
|
42
|
+
async start(isAutomatic = false) {
|
|
43
|
+
if (this.isWaiting) return;
|
|
44
|
+
if (!window.PublicKeyCredential || !navigator.credentials) { this.fail(messages.unsupported); return; }
|
|
45
|
+
this.isWaiting = true;
|
|
46
|
+
this.isError = false;
|
|
47
|
+
this.message = messages.waiting;
|
|
48
|
+
try {
|
|
49
|
+
const options = await this.$wire.callSchemaComponentMethod(key, 'getPublicKeyOptions', {});
|
|
50
|
+
if (!options) throw new Error('No options.');
|
|
51
|
+
const credential = mode === 'register'
|
|
52
|
+
? await navigator.credentials.create({ publicKey: creationOptions(options) })
|
|
53
|
+
: await navigator.credentials.get({ publicKey: requestOptions(options) });
|
|
54
|
+
if (!credential) throw new Error('No credential.');
|
|
55
|
+
this.state = JSON.stringify(serialize(credential));
|
|
56
|
+
this.message = messages.success;
|
|
57
|
+
if (submitOnSuccess) this.$nextTick(() => this.$root.closest('form')?.requestSubmit());
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// An automatic start may be refused without a click: the button stays there, without an error.
|
|
60
|
+
if (isAutomatic && error?.name === 'NotAllowedError') this.message = null;
|
|
61
|
+
else if (['NotAllowedError', 'AbortError'].includes(error?.name)) this.fail(messages.cancelled);
|
|
62
|
+
else { console.error(error); this.fail(messages.failed); }
|
|
63
|
+
} finally {
|
|
64
|
+
this.isWaiting = false;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
fail(text) {
|
|
68
|
+
this.isError = true;
|
|
69
|
+
this.message = text;
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
});
|
package/lib/auth.d.ts
CHANGED
|
@@ -46,7 +46,23 @@ export type TableQuery = (table: string) => any;
|
|
|
46
46
|
export declare function knexAuthProvider(db: TableQuery, options?: {
|
|
47
47
|
table?: string;
|
|
48
48
|
verify?: (password: string, hash: string) => boolean | Promise<boolean>;
|
|
49
|
+
/** Multi-factor authentication storage: users table columns and security keys table (see `multiFactorAuthenticationSchema()`). */
|
|
50
|
+
appAuthenticationSecretColumn?: string;
|
|
51
|
+
appAuthenticationRecoveryCodesColumn?: string;
|
|
52
|
+
securityKeysTable?: string;
|
|
53
|
+
emailAuthenticationColumn?: string;
|
|
49
54
|
}): AuthProvider;
|
|
55
|
+
/**
|
|
56
|
+
* Adds what multi-factor authentication stores, when missing: three columns of the users table (authenticator app secret,
|
|
57
|
+
* recovery codes, email codes flag) and a `security_keys` table. Call it from a migration; it can run more than once.
|
|
58
|
+
*/
|
|
59
|
+
export declare function multiFactorAuthenticationSchema(knex: any, options?: {
|
|
60
|
+
usersTable?: string;
|
|
61
|
+
securityKeysTable?: string;
|
|
62
|
+
appAuthenticationSecretColumn?: string;
|
|
63
|
+
appAuthenticationRecoveryCodesColumn?: string;
|
|
64
|
+
emailAuthenticationColumn?: string;
|
|
65
|
+
}): Promise<void>;
|
|
50
66
|
/**
|
|
51
67
|
* Tenants (teams) stored in a table and linked to users through a pivot table (`teams` and `team_user` by default).
|
|
52
68
|
* `label`: text shown above the tenant name in the menu ("Current team").
|
|
@@ -58,4 +74,5 @@ export declare function knexTenantProvider(db: TableQuery, options?: {
|
|
|
58
74
|
userKey?: string;
|
|
59
75
|
nameAttribute?: string;
|
|
60
76
|
label?: string | null;
|
|
77
|
+
defaultTenantAttribute?: string;
|
|
61
78
|
}): TenantProvider;
|
package/lib/auth.js
CHANGED
|
@@ -74,7 +74,45 @@ const now = () => new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
|
74
74
|
* `verify(password, hash)`: it is called for any stored hash that is not in scrypt format.
|
|
75
75
|
*/
|
|
76
76
|
export function knexAuthProvider(db, options = {}) {
|
|
77
|
+
const emailColumn = options.emailAuthenticationColumn ?? 'has_email_authentication';
|
|
77
78
|
const table = options.table ?? 'users';
|
|
79
|
+
const secretColumn = options.appAuthenticationSecretColumn ?? 'app_authentication_secret';
|
|
80
|
+
const recoveryColumn = options.appAuthenticationRecoveryCodesColumn ?? 'app_authentication_recovery_codes';
|
|
81
|
+
const keysTable = options.securityKeysTable ?? 'security_keys';
|
|
82
|
+
const parseList = (value) => { try {
|
|
83
|
+
const list = JSON.parse(String(value ?? '[]'));
|
|
84
|
+
return Array.isArray(list) ? list.map(String) : [];
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
} };
|
|
89
|
+
const toKey = (row) => ({
|
|
90
|
+
id: row.id, name: row.name, credentialId: row.credential_id, publicKey: row.public_key, algorithm: Number(row.algorithm), signCount: Number(row.sign_count ?? 0),
|
|
91
|
+
transports: parseList(row.transports), createdAt: row.created_at ? String(row.created_at) : null, lastUsedAt: row.last_used_at ? String(row.last_used_at) : null,
|
|
92
|
+
});
|
|
93
|
+
const multiFactor = {
|
|
94
|
+
async getAppAuthenticationSecret(user) { return (await db(table).where('id', user.id).first())?.[secretColumn] ?? null; },
|
|
95
|
+
async saveAppAuthenticationSecret(user, secret) { await db(table).where('id', user.id).update({ [secretColumn]: secret, updated_at: now() }); },
|
|
96
|
+
async getAppAuthenticationRecoveryCodes(user) { const value = (await db(table).where('id', user.id).first())?.[recoveryColumn]; return value === null || value === undefined ? null : parseList(value); },
|
|
97
|
+
async saveAppAuthenticationRecoveryCodes(user, codes) { await db(table).where('id', user.id).update({ [recoveryColumn]: codes === null ? null : JSON.stringify(codes), updated_at: now() }); },
|
|
98
|
+
async hasEmailAuthentication(user) { return Boolean((await db(table).where('id', user.id).first())?.[emailColumn]); },
|
|
99
|
+
async saveEmailAuthentication(user, enabled) { await db(table).where('id', user.id).update({ [emailColumn]: enabled, updated_at: now() }); },
|
|
100
|
+
async getSecurityKeys(user) { return (await db(keysTable).where('user_id', user.id).orderBy('id')).map(toKey); },
|
|
101
|
+
async createSecurityKey(user, key) {
|
|
102
|
+
await db(keysTable).insert({ user_id: user.id, name: key.name, credential_id: key.credentialId, public_key: key.publicKey, algorithm: key.algorithm, sign_count: key.signCount, transports: JSON.stringify(key.transports ?? []), created_at: now(), updated_at: now() });
|
|
103
|
+
},
|
|
104
|
+
async updateSecurityKey(user, id, changes) {
|
|
105
|
+
const values = { updated_at: now() };
|
|
106
|
+
if (changes.signCount !== undefined)
|
|
107
|
+
values.sign_count = changes.signCount;
|
|
108
|
+
if (changes.lastUsedAt !== undefined)
|
|
109
|
+
values.last_used_at = changes.lastUsedAt.slice(0, 19).replace('T', ' ');
|
|
110
|
+
if (changes.name !== undefined)
|
|
111
|
+
values.name = changes.name;
|
|
112
|
+
await db(keysTable).where({ id, user_id: user.id }).update(values);
|
|
113
|
+
},
|
|
114
|
+
async deleteSecurityKey(user, id) { await db(keysTable).where({ id, user_id: user.id }).delete(); },
|
|
115
|
+
};
|
|
78
116
|
const toUser = (row) => (row ? { id: row.id, name: row.name, email: row.email, emailVerifiedAt: row.email_verified_at ?? null } : null);
|
|
79
117
|
const byEmail = (email) => db(table).where('email', String(email)).first();
|
|
80
118
|
return {
|
|
@@ -110,8 +148,38 @@ export function knexAuthProvider(db, options = {}) {
|
|
|
110
148
|
async hasVerifiedEmail(user) { return Boolean((await db(table).where('id', user.id).first())?.email_verified_at); },
|
|
111
149
|
async markEmailAsVerified(user) { await db(table).where('id', user.id).update({ email_verified_at: now(), updated_at: now() }); },
|
|
112
150
|
async getPasswordFingerprint(user) { return fingerprint((await db(table).where('id', user.id).first())?.password); },
|
|
151
|
+
...multiFactor,
|
|
113
152
|
};
|
|
114
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Adds what multi-factor authentication stores, when missing: three columns of the users table (authenticator app secret,
|
|
156
|
+
* recovery codes, email codes flag) and a `security_keys` table. Call it from a migration; it can run more than once.
|
|
157
|
+
*/
|
|
158
|
+
export async function multiFactorAuthenticationSchema(knex, options = {}) {
|
|
159
|
+
const users = options.usersTable ?? 'users', keys = options.securityKeysTable ?? 'security_keys';
|
|
160
|
+
const secret = options.appAuthenticationSecretColumn ?? 'app_authentication_secret', codes = options.appAuthenticationRecoveryCodesColumn ?? 'app_authentication_recovery_codes';
|
|
161
|
+
for (const column of [secret, codes])
|
|
162
|
+
if (!(await knex.schema.hasColumn(users, column)))
|
|
163
|
+
await knex.schema.alterTable(users, (table) => { table.text(column).nullable(); });
|
|
164
|
+
const email = options.emailAuthenticationColumn ?? 'has_email_authentication';
|
|
165
|
+
if (!(await knex.schema.hasColumn(users, email)))
|
|
166
|
+
await knex.schema.alterTable(users, (table) => { table.boolean(email).notNullable().defaultTo(false); });
|
|
167
|
+
if (!(await knex.schema.hasTable(keys))) {
|
|
168
|
+
await knex.schema.createTable(keys, (table) => {
|
|
169
|
+
table.increments('id');
|
|
170
|
+
table.integer('user_id').notNullable().index();
|
|
171
|
+
table.string('name', 60);
|
|
172
|
+
// Unique: a credential can only belong to one account.
|
|
173
|
+
table.string('credential_id', 1400).notNullable().unique();
|
|
174
|
+
table.text('public_key').notNullable();
|
|
175
|
+
table.integer('algorithm').notNullable();
|
|
176
|
+
table.bigInteger('sign_count').defaultTo(0);
|
|
177
|
+
table.text('transports').nullable();
|
|
178
|
+
table.timestamp('last_used_at').nullable();
|
|
179
|
+
table.timestamps();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
115
183
|
/**
|
|
116
184
|
* Tenants (teams) stored in a table and linked to users through a pivot table (`teams` and `team_user` by default).
|
|
117
185
|
* `label`: text shown above the tenant name in the menu ("Current team").
|
|
@@ -126,6 +194,8 @@ export function knexTenantProvider(db, options = {}) {
|
|
|
126
194
|
getKey: (tenant) => String(tenant.id),
|
|
127
195
|
getName: (tenant) => String(tenant[nameAttribute] ?? ''),
|
|
128
196
|
getCurrentTenantLabel: () => options.label ?? null,
|
|
197
|
+
// The user's column holding their default tenant (`latest_team_id`): the tenant they land on after signing in.
|
|
198
|
+
async getDefaultTenant(user) { const key = options.defaultTenantAttribute ? user[options.defaultTenantAttribute] : null; return key === null || key === undefined ? null : this.find(String(key)); },
|
|
129
199
|
async create(data, user) {
|
|
130
200
|
const [id] = await db(table).insert({ ...data, created_at: now(), updated_at: now() });
|
|
131
201
|
const tenantId = typeof id === 'object' ? id.id : id;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Statuses with their own illustration and texts; the others use the closest one (4xx: 404, 5xx: 500). */
|
|
2
|
+
export declare const ERROR_PAGE_STATUSES: readonly [401, 403, 404, 419, 429, 500, 503];
|
|
3
|
+
export type ErrorPageStatus = (typeof ERROR_PAGE_STATUSES)[number];
|
|
4
|
+
/** The illustrated status for any HTTP status. */
|
|
5
|
+
export declare function errorPageScene(status: number): ErrorPageStatus;
|
|
6
|
+
/** The illustration of an error page. */
|
|
7
|
+
export declare function errorIllustration(status: number, label: string): string;
|
|
8
|
+
/** Styles of the error pages, on top of the panel's stylesheet. */
|
|
9
|
+
export declare const ERROR_PAGE_CSS = "\n.fi-error-page { text-align: center; }\n.fi-error-illustration { margin: -1rem auto 0; max-width: 24rem; }\n.fi-error-illustration-svg { display: block; width: 100%; height: auto; overflow: visible; font-family: var(--font-family), ui-sans-serif, system-ui, sans-serif; }\n.fi-error-page .fi-simple-header { margin-top: 1.5rem; }\n.fi-error-page .fi-simple-header-subheading { max-width: 26rem; margin-inline: auto; }\n.fi-error-badge-ctn { display: flex; justify-content: center; margin-top: 1.25rem; }\n.fi-error-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin-top: 2rem; }\n.fi-error-reference { margin-top: 1.5rem; font-size: 0.75rem; color: var(--gray-400); }\n.fi-error-details { margin-top: 2rem; text-align: start; }\n.fi-error-details summary { cursor: pointer; font-size: 0.875rem; font-weight: 500; color: var(--gray-500); }\n.fi-error-details pre { margin-top: 0.75rem; max-height: 18rem; overflow: auto; padding: 0.75rem 1rem; border-radius: 0.5rem; background: var(--gray-50); color: var(--gray-700); font-family: var(--mono-font-family, ui-monospace), monospace; font-size: 0.75rem; line-height: 1.25rem; white-space: pre-wrap; word-break: break-word; }\n.dark .fi-error-details pre { background: color-mix(in oklab, var(--gray-950) 60%, transparent); color: var(--gray-300); }\n\n.fi-error-cabin { fill: var(--primary-600); }\n.fi-error-wheel { fill: var(--primary-600); }\n.fi-error-hanger { fill: none; stroke: var(--primary-600); stroke-width: 10; stroke-linecap: round; }\n.fi-error-cabin-code { fill: #fff; font-size: 30px; font-weight: 700; letter-spacing: 0.02em; }\n.fi-error-cable, .fi-error-cable-faded { fill: none; stroke: var(--gray-400); stroke-width: 3; stroke-linecap: round; }\n.fi-error-cable-faded { stroke: url(#fi-error-cable-fade); }\n.fi-error-cable-stop { stop-color: var(--gray-400); }\n.fi-error-mountains { fill: url(#fi-error-ground); }\n.fi-error-ground-stop { stop-color: var(--gray-100); }\n.fi-error-snow { fill: #fff; }\n.fi-error-cloud { fill: #fff; stroke: var(--gray-200); stroke-width: 1.5; }\n.fi-error-pylon, .fi-error-pylon-arm { fill: none; stroke: var(--gray-300); stroke-width: 3; stroke-linecap: round; }\n.fi-error-pylon-arm { stroke-width: 4; }\n.fi-error-mark { fill: var(--primary-500); font-size: 26px; font-weight: 700; }\n.fi-error-mark-sm { font-size: 18px; opacity: 0.7; }\n.fi-error-sign-post { stroke: var(--gray-400); stroke-width: 3; }\n.fi-error-sign-danger { fill: var(--danger-500); }\n.fi-error-sign-bar { fill: #fff; }\n.fi-error-stop-dash { fill: none; stroke: var(--danger-500); stroke-width: 3; stroke-dasharray: 5 5; stroke-linecap: round; }\n.fi-error-lock { fill: var(--warning-500); }\n.fi-error-lock-shackle { fill: none; stroke: var(--gray-400); stroke-width: 4; stroke-linecap: round; }\n.fi-error-lock-hole { fill: #fff; }\n.fi-error-station { fill: var(--gray-200); }\n.fi-error-station-roof { fill: none; stroke: var(--gray-400); stroke-width: 4; stroke-linejoin: round; stroke-linecap: round; }\n.fi-error-clock-face { fill: #fff; stroke: var(--gray-400); stroke-width: 2.5; }\n.fi-error-clock-hands { fill: none; stroke: var(--primary-600); stroke-width: 2.5; stroke-linecap: round; }\n.fi-error-motion { fill: none; stroke: var(--gray-300); stroke-width: 3; stroke-linecap: round; }\n.fi-error-spark { fill: none; stroke: var(--warning-500); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }\n.fi-error-spark-alt { stroke: var(--danger-500); }\n.fi-error-cone { fill: var(--warning-500); }\n.fi-error-cone-stripe { stroke: #fff; stroke-width: 4; }\n.fi-error-cone-base { stroke: var(--gray-500); stroke-width: 4; stroke-linecap: round; }\n.fi-error-tools { fill: var(--gray-400); }\n.fi-error-barrier { fill: var(--warning-500); }\n.fi-error-barrier-stripes { stroke: #fff; stroke-width: 5; }\n.fi-error-barrier-leg { stroke: var(--gray-500); stroke-width: 3; stroke-linecap: round; }\n\n.dark .fi-error-cabin, .dark .fi-error-wheel { fill: var(--primary-500); }\n.dark .fi-error-hanger { stroke: var(--primary-500); }\n.dark .fi-error-cable, .dark .fi-error-cable-faded { stroke: var(--gray-500); }\n.dark .fi-error-cable-faded { stroke: url(#fi-error-cable-fade); }\n.dark .fi-error-cable-stop { stop-color: var(--gray-500); }\n.dark .fi-error-ground-stop { stop-color: var(--gray-800); }\n.dark .fi-error-snow { fill: var(--gray-600); }\n.dark .fi-error-cloud { fill: var(--gray-900); stroke: var(--gray-700); }\n.dark .fi-error-pylon, .dark .fi-error-pylon-arm { stroke: var(--gray-600); }\n.dark .fi-error-station { fill: var(--gray-800); }\n.dark .fi-error-clock-face { fill: var(--gray-900); stroke: var(--gray-500); }\n.dark .fi-error-motion { stroke: var(--gray-700); }\n\n.fi-error-slow, .fi-error-dangling, .fi-error-shake, .fi-error-bounce, .fi-error-tick, .fi-error-wrench-ctn { transform-box: view-box; }\n.fi-error-slow { animation: fi-error-swing 4s ease-in-out infinite; }\n.fi-error-dangling { animation: fi-error-dangle 2.6s ease-in-out infinite; }\n.fi-error-shake { animation: fi-error-shake 3.2s ease-in-out infinite; }\n.fi-error-bounce { animation: fi-error-bounce 1.6s ease-in-out infinite; }\n.fi-error-bob { animation: fi-error-bob 2.4s ease-in-out infinite; }\n.fi-error-drift { animation: fi-error-drift 9s ease-in-out infinite alternate; }\n.fi-error-sparks { animation: fi-error-flicker 1.1s steps(2, jump-none) infinite; }\n.fi-error-tick { animation: fi-error-tick 4s steps(8) infinite; }\n.fi-error-leave { animation: fi-error-leave 5s ease-in-out infinite; }\n.fi-error-wrench-ctn { animation: fi-error-wrench 2.8s ease-in-out infinite; }\n@keyframes fi-error-swing { 0%, 100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }\n@keyframes fi-error-dangle { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(5deg); } }\n@keyframes fi-error-shake { 0%, 70%, 100% { transform: translateX(0); } 74% { transform: translateX(3px) rotate(1.5deg); } 78% { transform: translateX(-3px) rotate(-1.5deg); } 82% { transform: translateX(2px); } 86% { transform: translateX(0); } }\n@keyframes fi-error-bounce { 0%, 100% { transform: rotate(-2deg); } 50% { transform: rotate(3deg); } }\n@keyframes fi-error-bob { 0%, 100% { transform: translateY(0); opacity: 1; } 50% { transform: translateY(-5px); opacity: 0.6; } }\n@keyframes fi-error-drift { from { transform: translateX(-6px); } to { transform: translateX(8px); } }\n@keyframes fi-error-flicker { 0% { opacity: 1; } 100% { opacity: 0.2; } }\n@keyframes fi-error-tick { to { transform: rotate(360deg); } }\n@keyframes fi-error-leave { 0%, 20% { transform: translate(0, 0); opacity: 1; } 80%, 100% { transform: translate(34px, -4px); opacity: 0.35; } }\n@keyframes fi-error-wrench { 0%, 100% { transform: rotate(-12deg); } 50% { transform: rotate(18deg); } }\n@media (prefers-reduced-motion: reduce) { .fi-error-illustration * { animation: none !important; } }\n";
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Error pages (404, 403, 500…): the Navette cable car in trouble, in the panel's colors.
|
|
2
|
+
// The illustrations are inline SVG: their colors come from the panel's CSS variables, light and dark.
|
|
3
|
+
/** Statuses with their own illustration and texts; the others use the closest one (4xx: 404, 5xx: 500). */
|
|
4
|
+
export const ERROR_PAGE_STATUSES = [401, 403, 404, 419, 429, 500, 503];
|
|
5
|
+
/** The illustrated status for any HTTP status. */
|
|
6
|
+
export function errorPageScene(status) {
|
|
7
|
+
if (ERROR_PAGE_STATUSES.includes(status))
|
|
8
|
+
return status;
|
|
9
|
+
return status >= 500 ? 500 : 404;
|
|
10
|
+
}
|
|
11
|
+
const W = 400;
|
|
12
|
+
/** The cable rises from left to right, like the logo's. */
|
|
13
|
+
const cableY = (x) => 72 - 0.12 * x;
|
|
14
|
+
/**
|
|
15
|
+
* A cabin hanging from its wheel at (x, y), drawn from the logo's geometry. `code` is painted on the cabin.
|
|
16
|
+
* `swing`: the cabin sways around its wheel.
|
|
17
|
+
*/
|
|
18
|
+
function cabin(x, y, options = {}) {
|
|
19
|
+
const scale = options.scale ?? 0.62;
|
|
20
|
+
const code = options.code ? `<text x="75" y="155" text-anchor="middle" class="fi-error-cabin-code">${options.code}</text>` : '';
|
|
21
|
+
const drawing = `<g transform="translate(${x} ${y}) rotate(${options.rotate ?? 0}) scale(${scale}) translate(-75 -27)">`
|
|
22
|
+
+ '<path class="fi-error-hanger" d="M75,30 V72" />'
|
|
23
|
+
+ '<circle class="fi-error-wheel" cx="75" cy="27" r="9" />'
|
|
24
|
+
+ '<path class="fi-error-cabin" fill-rule="evenodd" d="M38,72 H112 a20,20 0 0 1 20,20 V146 a20,20 0 0 1 -20,20 H38 a20,20 0 0 1 -20,-20 V92 a20,20 0 0 1 20,-20 Z M36,90 h30 v32 h-30 Z M84,90 h30 v32 h-30 Z" />'
|
|
25
|
+
+ `${code}</g>`;
|
|
26
|
+
if (!options.swing)
|
|
27
|
+
return drawing;
|
|
28
|
+
const delay = options.delay ? ` animation-delay: ${options.delay}s;` : '';
|
|
29
|
+
return `<g class="fi-error-${options.swing}" style="transform-origin: ${x}px ${y}px;${delay}">${drawing}</g>`;
|
|
30
|
+
}
|
|
31
|
+
const cloud = (x, y, scale = 1, drift = false) => `<g class="${drift ? 'fi-error-drift' : ''}"><path class="fi-error-cloud" transform="translate(${x} ${y}) scale(${scale})" d="M-34,10 a14,14 0 0 1 6,-26 a20,20 0 0 1 36,-8 a16,16 0 0 1 28,10 a12,12 0 0 1 4,24 Z" /></g>`;
|
|
32
|
+
const mountains = '<path class="fi-error-mountains" fill="url(#fi-error-ground)" d="M0,228 V196 L52,160 L92,184 L150,128 L214,190 L268,150 L318,178 L364,146 L400,166 V228 Z" />'
|
|
33
|
+
+ '<path class="fi-error-snow" d="M138,140 L150,128 L162,140 L156,138 L150,144 L144,138 Z M354,153 L364,146 L374,153 L369,152 L364,157 L359,152 Z" />';
|
|
34
|
+
const pylon = (x) => {
|
|
35
|
+
const top = cableY(x);
|
|
36
|
+
return `<path class="fi-error-pylon" d="M${x - 9},214 L${x - 2},${top + 4} M${x + 9},214 L${x + 2},${top + 4} M${x - 7},${top + 60} H${x + 7} M${x - 5},${top + 110} H${x + 5}" />`
|
|
37
|
+
+ `<path class="fi-error-pylon-arm" d="M${x - 12},${top + 4} H${x + 12}" />`;
|
|
38
|
+
};
|
|
39
|
+
const cable = (from = -10, to = W + 10, className = 'fi-error-cable') => `<path class="${className}" d="M${from},${cableY(from)} L${to},${cableY(to)}" />`;
|
|
40
|
+
/** Scenes, by status. */
|
|
41
|
+
const SCENES = {
|
|
42
|
+
// Lost: the cable disappears into the clouds; the cabin wonders where to go.
|
|
43
|
+
404: (code) => `<defs><linearGradient id="fi-error-cable-fade" x1="0" x2="1" y1="0" y2="0"><stop offset="0.55" class="fi-error-cable-stop" /><stop offset="0.85" class="fi-error-cable-stop" stop-opacity="0" /></linearGradient></defs>`
|
|
44
|
+
+ mountains + pylon(46) + `<path stroke="url(#fi-error-cable-fade)" class="fi-error-cable-faded" d="M-10,${cableY(-10)} L${W + 10},${cableY(W + 10)}" />`
|
|
45
|
+
+ cabin(205, cableY(205), { code, swing: 'slow' })
|
|
46
|
+
+ '<text x="250" y="22" class="fi-error-mark fi-error-bob">?</text><text x="266" y="36" class="fi-error-mark fi-error-mark-sm fi-error-bob" style="animation-delay: .6s">?</text>'
|
|
47
|
+
+ cloud(330, 40, 1.3, true) + cloud(372, 66, 1.1) + cloud(300, 74, 0.9, true) + cloud(60, 118, 0.7),
|
|
48
|
+
// Blocked: a no-entry sign stops the cabin on the line.
|
|
49
|
+
403: (code) => mountains + pylon(46) + pylon(316) + cable()
|
|
50
|
+
+ `<g class="fi-error-sign"><path class="fi-error-sign-post" d="M316,${cableY(316) - 2} V${cableY(316) - 22}" /><circle class="fi-error-sign-danger" cx="316" cy="${cableY(316) - 36}" r="17" /><rect x="306" y="${cableY(316) - 39}" width="20" height="6" rx="2" class="fi-error-sign-bar" /></g>`
|
|
51
|
+
+ `<path class="fi-error-stop-dash" d="M262,${cableY(262)} L300,${cableY(300)}" />`
|
|
52
|
+
+ cabin(190, cableY(190), { code, swing: 'shake' }) + cloud(98, 34, 0.8) + cloud(372, 112, 0.7, true),
|
|
53
|
+
// Sign in first: a padlock on the line.
|
|
54
|
+
401: (code) => mountains + pylon(46) + pylon(316) + cable()
|
|
55
|
+
+ `<g class="fi-error-sign"><path class="fi-error-sign-post" d="M316,${cableY(316) - 2} V${cableY(316) - 18}" /><path class="fi-error-lock-shackle" d="M307,${cableY(316) - 38} v-6 a9,9 0 0 1 18,0 v6" /><rect x="301" y="${cableY(316) - 40}" width="30" height="24" rx="5" class="fi-error-lock" /><circle cx="316" cy="${cableY(316) - 29}" r="3.5" class="fi-error-lock-hole" /></g>`
|
|
56
|
+
+ cabin(190, cableY(190), { code, swing: 'slow' }) + cloud(98, 34, 0.8) + cloud(372, 112, 0.7, true),
|
|
57
|
+
// Expired: the cabin left the station without you.
|
|
58
|
+
419: (code) => mountains + `<path class="fi-error-station" d="M8,${cableY(8) + 10} h70 v54 h-70 Z" /><path class="fi-error-station-roof" d="M2,${cableY(8) + 12} L43,${cableY(8) - 8} L84,${cableY(8) + 12}" />`
|
|
59
|
+
+ `<g class="fi-error-clock"><circle cx="43" cy="${cableY(8) + 36}" r="15" class="fi-error-clock-face" /><path class="fi-error-clock-hands fi-error-tick" style="transform-origin: 43px ${cableY(8) + 36}px" d="M43,${cableY(8) + 36} V${cableY(8) + 26}" /><path class="fi-error-clock-hands" d="M43,${cableY(8) + 36} H51" /></g>`
|
|
60
|
+
+ cable(78) + pylon(200)
|
|
61
|
+
+ `<path class="fi-error-motion" d="M280,${cableY(296) + 26} h-26 M284,${cableY(296) + 36} h-34 M280,${cableY(296) + 46} h-22" />`
|
|
62
|
+
+ `<g class="fi-error-leave">${cabin(312, cableY(312), { code, scale: 0.46 })}</g>` + cloud(150, 36, 0.8, true),
|
|
63
|
+
// Rush hour: cabins queue on the line, one digit each.
|
|
64
|
+
429: (code) => mountains + pylon(46) + pylon(356) + cable()
|
|
65
|
+
+ [...code.padEnd(3).slice(0, 3)].map((digit, index) => cabin(140 + index * 62, cableY(140 + index * 62), { code: digit.trim(), scale: 0.5, swing: 'bounce', delay: index * 0.18 })).join('')
|
|
66
|
+
+ cloud(96, 30, 0.7, true) + cloud(352, 104, 0.6),
|
|
67
|
+
// Broken: the cable snapped and the cabin dangles from the loose end.
|
|
68
|
+
500: (code) => mountains + pylon(46) + pylon(356)
|
|
69
|
+
+ `<path class="fi-error-cable" d="M-10,${cableY(-10)} L178,${cableY(178)} Q190,${cableY(190) - 1} 192,${cableY(190) + 12}" />`
|
|
70
|
+
+ `<path class="fi-error-cable" d="M${W + 10},${cableY(W + 10)} L236,${cableY(236)} Q224,${cableY(224) + 1} 222,${cableY(222) + 14}" />`
|
|
71
|
+
+ `<g class="fi-error-sparks"><path class="fi-error-spark" d="M207,${cableY(207) - 16} l4,8 l6,-3 l-3,8" /><path class="fi-error-spark fi-error-spark-alt" d="M199,${cableY(199) + 2} l-8,-4 M213,${cableY(213) + 4} l9,-2 M205,${cableY(205) - 4} l1,-9" /></g>`
|
|
72
|
+
+ cabin(192, cableY(190) + 12, { code, rotate: -14, swing: 'dangling' }) + cloud(110, 30, 0.7, true) + cloud(340, 110, 0.6),
|
|
73
|
+
// Maintenance: the cabin is parked; cones and a wrench on site.
|
|
74
|
+
503: (code) => mountains + pylon(46) + pylon(316) + cable()
|
|
75
|
+
+ cabin(190, cableY(190), { code })
|
|
76
|
+
// Heroicons "wrench-screwdriver" (MIT).
|
|
77
|
+
+ `<g class="fi-error-wrench-ctn" style="transform-origin: 258px 92px"><g transform="translate(236 70) scale(1.8)"><path class="fi-error-tools" fill-rule="evenodd" d="M12 6.75a5.25 5.25 0 0 1 6.775-5.025.75.75 0 0 1 .313 1.248l-3.32 3.319c.063.475.276.934.641 1.299.365.365.824.578 1.3.64l3.318-3.319a.75.75 0 0 1 1.248.313 5.25 5.25 0 0 1-5.472 6.756c-1.018-.086-1.87.1-2.309.634L7.344 21.3A3.298 3.298 0 1 1 2.7 16.657l8.684-7.151c.533-.44.72-1.291.634-2.309A5.342 5.342 0 0 1 12 6.75ZM4.117 19.125a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75h-.008a.75.75 0 0 1-.75-.75v-.008Z" clip-rule="evenodd"/><path class="fi-error-tools" d="m10.076 8.64-2.201-2.2V4.874a.75.75 0 0 0-.364-.643l-3.75-2.25a.75.75 0 0 0-.916.113l-.75.75a.75.75 0 0 0-.113.916l2.25 3.75a.75.75 0 0 0 .643.364h1.564l2.062 2.062 1.575-1.297Z"/><path class="fi-error-tools" fill-rule="evenodd" d="m12.556 17.329 4.183 4.182a3.375 3.375 0 0 0 4.773-4.773l-3.306-3.305a6.803 6.803 0 0 1-1.53.043c-.394-.034-.682-.006-.867.042a.589.589 0 0 0-.167.063l-3.086 3.748Zm3.414-1.36a.75.75 0 0 1 1.06 0l1.875 1.876a.75.75 0 1 1-1.06 1.06L15.97 17.03a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/></g></g>`
|
|
78
|
+
+ '<path class="fi-error-barrier-leg" d="M168,212 L174,190 M216,212 L210,190" /><rect x="160" y="184" width="64" height="11" rx="2" class="fi-error-barrier" /><path class="fi-error-barrier-stripes" d="M170,195 l8,-11 M186,195 l8,-11 M202,195 l8,-11 M218,195 l6,-8" />'
|
|
79
|
+
+ [138, 246].map((x) => `<path class="fi-error-cone" d="M${x - 11},212 L${x - 3},180 H${x + 3} L${x + 11},212 Z" /><path class="fi-error-cone-stripe" d="M${x - 7},198 H${x + 7}" /><path class="fi-error-cone-base" d="M${x - 15},212 H${x + 15}" />`).join('')
|
|
80
|
+
+ cloud(96, 30, 0.7, true) + cloud(366, 106, 0.6),
|
|
81
|
+
};
|
|
82
|
+
/** The illustration of an error page. */
|
|
83
|
+
export function errorIllustration(status, label) {
|
|
84
|
+
return `<svg viewBox="0 0 ${W} 228" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="${label.replace(/[&<>"]/g, (character) => `&#${character.charCodeAt(0)};`)}" class="fi-error-illustration-svg"><defs><linearGradient id="fi-error-ground" x1="0" x2="0" y1="0" y2="1"><stop offset="0.45" class="fi-error-ground-stop" /><stop offset="1" class="fi-error-ground-stop" stop-opacity="0" /></linearGradient></defs>${SCENES[errorPageScene(status)](String(status))}</svg>`;
|
|
85
|
+
}
|
|
86
|
+
/** Styles of the error pages, on top of the panel's stylesheet. */
|
|
87
|
+
export const ERROR_PAGE_CSS = `
|
|
88
|
+
.fi-error-page { text-align: center; }
|
|
89
|
+
.fi-error-illustration { margin: -1rem auto 0; max-width: 24rem; }
|
|
90
|
+
.fi-error-illustration-svg { display: block; width: 100%; height: auto; overflow: visible; font-family: var(--font-family), ui-sans-serif, system-ui, sans-serif; }
|
|
91
|
+
.fi-error-page .fi-simple-header { margin-top: 1.5rem; }
|
|
92
|
+
.fi-error-page .fi-simple-header-subheading { max-width: 26rem; margin-inline: auto; }
|
|
93
|
+
.fi-error-badge-ctn { display: flex; justify-content: center; margin-top: 1.25rem; }
|
|
94
|
+
.fi-error-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin-top: 2rem; }
|
|
95
|
+
.fi-error-reference { margin-top: 1.5rem; font-size: 0.75rem; color: var(--gray-400); }
|
|
96
|
+
.fi-error-details { margin-top: 2rem; text-align: start; }
|
|
97
|
+
.fi-error-details summary { cursor: pointer; font-size: 0.875rem; font-weight: 500; color: var(--gray-500); }
|
|
98
|
+
.fi-error-details pre { margin-top: 0.75rem; max-height: 18rem; overflow: auto; padding: 0.75rem 1rem; border-radius: 0.5rem; background: var(--gray-50); color: var(--gray-700); font-family: var(--mono-font-family, ui-monospace), monospace; font-size: 0.75rem; line-height: 1.25rem; white-space: pre-wrap; word-break: break-word; }
|
|
99
|
+
.dark .fi-error-details pre { background: color-mix(in oklab, var(--gray-950) 60%, transparent); color: var(--gray-300); }
|
|
100
|
+
|
|
101
|
+
.fi-error-cabin { fill: var(--primary-600); }
|
|
102
|
+
.fi-error-wheel { fill: var(--primary-600); }
|
|
103
|
+
.fi-error-hanger { fill: none; stroke: var(--primary-600); stroke-width: 10; stroke-linecap: round; }
|
|
104
|
+
.fi-error-cabin-code { fill: #fff; font-size: 30px; font-weight: 700; letter-spacing: 0.02em; }
|
|
105
|
+
.fi-error-cable, .fi-error-cable-faded { fill: none; stroke: var(--gray-400); stroke-width: 3; stroke-linecap: round; }
|
|
106
|
+
.fi-error-cable-faded { stroke: url(#fi-error-cable-fade); }
|
|
107
|
+
.fi-error-cable-stop { stop-color: var(--gray-400); }
|
|
108
|
+
.fi-error-mountains { fill: url(#fi-error-ground); }
|
|
109
|
+
.fi-error-ground-stop { stop-color: var(--gray-100); }
|
|
110
|
+
.fi-error-snow { fill: #fff; }
|
|
111
|
+
.fi-error-cloud { fill: #fff; stroke: var(--gray-200); stroke-width: 1.5; }
|
|
112
|
+
.fi-error-pylon, .fi-error-pylon-arm { fill: none; stroke: var(--gray-300); stroke-width: 3; stroke-linecap: round; }
|
|
113
|
+
.fi-error-pylon-arm { stroke-width: 4; }
|
|
114
|
+
.fi-error-mark { fill: var(--primary-500); font-size: 26px; font-weight: 700; }
|
|
115
|
+
.fi-error-mark-sm { font-size: 18px; opacity: 0.7; }
|
|
116
|
+
.fi-error-sign-post { stroke: var(--gray-400); stroke-width: 3; }
|
|
117
|
+
.fi-error-sign-danger { fill: var(--danger-500); }
|
|
118
|
+
.fi-error-sign-bar { fill: #fff; }
|
|
119
|
+
.fi-error-stop-dash { fill: none; stroke: var(--danger-500); stroke-width: 3; stroke-dasharray: 5 5; stroke-linecap: round; }
|
|
120
|
+
.fi-error-lock { fill: var(--warning-500); }
|
|
121
|
+
.fi-error-lock-shackle { fill: none; stroke: var(--gray-400); stroke-width: 4; stroke-linecap: round; }
|
|
122
|
+
.fi-error-lock-hole { fill: #fff; }
|
|
123
|
+
.fi-error-station { fill: var(--gray-200); }
|
|
124
|
+
.fi-error-station-roof { fill: none; stroke: var(--gray-400); stroke-width: 4; stroke-linejoin: round; stroke-linecap: round; }
|
|
125
|
+
.fi-error-clock-face { fill: #fff; stroke: var(--gray-400); stroke-width: 2.5; }
|
|
126
|
+
.fi-error-clock-hands { fill: none; stroke: var(--primary-600); stroke-width: 2.5; stroke-linecap: round; }
|
|
127
|
+
.fi-error-motion { fill: none; stroke: var(--gray-300); stroke-width: 3; stroke-linecap: round; }
|
|
128
|
+
.fi-error-spark { fill: none; stroke: var(--warning-500); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
|
129
|
+
.fi-error-spark-alt { stroke: var(--danger-500); }
|
|
130
|
+
.fi-error-cone { fill: var(--warning-500); }
|
|
131
|
+
.fi-error-cone-stripe { stroke: #fff; stroke-width: 4; }
|
|
132
|
+
.fi-error-cone-base { stroke: var(--gray-500); stroke-width: 4; stroke-linecap: round; }
|
|
133
|
+
.fi-error-tools { fill: var(--gray-400); }
|
|
134
|
+
.fi-error-barrier { fill: var(--warning-500); }
|
|
135
|
+
.fi-error-barrier-stripes { stroke: #fff; stroke-width: 5; }
|
|
136
|
+
.fi-error-barrier-leg { stroke: var(--gray-500); stroke-width: 3; stroke-linecap: round; }
|
|
137
|
+
|
|
138
|
+
.dark .fi-error-cabin, .dark .fi-error-wheel { fill: var(--primary-500); }
|
|
139
|
+
.dark .fi-error-hanger { stroke: var(--primary-500); }
|
|
140
|
+
.dark .fi-error-cable, .dark .fi-error-cable-faded { stroke: var(--gray-500); }
|
|
141
|
+
.dark .fi-error-cable-faded { stroke: url(#fi-error-cable-fade); }
|
|
142
|
+
.dark .fi-error-cable-stop { stop-color: var(--gray-500); }
|
|
143
|
+
.dark .fi-error-ground-stop { stop-color: var(--gray-800); }
|
|
144
|
+
.dark .fi-error-snow { fill: var(--gray-600); }
|
|
145
|
+
.dark .fi-error-cloud { fill: var(--gray-900); stroke: var(--gray-700); }
|
|
146
|
+
.dark .fi-error-pylon, .dark .fi-error-pylon-arm { stroke: var(--gray-600); }
|
|
147
|
+
.dark .fi-error-station { fill: var(--gray-800); }
|
|
148
|
+
.dark .fi-error-clock-face { fill: var(--gray-900); stroke: var(--gray-500); }
|
|
149
|
+
.dark .fi-error-motion { stroke: var(--gray-700); }
|
|
150
|
+
|
|
151
|
+
.fi-error-slow, .fi-error-dangling, .fi-error-shake, .fi-error-bounce, .fi-error-tick, .fi-error-wrench-ctn { transform-box: view-box; }
|
|
152
|
+
.fi-error-slow { animation: fi-error-swing 4s ease-in-out infinite; }
|
|
153
|
+
.fi-error-dangling { animation: fi-error-dangle 2.6s ease-in-out infinite; }
|
|
154
|
+
.fi-error-shake { animation: fi-error-shake 3.2s ease-in-out infinite; }
|
|
155
|
+
.fi-error-bounce { animation: fi-error-bounce 1.6s ease-in-out infinite; }
|
|
156
|
+
.fi-error-bob { animation: fi-error-bob 2.4s ease-in-out infinite; }
|
|
157
|
+
.fi-error-drift { animation: fi-error-drift 9s ease-in-out infinite alternate; }
|
|
158
|
+
.fi-error-sparks { animation: fi-error-flicker 1.1s steps(2, jump-none) infinite; }
|
|
159
|
+
.fi-error-tick { animation: fi-error-tick 4s steps(8) infinite; }
|
|
160
|
+
.fi-error-leave { animation: fi-error-leave 5s ease-in-out infinite; }
|
|
161
|
+
.fi-error-wrench-ctn { animation: fi-error-wrench 2.8s ease-in-out infinite; }
|
|
162
|
+
@keyframes fi-error-swing { 0%, 100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }
|
|
163
|
+
@keyframes fi-error-dangle { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(5deg); } }
|
|
164
|
+
@keyframes fi-error-shake { 0%, 70%, 100% { transform: translateX(0); } 74% { transform: translateX(3px) rotate(1.5deg); } 78% { transform: translateX(-3px) rotate(-1.5deg); } 82% { transform: translateX(2px); } 86% { transform: translateX(0); } }
|
|
165
|
+
@keyframes fi-error-bounce { 0%, 100% { transform: rotate(-2deg); } 50% { transform: rotate(3deg); } }
|
|
166
|
+
@keyframes fi-error-bob { 0%, 100% { transform: translateY(0); opacity: 1; } 50% { transform: translateY(-5px); opacity: 0.6; } }
|
|
167
|
+
@keyframes fi-error-drift { from { transform: translateX(-6px); } to { transform: translateX(8px); } }
|
|
168
|
+
@keyframes fi-error-flicker { 0% { opacity: 1; } 100% { opacity: 0.2; } }
|
|
169
|
+
@keyframes fi-error-tick { to { transform: rotate(360deg); } }
|
|
170
|
+
@keyframes fi-error-leave { 0%, 20% { transform: translate(0, 0); opacity: 1; } 80%, 100% { transform: translate(34px, -4px); opacity: 0.35; } }
|
|
171
|
+
@keyframes fi-error-wrench { 0%, 100% { transform: rotate(-12deg); } 50% { transform: rotate(18deg); } }
|
|
172
|
+
@media (prefers-reduced-motion: reduce) { .fi-error-illustration * { animation: none !important; } }
|
|
173
|
+
`;
|