sveltekit-admin 0.8.1 → 0.9.0
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/dist/server/adapters/drizzle/dataAdapter.js +76 -7
- package/dist/server/adapters/prisma/dataAdapter.js +19 -1
- package/dist/server/adapters/types.d.ts +33 -1
- package/dist/server/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/handler.d.ts +35 -0
- package/dist/server/handler.js +67 -15
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +8 -4
- package/dist/server/mutations.js +203 -22
- package/dist/server/query/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- package/dist/server/query/pageSize.d.ts +19 -0
- package/dist/server/query/pageSize.js +26 -0
- package/dist/server/query/sortQuery.d.ts +31 -0
- package/dist/server/query/sortQuery.js +33 -0
- package/dist/server/query/urls.js +9 -1
- package/dist/server/runtime.d.ts +5 -0
- package/dist/server/runtime.js +54 -1
- package/dist/server/submitted.d.ts +20 -0
- package/dist/server/submitted.js +54 -0
- package/dist/server/views/FieldInput.svelte +114 -8
- package/dist/server/views/FieldInput.svelte.d.ts +7 -0
- package/dist/server/views/Form.svelte +71 -4
- package/dist/server/views/Form.svelte.d.ts +17 -0
- package/dist/server/views/Layout.svelte +5 -2
- package/dist/server/views/List.svelte +128 -23
- package/dist/server/views/List.svelte.d.ts +5 -0
- package/dist/server/views/RelationCheckboxes.svelte +26 -5
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +14 -3
- package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
- package/dist/server/views/html.d.ts +19 -0
- package/dist/server/views/html.js +25 -0
- package/dist/server/views/pagination.d.ts +9 -0
- package/dist/server/views/pagination.js +31 -0
- package/dist/server/views/theme.js +147 -3
- package/dist/server/views/types.d.ts +7 -0
- package/package.json +2 -1
package/dist/server/mutations.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST create/update/delete handling — split out of `handler.ts`, pure
|
|
3
|
-
* orchestration over `AdminRuntime`.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* orchestration over `AdminRuntime`.
|
|
4
|
+
*
|
|
5
|
+
* Le corps est lu par l'appelant et reçu en paramètre, pas consommé ici : le
|
|
6
|
+
* handler doit garder ce qui a été soumis pour le re-rendre si cette fonction
|
|
7
|
+
* lève (`readSubmittedForm`), et un corps de requête ne se lit qu'une fois.
|
|
8
|
+
* L'appelant n'invoque cette fonction que sur `POST`, donc aucun GET ne lit de
|
|
9
|
+
* corps.
|
|
6
10
|
*/
|
|
7
11
|
import { primaryKeyOf, coerceId, formDataToPrisma } from './data.js';
|
|
12
|
+
import { isSensitiveStringField } from './introspection/parser.js';
|
|
8
13
|
import { AdminMutationError, classifyWriteError } from './errors.js';
|
|
9
14
|
import { buildAuditEvent, emitAudit, readAuditSnapshot } from './audit.js';
|
|
10
15
|
import { scopeFrom, modelScopeFrom, modelScopeValues } from './runtime.js';
|
|
11
|
-
|
|
16
|
+
/** Plafond de sélection : la page la plus large de l'admin fait 200 lignes. */
|
|
17
|
+
const MAX_BULK_IDS = 200;
|
|
18
|
+
export async function handleMutation(runtime, event, route, formData) {
|
|
12
19
|
const modelsConfig = runtime.config.models ?? {};
|
|
13
20
|
const audit = runtime.config.audit;
|
|
14
|
-
const formData = await event.request.formData();
|
|
15
21
|
const action = formData.get('_action');
|
|
16
22
|
if (!route.model)
|
|
17
23
|
return null;
|
|
@@ -43,10 +49,10 @@ export async function handleMutation(runtime, event, route) {
|
|
|
43
49
|
await runtime.adapter.data.deleteRecord(model, route.id, modelScopeFrom(runtime, model, { locals: event.locals }));
|
|
44
50
|
}
|
|
45
51
|
catch (e) {
|
|
46
|
-
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// c'est le handler qui le masquera.
|
|
52
|
+
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action
|
|
53
|
+
// réellement en cours, et `reference` / `restrict` partagent le même code
|
|
54
|
+
// SQLSTATE — c'est l'action qui les sépare. Un code non reconnu est relayé
|
|
55
|
+
// tel quel, et c'est le handler qui le masquera.
|
|
50
56
|
throw classifyWriteError(e, 'delete') ?? e;
|
|
51
57
|
}
|
|
52
58
|
if (audit) {
|
|
@@ -61,8 +67,88 @@ export async function handleMutation(runtime, event, route) {
|
|
|
61
67
|
}
|
|
62
68
|
return redirectToList(route.model);
|
|
63
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Suppression en masse. Une seule opération d'écriture, pas une boucle de
|
|
72
|
+
* `deleteRecord` : une boucle qui casse au septième id sur une contrainte de
|
|
73
|
+
* clé étrangère laisserait six lignes supprimées et rien pour revenir en
|
|
74
|
+
* arrière. Ici, tout part ou rien ne part.
|
|
75
|
+
*
|
|
76
|
+
* La portée du modèle est composée avec les ids DANS le `where` de
|
|
77
|
+
* l'adapter, jamais vérifiée à part : un id hors portée ne matche pas, sans
|
|
78
|
+
* erreur — donc rien ne distingue « n'existe pas » de « appartient à un
|
|
79
|
+
* autre tenant ». Le compte rendu est celui des lignes réellement
|
|
80
|
+
* supprimées ; un écart avec la sélection ne peut venir que d'un POST forgé,
|
|
81
|
+
* puisque l'interface n'offre que des lignes en portée.
|
|
82
|
+
*/
|
|
83
|
+
if (action === 'bulk-delete') {
|
|
84
|
+
const ids = formData.getAll('ids').map(String);
|
|
85
|
+
if (ids.length === 0) {
|
|
86
|
+
throw new AdminMutationError('validation', 'Select at least one record to delete.');
|
|
87
|
+
}
|
|
88
|
+
// L'interface ne peut cocher que ce qu'elle affiche, et une page plafonne
|
|
89
|
+
// à 200 lignes : au-delà la requête est forgée, et un `IN (…)` de plusieurs
|
|
90
|
+
// milliers d'éléments est un vecteur de charge à lui seul.
|
|
91
|
+
if (ids.length > MAX_BULK_IDS) {
|
|
92
|
+
throw new AdminMutationError('validation', `Cannot delete more than ${MAX_BULK_IDS} records at once.`);
|
|
93
|
+
}
|
|
94
|
+
const coerced = ids.map((id) => coerceId(id, model));
|
|
95
|
+
const scope = modelScopeFrom(runtime, model, { locals: event.locals });
|
|
96
|
+
// Instantané AVANT suppression, et seulement si un puits d'audit existe :
|
|
97
|
+
// sans lui le journal serait muet sur l'opération la plus destructive.
|
|
98
|
+
// Lu avec la même portée que la suppression, donc exactement les lignes qui
|
|
99
|
+
// vont partir.
|
|
100
|
+
const idFilter = { op: 'in', field: primaryKeyOf(model), value: coerced };
|
|
101
|
+
const before = audit
|
|
102
|
+
? await runtime.adapter.data.findMany(model, {
|
|
103
|
+
filter: scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter
|
|
104
|
+
})
|
|
105
|
+
: [];
|
|
106
|
+
let deleted;
|
|
107
|
+
try {
|
|
108
|
+
deleted = await runtime.adapter.data.deleteMany(model, coerced, scope);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
throw classifyWriteError(e, 'delete') ?? e;
|
|
112
|
+
}
|
|
113
|
+
for (const row of before) {
|
|
114
|
+
await emitAudit(audit, buildAuditEvent({
|
|
115
|
+
event,
|
|
116
|
+
action: 'delete',
|
|
117
|
+
model,
|
|
118
|
+
id: row[primaryKeyOf(model)],
|
|
119
|
+
hidden: runtime.hiddenFieldsOf(model),
|
|
120
|
+
before: row
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
return new Response(null, {
|
|
124
|
+
status: 303,
|
|
125
|
+
headers: {
|
|
126
|
+
Location: `${runtime.basePath}/${route.model.toLowerCase()}?deleted=${deleted}`
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
64
130
|
if (action === 'create' || action === 'update') {
|
|
65
|
-
const data = formDataToPrisma(formData, model);
|
|
131
|
+
const { data, invalid } = formDataToPrisma(formData, model);
|
|
132
|
+
// Refusé d'entrée : la valeur ne se convertit pas vers le type de la
|
|
133
|
+
// colonne (JSON illisible, nombre qui n'en est pas un), donc il n'y a même
|
|
134
|
+
// pas de payload à valider plus loin. Un seul champ est rapporté, comme
|
|
135
|
+
// partout ailleurs sur ce chemin — le formulaire re-rendu porte une erreur,
|
|
136
|
+
// pas une liste.
|
|
137
|
+
//
|
|
138
|
+
// Aucun conflit avec l'imposition du scope plus bas : le formulaire de
|
|
139
|
+
// création rend la colonne de tenant VIDE, et un vide n'est pas une valeur
|
|
140
|
+
// illisible (voir `formDataToPrisma`), donc rien ne devient incréable ici.
|
|
141
|
+
//
|
|
142
|
+
// Les scalaires de relation en sont exclus : la boucle FK plus bas fait sa
|
|
143
|
+
// propre coercion en relisant le FormData, et rattache son refus à l'arête
|
|
144
|
+
// (`author: invalid id`) plutôt qu'au scalaire (`authorId`). Contrairement
|
|
145
|
+
// au contrôle du vide, ce garde-ci n'est pas redondant avec l'ordre des
|
|
146
|
+
// boucles : `parseInt('abc')` produit un NaN que celle-ci verrait en
|
|
147
|
+
// premier, et le message perdrait le nom de la relation.
|
|
148
|
+
const firstInvalid = invalid.find((name) => !runtime.relationGraph?.scalarToRelation.has(name));
|
|
149
|
+
if (firstInvalid !== undefined) {
|
|
150
|
+
throw new AdminMutationError('validation', `${firstInvalid}: invalid value`, firstInvalid);
|
|
151
|
+
}
|
|
66
152
|
// Appelé tôt pour échouer vite sur un scope non injectable (`or`, opérateur
|
|
67
153
|
// autre que `eq`, tenant absent), avant tout travail de validation.
|
|
68
154
|
// Volontairement PAS appliqué ici : `data` doit conserver ce que le client
|
|
@@ -72,6 +158,68 @@ export async function handleMutation(runtime, event, route) {
|
|
|
72
158
|
const scopeValues = modelScopeValues(runtime, model, { locals: event.locals });
|
|
73
159
|
const m2mInput = {};
|
|
74
160
|
const targetGuards = [];
|
|
161
|
+
// Colonnes de texte sensibles (`password`, `passwordHash`, `apiToken`…).
|
|
162
|
+
// Le formulaire d'édition ne les rend pas — cf. `Form.svelte` — donc rien
|
|
163
|
+
// de légitime n'arrive par là ; une valeur présente vient d'un POST forgé
|
|
164
|
+
// ou d'un client qui invente un champ, et l'écrire remplacerait un
|
|
165
|
+
// credential par ce que l'appelant a choisi. La clé sort du payload, sans
|
|
166
|
+
// lever : ce n'est pas une donnée refusée, c'est une donnée non demandée.
|
|
167
|
+
//
|
|
168
|
+
// À la création, à l'inverse, le champ est offert. Un vide y était écrit
|
|
169
|
+
// comme `''`, ce qui produisait un compte au credential inutilisable avec
|
|
170
|
+
// un `303` d'apparence réussie. On refuse plutôt, et seulement quand la
|
|
171
|
+
// colonne est réellement obligatoire — une colonne optionnelle garde le
|
|
172
|
+
// droit d'être vide.
|
|
173
|
+
for (const field of model.fields) {
|
|
174
|
+
if (!isSensitiveStringField(field))
|
|
175
|
+
continue;
|
|
176
|
+
if (action === 'update') {
|
|
177
|
+
delete data[field.name];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const submitted = data[field.name];
|
|
181
|
+
const isEmpty = submitted === undefined || submitted === null || submitted === '';
|
|
182
|
+
if (isEmpty && field.isRequired && !field.hasDefault) {
|
|
183
|
+
throw new AdminMutationError('validation', `${field.name} is required`, field.name);
|
|
184
|
+
}
|
|
185
|
+
// Optionnelle et vide : ne rien écrire plutôt qu'une chaîne vide, qui
|
|
186
|
+
// serait indistinguable d'un secret réellement égal à ''.
|
|
187
|
+
if (isEmpty)
|
|
188
|
+
delete data[field.name];
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Revalidation des enums. Le `<select>` rendu par `FieldInput` ne propose
|
|
192
|
+
* que des valeurs déclarées, mais un POST forgé n'y est pas tenu — même
|
|
193
|
+
* raison que la revalidation des cibles FK/m2m plus bas : ce que
|
|
194
|
+
* l'interface n'aurait pas offert ne doit pas passer sous prétexte qu'elle
|
|
195
|
+
* ne l'aurait pas offert. Sans ça, la valeur inventée part au pilote et
|
|
196
|
+
* ressort en message générique, sans désigner le champ fautif.
|
|
197
|
+
*
|
|
198
|
+
* `schema!` et `get(...)!` sont sûrs par construction : `isEnum` n'est posé
|
|
199
|
+
* qu'à partir de la table des enums du schéma (`parser.ts:220`, et
|
|
200
|
+
* `inspect.ts` côté Drizzle qui alimente les deux ensemble), et un modèle
|
|
201
|
+
* résolu implique un schéma introspecté.
|
|
202
|
+
*
|
|
203
|
+
* Le vide se décide sur `isRequired` seul, sans le `!hasDefault` de la
|
|
204
|
+
* boucle au-dessus : celle-ci couvre une création où le défaut de la base
|
|
205
|
+
* peut encore remplir la colonne, alors qu'ici « vide » vise une colonne
|
|
206
|
+
* qui n'accepte pas NULL — un `@default` n'y change rien.
|
|
207
|
+
*/
|
|
208
|
+
const schemaEnums = runtime.schema.enums;
|
|
209
|
+
for (const field of model.fields) {
|
|
210
|
+
if (!field.isEnum || !(field.name in data))
|
|
211
|
+
continue;
|
|
212
|
+
// Le vide (ce que poste le « — aucun — » du widget) ne se décide pas ici
|
|
213
|
+
// mais dans le contrôle unique en fin de fonction, après l'imposition du
|
|
214
|
+
// scope : une colonne de tenant qui se trouve être un enum est rendue
|
|
215
|
+
// vide par le formulaire de création, et la refuser ici rendrait le
|
|
216
|
+
// modèle incréable. Cette boucle ne valide plus que le domaine.
|
|
217
|
+
if (data[field.name] === null)
|
|
218
|
+
continue;
|
|
219
|
+
if (!schemaEnums.get(field.type).includes(String(data[field.name]))) {
|
|
220
|
+
throw new AdminMutationError('validation', `${field.name}: invalid value`, field.name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
75
223
|
// Validation des FK owning : coercion + existence + self-ref.
|
|
76
224
|
// Rejoue le `where` de scoping : un ID hors du where est rejeté,
|
|
77
225
|
// pas seulement caché du select (IDOR par POST forgé).
|
|
@@ -209,10 +357,10 @@ export async function handleMutation(runtime, event, route) {
|
|
|
209
357
|
// coercée ne divergent pas sur le seul type.
|
|
210
358
|
//
|
|
211
359
|
// Seule une valeur réellement affirmée par le client est confrontée au
|
|
212
|
-
// scope. `formDataToPrisma` renvoie `
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
360
|
+
// scope. `formDataToPrisma` renvoie `null` pour un champ présent mais vide,
|
|
361
|
+
// quel que soit son type — et le formulaire de création rend justement la
|
|
362
|
+
// colonne de scope vide. Traiter ce vide comme un conflit rendrait toute
|
|
363
|
+
// création impossible dès que la colonne est visible.
|
|
216
364
|
// Un vide veut dire « le formulaire n'a rien fourni », pas « le client
|
|
217
365
|
// revendique un autre tenant » : on impose alors la valeur sans lever.
|
|
218
366
|
//
|
|
@@ -228,6 +376,39 @@ export async function handleMutation(runtime, event, route) {
|
|
|
228
376
|
}
|
|
229
377
|
data[field] = value;
|
|
230
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Vide sur une colonne qui n'accepte pas NULL : refus, et le champ fautif
|
|
381
|
+
* est nommé. `formDataToPrisma` distingue déjà les deux sens du mot vide —
|
|
382
|
+
* une clé ABSENTE n'a pas été soumise (readonly, masquée, colonne à défaut
|
|
383
|
+
* que le formulaire de création n'affiche pas) et n'écrit rien ; une clé
|
|
384
|
+
* PRÉSENTE à `null` est une saisie vidée.
|
|
385
|
+
*
|
|
386
|
+
* Placé ici, en dernier, et pas plus haut avec les autres validations :
|
|
387
|
+
*
|
|
388
|
+
* - après l'imposition du scope, parce que le formulaire de création rend
|
|
389
|
+
* la colonne de tenant vide (voir le bloc juste au-dessus). À ce point la
|
|
390
|
+
* valeur imposée par le serveur est déjà posée, donc plus rien à refuser ;
|
|
391
|
+
* - après la boucle FK, qui rattache son refus à l'arête (`author is
|
|
392
|
+
* required`) et non au scalaire (`authorId`). C'est cet ordre qui lui en
|
|
393
|
+
* laisse la propriété, pas un garde ici : une arête optionnelle a un
|
|
394
|
+
* scalaire optionnel (Prisma lie les deux), donc le seul scalaire de
|
|
395
|
+
* relation qui puisse encore être `null` en arrivant ici appartient à une
|
|
396
|
+
* arête que la boucle FK ne gère pas — une FK composite, qu'aucun widget
|
|
397
|
+
* ne rend et que seul ce contrôle-ci peut alors nommer.
|
|
398
|
+
*
|
|
399
|
+
* Les champs sensibles n'y passent pas non plus : leur boucle, tout en
|
|
400
|
+
* haut, supprime déjà la clé d'une colonne optionnelle vide plutôt que d'y
|
|
401
|
+
* écrire `null` — `''` y serait indistinguable d'un secret réellement vide.
|
|
402
|
+
*
|
|
403
|
+
* `hasDefault` n'entre pas dans la décision : à la création la colonne à
|
|
404
|
+
* défaut n'est pas rendue, donc la clé est absente ; à l'édition la ligne a
|
|
405
|
+
* déjà une valeur, et la vider est une saisie, pas une absence.
|
|
406
|
+
*/
|
|
407
|
+
for (const field of model.fields) {
|
|
408
|
+
if (!field.isRequired || !(field.name in data) || data[field.name] !== null)
|
|
409
|
+
continue;
|
|
410
|
+
throw new AdminMutationError('validation', `${field.name} is required`, field.name);
|
|
411
|
+
}
|
|
231
412
|
if (action === 'create') {
|
|
232
413
|
let created;
|
|
233
414
|
try {
|
|
@@ -238,10 +419,10 @@ export async function handleMutation(runtime, event, route) {
|
|
|
238
419
|
});
|
|
239
420
|
}
|
|
240
421
|
catch (e) {
|
|
241
|
-
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// c'est le handler qui le masquera.
|
|
422
|
+
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action
|
|
423
|
+
// réellement en cours, et `reference` / `restrict` partagent le même code
|
|
424
|
+
// SQLSTATE — c'est l'action qui les sépare. Un code non reconnu est relayé
|
|
425
|
+
// tel quel, et c'est le handler qui le masquera.
|
|
245
426
|
throw classifyWriteError(e, 'create') ?? e;
|
|
246
427
|
}
|
|
247
428
|
if (audit) {
|
|
@@ -269,10 +450,10 @@ export async function handleMutation(runtime, event, route) {
|
|
|
269
450
|
updated = await runtime.adapter.data.updateRecord(model, route.id, { scalars: data, m2m: m2mInput, targetGuards }, modelScopeFrom(runtime, model, { locals: event.locals }));
|
|
270
451
|
}
|
|
271
452
|
catch (e) {
|
|
272
|
-
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// c'est le handler qui le masquera.
|
|
453
|
+
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action
|
|
454
|
+
// réellement en cours, et `reference` / `restrict` partagent le même code
|
|
455
|
+
// SQLSTATE — c'est l'action qui les sépare. Un code non reconnu est relayé
|
|
456
|
+
// tel quel, et c'est le handler qui le masquera.
|
|
276
457
|
throw classifyWriteError(e, 'update') ?? e;
|
|
277
458
|
}
|
|
278
459
|
if (audit) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colonnes réellement rendues par la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Extrait de `List.svelte`, où cette composition vivait dans un `$derived` de
|
|
5
|
+
* composant : le handler en a besoin CÔTÉ SERVEUR pour décider quels champs un
|
|
6
|
+
* `?sort=` a le droit de désigner. Deux implémentations de « ce que la liste
|
|
7
|
+
* affiche » finiraient par diverger, et un tri autorisé sur une colonne que la
|
|
8
|
+
* liste n'affiche pas est un oracle — `?sort=passwordHash` plus la pagination
|
|
9
|
+
* suffit à ordonner des secrets par dichotomie. Même raison que le prédicat de
|
|
10
|
+
* sensibilité partagé (`parser.ts`) : une seule source, jamais deux.
|
|
11
|
+
*
|
|
12
|
+
* `getDisplayFields` reste la première passe (relations, listes, noms
|
|
13
|
+
* sensibles) ; cette fonction y ajoute ce que seule la config connaît.
|
|
14
|
+
*/
|
|
15
|
+
import { type PrismaField } from '../introspection/parser.js';
|
|
16
|
+
export declare function resolveListColumns(fields: PrismaField[], opts: {
|
|
17
|
+
hidden?: string[];
|
|
18
|
+
listFields?: string[];
|
|
19
|
+
}): PrismaField[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colonnes réellement rendues par la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Extrait de `List.svelte`, où cette composition vivait dans un `$derived` de
|
|
5
|
+
* composant : le handler en a besoin CÔTÉ SERVEUR pour décider quels champs un
|
|
6
|
+
* `?sort=` a le droit de désigner. Deux implémentations de « ce que la liste
|
|
7
|
+
* affiche » finiraient par diverger, et un tri autorisé sur une colonne que la
|
|
8
|
+
* liste n'affiche pas est un oracle — `?sort=passwordHash` plus la pagination
|
|
9
|
+
* suffit à ordonner des secrets par dichotomie. Même raison que le prédicat de
|
|
10
|
+
* sensibilité partagé (`parser.ts`) : une seule source, jamais deux.
|
|
11
|
+
*
|
|
12
|
+
* `getDisplayFields` reste la première passe (relations, listes, noms
|
|
13
|
+
* sensibles) ; cette fonction y ajoute ce que seule la config connaît.
|
|
14
|
+
*/
|
|
15
|
+
import { getDisplayFields } from '../introspection/parser.js';
|
|
16
|
+
/** Types qu'aucune cellule ne sait rendre lisiblement. */
|
|
17
|
+
const UNRENDERABLE_TYPES = ['Json', 'Bytes'];
|
|
18
|
+
/** Au-delà, la table déborde horizontalement sur un écran ordinaire. */
|
|
19
|
+
const MAX_COLUMNS = 6;
|
|
20
|
+
export function resolveListColumns(fields, opts) {
|
|
21
|
+
const hidden = opts.hidden ?? [];
|
|
22
|
+
const listFields = opts.listFields;
|
|
23
|
+
const explicit = new Set(listFields ?? []);
|
|
24
|
+
const safeNames = new Set(getDisplayFields({ fields }).map((f) => f.name));
|
|
25
|
+
let columns = fields.filter((f) =>
|
|
26
|
+
// `listFields` explicite l'emporte sur l'heuristique de nom sensible —
|
|
27
|
+
// échappatoire documentée pour ses faux positifs (`tokenCount`,
|
|
28
|
+
// `hashtagCount`), et couverte par les tests de `List.svelte`. Elle ne
|
|
29
|
+
// l'emporte jamais sur `hidden`, qui est un refus explicite.
|
|
30
|
+
//
|
|
31
|
+
// C'est aussi ce qui rend la whitelist de tri sûre sans règle en plus :
|
|
32
|
+
// trier ne porte que sur des colonnes DÉJÀ rendues, donc sur des valeurs
|
|
33
|
+
// que le lecteur peut lire de toute façon. Le tri n'ouvre aucun oracle
|
|
34
|
+
// qui ne soit pas déjà une lecture directe.
|
|
35
|
+
(explicit.has(f.name) || safeNames.has(f.name)) &&
|
|
36
|
+
!hidden.includes(f.name) &&
|
|
37
|
+
!f.relation &&
|
|
38
|
+
!UNRENDERABLE_TYPES.includes(f.type));
|
|
39
|
+
if (listFields?.length) {
|
|
40
|
+
columns = columns.filter((f) => listFields.includes(f.name));
|
|
41
|
+
}
|
|
42
|
+
return columns.slice(0, MAX_COLUMNS);
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taille de page : celle configurée, et celles qu'un `?perPage=` a le droit de
|
|
3
|
+
* demander.
|
|
4
|
+
*
|
|
5
|
+
* Le point qui compte : la valeur venue de l'URL n'est jamais utilisée telle
|
|
6
|
+
* quelle. Elle doit appartenir à la liste proposée, sinon `?perPage=100000`
|
|
7
|
+
* devient un `take` non borné — un déni de service à un paramètre près, et sur
|
|
8
|
+
* une table volumineuse une requête qui tient la connexion. C'est la même règle
|
|
9
|
+
* d'or que les opérateurs de filtre et les colonnes de tri : l'URL choisit dans
|
|
10
|
+
* une liste finie, elle ne décrit rien.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Tailles sélectionnables : les options configurées, plus la taille par défaut
|
|
14
|
+
* (sinon elle serait active sans figurer dans le sélecteur), triées et
|
|
15
|
+
* dédoublonnées. Une liste d'options vide désactive entièrement le mécanisme —
|
|
16
|
+
* pas de sélecteur, et `?perPage=` sans effet.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolvePageSizes(perPage: number, options: number[]): number[];
|
|
19
|
+
export declare function parsePageSize(params: URLSearchParams, fallback: number, selectable: number[]): number;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taille de page : celle configurée, et celles qu'un `?perPage=` a le droit de
|
|
3
|
+
* demander.
|
|
4
|
+
*
|
|
5
|
+
* Le point qui compte : la valeur venue de l'URL n'est jamais utilisée telle
|
|
6
|
+
* quelle. Elle doit appartenir à la liste proposée, sinon `?perPage=100000`
|
|
7
|
+
* devient un `take` non borné — un déni de service à un paramètre près, et sur
|
|
8
|
+
* une table volumineuse une requête qui tient la connexion. C'est la même règle
|
|
9
|
+
* d'or que les opérateurs de filtre et les colonnes de tri : l'URL choisit dans
|
|
10
|
+
* une liste finie, elle ne décrit rien.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Tailles sélectionnables : les options configurées, plus la taille par défaut
|
|
14
|
+
* (sinon elle serait active sans figurer dans le sélecteur), triées et
|
|
15
|
+
* dédoublonnées. Une liste d'options vide désactive entièrement le mécanisme —
|
|
16
|
+
* pas de sélecteur, et `?perPage=` sans effet.
|
|
17
|
+
*/
|
|
18
|
+
export function resolvePageSizes(perPage, options) {
|
|
19
|
+
if (options.length === 0)
|
|
20
|
+
return [];
|
|
21
|
+
return [...new Set([...options, perPage])].sort((a, b) => a - b);
|
|
22
|
+
}
|
|
23
|
+
export function parsePageSize(params, fallback, selectable) {
|
|
24
|
+
const requested = Number(params.get('perPage'));
|
|
25
|
+
return selectable.includes(requested) ? requested : fallback;
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `?sort=<colonne>&dir=asc|desc` → ordre de tri de la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Même règle d'or que les filtres (`listQuery.ts`) : la chaîne venue de l'URL
|
|
5
|
+
* ne devient jamais une clé de requête. Elle est cherchée dans la liste des
|
|
6
|
+
* colonnes que la vue rend réellement (`resolveListColumns`), et seul un membre
|
|
7
|
+
* de cette liste ressort. Une colonne masquée par `hidden`, écartée par
|
|
8
|
+
* l'heuristique de nom, ou simplement tronquée par le plafond de colonnes n'y
|
|
9
|
+
* est pas — donc pas triable.
|
|
10
|
+
*
|
|
11
|
+
* Trier ne peut ainsi ordonner que des valeurs déjà lisibles à l'écran : le tri
|
|
12
|
+
* n'ouvre aucune lecture que la liste n'offrait pas.
|
|
13
|
+
*/
|
|
14
|
+
export type SortDirection = 'asc' | 'desc';
|
|
15
|
+
export interface ActiveSort {
|
|
16
|
+
field: string;
|
|
17
|
+
dir: SortDirection;
|
|
18
|
+
}
|
|
19
|
+
export interface SortState {
|
|
20
|
+
/** null = ordre par défaut (clé primaire décroissante, côté adapter). */
|
|
21
|
+
active: ActiveSort | null;
|
|
22
|
+
/** true quand l'URL a demandé une colonne non triable — rendu comme message. */
|
|
23
|
+
ignored: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `fallback` est le `models[].defaultSort` déjà validé au démarrage. Il
|
|
27
|
+
* s'applique quand l'URL ne demande rien, ET quand elle demande une colonne
|
|
28
|
+
* refusée : le refus reste signalé (`ignored`), mais la liste garde un ordre
|
|
29
|
+
* intentionnel plutôt que de retomber sur la clé primaire.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseSortQuery(params: URLSearchParams, sortable: string[], fallback?: ActiveSort): SortState;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `?sort=<colonne>&dir=asc|desc` → ordre de tri de la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Même règle d'or que les filtres (`listQuery.ts`) : la chaîne venue de l'URL
|
|
5
|
+
* ne devient jamais une clé de requête. Elle est cherchée dans la liste des
|
|
6
|
+
* colonnes que la vue rend réellement (`resolveListColumns`), et seul un membre
|
|
7
|
+
* de cette liste ressort. Une colonne masquée par `hidden`, écartée par
|
|
8
|
+
* l'heuristique de nom, ou simplement tronquée par le plafond de colonnes n'y
|
|
9
|
+
* est pas — donc pas triable.
|
|
10
|
+
*
|
|
11
|
+
* Trier ne peut ainsi ordonner que des valeurs déjà lisibles à l'écran : le tri
|
|
12
|
+
* n'ouvre aucune lecture que la liste n'offrait pas.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `fallback` est le `models[].defaultSort` déjà validé au démarrage. Il
|
|
16
|
+
* s'applique quand l'URL ne demande rien, ET quand elle demande une colonne
|
|
17
|
+
* refusée : le refus reste signalé (`ignored`), mais la liste garde un ordre
|
|
18
|
+
* intentionnel plutôt que de retomber sur la clé primaire.
|
|
19
|
+
*/
|
|
20
|
+
export function parseSortQuery(params, sortable, fallback) {
|
|
21
|
+
const requested = params.get('sort');
|
|
22
|
+
// Vide == absent : `?sort=` est un artefact d'interface (un form GET qui
|
|
23
|
+
// sérialise un champ non renseigné), pas une demande à refuser bruyamment.
|
|
24
|
+
if (!requested)
|
|
25
|
+
return { active: fallback ?? null, ignored: false };
|
|
26
|
+
if (!sortable.includes(requested))
|
|
27
|
+
return { active: fallback ?? null, ignored: true };
|
|
28
|
+
// Domaine à deux valeurs : tout ce qui n'est pas `desc` est ascendant. Rien à
|
|
29
|
+
// refuser ici — contrairement au champ, une direction inconnue ne désigne
|
|
30
|
+
// aucune colonne et ne peut donc rien révéler.
|
|
31
|
+
const dir = params.get('dir') === 'desc' ? 'desc' : 'asc';
|
|
32
|
+
return { active: { field: requested, dir }, ignored: false };
|
|
33
|
+
}
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* "always drop `page` on filter change" invariant (docs/design §3.3, §7.1)
|
|
8
8
|
* and risks parameter-order drift, which makes snapshots flaky.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Paramètres qui ne sont pas un état de liste : un compte rendu d'action posé
|
|
12
|
+
* une fois par une redirection après écriture. Retiré de tout lien construit
|
|
13
|
+
* ensuite, sinon « 3 supprimés » réapparaît à chaque clic de filtre ou de page.
|
|
14
|
+
*/
|
|
15
|
+
const ONE_SHOT_PARAMS = ['deleted'];
|
|
10
16
|
/**
|
|
11
17
|
* Build a list-view URL from the current one, applying a patch of query
|
|
12
18
|
* params. `null` in the patch removes that key. `page` is ALWAYS dropped
|
|
@@ -23,6 +29,8 @@ export function buildListUrl(currentUrl, patch) {
|
|
|
23
29
|
if (!('page' in patch)) {
|
|
24
30
|
params.delete('page');
|
|
25
31
|
}
|
|
32
|
+
for (const key of ONE_SHOT_PARAMS)
|
|
33
|
+
params.delete(key);
|
|
26
34
|
for (const [key, value] of Object.entries(patch)) {
|
|
27
35
|
if (value === null)
|
|
28
36
|
params.delete(key);
|
|
@@ -47,7 +55,7 @@ export function buildListUrl(currentUrl, patch) {
|
|
|
47
55
|
* `page` is always excluded too, since any new search/filter resets it.
|
|
48
56
|
*/
|
|
49
57
|
export function hiddenParams(currentUrl, exclude) {
|
|
50
|
-
const excluded = new Set([...exclude, 'page']);
|
|
58
|
+
const excluded = new Set([...exclude, 'page', ...ONE_SHOT_PARAMS]);
|
|
51
59
|
const out = [];
|
|
52
60
|
for (const [key, value] of currentUrl.searchParams) {
|
|
53
61
|
if (excluded.has(key))
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Schema, Model } from './types/schema.js';
|
|
2
|
+
import type { ActiveSort } from './query/sortQuery.js';
|
|
2
3
|
import { type RelationGraph } from './introspection/relations.js';
|
|
3
4
|
import type { ViewModel } from './views/types.js';
|
|
4
5
|
import type { DataAdapter, SchemaIntrospector, Filter } from './adapters/types.js';
|
|
@@ -33,6 +34,10 @@ export interface AdminRuntime {
|
|
|
33
34
|
config: AdminHandlerConfig;
|
|
34
35
|
basePath: string;
|
|
35
36
|
perPage: number;
|
|
37
|
+
/** Tailles sélectionnables, vide quand le mécanisme est désactivé. */
|
|
38
|
+
pageSizes: number[];
|
|
39
|
+
/** `models[].defaultSort` validé au démarrage, par nom de modèle. */
|
|
40
|
+
defaultSortOf(model: Model): ActiveSort | undefined;
|
|
36
41
|
selectThreshold: number;
|
|
37
42
|
filterLinkThreshold: number;
|
|
38
43
|
labelFieldCandidates: string[];
|
package/dist/server/runtime.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { isSensitiveFieldName } from './introspection/parser.js';
|
|
2
|
+
import { resolveListColumns } from './query/listColumns.js';
|
|
3
|
+
import { resolvePageSizes } from './query/pageSize.js';
|
|
2
4
|
import { buildRelationGraph } from './introspection/relations.js';
|
|
3
5
|
import { primaryKeyOf } from './data.js';
|
|
4
6
|
import { validateListFilterConfig } from './query/filterDetection.js';
|
|
@@ -94,6 +96,13 @@ export function createAdminRuntime(config) {
|
|
|
94
96
|
catch (e) {
|
|
95
97
|
console.warn('[sveltekit-admin] Could not introspect schema:', e);
|
|
96
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Résolu une fois ici plutôt que dans `viewModel` : un `schema?.enums ?? …`
|
|
101
|
+
* par appel serait une branche que rien ne peut exercer (un schéma nul donne
|
|
102
|
+
* `models` vide, donc aucune vue à construire), alors qu'à ce niveau les deux
|
|
103
|
+
* cas sont ceux du démarrage — schéma lu, ou introspection en échec.
|
|
104
|
+
*/
|
|
105
|
+
const schemaEnums = schema?.enums ?? new Map();
|
|
97
106
|
const models = schema?.models.filter((m) => {
|
|
98
107
|
// Exclude explicitly excluded models
|
|
99
108
|
if (exclude.includes(m.name))
|
|
@@ -116,6 +125,31 @@ export function createAdminRuntime(config) {
|
|
|
116
125
|
if (entries)
|
|
117
126
|
validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
|
|
118
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* `defaultSort` validé ici pour la même raison que `listFilter` : une colonne
|
|
130
|
+
* inexistante, ou que la liste n'affiche pas, produirait un tri mort à chaque
|
|
131
|
+
* rendu sans qu'aucun en-tête ne l'annonce — et l'utilisateur n'aurait aucun
|
|
132
|
+
* moyen d'en sortir, puisque seule une colonne affichée porte un lien. La
|
|
133
|
+
* liste des colonnes autorisées est la même que celle du tri par URL.
|
|
134
|
+
*/
|
|
135
|
+
const defaultSortOf = (m) => {
|
|
136
|
+
const configured = modelsConfig[m.name]?.defaultSort;
|
|
137
|
+
if (!configured)
|
|
138
|
+
return undefined;
|
|
139
|
+
const sortable = resolveListColumns(m.fields, {
|
|
140
|
+
hidden: modelsConfig[m.name]?.hidden,
|
|
141
|
+
listFields: modelsConfig[m.name]?.listFields
|
|
142
|
+
}).map((f) => f.name);
|
|
143
|
+
if (!sortable.includes(configured.field)) {
|
|
144
|
+
throw new AdminConfigError(`[sveltekit-admin] models.${m.name}.defaultSort targets "${configured.field}", ` +
|
|
145
|
+
`which the list view does not display. Displayed columns: [${sortable.join(', ')}].`);
|
|
146
|
+
}
|
|
147
|
+
if (configured.dir !== undefined && configured.dir !== 'asc' && configured.dir !== 'desc') {
|
|
148
|
+
throw new AdminConfigError(`[sveltekit-admin] models.${m.name}.defaultSort.dir must be "asc" or "desc".`);
|
|
149
|
+
}
|
|
150
|
+
return { field: configured.field, dir: configured.dir ?? 'asc' };
|
|
151
|
+
};
|
|
152
|
+
const defaultSorts = new Map(models.map((m) => [m.name, defaultSortOf(m)]));
|
|
119
153
|
const labelOf = (m) => {
|
|
120
154
|
const configured = modelsConfig[m.name]?.label;
|
|
121
155
|
if (configured)
|
|
@@ -130,10 +164,27 @@ export function createAdminRuntime(config) {
|
|
|
130
164
|
label: labelOf(m),
|
|
131
165
|
fields: m.fields,
|
|
132
166
|
primaryKey: primaryKeyOf(m),
|
|
167
|
+
enums: schemaEnums,
|
|
133
168
|
// Non-null par construction : `m` vient toujours de `models`,
|
|
134
169
|
// dérivé du schéma qu'on vient de parser avec succès.
|
|
135
170
|
relationGraph: relationGraph
|
|
136
171
|
});
|
|
172
|
+
/**
|
|
173
|
+
* Plafond dur. Au-delà ce n'est plus une page mais un export, et sur une
|
|
174
|
+
* table volumineuse une requête qui tient la connexion. Vaut pour la valeur
|
|
175
|
+
* configurée comme pour chaque option proposée.
|
|
176
|
+
*/
|
|
177
|
+
const MAX_PAGE_SIZE = 200;
|
|
178
|
+
const validPageSize = (n) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 1 && n <= MAX_PAGE_SIZE;
|
|
179
|
+
if (config.perPage !== undefined && !validPageSize(config.perPage)) {
|
|
180
|
+
throw new AdminConfigError(`[sveltekit-admin] perPage must be an integer between 1 and ${MAX_PAGE_SIZE}.`);
|
|
181
|
+
}
|
|
182
|
+
const perPage = config.perPage ?? 20;
|
|
183
|
+
const configuredSizes = config.pageSizeOptions ?? [10, 20, 50, 100];
|
|
184
|
+
if (!Array.isArray(configuredSizes) || !configuredSizes.every(validPageSize)) {
|
|
185
|
+
throw new AdminConfigError(`[sveltekit-admin] pageSizeOptions must be integers between 1 and ${MAX_PAGE_SIZE}.`);
|
|
186
|
+
}
|
|
187
|
+
const pageSizes = resolvePageSizes(perPage, configuredSizes);
|
|
137
188
|
const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
|
|
138
189
|
const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
|
|
139
190
|
const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
|
|
@@ -196,7 +247,9 @@ export function createAdminRuntime(config) {
|
|
|
196
247
|
modelList,
|
|
197
248
|
config,
|
|
198
249
|
basePath,
|
|
199
|
-
perPage
|
|
250
|
+
perPage,
|
|
251
|
+
pageSizes,
|
|
252
|
+
defaultSortOf: (m) => defaultSorts.get(m.name),
|
|
200
253
|
selectThreshold,
|
|
201
254
|
filterLinkThreshold,
|
|
202
255
|
labelFieldCandidates,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extraction des valeurs réellement soumises par un POST de formulaire admin,
|
|
3
|
+
* pour les re-rendre après un échec de mutation.
|
|
4
|
+
*
|
|
5
|
+
* Ce module ne rend rien : il traduit un `FormData` en la forme minimale dont
|
|
6
|
+
* les vues ont besoin. Il porte en revanche la décision de sécurité de ce
|
|
7
|
+
* chemin — ce qui NE doit pas repartir dans le HTML.
|
|
8
|
+
*/
|
|
9
|
+
export interface SubmittedForm {
|
|
10
|
+
/** Scalaires et scalaires de relation, par nom de champ. */
|
|
11
|
+
values: Record<string, string>;
|
|
12
|
+
/**
|
|
13
|
+
* IDs cochés par arête m2m. Une entrée présente avec un tableau vide dit
|
|
14
|
+
* « l'utilisateur a tout décoché » ; une arête absente dit « le widget
|
|
15
|
+
* n'était pas dans le formulaire ». Même distinction, et même raison, que
|
|
16
|
+
* le sentinelle côté écriture.
|
|
17
|
+
*/
|
|
18
|
+
m2m: Record<string, string[]>;
|
|
19
|
+
}
|
|
20
|
+
export declare function readSubmittedForm(formData: FormData, hidden: ReadonlySet<string>): SubmittedForm;
|