@navette-js/panels 1.0.0-beta.4 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +1 -0
- package/dist/navette/navette/calendar.css +5 -5
- package/dist/navette/navette/motion.css +21 -21
- package/dist/navette/navette/motion.js +6 -6
- package/dist/navette/navette/security-keys.css +11 -0
- package/dist/navette/navette/security-keys.js +72 -0
- package/lib/auth.d.ts +17 -0
- package/lib/auth.js +70 -0
- package/lib/error-pages.d.ts +9 -0
- package/lib/error-pages.js +173 -0
- package/lib/import-export.d.ts +45 -2
- package/lib/import-export.js +235 -62
- package/lib/index.d.ts +447 -74
- package/lib/index.js +1280 -267
- package/lib/multi-factor.d.ts +348 -0
- package/lib/multi-factor.js +928 -0
- package/lib/qr-code.d.ts +16 -0
- package/lib/qr-code.js +255 -0
- package/lib/throttle.d.ts +10 -0
- package/lib/throttle.js +25 -0
- package/lib/topbar.d.ts +3 -1
- package/lib/topbar.js +18 -1
- package/lib/totp.d.ts +38 -0
- package/lib/totp.js +104 -0
- package/lib/webauthn.d.ts +155 -0
- package/lib/webauthn.js +287 -0
- package/lib/xlsx.d.ts +5 -0
- package/lib/xlsx.js +103 -1
- package/package.json +9 -9
- package/views/components/tenant-menu.ejs +3 -3
- package/views/errors/page.ejs +87 -0
- package/views/layout/base.ejs +2 -0
- package/views/livewire/sidebar.ejs +29 -15
- package/views/livewire/topbar.ejs +1 -1
- package/views/pages/page.ejs +53 -35
- package/views/pages/simple.ejs +4 -0
package/lib/webauthn.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// WebAuthn (Web Authentication, level 2) relying party: registration and authentication ceremonies for security keys
|
|
2
|
+
// and passkeys. No dependency: CBOR and COSE parsing, signature checks with `node:crypto`.
|
|
3
|
+
// Attestation statements are not verified (`attestation: 'none'`): the key proves possession, not its make or model.
|
|
4
|
+
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify } from 'node:crypto';
|
|
5
|
+
export class WebAuthnError extends Error {
|
|
6
|
+
}
|
|
7
|
+
const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
|
|
8
|
+
const fromB64url = (value, what) => {
|
|
9
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*={0,2}$/.test(value) || value.length > 100_000)
|
|
10
|
+
throw new WebAuthnError(`Invalid ${what}.`);
|
|
11
|
+
return Buffer.from(value, 'base64url');
|
|
12
|
+
};
|
|
13
|
+
// --- CBOR (RFC 8949): the definite-length subset that authenticators produce.
|
|
14
|
+
/** Decodes one CBOR item at `offset`; returns it with the offset after it. Maps are returned as `Map`. */
|
|
15
|
+
export function decodeCborItem(bytes, offset = 0, depth = 0) {
|
|
16
|
+
if (depth > 16)
|
|
17
|
+
throw new WebAuthnError('CBOR nesting too deep.');
|
|
18
|
+
const need = (count) => { if (offset + count > bytes.length)
|
|
19
|
+
throw new WebAuthnError('Truncated CBOR data.'); };
|
|
20
|
+
need(1);
|
|
21
|
+
const initial = bytes[offset++];
|
|
22
|
+
const major = initial >> 5, info = initial & 31;
|
|
23
|
+
let length;
|
|
24
|
+
if (info < 24)
|
|
25
|
+
length = info;
|
|
26
|
+
else if (info === 24) {
|
|
27
|
+
need(1);
|
|
28
|
+
length = bytes[offset];
|
|
29
|
+
offset += 1;
|
|
30
|
+
}
|
|
31
|
+
else if (info === 25) {
|
|
32
|
+
need(2);
|
|
33
|
+
length = (bytes[offset] << 8) | bytes[offset + 1];
|
|
34
|
+
offset += 2;
|
|
35
|
+
}
|
|
36
|
+
else if (info === 26) {
|
|
37
|
+
need(4);
|
|
38
|
+
length = Buffer.from(bytes.subarray(offset, offset + 4)).readUInt32BE(0);
|
|
39
|
+
offset += 4;
|
|
40
|
+
}
|
|
41
|
+
else if (info === 27) {
|
|
42
|
+
need(8);
|
|
43
|
+
const big = Buffer.from(bytes.subarray(offset, offset + 8)).readBigUInt64BE(0);
|
|
44
|
+
offset += 8;
|
|
45
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER))
|
|
46
|
+
throw new WebAuthnError('CBOR integer too large.');
|
|
47
|
+
length = Number(big);
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
throw new WebAuthnError('Unsupported CBOR encoding.');
|
|
51
|
+
switch (major) {
|
|
52
|
+
case 0: return [length, offset];
|
|
53
|
+
case 1: return [-1 - length, offset];
|
|
54
|
+
case 2:
|
|
55
|
+
need(length);
|
|
56
|
+
return [Buffer.from(bytes.subarray(offset, offset + length)), offset + length];
|
|
57
|
+
case 3:
|
|
58
|
+
need(length);
|
|
59
|
+
return [Buffer.from(bytes.subarray(offset, offset + length)).toString('utf8'), offset + length];
|
|
60
|
+
case 4: {
|
|
61
|
+
if (length > 10_000)
|
|
62
|
+
throw new WebAuthnError('CBOR array too long.');
|
|
63
|
+
const items = [];
|
|
64
|
+
for (let i = 0; i < length; i++) {
|
|
65
|
+
const [item, next] = decodeCborItem(bytes, offset, depth + 1);
|
|
66
|
+
items.push(item);
|
|
67
|
+
offset = next;
|
|
68
|
+
}
|
|
69
|
+
return [items, offset];
|
|
70
|
+
}
|
|
71
|
+
case 5: {
|
|
72
|
+
if (length > 10_000)
|
|
73
|
+
throw new WebAuthnError('CBOR map too long.');
|
|
74
|
+
const map = new Map();
|
|
75
|
+
for (let i = 0; i < length; i++) {
|
|
76
|
+
const [key, afterKey] = decodeCborItem(bytes, offset, depth + 1);
|
|
77
|
+
const [value, afterValue] = decodeCborItem(bytes, afterKey, depth + 1);
|
|
78
|
+
map.set(key, value);
|
|
79
|
+
offset = afterValue;
|
|
80
|
+
}
|
|
81
|
+
return [map, offset];
|
|
82
|
+
}
|
|
83
|
+
case 6: return decodeCborItem(bytes, offset, depth + 1); // tag: its content
|
|
84
|
+
default: {
|
|
85
|
+
if (info === 20)
|
|
86
|
+
return [false, offset];
|
|
87
|
+
if (info === 21)
|
|
88
|
+
return [true, offset];
|
|
89
|
+
if (info === 22 || info === 23)
|
|
90
|
+
return [null, offset];
|
|
91
|
+
if (info === 25) {
|
|
92
|
+
const half = length, exponent = (half >> 10) & 31, fraction = half & 1023;
|
|
93
|
+
const value = exponent === 0 ? fraction * 2 ** -24 : exponent === 31 ? (fraction ? NaN : Infinity) : (fraction + 1024) * 2 ** (exponent - 25);
|
|
94
|
+
return [half & 0x8000 ? -value : value, offset];
|
|
95
|
+
}
|
|
96
|
+
if (info === 26) {
|
|
97
|
+
const buffer = Buffer.alloc(4);
|
|
98
|
+
buffer.writeUInt32BE(length);
|
|
99
|
+
return [buffer.readFloatBE(0), offset];
|
|
100
|
+
}
|
|
101
|
+
if (info === 27)
|
|
102
|
+
throw new WebAuthnError('Unsupported CBOR float.');
|
|
103
|
+
return [length, offset];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export const decodeCbor = (bytes) => decodeCborItem(bytes, 0)[0];
|
|
108
|
+
// --- COSE keys (RFC 9053) and signatures.
|
|
109
|
+
/** COSE algorithms supported: ES256, EdDSA (Ed25519), ES384, ES512, RS256. */
|
|
110
|
+
export const SUPPORTED_ALGORITHMS = [-8, -7, -257, -35, -36];
|
|
111
|
+
const EC_CURVES = { 1: 'P-256', 2: 'P-384', 3: 'P-521' };
|
|
112
|
+
/** Public key of a COSE key map. */
|
|
113
|
+
export function coseToPublicKey(cose) {
|
|
114
|
+
const kty = cose.get(1), algorithm = Number(cose.get(3));
|
|
115
|
+
if (!SUPPORTED_ALGORITHMS.includes(algorithm))
|
|
116
|
+
throw new WebAuthnError(`Unsupported key algorithm: ${algorithm}.`);
|
|
117
|
+
const bytes = (label) => { const value = cose.get(label); if (!Buffer.isBuffer(value))
|
|
118
|
+
throw new WebAuthnError('Invalid public key.'); return b64url(value); };
|
|
119
|
+
let jwk;
|
|
120
|
+
if (kty === 2 && [-7, -35, -36].includes(algorithm)) {
|
|
121
|
+
const curve = EC_CURVES[Number(cose.get(-1))];
|
|
122
|
+
if (!curve || curve !== { [-7]: 'P-256', [-35]: 'P-384', [-36]: 'P-521' }[algorithm])
|
|
123
|
+
throw new WebAuthnError('Invalid elliptic curve.');
|
|
124
|
+
jwk = { kty: 'EC', crv: curve, x: bytes(-2), y: bytes(-3) };
|
|
125
|
+
}
|
|
126
|
+
else if (kty === 1 && algorithm === -8) {
|
|
127
|
+
if (cose.get(-1) !== 6)
|
|
128
|
+
throw new WebAuthnError('Only the Ed25519 curve is supported for EdDSA.');
|
|
129
|
+
jwk = { kty: 'OKP', crv: 'Ed25519', x: bytes(-2) };
|
|
130
|
+
}
|
|
131
|
+
else if (kty === 3 && algorithm === -257) {
|
|
132
|
+
jwk = { kty: 'RSA', n: bytes(-1), e: bytes(-2) };
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
throw new WebAuthnError('Invalid public key type.');
|
|
136
|
+
try {
|
|
137
|
+
return { algorithm, key: createPublicKey({ key: jwk, format: 'jwk' }) };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new WebAuthnError('Invalid public key.');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Checks an assertion signature with a stored public key (SPKI DER, base64url). */
|
|
144
|
+
export function verifySignature(algorithm, publicKey, data, signature) {
|
|
145
|
+
const key = createPublicKey({ key: fromB64url(publicKey, 'public key'), format: 'der', type: 'spki' });
|
|
146
|
+
try {
|
|
147
|
+
if (algorithm === -8)
|
|
148
|
+
return verify(null, data, key, signature);
|
|
149
|
+
if (algorithm === -7)
|
|
150
|
+
return verify('sha256', data, { key, dsaEncoding: 'der' }, signature);
|
|
151
|
+
if (algorithm === -35)
|
|
152
|
+
return verify('sha384', data, { key, dsaEncoding: 'der' }, signature);
|
|
153
|
+
if (algorithm === -36)
|
|
154
|
+
return verify('sha512', data, { key, dsaEncoding: 'der' }, signature);
|
|
155
|
+
if (algorithm === -257)
|
|
156
|
+
return verify('sha256', data, key, signature);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
export function parseAuthenticatorData(data) {
|
|
164
|
+
if (data.length < 37)
|
|
165
|
+
throw new WebAuthnError('Authenticator data too short.');
|
|
166
|
+
const flags = data[32];
|
|
167
|
+
let offset = 37, attestedCredential = null;
|
|
168
|
+
if (flags & 0x40) {
|
|
169
|
+
if (data.length < offset + 18)
|
|
170
|
+
throw new WebAuthnError('Attested credential data too short.');
|
|
171
|
+
const aaguid = data.subarray(offset, offset + 16).toString('hex');
|
|
172
|
+
const idLength = data.readUInt16BE(offset + 16);
|
|
173
|
+
offset += 18;
|
|
174
|
+
if (idLength > 1023 || data.length < offset + idLength)
|
|
175
|
+
throw new WebAuthnError('Invalid credential id.');
|
|
176
|
+
const credentialId = Buffer.from(data.subarray(offset, offset + idLength));
|
|
177
|
+
offset += idLength;
|
|
178
|
+
const [publicKey, next] = decodeCborItem(data, offset);
|
|
179
|
+
if (!(publicKey instanceof Map))
|
|
180
|
+
throw new WebAuthnError('Invalid credential public key.');
|
|
181
|
+
offset = next;
|
|
182
|
+
attestedCredential = { aaguid: `${aaguid.slice(0, 8)}-${aaguid.slice(8, 12)}-${aaguid.slice(12, 16)}-${aaguid.slice(16, 20)}-${aaguid.slice(20)}`, credentialId, publicKey };
|
|
183
|
+
}
|
|
184
|
+
if (flags & 0x80)
|
|
185
|
+
offset = decodeCborItem(data, offset)[1]; // extensions
|
|
186
|
+
if (offset !== data.length)
|
|
187
|
+
throw new WebAuthnError('Unexpected data after the authenticator data.');
|
|
188
|
+
return { rpIdHash: data.subarray(0, 32), userPresent: Boolean(flags & 0x01), userVerified: Boolean(flags & 0x04), backupEligible: Boolean(flags & 0x08), backedUp: Boolean(flags & 0x10), signCount: data.readUInt32BE(33), attestedCredential };
|
|
189
|
+
}
|
|
190
|
+
const sameBytes = (a, b) => a.length === b.length && timingSafeEqual(a, b);
|
|
191
|
+
function checkClientData(raw, type, expected) {
|
|
192
|
+
let clientData;
|
|
193
|
+
try {
|
|
194
|
+
clientData = JSON.parse(raw.toString('utf8'));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
throw new WebAuthnError('Invalid client data.');
|
|
198
|
+
}
|
|
199
|
+
if (clientData?.type !== type)
|
|
200
|
+
throw new WebAuthnError('Unexpected ceremony type.');
|
|
201
|
+
if (typeof clientData.challenge !== 'string' || !sameBytes(fromB64url(clientData.challenge, 'challenge'), fromB64url(expected.challenge, 'challenge')))
|
|
202
|
+
throw new WebAuthnError('The challenge does not match.');
|
|
203
|
+
const origins = [expected.origin].flat();
|
|
204
|
+
if (!origins.includes(clientData.origin))
|
|
205
|
+
throw new WebAuthnError(`Unexpected origin: ${String(clientData.origin)}.`);
|
|
206
|
+
if (clientData.crossOrigin === true)
|
|
207
|
+
throw new WebAuthnError('Cross-origin ceremonies are not allowed.');
|
|
208
|
+
}
|
|
209
|
+
function checkAuthenticatorData(data, expected) {
|
|
210
|
+
if (!sameBytes(data.rpIdHash, createHash('sha256').update(expected.rpId).digest()))
|
|
211
|
+
throw new WebAuthnError('The relying party id does not match.');
|
|
212
|
+
if (!data.userPresent)
|
|
213
|
+
throw new WebAuthnError('The user was not present.');
|
|
214
|
+
if (expected.requireUserVerification && !data.userVerified)
|
|
215
|
+
throw new WebAuthnError('The user was not verified.');
|
|
216
|
+
}
|
|
217
|
+
/** Verifies a registration (`navigator.credentials.create()`), and returns the credential to store. Throws `WebAuthnError`. */
|
|
218
|
+
export function verifyRegistration(response, expected) {
|
|
219
|
+
const body = response;
|
|
220
|
+
if (!body || typeof body !== 'object' || body.type !== 'public-key' || !body.response)
|
|
221
|
+
throw new WebAuthnError('Invalid credential.');
|
|
222
|
+
checkClientData(fromB64url(body.response.clientDataJSON, 'client data'), 'webauthn.create', expected);
|
|
223
|
+
const attestation = decodeCbor(fromB64url(body.response.attestationObject, 'attestation'));
|
|
224
|
+
if (!(attestation instanceof Map) || !Buffer.isBuffer(attestation.get('authData')))
|
|
225
|
+
throw new WebAuthnError('Invalid attestation object.');
|
|
226
|
+
const data = parseAuthenticatorData(attestation.get('authData'));
|
|
227
|
+
checkAuthenticatorData(data, expected);
|
|
228
|
+
if (!data.attestedCredential)
|
|
229
|
+
throw new WebAuthnError('No credential in the attestation.');
|
|
230
|
+
const credentialId = b64url(data.attestedCredential.credentialId);
|
|
231
|
+
if (credentialId !== b64url(fromB64url(body.rawId ?? body.id, 'credential id')))
|
|
232
|
+
throw new WebAuthnError('The credential id does not match.');
|
|
233
|
+
const { algorithm, key } = coseToPublicKey(data.attestedCredential.publicKey);
|
|
234
|
+
const transports = Array.isArray(body.response.transports) ? body.response.transports.filter((transport) => typeof transport === 'string' && /^[a-z-]{1,20}$/.test(transport)).slice(0, 10) : [];
|
|
235
|
+
return {
|
|
236
|
+
credentialId, publicKey: b64url(key.export({ type: 'spki', format: 'der' })), algorithm, signCount: data.signCount, transports,
|
|
237
|
+
aaguid: data.attestedCredential.aaguid, backedUp: data.backedUp, userVerified: data.userVerified,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Verifies an authentication (`navigator.credentials.get()`) against the stored credential whose id the response carries.
|
|
242
|
+
* Returns the new signature counter, to store. Throws `WebAuthnError`, notably when the counter went backwards (cloned key).
|
|
243
|
+
*/
|
|
244
|
+
export function verifyAuthentication(response, expected) {
|
|
245
|
+
const body = response;
|
|
246
|
+
if (!body || typeof body !== 'object' || body.type !== 'public-key' || !body.response)
|
|
247
|
+
throw new WebAuthnError('Invalid credential.');
|
|
248
|
+
if (b64url(fromB64url(body.rawId ?? body.id, 'credential id')) !== expected.credential.credentialId)
|
|
249
|
+
throw new WebAuthnError('Unknown credential.');
|
|
250
|
+
const clientDataJSON = fromB64url(body.response.clientDataJSON, 'client data');
|
|
251
|
+
checkClientData(clientDataJSON, 'webauthn.get', expected);
|
|
252
|
+
const authenticatorData = fromB64url(body.response.authenticatorData, 'authenticator data');
|
|
253
|
+
const data = parseAuthenticatorData(authenticatorData);
|
|
254
|
+
checkAuthenticatorData(data, expected);
|
|
255
|
+
const signed = Buffer.concat([authenticatorData, createHash('sha256').update(clientDataJSON).digest()]);
|
|
256
|
+
if (!verifySignature(expected.credential.algorithm, expected.credential.publicKey, signed, fromB64url(body.response.signature, 'signature')))
|
|
257
|
+
throw new WebAuthnError('Invalid signature.');
|
|
258
|
+
// A counter that does not increase means two copies of the key exist (§6.1.1); 0 means the authenticator keeps none.
|
|
259
|
+
if ((data.signCount !== 0 || expected.credential.signCount !== 0) && data.signCount <= expected.credential.signCount)
|
|
260
|
+
throw new WebAuthnError('The signature counter did not increase: this key may have been cloned.');
|
|
261
|
+
return { signCount: data.signCount, userVerified: data.userVerified };
|
|
262
|
+
}
|
|
263
|
+
/** Random challenge (base64url, 32 bytes). */
|
|
264
|
+
export const generateChallenge = () => b64url(randomBytes(32));
|
|
265
|
+
/** Options for `navigator.credentials.create()`, as JSON (`PublicKeyCredential.parseCreationOptionsFromJSON()`). */
|
|
266
|
+
export function registrationOptions(options) {
|
|
267
|
+
return {
|
|
268
|
+
rp: { id: options.rpId, name: options.rpName },
|
|
269
|
+
user: { id: b64url(Buffer.from(options.userId)), name: options.userName, displayName: options.userDisplayName ?? options.userName },
|
|
270
|
+
challenge: options.challenge,
|
|
271
|
+
pubKeyCredParams: SUPPORTED_ALGORITHMS.map((alg) => ({ type: 'public-key', alg })),
|
|
272
|
+
timeout: options.timeout ?? 120_000,
|
|
273
|
+
excludeCredentials: (options.excludeCredentials ?? []).map((credential) => ({ type: 'public-key', id: credential.credentialId, ...(credential.transports?.length ? { transports: credential.transports } : {}) })),
|
|
274
|
+
authenticatorSelection: { residentKey: options.residentKey ?? 'discouraged', requireResidentKey: options.residentKey === 'required', userVerification: options.userVerification ?? 'preferred', ...(options.authenticatorAttachment ? { authenticatorAttachment: options.authenticatorAttachment } : {}) },
|
|
275
|
+
attestation: 'none',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Options for `navigator.credentials.get()`, as JSON (`PublicKeyCredential.parseRequestOptionsFromJSON()`). */
|
|
279
|
+
export function authenticationOptions(options) {
|
|
280
|
+
return {
|
|
281
|
+
rpId: options.rpId,
|
|
282
|
+
challenge: options.challenge,
|
|
283
|
+
timeout: options.timeout ?? 120_000,
|
|
284
|
+
allowCredentials: (options.allowCredentials ?? []).map((credential) => ({ type: 'public-key', id: credential.credentialId, ...(credential.transports?.length ? { transports: credential.transports } : {}) })),
|
|
285
|
+
userVerification: options.userVerification ?? 'preferred',
|
|
286
|
+
};
|
|
287
|
+
}
|
package/lib/xlsx.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
/** Workbook with a single sheet: the first row is bold (header). */
|
|
2
2
|
export declare function buildXlsx(rows: unknown[][], sheetName?: string): Buffer;
|
|
3
|
+
/**
|
|
4
|
+
* Rows of the first worksheet of an XLSX file, as text (numbers as written, booleans as `1`/`0`, formulas as their cached
|
|
5
|
+
* value). Stops after `maxRows` rows. Throws on a file that is not a valid workbook.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readXlsx(archive: Buffer, maxRows?: number, maxEntryBytes?: number): string[][];
|
package/lib/xlsx.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Minimal XLSX writer: one worksheet of text cells (inline strings), zipped with Node's zlib. Cells are never formulas, so
|
|
2
2
|
// spreadsheet software does not evaluate them; numbers stay text, as in Filament's export.
|
|
3
|
-
import { crc32, deflateRawSync } from 'node:zlib';
|
|
3
|
+
import { crc32, deflateRawSync, inflateRawSync } from 'node:zlib';
|
|
4
4
|
/** Escapes a value for XML; characters not allowed in XML 1.0 are dropped. */
|
|
5
5
|
const xml = (value) => value.replace(/[^\x09\x0A\x0D\x20--�\u{10000}-\u{10FFFF}]/gu, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
6
6
|
/** Column letters of a 0-based index: 0 → A, 26 → AA. */
|
|
@@ -67,3 +67,105 @@ function zip(entries) {
|
|
|
67
67
|
end.writeUInt32LE(offset, 16);
|
|
68
68
|
return Buffer.concat([...locals, ...centrals, end]);
|
|
69
69
|
}
|
|
70
|
+
// ------------------------------------------------------------------ reading
|
|
71
|
+
/** Files of a ZIP archive (stored or deflated entries), read from its central directory. Each entry is limited in size. */
|
|
72
|
+
function unzip(archive, maxEntryBytes) {
|
|
73
|
+
const files = new Map();
|
|
74
|
+
let end = -1;
|
|
75
|
+
for (let i = archive.length - 22; i >= Math.max(0, archive.length - 65557); i--)
|
|
76
|
+
if (archive.readUInt32LE(i) === 0x06054b50) {
|
|
77
|
+
end = i;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (end < 0)
|
|
81
|
+
throw new Error('Not a ZIP archive.');
|
|
82
|
+
const count = archive.readUInt16LE(end + 10);
|
|
83
|
+
let pointer = archive.readUInt32LE(end + 16);
|
|
84
|
+
for (let index = 0; index < count && index < 10_000; index++) {
|
|
85
|
+
if (archive.readUInt32LE(pointer) !== 0x02014b50)
|
|
86
|
+
throw new Error('Damaged ZIP archive.');
|
|
87
|
+
const method = archive.readUInt16LE(pointer + 10), compressedSize = archive.readUInt32LE(pointer + 20), size = archive.readUInt32LE(pointer + 24);
|
|
88
|
+
const nameLength = archive.readUInt16LE(pointer + 28), extraLength = archive.readUInt16LE(pointer + 30), commentLength = archive.readUInt16LE(pointer + 32);
|
|
89
|
+
const localOffset = archive.readUInt32LE(pointer + 42);
|
|
90
|
+
const name = archive.subarray(pointer + 46, pointer + 46 + nameLength).toString('utf8');
|
|
91
|
+
pointer += 46 + nameLength + extraLength + commentLength;
|
|
92
|
+
if (archive.readUInt32LE(localOffset) !== 0x04034b50)
|
|
93
|
+
throw new Error('Damaged ZIP archive.');
|
|
94
|
+
const dataStart = localOffset + 30 + archive.readUInt16LE(localOffset + 26) + archive.readUInt16LE(localOffset + 28);
|
|
95
|
+
const data = archive.subarray(dataStart, dataStart + compressedSize);
|
|
96
|
+
if (size > maxEntryBytes)
|
|
97
|
+
throw new Error('XLSX file too large.');
|
|
98
|
+
if (method === 0)
|
|
99
|
+
files.set(name, Buffer.from(data));
|
|
100
|
+
else if (method === 8)
|
|
101
|
+
files.set(name, inflateRawSync(data, { maxOutputLength: maxEntryBytes }));
|
|
102
|
+
}
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
const unescapeXml = (text) => text.replace(/&(lt|gt|quot|apos|amp|#\d+|#x[0-9a-f]+);/gi, (_match, entity) => {
|
|
106
|
+
const lower = entity.toLowerCase();
|
|
107
|
+
if (lower === 'lt')
|
|
108
|
+
return '<';
|
|
109
|
+
if (lower === 'gt')
|
|
110
|
+
return '>';
|
|
111
|
+
if (lower === 'quot')
|
|
112
|
+
return '"';
|
|
113
|
+
if (lower === 'apos')
|
|
114
|
+
return "'";
|
|
115
|
+
if (lower === 'amp')
|
|
116
|
+
return '&';
|
|
117
|
+
const code = lower.startsWith('#x') ? parseInt(lower.slice(2), 16) : parseInt(lower.slice(1), 10);
|
|
118
|
+
return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : '';
|
|
119
|
+
});
|
|
120
|
+
/** Text of the `<t>` elements of a shared or inline string (rich text runs joined, phonetic runs skipped). */
|
|
121
|
+
const stringOf = (xmlText) => [...xmlText.replace(/<rPh\b[\s\S]*?<\/rPh>/g, '').matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>|<t(?:\s[^>]*)?\/>/g)].map((match) => unescapeXml(match[1] ?? '')).join('');
|
|
122
|
+
const columnIndex = (reference) => { let index = 0; for (const char of reference.replace(/\d+$/, '').toUpperCase())
|
|
123
|
+
index = index * 26 + (char.charCodeAt(0) - 64); return index - 1; };
|
|
124
|
+
/**
|
|
125
|
+
* Rows of the first worksheet of an XLSX file, as text (numbers as written, booleans as `1`/`0`, formulas as their cached
|
|
126
|
+
* value). Stops after `maxRows` rows. Throws on a file that is not a valid workbook.
|
|
127
|
+
*/
|
|
128
|
+
export function readXlsx(archive, maxRows = Infinity, maxEntryBytes = 100 * 1024 * 1024) {
|
|
129
|
+
const files = unzip(archive, maxEntryBytes);
|
|
130
|
+
const text = (name) => files.get(name)?.toString('utf8') ?? null;
|
|
131
|
+
const workbook = text('xl/workbook.xml');
|
|
132
|
+
if (!workbook)
|
|
133
|
+
throw new Error('Not an XLSX workbook.');
|
|
134
|
+
const firstSheet = /<sheet\b[^>]*\br:id="([^"]+)"/.exec(workbook)?.[1];
|
|
135
|
+
const relations = text('xl/_rels/workbook.xml.rels') ?? '';
|
|
136
|
+
// The relationship of the first sheet, whatever the order of its attributes.
|
|
137
|
+
const relation = firstSheet ? [...relations.matchAll(/<Relationship\s([^>]*)>/g)].map((match) => match[1]).find((attributes) => /\bId="([^"]+)"/.exec(attributes)?.[1] === firstSheet) : undefined;
|
|
138
|
+
let target = relation ? /\bTarget="([^"]+)"/.exec(relation)?.[1] ?? null : null;
|
|
139
|
+
target ??= 'worksheets/sheet1.xml';
|
|
140
|
+
const sheetPath = target.startsWith('/') ? target.slice(1) : `xl/${target.replace(/^\.\//, '')}`;
|
|
141
|
+
const sheet = text(sheetPath);
|
|
142
|
+
if (!sheet)
|
|
143
|
+
throw new Error('The workbook has no worksheet.');
|
|
144
|
+
const shared = [...(text('xl/sharedStrings.xml') ?? '').matchAll(/<si>([\s\S]*?)<\/si>/g)].map((match) => stringOf(match[1]));
|
|
145
|
+
const rows = [];
|
|
146
|
+
for (const rowMatch of sheet.matchAll(/<row\b[^>]*?(?:\/>|>([\s\S]*?)<\/row>)/g)) {
|
|
147
|
+
if (rows.length >= maxRows)
|
|
148
|
+
break;
|
|
149
|
+
const row = [];
|
|
150
|
+
let next = 0;
|
|
151
|
+
for (const cell of (rowMatch[1] ?? '').matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
|
152
|
+
const attributes = cell[1], body = cell[2] ?? '';
|
|
153
|
+
const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1];
|
|
154
|
+
const index = reference ? columnIndex(reference) : next;
|
|
155
|
+
if (index > 16384)
|
|
156
|
+
continue;
|
|
157
|
+
const type = /\bt="([^"]+)"/.exec(attributes)?.[1] ?? 'n';
|
|
158
|
+
const raw = /<v>([\s\S]*?)<\/v>/.exec(body)?.[1];
|
|
159
|
+
const value = type === 's' ? (shared[Number(raw)] ?? '') : type === 'inlineStr' ? stringOf(body) : type === 'b' ? (raw === '1' ? '1' : '0') : unescapeXml(raw ?? '');
|
|
160
|
+
while (row.length < index)
|
|
161
|
+
row.push('');
|
|
162
|
+
row[index] = value;
|
|
163
|
+
next = index + 1;
|
|
164
|
+
}
|
|
165
|
+
rows.push(row);
|
|
166
|
+
}
|
|
167
|
+
// Trailing empty rows are dropped.
|
|
168
|
+
while (rows.length && rows[rows.length - 1].every((cell) => cell === ''))
|
|
169
|
+
rows.pop();
|
|
170
|
+
return rows;
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/panels",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.5",
|
|
4
4
|
"description": "Admin panels for Node.js: resources, pages, navigation, authentication, tenancy.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"node"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@navette-js/support": "1.0.0-beta.
|
|
47
|
-
"@navette-js/schemas": "1.0.0-beta.
|
|
48
|
-
"@navette-js/forms": "1.0.0-beta.
|
|
49
|
-
"@navette-js/actions": "1.0.0-beta.
|
|
50
|
-
"@navette-js/wire": "1.0.0-beta.
|
|
51
|
-
"@navette-js/tables": "1.0.0-beta.
|
|
52
|
-
"@navette-js/infolists": "1.0.0-beta.
|
|
53
|
-
"@navette-js/database": "1.0.0-beta.
|
|
46
|
+
"@navette-js/support": "1.0.0-beta.5",
|
|
47
|
+
"@navette-js/schemas": "1.0.0-beta.5",
|
|
48
|
+
"@navette-js/forms": "1.0.0-beta.5",
|
|
49
|
+
"@navette-js/actions": "1.0.0-beta.5",
|
|
50
|
+
"@navette-js/wire": "1.0.0-beta.5",
|
|
51
|
+
"@navette-js/tables": "1.0.0-beta.5",
|
|
52
|
+
"@navette-js/infolists": "1.0.0-beta.5",
|
|
53
|
+
"@navette-js/database": "1.0.0-beta.5",
|
|
54
54
|
"express": "^5.1.0"
|
|
55
55
|
},
|
|
56
56
|
"publishConfig": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
<%# Tenant menu (
|
|
1
|
+
<%# Tenant menu; with many tenants (or `searchableTenantMenu()`), a search field filters them and the dropdown is not a menu %>
|
|
2
2
|
<%- renderHook('panels::tenant-menu.before') %>
|
|
3
|
-
<div x-data="filamentDropdown({ isMenu: true })" class="fi-dropdown fi-tenant-menu" ><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger" ><button<% if (isSidebarCollapsibleOnDesktop) { %> x-data="{ tooltip: false }" x-effect="
|
|
3
|
+
<div x-data="filamentDropdown({ isMenu: <%= locals.isSearchable ? 'false' : 'true' %> })" class="fi-dropdown fi-tenant-menu" ><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger" ><button<% if (isSidebarCollapsibleOnDesktop) { %> x-data="{ tooltip: false }" x-effect="
|
|
4
4
|
tooltip = $store.sidebar.isOpen
|
|
5
5
|
? false
|
|
6
6
|
: {
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
placement: document.dir === 'rtl' ? 'left' : 'right',
|
|
10
10
|
theme: $store.theme,
|
|
11
11
|
}
|
|
12
|
-
" x-tooltip="tooltip"<% } %> type="button" class="fi-tenant-menu-trigger" ><img alt="<%= t('filament-panels::layout.avatar.alt', { name: tenant.name }) %>" class="fi-avatar fi-size-md fi-tenant-avatar" src="<%= tenant.avatarUrl %>" loading="lazy" /><span<% if (isSidebarCollapsibleOnDesktop) { %> x-show="$store.sidebar.isOpen"<% } %> class="fi-tenant-menu-trigger-text" ><% if (tenant.label) { %><span class="fi-tenant-menu-trigger-current-tenant-label"> <%= tenant.label %> </span><% } %><span class="fi-tenant-menu-trigger-tenant-name"> <%= tenant.name %> </span></span><%- icon('m-chevron-down', isSidebarCollapsibleOnDesktop ? { attributes: attrs({ 'x-show': '$store.sidebar.isOpen' }) } : {}) %></button></div><div x-cloak x-float.placement.bottom-start.size.flip<% if (teleport) { %>.teleport<% } %>.offset="{ offset: 8, size: {"padding":16} }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="fi-dropdown-panel fi-scrollable" style="" ><% if (itemsBefore.length) { %><div class="fi-dropdown-list"><%- itemsBefore.join('') %></div><% } %><% if (switchable.length) { %><div x-data="filamentTenantMenu"><div class="fi-dropdown-list"><% for (const other of switchable) { %><div class="fi-tenant-menu-item" x-show="matchesSearch(<%= js(other.name) %>)"><a href="<%= other.url %>" class="fi-dropdown-list-item" role="menuitem" tabindex="-1"
|
|
12
|
+
" x-tooltip="tooltip"<% } %> type="button" class="fi-tenant-menu-trigger" ><img alt="<%= t('filament-panels::layout.avatar.alt', { name: tenant.name }) %>" class="fi-avatar fi-size-md fi-tenant-avatar" src="<%= tenant.avatarUrl %>" loading="lazy" /><span<% if (isSidebarCollapsibleOnDesktop) { %> x-show="$store.sidebar.isOpen"<% } %> class="fi-tenant-menu-trigger-text" ><% if (tenant.label) { %><span class="fi-tenant-menu-trigger-current-tenant-label"> <%= tenant.label %> </span><% } %><span class="fi-tenant-menu-trigger-tenant-name"> <%= tenant.name %> </span></span><%- icon('m-chevron-down', isSidebarCollapsibleOnDesktop ? { attributes: attrs({ 'x-show': '$store.sidebar.isOpen' }) } : {}) %></button></div><div x-cloak x-float.placement.bottom-start.size.flip<% if (teleport) { %>.teleport<% } %>.offset="{ offset: 8, size: {"padding":16} }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0"<% if (!locals.isSearchable) { %> role="menu"<% } %> class="fi-dropdown-panel fi-scrollable" style="" ><% if (itemsBefore.length) { %><div class="fi-dropdown-list"><%- itemsBefore.join('') %></div><% } %><% if (switchable.length) { %><div x-data="filamentTenantMenu"><div class="fi-dropdown-list"><% if (locals.isSearchable) { %><div x-id="['input']"><label x-bind:for="$id('input')" class="fi-sr-only"><%= t('filament-panels::layout.tenant_menu.search_field.label') %></label><input x-bind:id="$id('input')" x-model="search" placeholder="<%= t('filament-panels::layout.tenant_menu.search_field.placeholder') %>" type="search" class="fi-input" /></div><% } %><% for (const other of switchable) { %><div class="fi-tenant-menu-item" x-show="matchesSearch(<%= js(other.name) %>)"><a href="<%= other.url %>" class="fi-dropdown-list-item"<% if (!locals.isSearchable) { %> role="menuitem" tabindex="-1"<% } %>><div class="fi-dropdown-list-item-image" style="background-image: url('<%= other.avatarUrl %>')"></div><span class="fi-dropdown-list-item-label"><%= other.name %></span></a></div><% } %></div></div><% } %><% if (itemsAfter.length) { %><div class="fi-dropdown-list"><%- itemsAfter.join('') %></div><% } %></div></div>
|
|
13
13
|
<%- renderHook('panels::tenant-menu.after') %>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<%# Error page (404, 403, 500…): a standalone document, so that it renders even when components cannot %>
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html lang="<%= locale %>" dir="ltr" class="fi">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<meta name="robots" content="noindex" />
|
|
8
|
+
<% if (locals.favicon) { %><link rel="icon" href="<%= favicon %>" /><% } %>
|
|
9
|
+
|
|
10
|
+
<title>
|
|
11
|
+
<%= title %>
|
|
12
|
+
-
|
|
13
|
+
<%= brandName %>
|
|
14
|
+
</title>
|
|
15
|
+
|
|
16
|
+
<link href="<%- assetUrl('fonts/filament/filament/inter/index.css') %>" rel="stylesheet" />
|
|
17
|
+
|
|
18
|
+
<%- colorVariables %>
|
|
19
|
+
|
|
20
|
+
<link rel="stylesheet" href="<%- locals.theme ? theme : assetUrl('css/filament/filament/app.css') %>" />
|
|
21
|
+
|
|
22
|
+
<% if (font.url) { %><link href="<%= font.url %>" rel="stylesheet" /><% } %>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
:root {
|
|
26
|
+
--font-family: '<%- font.family %>';
|
|
27
|
+
--font-family-mono: 'ui-monospace';
|
|
28
|
+
--font-family-serif: 'ui-serif';
|
|
29
|
+
--default-theme-mode: system;
|
|
30
|
+
}
|
|
31
|
+
<%- css %>
|
|
32
|
+
</style>
|
|
33
|
+
|
|
34
|
+
<% if (locals.darkMode === false) { %><% } else if (locals.darkModeForced) { %>
|
|
35
|
+
<script>
|
|
36
|
+
document.documentElement.classList.add('dark')
|
|
37
|
+
</script>
|
|
38
|
+
<% } else { %>
|
|
39
|
+
<script>
|
|
40
|
+
const theme = localStorage.getItem('filament:theme:mode') ?? <%- js(locals.defaultThemeMode ?? 'system') %>
|
|
41
|
+
|
|
42
|
+
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
43
|
+
document.documentElement.classList.add('dark')
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
<% } %>
|
|
47
|
+
|
|
48
|
+
<% if (locals.themeCss) { %><style data-navette-theme><%- themeCss %></style><% } %>
|
|
49
|
+
</head>
|
|
50
|
+
|
|
51
|
+
<body class="<%- cls(['fi-body', 'fi-panel-' + panelId, 'fi-error-body', 'fi-error-' + status]) %>">
|
|
52
|
+
<div class="fi-simple-layout">
|
|
53
|
+
<div class="fi-simple-main-ctn">
|
|
54
|
+
<main id="fi-main-content" class="fi-simple-main fi-width-lg fi-error-page">
|
|
55
|
+
<div class="fi-error-illustration">
|
|
56
|
+
<%- illustration %>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="fi-error-badge-ctn">
|
|
60
|
+
<%- badge %>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<header class="fi-simple-header">
|
|
64
|
+
<h1 class="fi-simple-header-heading">
|
|
65
|
+
<%= heading %>
|
|
66
|
+
</h1>
|
|
67
|
+
|
|
68
|
+
<p class="fi-simple-header-subheading">
|
|
69
|
+
<%= description %>
|
|
70
|
+
</p>
|
|
71
|
+
</header>
|
|
72
|
+
|
|
73
|
+
<div class="fi-error-actions">
|
|
74
|
+
<%- actions.join('') %>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<% if (locals.details) { %>
|
|
78
|
+
<details class="fi-error-details">
|
|
79
|
+
<summary><%= t('filament-panels::errors.details') %></summary>
|
|
80
|
+
<pre><%= details %></pre>
|
|
81
|
+
</details>
|
|
82
|
+
<% } %>
|
|
83
|
+
</main>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
package/views/layout/base.ejs
CHANGED
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
|
|
99
99
|
<% if (locals.motion) { %><link rel="stylesheet" href="<%- assetUrl('navette/motion.css') %>" data-navigate-track /><% } %>
|
|
100
100
|
<link rel="stylesheet" href="<%- assetUrl('navette/calendar.css') %>" data-navigate-track />
|
|
101
|
+
<% if (locals.securityKeys) { %><link rel="stylesheet" href="<%- assetUrl('navette/security-keys.css') %>" data-navigate-track /><% } %>
|
|
101
102
|
|
|
102
103
|
<%- wireStyles %>
|
|
103
104
|
<%- renderHook('panels::head.end', renderHookScopes) %>
|
|
@@ -127,6 +128,7 @@
|
|
|
127
128
|
loadDarkMode()
|
|
128
129
|
</script>
|
|
129
130
|
|
|
131
|
+
<% if (locals.securityKeys) { %><script src="<%- assetUrl('navette/security-keys.js') %>" data-navigate-once></script><% } %>
|
|
130
132
|
<%- wireScripts %>
|
|
131
133
|
<%- renderHook('panels::scripts.after', renderHookScopes) %>
|
|
132
134
|
<%- renderHook('panels::body.end', renderHookScopes) %>
|
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
<%# Sidebar: navigation groups and items %>
|
|
2
2
|
<% const collapsible = isSidebarCollapsibleOnDesktop || isSidebarFullyCollapsibleOnDesktop; %>
|
|
3
|
+
<%# One item, with its child items below it when it has no URL, is active, or one of them is. %>
|
|
4
|
+
<% const sidebarItem = (item, { grouped, subGrouped = false, first, last }) => { %>
|
|
5
|
+
<li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.isActive, 'fi-sidebar-item-has-active-child-items': item.activeChildItems, 'fi-sidebar-item-has-url': Boolean(item.url) }]) %>">
|
|
6
|
+
<a<% if (item.url) { %> href="<%= item.url %>"<% if (spa) { %> wire:navigate<% } %><% } %><% if (item.isActive) { %> aria-current="page"<% } %> x-on:click="$store.sidebar.isMobile && $store.sidebar.close()" class="fi-sidebar-item-btn">
|
|
7
|
+
<% if (item.icon && (!subGrouped || isSidebarCollapsibleOnDesktop)) { %><%- icon(item.icon, { size: 'lg', attributes: attrs({ class: 'fi-sidebar-item-icon', 'x-show': subGrouped ? '! $store.sidebar.isOpen' : false }) }) %><% } %>
|
|
8
|
+
<% if ((!item.icon && grouped) || subGrouped) { %>
|
|
9
|
+
<div<% if (item.icon && subGrouped && isSidebarCollapsibleOnDesktop) { %> x-show="$store.sidebar.isOpen"<% } %> class="fi-sidebar-item-grouped-border">
|
|
10
|
+
<% if (!first) { %><div class="fi-sidebar-item-grouped-border-part-not-first"></div><% } %>
|
|
11
|
+
<% if (!last) { %><div class="fi-sidebar-item-grouped-border-part-not-last"></div><% } %>
|
|
12
|
+
<div class="fi-sidebar-item-grouped-border-part"></div>
|
|
13
|
+
</div>
|
|
14
|
+
<% } %>
|
|
15
|
+
<span class="fi-sidebar-item-label">
|
|
16
|
+
<%= item.label %>
|
|
17
|
+
</span>
|
|
18
|
+
<% if (item.badge !== null) { %>
|
|
19
|
+
<span class="fi-sidebar-item-badge-ctn">
|
|
20
|
+
<%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses }) %>
|
|
21
|
+
</span>
|
|
22
|
+
<% } %>
|
|
23
|
+
</a>
|
|
24
|
+
<% if (item.childItems?.length && (!item.url || item.isActive || item.activeChildItems)) { %>
|
|
25
|
+
<ul class="fi-sidebar-sub-group-items">
|
|
26
|
+
<% item.childItems.forEach((child, index) => sidebarItem(child, { grouped: true, subGrouped: true, first: index === 0, last: index === item.childItems.length - 1 })) %>
|
|
27
|
+
</ul>
|
|
28
|
+
<% } %>
|
|
29
|
+
</li>
|
|
30
|
+
<% } %>
|
|
3
31
|
<div>
|
|
4
32
|
<div x-data="filamentSidebar({
|
|
5
33
|
hasTopNavigation: <%= hasTopNavigation ? 'true' : 'false' %>,
|
|
@@ -56,21 +84,7 @@
|
|
|
56
84
|
</div>
|
|
57
85
|
<% } %>
|
|
58
86
|
<ul<% if (group.label) { %> id="<%= group.itemsId %>"<% if (group.collapsible !== false) { %> x-show="! $store.sidebar.groupIsCollapsed(label)" x-collapse.duration.200ms<% } %><% } %> class="fi-sidebar-group-items">
|
|
59
|
-
<%
|
|
60
|
-
<li class="<%- cls(['fi-sidebar-item', { 'fi-active': item.isActive }, 'fi-sidebar-item-has-url']) %>">
|
|
61
|
-
<a href="<%= item.url %>"<% if (spa) { %> wire:navigate<% } %><% if (item.isActive) { %> aria-current="page"<% } %> x-on:click="$store.sidebar.isMobile && $store.sidebar.close()" class="fi-sidebar-item-btn">
|
|
62
|
-
<% if (item.icon) { %><%- icon(item.icon, { size: 'lg', attributes: attrs({ class: 'fi-sidebar-item-icon' }) }) %><% } %>
|
|
63
|
-
<span class="fi-sidebar-item-label">
|
|
64
|
-
<%= item.label %>
|
|
65
|
-
</span>
|
|
66
|
-
<% if (item.badge !== null) { %>
|
|
67
|
-
<span class="fi-sidebar-item-badge-ctn">
|
|
68
|
-
<%- view('filament::components.badge', { label: item.badge, colorClasses: item.badgeColorClasses }) %>
|
|
69
|
-
</span>
|
|
70
|
-
<% } %>
|
|
71
|
-
</a>
|
|
72
|
-
</li>
|
|
73
|
-
<% } %>
|
|
87
|
+
<% group.items.forEach((item, index) => sidebarItem(item, { grouped: Boolean(group.label), first: index === 0, last: index === group.items.length - 1 })) %>
|
|
74
88
|
</ul>
|
|
75
89
|
</li>
|
|
76
90
|
<% } %>
|