sveltekit-admin 0.6.0 → 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/README.md +61 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
- package/dist/server/adapters/drizzle/index.d.ts +10 -1
- package/dist/server/adapters/drizzle/index.js +4 -0
- package/dist/server/adapters/prisma/dataAdapter.js +71 -11
- package/dist/server/adapters/prisma/handler.d.ts +14 -0
- package/dist/server/adapters/prisma/handler.js +37 -0
- package/dist/server/adapters/retry.d.ts +27 -0
- package/dist/server/adapters/retry.js +53 -0
- package/dist/server/adapters/types.d.ts +42 -3
- package/dist/server/audit.d.ts +65 -0
- package/dist/server/audit.js +106 -0
- package/dist/server/csrf.d.ts +33 -0
- package/dist/server/csrf.js +55 -0
- package/dist/server/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +92 -26
- package/dist/server/handler.js +263 -560
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +13 -0
- package/dist/server/mutations.js +476 -0
- package/dist/server/plugin.d.ts +47 -0
- package/dist/server/plugin.js +1 -0
- package/dist/server/pluginAccess.d.ts +7 -0
- package/dist/server/pluginAccess.js +79 -0
- package/dist/server/pluginRegistry.d.ts +12 -0
- package/dist/server/pluginRegistry.js +72 -0
- package/dist/server/query/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- package/dist/server/query/listQuery.d.ts +1 -1
- 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/relationLoaders.d.ts +42 -0
- package/dist/server/relationLoaders.js +188 -0
- package/dist/server/router.d.ts +10 -0
- package/dist/server/router.js +42 -19
- package/dist/server/runtime.d.ts +51 -0
- package/dist/server/runtime.js +263 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- 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 +96 -5
- package/dist/server/views/Form.svelte.d.ts +19 -1
- package/dist/server/views/Layout.svelte +32 -4
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +156 -26
- package/dist/server/views/List.svelte.d.ts +7 -2
- 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 +15 -0
- package/package.json +24 -21
package/dist/server/handler.js
CHANGED
|
@@ -3,392 +3,62 @@
|
|
|
3
3
|
* Zero files needed in routes - everything handled via hook
|
|
4
4
|
*/
|
|
5
5
|
import { render } from 'svelte/server';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { parseRoute } from './router.js';
|
|
9
|
-
import { primaryKeyOf, coerceId, formDataToPrisma, paginate } from './data.js';
|
|
6
|
+
import { matchRoute, BUILTIN_ROUTES } from './router.js';
|
|
7
|
+
import { paginate, coerceId } from './data.js';
|
|
10
8
|
import { parseListQuery, buildWhere, resolveSearchFields } from './query/listQuery.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
9
|
+
import { normalizeScope } from './adapters/filter.js';
|
|
10
|
+
import { resolveListFilters } from './query/filterDetection.js';
|
|
11
|
+
import { resolveListColumns } from './query/listColumns.js';
|
|
12
|
+
import { parseSortQuery } from './query/sortQuery.js';
|
|
13
|
+
import { parsePageSize } from './query/pageSize.js';
|
|
16
14
|
import { escapeHtml, toLabel } from './views/html.js';
|
|
17
15
|
import NotFound from './views/NotFound.svelte';
|
|
18
16
|
import Layout from './views/Layout.svelte';
|
|
19
17
|
import Dashboard from './views/Dashboard.svelte';
|
|
20
18
|
import Form from './views/Form.svelte';
|
|
21
19
|
import List from './views/List.svelte';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
20
|
+
import { createAdminRuntime, listScopeFrom, modelScopeFrom } from './runtime.js';
|
|
21
|
+
import { loadRelationOptions, resolveFkFilterOptions, loadRelatedCounts } from './relationLoaders.js';
|
|
22
|
+
import { handleSearch } from './search.js';
|
|
23
|
+
import { handleMutation } from './mutations.js';
|
|
24
|
+
import { AdminMutationError, AdminConfigError } from './errors.js';
|
|
25
|
+
import { verifyOrigin, resolveCsrfConfig } from './csrf.js';
|
|
26
|
+
import { readSubmittedForm } from './submitted.js';
|
|
27
|
+
import { resolvePluginRegistry, actionsForModel } from './pluginRegistry.js';
|
|
28
|
+
import { createPluginPageContext } from './pluginAccess.js';
|
|
26
29
|
// ============================================
|
|
27
30
|
// Main Handler
|
|
28
31
|
// ============================================
|
|
29
32
|
export function createAdminHandler(config) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
throw new Error('[sveltekit-admin] createAdminHandler requires either `prisma` (with optional `prismaSchemaPath`) or `adapter` — neither was provided.');
|
|
33
|
-
}
|
|
34
|
-
const introspector = config.adapter?.introspector ?? createPrismaIntrospector({ schemaPath: prismaSchemaPath });
|
|
35
|
-
// Introspect the schema once at startup — same failure handling as before:
|
|
36
|
-
// a broken/missing schema source degrades to "no models known" rather than
|
|
37
|
-
// throwing out of `createAdminHandler` itself.
|
|
38
|
-
let schema = null;
|
|
39
|
-
let relationGraph = null;
|
|
40
|
-
try {
|
|
41
|
-
const introspected = introspector.introspect();
|
|
42
|
-
if (introspected instanceof Promise) {
|
|
43
|
-
throw new Error('[sveltekit-admin] SchemaIntrospector.introspect() returned a Promise — ' +
|
|
44
|
-
'createAdminHandler only supports synchronous introspection today.');
|
|
45
|
-
}
|
|
46
|
-
schema = introspected;
|
|
47
|
-
relationGraph = buildRelationGraph(schema);
|
|
48
|
-
for (const d of relationGraph.diagnostics) {
|
|
49
|
-
console.warn(`[sveltekit-admin] ${d}`);
|
|
50
|
-
}
|
|
33
|
+
if (!config.adapter) {
|
|
34
|
+
throw new Error('[sveltekit-admin] createAdminHandler requires `adapter`.');
|
|
51
35
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
// Exclude explicitly excluded models
|
|
57
|
-
if (exclude.includes(m.name))
|
|
58
|
-
return false;
|
|
59
|
-
// Exclude pivot tables if option is enabled
|
|
60
|
-
if (hidePivotTables && m.isPivotTable)
|
|
61
|
-
return false;
|
|
62
|
-
return true;
|
|
63
|
-
}) || [];
|
|
64
|
-
// Valider `listFilter` au démarrage : une config invalide (champ
|
|
65
|
-
// inexistant, sensible, relation, type non supporté) doit échouer fort
|
|
66
|
-
// ici plutôt que produire silencieusement un filtre mort à chaque rendu
|
|
67
|
-
// de liste (docs/design §8, même politique que le groupe ambigu de
|
|
68
|
-
// relations.ts).
|
|
69
|
-
const hiddenFieldsOf = (m) => new Set(modelsConfig[m.name]?.hidden ?? []);
|
|
70
|
-
for (const m of filteredModels) {
|
|
71
|
-
const entries = modelsConfig[m.name]?.listFilter;
|
|
72
|
-
// Non-null par construction : `filteredModels` n'existe que si le schéma
|
|
73
|
-
// a été parsé, et le graphe est construit dans la même branche de boot.
|
|
74
|
-
if (entries)
|
|
75
|
-
validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
|
|
76
|
-
}
|
|
77
|
-
const labelOf = (m) => {
|
|
78
|
-
const configured = modelsConfig[m.name]?.label;
|
|
79
|
-
if (configured)
|
|
80
|
-
return configured;
|
|
81
|
-
const label = toLabel(m.name);
|
|
82
|
-
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
83
|
-
};
|
|
84
|
-
const modelList = filteredModels.map((m) => ({ name: m.name, label: labelOf(m) }));
|
|
85
|
-
const findModel = (name) => filteredModels.find((m) => m.name.toLowerCase() === name?.toLowerCase());
|
|
86
|
-
const viewModel = (m) => ({
|
|
87
|
-
name: m.name,
|
|
88
|
-
label: labelOf(m),
|
|
89
|
-
fields: m.fields,
|
|
90
|
-
primaryKey: primaryKeyOf(m),
|
|
91
|
-
// Non-null par construction : `m` vient toujours de `filteredModels`,
|
|
92
|
-
// dérivé du schéma qu'on vient de parser avec succès.
|
|
93
|
-
relationGraph: relationGraph
|
|
94
|
-
});
|
|
95
|
-
const redirectToList = (model) => new Response(null, {
|
|
96
|
-
status: 303,
|
|
97
|
-
headers: { Location: `${basePath}/${model.toLowerCase()}` }
|
|
98
|
-
});
|
|
99
|
-
const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
|
|
100
|
-
const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
|
|
101
|
-
const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
|
|
102
|
-
'name', 'title', 'label', 'email', 'username', 'slug'
|
|
103
|
-
];
|
|
104
|
-
// `mode: 'insensitive'` n'est supporté par Prisma que sur
|
|
105
|
-
// postgresql/cockroachdb/mongodb — l'émettre sur sqlite/mysql/sqlserver
|
|
106
|
-
// lève une erreur Prisma dure. Détection auto via le provider extrait du
|
|
107
|
-
// schéma ; `search.mode` permet de forcer le comportement (provider non
|
|
108
|
-
// littéral dans le schéma, index citext, etc.). Voir docs/design §2.5.
|
|
109
|
-
const caseInsensitiveSearch = resolveCaseInsensitiveSearch(schema, config.search?.mode);
|
|
110
|
-
const adapter = config.adapter ?? { introspector, data: createPrismaDataAdapter(prisma, { caseInsensitiveSearch }) };
|
|
111
|
-
/**
|
|
112
|
-
* Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
|
|
113
|
-
* champ scalaire non-liste, non-relation, de type filtrable
|
|
114
|
-
* (String/Int/Float/Decimal/BigInt/Boolean/DateTime/enum — donc pas
|
|
115
|
-
* Json/Bytes), non sensible, et non listé dans `hidden` pour ce modèle.
|
|
116
|
-
* Sans ce dernier point, `hidden: ['internalNotes']` ne fait que masquer
|
|
117
|
-
* l'affichage : le champ reste un oracle de confirmation de valeur via
|
|
118
|
-
* `?f.internalNotes=...contains...`, exactement la faille §0.a fermée
|
|
119
|
-
* ailleurs pour les champs sensibles par nom — `hidden` et le prédicat
|
|
120
|
-
* de sensibilité sont deux sources distinctes, toutes deux doivent
|
|
121
|
-
* fermer l'oracle (docs/design §10, "deux sources, un seul prédicat
|
|
122
|
-
* partagé, sinon divergence garantie"). Défense en profondeur :
|
|
123
|
-
* `listQuery.ts` revérifie lui-même la sensibilité par nom, ce set est
|
|
124
|
-
* la première passe et la seule à connaître la config `hidden`.
|
|
125
|
-
*/
|
|
126
|
-
const resolveFilterableFields = (model) => {
|
|
127
|
-
const hidden = hiddenFieldsOf(model);
|
|
128
|
-
const out = new Set();
|
|
129
|
-
for (const f of model.fields) {
|
|
130
|
-
if (f.relation || f.isList)
|
|
131
|
-
continue;
|
|
132
|
-
if (['Json', 'Bytes'].includes(f.type))
|
|
133
|
-
continue;
|
|
134
|
-
if (isSensitiveFieldName(f.name))
|
|
135
|
-
continue;
|
|
136
|
-
if (hidden.has(f.name))
|
|
137
|
-
continue;
|
|
138
|
-
out.add(f.name);
|
|
139
|
-
}
|
|
140
|
-
return out;
|
|
141
|
-
};
|
|
142
|
-
/**
|
|
143
|
-
* Résout le label BRUT (non échappé) d'une ligne : premier champ String
|
|
144
|
-
* candidat présent, sinon template `{a} {b}` si configuré, sinon la PK.
|
|
145
|
-
* Déterministe. Svelte échappe automatiquement à l'interpolation dans les
|
|
146
|
-
* composants — pas besoin d'échapper ici.
|
|
147
|
-
*/
|
|
148
|
-
const resolveLabel = (targetModel, row, labelTemplate) => {
|
|
149
|
-
if (labelTemplate) {
|
|
150
|
-
return labelTemplate.replace(/\{(\w+)\}/g, (_, k) => String(row[k] ?? ''));
|
|
151
|
-
}
|
|
152
|
-
for (const candidate of labelFieldCandidates) {
|
|
153
|
-
const field = targetModel.fields.find((f) => f.name === candidate);
|
|
154
|
-
if (field && field.type === 'String' && row[candidate] != null) {
|
|
155
|
-
return String(row[candidate]);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return String(row[primaryKeyOf(targetModel)]);
|
|
159
|
-
};
|
|
160
|
-
/**
|
|
161
|
-
* Charge les options pour toutes les arêtes to-one-owning et m2m
|
|
162
|
-
* d'un modèle. Une requête COUNT par relation avant le findMany : évite de
|
|
163
|
-
* charger 10k lignes pour découvrir qu'il y en a 10k.
|
|
164
|
-
*/
|
|
165
|
-
const loadRelationOptions = async (model, ctx, currentId) => {
|
|
166
|
-
const edges = [...relationGraph.edges.values()].filter((edge) => {
|
|
167
|
-
if (edge.model !== model.name)
|
|
168
|
-
return false;
|
|
169
|
-
if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m')
|
|
170
|
-
return false;
|
|
171
|
-
if (edge.unsupported)
|
|
172
|
-
return false;
|
|
173
|
-
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
174
|
-
return relConfig?.widget !== 'hidden';
|
|
175
|
-
});
|
|
176
|
-
// Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
|
|
177
|
-
// qu'en série (un modèle avec N relations ne doit pas payer N
|
|
178
|
-
// aller-retours DB empilés pour afficher un seul formulaire).
|
|
179
|
-
const entries = await Promise.all(edges.map(async (edge) => {
|
|
180
|
-
const key = `${edge.model}.${edge.field}`;
|
|
181
|
-
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
182
|
-
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
183
|
-
const filter = scopeFrom(relConfig, ctx);
|
|
184
|
-
try {
|
|
185
|
-
const total = await adapter.data.countRecords(targetModel, filter);
|
|
186
|
-
if (total > selectThreshold || relConfig?.widget === 'raw-id') {
|
|
187
|
-
const selectedIds = edge.kind === 'm2m' && currentId
|
|
188
|
-
? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
|
|
189
|
-
: undefined;
|
|
190
|
-
return [key, { tooMany: true, options: [], selectedIds }];
|
|
191
|
-
}
|
|
192
|
-
const rows = await adapter.data.findMany(targetModel, { filter, orderBy: relConfig?.orderBy });
|
|
193
|
-
const options = rows.map((row) => ({
|
|
194
|
-
id: row[primaryKeyOf(targetModel)],
|
|
195
|
-
label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
196
|
-
}));
|
|
197
|
-
const selectedIds = edge.kind === 'm2m' && currentId
|
|
198
|
-
? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
|
|
199
|
-
: undefined;
|
|
200
|
-
return [key, { tooMany: false, options, selectedIds }];
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
// Cible absente de la base ou client incomplet : repli raw-id pour
|
|
204
|
-
// garder le champ éditable plutôt que de faire échouer tout le form.
|
|
205
|
-
return [key, { tooMany: true, options: [] }];
|
|
206
|
-
}
|
|
207
|
-
}));
|
|
208
|
-
return new Map(entries);
|
|
209
|
-
};
|
|
210
|
-
/**
|
|
211
|
-
* Options d'un filtre FK : charge et scope les valeurs possibles pour la
|
|
212
|
-
* sidebar, ET résout le label du chip actif. Doctrine IDOR (docs/design
|
|
213
|
-
* §6.3) : les options ET le label du chip passent par le `where` de
|
|
214
|
-
* scoping de la relation — un chip forgé avec un ID hors scope affiche
|
|
215
|
-
* l'ID brut, jamais le label (sinon c'est un oracle sur le nom d'un
|
|
216
|
-
* enregistrement d'un autre tenant).
|
|
217
|
-
*/
|
|
218
|
-
const resolveFkFilterOptions = async (model, fkFieldName, label, ctx, activeRawValue) => {
|
|
219
|
-
// Appelé uniquement pour un filtre `kind: 'fk'` retourné par
|
|
220
|
-
// resolveListFilters avec CE MÊME graphe : graphe et arête existent donc
|
|
221
|
-
// par construction. Garder des gardes here masquerait une incohérence
|
|
222
|
-
// interne et ajouterait du code mort (coverage artificielle).
|
|
223
|
-
const edge = findFkEdge(relationGraph, model.name, fkFieldName);
|
|
224
|
-
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
225
|
-
// Non-null par construction : `edge` vient du graphe dérivé du même
|
|
226
|
-
// schéma parsé avec succès — une arête ne peut pas cibler un modèle qui
|
|
227
|
-
// n'existe pas dans `schema.models`.
|
|
228
|
-
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
229
|
-
const scope = scopeFrom(relConfig, ctx);
|
|
230
|
-
// Options de la sidebar (comptées puis chargées si sous le seuil) et
|
|
231
|
-
// label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
|
|
232
|
-
// ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
|
|
233
|
-
// série.
|
|
234
|
-
const loadOptions = async () => {
|
|
235
|
-
try {
|
|
236
|
-
const total = await adapter.data.countRecords(targetModel, scope);
|
|
237
|
-
if (total > selectThreshold) {
|
|
238
|
-
return { options: [], tooMany: true };
|
|
239
|
-
}
|
|
240
|
-
const rows = await adapter.data.findMany(targetModel, { filter: scope, orderBy: relConfig?.orderBy });
|
|
241
|
-
const options = rows.map((row) => ({
|
|
242
|
-
id: row[primaryKeyOf(targetModel)],
|
|
243
|
-
label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
244
|
-
}));
|
|
245
|
-
return { options, tooMany: false };
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
return { options: [], tooMany: true };
|
|
249
|
-
}
|
|
250
|
-
};
|
|
251
|
-
// Un ID hors scope retourne null ici → le composant affiche l'ID brut,
|
|
252
|
-
// jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
|
|
253
|
-
// d'un autre tenant).
|
|
254
|
-
const loadActiveLabel = async () => {
|
|
255
|
-
if (activeRawValue === undefined)
|
|
256
|
-
return undefined;
|
|
257
|
-
const activeId = coerceId(activeRawValue, targetModel);
|
|
258
|
-
try {
|
|
259
|
-
const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: activeId };
|
|
260
|
-
const filter = scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter;
|
|
261
|
-
const row = await adapter.data.findFirst(targetModel, filter);
|
|
262
|
-
return row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
return undefined;
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
|
|
269
|
-
return {
|
|
270
|
-
field: fkFieldName,
|
|
271
|
-
label,
|
|
272
|
-
relationField: edge.field,
|
|
273
|
-
targetModel: edge.target,
|
|
274
|
-
options,
|
|
275
|
-
mode: tooMany ? 'raw-id' : options.length <= filterLinkThreshold ? 'links' : 'select',
|
|
276
|
-
tooMany,
|
|
277
|
-
activeLabel,
|
|
278
|
-
// Une cible exclue/masquée n'a pas de page admin : le chip reste du
|
|
279
|
-
// texte, jamais un lien mort (docs/design §6.4).
|
|
280
|
-
activeHref: activeLabel && findModel(edge.target)
|
|
281
|
-
? `${basePath}/${edge.target.toLowerCase()}/${encodeURIComponent(activeRawValue)}`
|
|
282
|
-
: undefined
|
|
283
|
-
};
|
|
284
|
-
};
|
|
285
|
-
/**
|
|
286
|
-
* Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
|
|
287
|
-
* d'enregistrements liés côté cible. Résilient : une cible dont le client
|
|
288
|
-
* échoue (mock partiel, modèle absent) retombe sur 0 plutôt que de casser
|
|
289
|
-
* le rendu du formulaire.
|
|
290
|
-
*/
|
|
291
|
-
const loadRelatedCounts = async (model, currentId) => {
|
|
292
|
-
const edges = [...relationGraph.edges.values()].filter((edge) => edge.model === model.name && (edge.kind === 'to-many-inverse' || edge.kind === 'to-one-inverse'));
|
|
293
|
-
// Un count par relation inverse, indépendants entre eux : en parallèle
|
|
294
|
-
// plutôt qu'empilés un par un (même raisonnement que loadRelationOptions).
|
|
295
|
-
const entries = await Promise.all(edges.map(async (edge) => {
|
|
296
|
-
const owning = [...relationGraph.edges.values()].find((o) => o.model === edge.target && o.kind === 'to-one-owning' && o.relationName === edge.relationName);
|
|
297
|
-
if (!owning || owning.unsupported)
|
|
298
|
-
return undefined;
|
|
299
|
-
const scalarName = owning.scalarFields[0];
|
|
300
|
-
const key = `${edge.model}.${edge.field}`;
|
|
301
|
-
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
302
|
-
try {
|
|
303
|
-
const count = await adapter.data.countRecords(targetModel, {
|
|
304
|
-
op: 'eq',
|
|
305
|
-
field: scalarName,
|
|
306
|
-
value: coerceId(currentId, model)
|
|
307
|
-
});
|
|
308
|
-
return [key, count];
|
|
309
|
-
}
|
|
310
|
-
catch {
|
|
311
|
-
return [key, 0];
|
|
312
|
-
}
|
|
313
|
-
}));
|
|
314
|
-
return new Map(entries.filter((e) => e !== undefined));
|
|
315
|
-
};
|
|
316
|
-
/**
|
|
317
|
-
* Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
|
|
318
|
-
* Sert les options d'une relation to-one-owning ou m2m en JSON
|
|
319
|
-
* paginé — la voie prévue pour un futur widget autocomplete côté client
|
|
320
|
-
* quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
|
|
321
|
-
* de scoping configuré sur la relation, comme le select et la validation
|
|
322
|
-
* POST : même garantie anti-IDOR sur les trois chemins.
|
|
323
|
-
*/
|
|
324
|
-
const handleSearch = async (event) => {
|
|
325
|
-
const relParam = event.url.searchParams.get('rel') ?? '';
|
|
326
|
-
const [modelName, fieldName] = relParam.split('.');
|
|
327
|
-
const q = event.url.searchParams.get('q') ?? '';
|
|
328
|
-
const { page } = paginate(event.url.searchParams.get('page'), PER_PAGE);
|
|
329
|
-
const model = findModel(modelName);
|
|
330
|
-
const edge = model && relationGraph
|
|
331
|
-
? relationGraph.edges.get(`${model.name}.${fieldName}`)
|
|
332
|
-
: undefined;
|
|
333
|
-
if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m') || edge.unsupported) {
|
|
334
|
-
return new Response(JSON.stringify({ error: 'unknown relation' }), {
|
|
335
|
-
status: 404,
|
|
336
|
-
headers: { 'Content-Type': 'application/json' }
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
340
|
-
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
341
|
-
const configWhere = scopeFrom(relConfig, { locals: event.locals });
|
|
342
|
-
// Recherche sur le premier champ String candidat du modèle cible — le
|
|
343
|
-
// même champ que celui utilisé pour construire le label par défaut.
|
|
344
|
-
const searchField = labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
|
|
345
|
-
// `_search` must stay case-sensitive on every adapter/provider. A
|
|
346
|
-
// `{ op: 'contains' }` leaf would pick up the adapter-wide
|
|
347
|
-
// `caseInsensitiveSearch` flag (Prisma `mode: 'insensitive'`, Drizzle
|
|
348
|
-
// `ilike`). `containsExact` compiles to `{ contains }` / `LIKE` with
|
|
349
|
-
// no case-folding — same observable Prisma behavior as the previous
|
|
350
|
-
// opaque `{ [field]: { contains: q } }` pass-through.
|
|
351
|
-
const containsFilter = q && searchField
|
|
352
|
-
? { op: 'containsExact', field: searchField, value: q }
|
|
353
|
-
: undefined;
|
|
354
|
-
const searchFilter = containsFilter && configWhere
|
|
355
|
-
? { op: 'and', clauses: [configWhere, containsFilter] }
|
|
356
|
-
: containsFilter ?? configWhere;
|
|
357
|
-
try {
|
|
358
|
-
// Count + fetch are independent reads — run them in parallel (as this
|
|
359
|
-
// endpoint always has, pre-refactor) rather than doubling latency with
|
|
360
|
-
// two sequential awaits.
|
|
361
|
-
const [total, rows] = await Promise.all([
|
|
362
|
-
adapter.data.countRecords(targetModel, searchFilter),
|
|
363
|
-
adapter.data.findMany(targetModel, {
|
|
364
|
-
filter: searchFilter,
|
|
365
|
-
orderBy: relConfig?.orderBy,
|
|
366
|
-
skip: (page - 1) * PER_PAGE,
|
|
367
|
-
take: PER_PAGE
|
|
368
|
-
})
|
|
369
|
-
]);
|
|
370
|
-
const options = rows.map((row) => ({
|
|
371
|
-
id: row[primaryKeyOf(targetModel)],
|
|
372
|
-
label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
373
|
-
}));
|
|
374
|
-
return new Response(JSON.stringify({ options, total, page }), {
|
|
375
|
-
headers: { 'Content-Type': 'application/json' }
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
return new Response(JSON.stringify({ error: 'search failed' }), {
|
|
380
|
-
status: 500,
|
|
381
|
-
headers: { 'Content-Type': 'application/json' }
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
};
|
|
36
|
+
const runtime = createAdminRuntime(config);
|
|
37
|
+
const csrf = resolveCsrfConfig(config.csrf);
|
|
38
|
+
const registry = resolvePluginRegistry(config.plugins ?? [], BUILTIN_ROUTES, runtime.models);
|
|
39
|
+
const { authCheck, logout, logoutRedirectTo = '/' } = config;
|
|
385
40
|
return async ({ event, resolve }) => {
|
|
386
41
|
const { pathname } = event.url;
|
|
387
42
|
// Only handle admin routes
|
|
388
|
-
if (!pathname.startsWith(basePath)) {
|
|
43
|
+
if (!pathname.startsWith(runtime.basePath)) {
|
|
389
44
|
return resolve(event);
|
|
390
45
|
}
|
|
391
|
-
|
|
46
|
+
// Avant `matchRoute` : couvre le logout (dispatché avant `authCheck`),
|
|
47
|
+
// `_search`, les mutations, et toute route ajoutée plus tard.
|
|
48
|
+
const forbidden = verifyOrigin(csrf, event);
|
|
49
|
+
if (forbidden)
|
|
50
|
+
return forbidden;
|
|
51
|
+
// Plugin routes are checked BEFORE builtins: `resolvePluginRegistry` only
|
|
52
|
+
// rejects a plugin pattern that is an EXACT token-for-token match of a
|
|
53
|
+
// builtin one (e.g. `[':model', ':id']`), not one that merely happens to
|
|
54
|
+
// overlap at match time (e.g. `[':model', 'stats']` vs the builtin edit
|
|
55
|
+
// route `[':model', ':id']` — both match `user/stats`, ':id' being a
|
|
56
|
+
// wildcard). Checking builtins first would let that generic edit route
|
|
57
|
+
// silently swallow every such plugin page.
|
|
58
|
+
const route = matchRoute(pathname, runtime.basePath, [
|
|
59
|
+
...registry.routes,
|
|
60
|
+
...BUILTIN_ROUTES
|
|
61
|
+
]);
|
|
392
62
|
// Logout: dispatched BEFORE authCheck, deliberately. A user whose
|
|
393
63
|
// session already expired (authCheck would now reject them) must
|
|
394
64
|
// still be able to hit this route to clear client-side state (a
|
|
@@ -412,246 +82,229 @@ export function createAdminHandler(config) {
|
|
|
412
82
|
return new Response('Unauthorized', { status: 401 });
|
|
413
83
|
}
|
|
414
84
|
}
|
|
415
|
-
let content = '';
|
|
416
|
-
let currentModel;
|
|
417
85
|
if (route.view === 'search') {
|
|
418
|
-
return handleSearch(event);
|
|
86
|
+
return handleSearch(runtime, event);
|
|
419
87
|
}
|
|
88
|
+
// Plugin page views are GET-only: dispatched here, BEFORE `handleMutation`,
|
|
89
|
+
// so a forged POST to a plugin route (e.g. `/user/1/graph` with
|
|
90
|
+
// `_action=delete`) can never reach the mutation path just because its
|
|
91
|
+
// pattern happens to overlap `:model/:id`-shaped segments.
|
|
92
|
+
const pluginPage = registry.pagesByView.get(route.view);
|
|
93
|
+
if (pluginPage) {
|
|
94
|
+
if (event.request.method !== 'GET') {
|
|
95
|
+
return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET' } });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let content = '';
|
|
99
|
+
let currentModel;
|
|
100
|
+
let extraStyles = '';
|
|
101
|
+
let extraScripts = '';
|
|
102
|
+
/** Renseignés ensemble : un formulaire re-rendu porte toujours les deux. */
|
|
103
|
+
let mutationError;
|
|
104
|
+
let submitted;
|
|
420
105
|
try {
|
|
421
106
|
// Handle POST requests (create, update, delete). Unrecognised actions fall
|
|
422
107
|
// through to the GET rendering below, as they always have.
|
|
108
|
+
// Invariant: a plugin view never reaches this call — `pluginPage` above
|
|
109
|
+
// already 405'd any non-GET before this `try` block. `handleMutation`
|
|
110
|
+
// only ever reads `route.model` / `route.id` / the `_action` form
|
|
111
|
+
// field, never `route.view`, so it cannot be confused by a plugin's
|
|
112
|
+
// view id landing here.
|
|
423
113
|
if (event.request.method === 'POST') {
|
|
114
|
+
// Lu ici et non dans `handleMutation` : un corps de requête ne se lit
|
|
115
|
+
// qu'une fois, et il faut le garder pour re-rendre le formulaire si la
|
|
116
|
+
// mutation échoue.
|
|
424
117
|
const formData = await event.request.formData();
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
118
|
+
// `try` propre au chemin de mutation, et non le `catch` partagé plus
|
|
119
|
+
// bas : celui-ci couvre aussi le rendu GET et les pages de plugin,
|
|
120
|
+
// dont le contrat (rendre le message levé) ne change pas ici.
|
|
121
|
+
try {
|
|
122
|
+
const mutationResponse = await handleMutation(runtime, event, route, formData);
|
|
123
|
+
if (mutationResponse)
|
|
124
|
+
return mutationResponse;
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
// Pas de classification ici, même si `formData` est maintenant à
|
|
128
|
+
// portée : ce site verrait bien `_action`, mais `reference` et
|
|
129
|
+
// `restrict` se distinguent par l'écriture qui a effectivement
|
|
130
|
+
// échoué, pas par l'action demandée. Seul `mutations.ts` le sait,
|
|
131
|
+
// au site d'appel de chaque `*Record`. Ce `catch` n'est donc qu'un
|
|
132
|
+
// aiguillage à trois branches sur le type de l'erreur déjà classée.
|
|
133
|
+
if (e instanceof AdminMutationError) {
|
|
134
|
+
// Message construit par la bibliothèque (validation, scope, ou
|
|
135
|
+
// code pilote reconnu) : sûr à rendre tel quel.
|
|
136
|
+
mutationError = { message: e.message, field: e.field };
|
|
430
137
|
}
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
|
|
138
|
+
else if (e instanceof AdminConfigError) {
|
|
139
|
+
// Mauvaise configuration côté consommateur : message écrit par la
|
|
140
|
+
// bibliothèque, destiné au développeur. Le `catch` partagé plus bas
|
|
141
|
+
// garde son contrat historique — on le laisse la traiter.
|
|
142
|
+
throw e;
|
|
434
143
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
try {
|
|
479
|
-
const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: coerced };
|
|
480
|
-
const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
|
|
481
|
-
const filter = scopeFilter ? { op: 'and', clauses: [idFilter, scopeFilter] } : idFilter;
|
|
482
|
-
const found = await adapter.data.findFirst(targetModel, filter);
|
|
483
|
-
if (!found) {
|
|
484
|
-
throw new Error(`${edge.field}: invalid value`);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
catch (e) {
|
|
488
|
-
if (e?.message?.includes('invalid value'))
|
|
489
|
-
throw e;
|
|
490
|
-
if (e?.message &&
|
|
491
|
-
(e.message.includes(OPAQUE_FILTER_ERROR) ||
|
|
492
|
-
OPAQUE_FILTER_ERROR.startsWith(e.message))) {
|
|
493
|
-
throw new Error(`${edge.field}: invalid value`);
|
|
494
|
-
}
|
|
495
|
-
if (e?.message?.includes('unknown field')) {
|
|
496
|
-
throw new Error(`${edge.field}: invalid value`);
|
|
497
|
-
}
|
|
498
|
-
// Client incapable de vérifier (mock partiel, etc.) : on laisse passer.
|
|
499
|
-
}
|
|
500
|
-
data[scalarName] = coerced;
|
|
501
|
-
}
|
|
502
|
-
// N-N implicite : lit `__rel__<field>` (valeurs cochées) et
|
|
503
|
-
// `__rel_present__<field>` (sentinelle). Sans le sentinelle,
|
|
504
|
-
// le champ est absent du form (readonly/exclu) → no-op.
|
|
505
|
-
// Avec le sentinelle mais zéro valeur cochée → vider la
|
|
506
|
-
// relation (`set: []` / rien à connecter en création).
|
|
507
|
-
for (const edge of relationGraph.edges.values()) {
|
|
508
|
-
if (edge.model !== model.name || edge.kind !== 'm2m')
|
|
509
|
-
continue;
|
|
510
|
-
// Pas de garde `edge.unsupported` ici : par construction du
|
|
511
|
-
// graphe, `unsupported` n'est jamais posé sur une arête
|
|
512
|
-
// m2m (seulement sur to-one-owning / groupes
|
|
513
|
-
// ambigus, qui retombent toujours en to-one-owning).
|
|
514
|
-
const present = formData.get(`__rel_present__${edge.field}`);
|
|
515
|
-
if (present === null)
|
|
516
|
-
continue;
|
|
517
|
-
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
518
|
-
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
519
|
-
const targetPk = primaryKeyOf(targetModel);
|
|
520
|
-
const pkIsInt = targetModel.fields.find((f) => f.isId)?.type === 'Int';
|
|
521
|
-
const submitted = formData.getAll(`__rel__${edge.field}`).map(String);
|
|
522
|
-
const rawIds = submitted.length === 1 && submitted[0].includes(',')
|
|
523
|
-
? submitted[0].split(',').map((s) => s.trim()).filter(Boolean)
|
|
524
|
-
: submitted;
|
|
525
|
-
const ids = rawIds.map((v) => pkIsInt ? parseInt(v) : v);
|
|
526
|
-
if (pkIsInt && ids.some((v) => !Number.isSafeInteger(v))) {
|
|
527
|
-
throw new Error(`${edge.field}: invalid id`);
|
|
528
|
-
}
|
|
529
|
-
// Existence + scoping en une requête, sur l'ensemble des IDs
|
|
530
|
-
// soumis. Un compte différent = au moins un ID invalide ou
|
|
531
|
-
// hors scoping — IDOR bloqué au même titre que pour les FK.
|
|
532
|
-
if (ids.length > 0) {
|
|
533
|
-
const inFilter = { op: 'in', field: targetPk, value: ids };
|
|
534
|
-
const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
|
|
535
|
-
const filter = scopeFilter ? { op: 'and', clauses: [inFilter, scopeFilter] } : inFilter;
|
|
536
|
-
try {
|
|
537
|
-
const found = await adapter.data.findMany(targetModel, { filter });
|
|
538
|
-
if (found.length !== new Set(ids.map(String)).size) {
|
|
539
|
-
throw new Error(`${edge.field}: invalid value`);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
catch (e) {
|
|
543
|
-
if (e?.message?.includes('invalid value'))
|
|
544
|
-
throw e;
|
|
545
|
-
if (e?.message &&
|
|
546
|
-
(e.message.includes(OPAQUE_FILTER_ERROR) ||
|
|
547
|
-
OPAQUE_FILTER_ERROR.startsWith(e.message))) {
|
|
548
|
-
throw new Error(`${edge.field}: invalid value`);
|
|
549
|
-
}
|
|
550
|
-
if (e?.message?.includes('unknown field')) {
|
|
551
|
-
throw new Error(`${edge.field}: invalid value`);
|
|
552
|
-
}
|
|
553
|
-
// Client incapable de vérifier : on laisse passer.
|
|
554
|
-
}
|
|
144
|
+
else {
|
|
145
|
+
// Tout le reste est présumé venir du moteur : son texte peut porter le nom
|
|
146
|
+
// de la table, un fragment de requête ou un dump d'arguments. Jamais rendu.
|
|
147
|
+
console.error('[sveltekit-admin] mutation failed:', e);
|
|
148
|
+
mutationError = { message: 'The change could not be saved.' };
|
|
149
|
+
}
|
|
150
|
+
// Placé après le `throw` de la branche AdminConfigError : une erreur
|
|
151
|
+
// de montage remplace la page entière (contrat inchangé) et ne
|
|
152
|
+
// re-rend aucun formulaire, donc rien à conserver pour elle.
|
|
153
|
+
//
|
|
154
|
+
// `findModel` peut ne rien rendre — un POST sur un modèle inconnu est
|
|
155
|
+
// justement ce que la branche `notFound` de `handleMutation` lève. Ce
|
|
156
|
+
// cas ne rend aucun formulaire, la liste de champs masqués y est donc
|
|
157
|
+
// sans objet. Pas de garde sur `route.model` en revanche : sans
|
|
158
|
+
// modèle, `handleMutation` rend `null` sans lever et ce `catch` n'est
|
|
159
|
+
// pas atteint.
|
|
160
|
+
const errorModel = runtime.findModel(route.model);
|
|
161
|
+
submitted = readSubmittedForm(formData, errorModel ? runtime.hiddenFieldsOf(errorModel) : new Set());
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// GET requests - render views
|
|
165
|
+
if (pluginPage) {
|
|
166
|
+
const hasModel = pluginPage.pattern.includes(':model');
|
|
167
|
+
const hasId = pluginPage.pattern.includes(':id');
|
|
168
|
+
if (hasModel) {
|
|
169
|
+
currentModel = route.model;
|
|
170
|
+
const model = runtime.findModel(route.model);
|
|
171
|
+
const allowed = model &&
|
|
172
|
+
(!pluginPage.models ||
|
|
173
|
+
pluginPage.models.some((n) => n.toLowerCase() === model.name.toLowerCase()));
|
|
174
|
+
if (!model || !allowed) {
|
|
175
|
+
content = render(NotFound, {
|
|
176
|
+
props: { message: 'Page not found', basePath: runtime.basePath }
|
|
177
|
+
}).body;
|
|
178
|
+
}
|
|
179
|
+
else if (hasId) {
|
|
180
|
+
const ctx = createPluginPageContext(runtime, event, route);
|
|
181
|
+
const loaded = await ctx.loadRecord(model.name, route.id);
|
|
182
|
+
if (!loaded) {
|
|
183
|
+
content = render(NotFound, {
|
|
184
|
+
props: {
|
|
185
|
+
message: `${model.name} with ID "${route.id}" not found`,
|
|
186
|
+
basePath: runtime.basePath
|
|
555
187
|
}
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
if (action === 'create') {
|
|
560
|
-
await adapter.data.createRecord(model, { scalars: data, m2m: m2mInput });
|
|
188
|
+
}).body;
|
|
561
189
|
}
|
|
562
|
-
else
|
|
563
|
-
await
|
|
190
|
+
else {
|
|
191
|
+
const result = await pluginPage.render(createPluginPageContext(runtime, event, route, loaded));
|
|
192
|
+
content = result.html;
|
|
193
|
+
extraStyles = result.styles ?? '';
|
|
194
|
+
extraScripts = result.scripts ?? '';
|
|
564
195
|
}
|
|
565
|
-
return redirectToList(route.model);
|
|
566
196
|
}
|
|
197
|
+
else {
|
|
198
|
+
const result = await pluginPage.render(createPluginPageContext(runtime, event, route));
|
|
199
|
+
content = result.html;
|
|
200
|
+
extraStyles = result.styles ?? '';
|
|
201
|
+
extraScripts = result.scripts ?? '';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const result = await pluginPage.render(createPluginPageContext(runtime, event, route));
|
|
206
|
+
content = result.html;
|
|
207
|
+
extraStyles = result.styles ?? '';
|
|
208
|
+
extraScripts = result.scripts ?? '';
|
|
567
209
|
}
|
|
568
210
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
content = render(NotFound, { props: { message: 'Page not found', basePath } }).body;
|
|
211
|
+
else if (route.view === 'notFound') {
|
|
212
|
+
content = render(NotFound, { props: { message: 'Page not found', basePath: runtime.basePath } }).body;
|
|
572
213
|
}
|
|
573
214
|
else if (route.view === 'dashboard') {
|
|
574
|
-
const modelsWithCounts = await Promise.all(
|
|
215
|
+
const modelsWithCounts = await Promise.all(runtime.models.map(async (m) => {
|
|
575
216
|
let count = 0;
|
|
576
217
|
try {
|
|
577
|
-
count = await adapter.data.countRecords(m);
|
|
218
|
+
count = await runtime.adapter.data.countRecords(m, modelScopeFrom(runtime, m, { locals: event.locals }));
|
|
578
219
|
}
|
|
579
220
|
catch {
|
|
580
221
|
// model absent from the database
|
|
581
222
|
}
|
|
582
|
-
return { name: m.name, label: labelOf(m), count };
|
|
223
|
+
return { name: m.name, label: runtime.labelOf(m), count };
|
|
583
224
|
}));
|
|
584
225
|
const totalRecords = modelsWithCounts.reduce((sum, m) => sum + m.count, 0);
|
|
585
226
|
content = render(Dashboard, {
|
|
586
227
|
props: {
|
|
587
228
|
models: modelsWithCounts,
|
|
588
229
|
stats: { total: totalRecords, models: modelsWithCounts.length },
|
|
589
|
-
basePath
|
|
230
|
+
basePath: runtime.basePath
|
|
590
231
|
}
|
|
591
232
|
}).body;
|
|
592
233
|
}
|
|
593
234
|
else if (route.model) {
|
|
594
235
|
currentModel = route.model;
|
|
595
|
-
const model = findModel(route.model);
|
|
236
|
+
const model = runtime.findModel(route.model);
|
|
596
237
|
if (!model) {
|
|
597
238
|
content = render(NotFound, {
|
|
598
|
-
props: { message: `Model "${route.model}" not found`, basePath }
|
|
239
|
+
props: { message: `Model "${route.model}" not found`, basePath: runtime.basePath }
|
|
599
240
|
}).body;
|
|
600
241
|
}
|
|
601
242
|
else if (route.view === 'list') {
|
|
602
|
-
const
|
|
243
|
+
const modelsConfig = runtime.config.models ?? {};
|
|
244
|
+
const perPage = parsePageSize(event.url.searchParams, runtime.perPage, runtime.pageSizes);
|
|
245
|
+
const { page } = paginate(event.url.searchParams.get('page'), perPage);
|
|
603
246
|
const modelSearchConfig = modelsConfig[model.name]?.searchFields;
|
|
604
|
-
const searchFields = resolveSearchFields(model, modelSearchConfig, labelFieldCandidates, hiddenFieldsOf(model));
|
|
605
|
-
const filterableFields = resolveFilterableFields(model);
|
|
606
|
-
const listQuery = parseListQuery(event.url.searchParams, model, schema.enums, searchFields, filterableFields);
|
|
607
|
-
const listScope =
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
247
|
+
const searchFields = resolveSearchFields(model, modelSearchConfig, runtime.labelFieldCandidates, runtime.hiddenFieldsOf(model));
|
|
248
|
+
const filterableFields = runtime.resolveFilterableFields(model);
|
|
249
|
+
const listQuery = parseListQuery(event.url.searchParams, model, runtime.schema.enums, searchFields, filterableFields);
|
|
250
|
+
const listScope = listScopeFrom(runtime, model, { locals: event.locals });
|
|
251
|
+
const modelScope = modelScopeFrom(runtime, model, { locals: event.locals });
|
|
252
|
+
const scope = modelScope && listScope
|
|
253
|
+
? { op: 'and', clauses: [modelScope, normalizeScope(listScope)] }
|
|
254
|
+
: modelScope ?? listScope;
|
|
255
|
+
// Adapter compiles case-sensitivity; this arg is unused by buildWhere.
|
|
256
|
+
const filter = buildWhere(listQuery, scope, false, model);
|
|
257
|
+
// Whitelist de tri = colonnes RÉELLEMENT rendues, résolues par la même
|
|
258
|
+
// fonction que la vue et à partir de la même config. Un `?sort=` ne
|
|
259
|
+
// peut donc ordonner que des valeurs déjà lisibles à l'écran.
|
|
260
|
+
const sortableColumns = resolveListColumns(model.fields, {
|
|
261
|
+
hidden: modelsConfig[model.name]?.hidden,
|
|
262
|
+
listFields: modelsConfig[model.name]?.listFields
|
|
263
|
+
}).map((f) => f.name);
|
|
264
|
+
const sort = parseSortQuery(event.url.searchParams, sortableColumns, runtime.defaultSortOf(model));
|
|
265
|
+
const { rows: items, total } = await runtime.adapter.data.listRecords(model, {
|
|
266
|
+
filter,
|
|
267
|
+
skip: (page - 1) * perPage,
|
|
268
|
+
take: perPage,
|
|
269
|
+
orderBy: sort.active ?? undefined
|
|
270
|
+
});
|
|
271
|
+
const listFilters = resolveListFilters(model, runtime.schema.enums, modelsConfig[model.name]?.listFilter, toLabel, runtime.relationGraph, runtime.hiddenFieldsOf(model), runtime.config.listFilterDefaults?.autoDetect ?? true);
|
|
625
272
|
// Un filtre FK ne dépend pas de l'autre : résolus en parallèle
|
|
626
273
|
// plutôt qu'un par un (même raisonnement que loadRelationOptions).
|
|
627
274
|
const fkFilterEntries = await Promise.all(listFilters
|
|
628
275
|
.filter((filter) => filter.kind === 'fk')
|
|
629
276
|
.map(async (filter) => {
|
|
630
277
|
const activeRawValue = listQuery.filters.find((f) => f.field === filter.field && f.op === 'equals')?.raw;
|
|
631
|
-
const meta = await resolveFkFilterOptions(model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
|
|
278
|
+
const meta = await resolveFkFilterOptions(runtime, model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
|
|
632
279
|
return [filter.field, meta];
|
|
633
280
|
}));
|
|
634
281
|
const fkFilterMeta = new Map(fkFilterEntries);
|
|
635
282
|
content = render(List, {
|
|
636
283
|
props: {
|
|
637
|
-
model: viewModel(model),
|
|
284
|
+
model: runtime.viewModel(model),
|
|
638
285
|
items,
|
|
639
|
-
pagination: { page, perPage
|
|
640
|
-
|
|
641
|
-
|
|
286
|
+
pagination: { page, perPage, total },
|
|
287
|
+
pageSizes: runtime.pageSizes,
|
|
288
|
+
basePath: runtime.basePath,
|
|
289
|
+
config: runtime.config,
|
|
642
290
|
query: listQuery,
|
|
643
291
|
currentUrl: event.url,
|
|
644
292
|
listFilters,
|
|
645
|
-
fkFilterMeta
|
|
293
|
+
fkFilterMeta,
|
|
294
|
+
sort,
|
|
295
|
+
recordActions: actionsForModel(registry, model.name).map((action) => ({
|
|
296
|
+
label: action.label,
|
|
297
|
+
hrefFor: (id) => action.href({ model: model.name, id, basePath: runtime.basePath })
|
|
298
|
+
}))
|
|
646
299
|
}
|
|
647
300
|
}).body;
|
|
648
301
|
}
|
|
649
302
|
else if (route.view === 'create') {
|
|
650
|
-
const relationOptions = await loadRelationOptions(model, { locals: event.locals });
|
|
303
|
+
const relationOptions = await loadRelationOptions(runtime, model, { locals: event.locals });
|
|
651
304
|
// Pré-remplissage FK depuis la query string (`?authorId=3`), posé
|
|
652
305
|
// par le lien "Ajouter" du bloc de liaisons inverses.
|
|
653
306
|
const prefill = {};
|
|
654
|
-
for (const edge of relationGraph.edges.values()) {
|
|
307
|
+
for (const edge of runtime.relationGraph.edges.values()) {
|
|
655
308
|
if (edge.model !== model.name || edge.kind !== 'to-one-owning')
|
|
656
309
|
continue;
|
|
657
310
|
const scalarName = edge.scalarFields[0];
|
|
@@ -663,10 +316,13 @@ export function createAdminHandler(config) {
|
|
|
663
316
|
content = render(Form, {
|
|
664
317
|
props: {
|
|
665
318
|
mode: 'create',
|
|
666
|
-
model: { ...viewModel(model), relationOptions },
|
|
667
|
-
basePath,
|
|
668
|
-
config,
|
|
669
|
-
item: itemPrefill
|
|
319
|
+
model: { ...runtime.viewModel(model), relationOptions },
|
|
320
|
+
basePath: runtime.basePath,
|
|
321
|
+
config: runtime.config,
|
|
322
|
+
item: itemPrefill,
|
|
323
|
+
recordActions: [],
|
|
324
|
+
submitted,
|
|
325
|
+
mutationError
|
|
670
326
|
}
|
|
671
327
|
}).body;
|
|
672
328
|
}
|
|
@@ -676,21 +332,40 @@ export function createAdminHandler(config) {
|
|
|
676
332
|
// atteint ce `else` — or 'edit' est la branche à 2 segments, donc `id` y est
|
|
677
333
|
// toujours défini. La variante 'notFound' ne porte pas de `model` : elle est
|
|
678
334
|
// interceptée en amont et ne peut pas arriver ici.
|
|
679
|
-
const
|
|
680
|
-
const
|
|
681
|
-
|
|
335
|
+
const modelScope = modelScopeFrom(runtime, model, { locals: event.locals });
|
|
336
|
+
const item = modelScope
|
|
337
|
+
? await runtime.adapter.data.findFirst(model, {
|
|
338
|
+
op: 'and',
|
|
339
|
+
clauses: [
|
|
340
|
+
{ op: 'eq', field: runtime.viewModel(model).primaryKey, value: coerceId(route.id, model) },
|
|
341
|
+
modelScope
|
|
342
|
+
]
|
|
343
|
+
})
|
|
344
|
+
: await runtime.adapter.data.getRecord(model, route.id);
|
|
345
|
+
const relationOptions = await loadRelationOptions(runtime, model, { locals: event.locals }, route.id);
|
|
346
|
+
const relatedCounts = item ? await loadRelatedCounts(runtime, model, route.id, { locals: event.locals }) : undefined;
|
|
682
347
|
content = item
|
|
683
348
|
? render(Form, {
|
|
684
349
|
props: {
|
|
685
350
|
mode: 'edit',
|
|
686
|
-
model: { ...viewModel(model), relationOptions, relatedCounts },
|
|
687
|
-
basePath,
|
|
688
|
-
config,
|
|
689
|
-
item
|
|
351
|
+
model: { ...runtime.viewModel(model), relationOptions, relatedCounts },
|
|
352
|
+
basePath: runtime.basePath,
|
|
353
|
+
config: runtime.config,
|
|
354
|
+
item,
|
|
355
|
+
submitted,
|
|
356
|
+
mutationError,
|
|
357
|
+
recordActions: actionsForModel(registry, model.name).map((action) => ({
|
|
358
|
+
label: action.label,
|
|
359
|
+
href: action.href({
|
|
360
|
+
model: model.name,
|
|
361
|
+
id: item[runtime.viewModel(model).primaryKey],
|
|
362
|
+
basePath: runtime.basePath
|
|
363
|
+
})
|
|
364
|
+
}))
|
|
690
365
|
}
|
|
691
366
|
}).body
|
|
692
367
|
: render(NotFound, {
|
|
693
|
-
props: { message: `${model.name} with ID "${route.id}" not found`, basePath }
|
|
368
|
+
props: { message: `${model.name} with ID "${route.id}" not found`, basePath: runtime.basePath }
|
|
694
369
|
}).body;
|
|
695
370
|
}
|
|
696
371
|
}
|
|
@@ -699,8 +374,36 @@ export function createAdminHandler(config) {
|
|
|
699
374
|
console.error('[sveltekit-admin] Error:', e);
|
|
700
375
|
content = `<div class="ska-alert ska-alert--error">Error: ${escapeHtml(e.message || 'Unknown error')}</div>`;
|
|
701
376
|
}
|
|
702
|
-
|
|
377
|
+
if (mutationError) {
|
|
378
|
+
// Même préfixe « Error: » que le `catch` partagé, pour une seule
|
|
379
|
+
// convention d'alerte dans toute la page (cf. handler.test.ts, l'alerte
|
|
380
|
+
// « modèle inconnu en POST »).
|
|
381
|
+
content =
|
|
382
|
+
`<div class="ska-alert ska-alert--error">Error: ${escapeHtml(mutationError.message)}</div>` +
|
|
383
|
+
content;
|
|
384
|
+
}
|
|
385
|
+
const html = render(Layout, {
|
|
386
|
+
props: {
|
|
387
|
+
content,
|
|
388
|
+
config: runtime.config,
|
|
389
|
+
modelList: runtime.modelList,
|
|
390
|
+
currentModel,
|
|
391
|
+
extraStyles,
|
|
392
|
+
extraScripts
|
|
393
|
+
}
|
|
394
|
+
}).body;
|
|
703
395
|
return new Response(html, {
|
|
396
|
+
// 422 pour un formulaire refusé : la requête était bien formée, son
|
|
397
|
+
// contenu a été rejeté. Un 200 fait passer l'échec pour un succès aux
|
|
398
|
+
// yeux de tout ce qui lit le statut sans lire le HTML — un test, un
|
|
399
|
+
// client, un log, un cache. Uniforme sur tous les `kind` : la nuance
|
|
400
|
+
// entre 409 (conflit) et 403 (scope) n'apporterait rien à un formulaire
|
|
401
|
+
// rendu en HTML, et le seul consommateur est un navigateur qui affiche
|
|
402
|
+
// la page.
|
|
403
|
+
//
|
|
404
|
+
// Le `catch` partagé plus haut garde son 200 : il couvre aussi le rendu
|
|
405
|
+
// GET et les pages de plugin, dont le contrat ne change pas ici.
|
|
406
|
+
status: mutationError ? 422 : 200,
|
|
704
407
|
headers: {
|
|
705
408
|
'Content-Type': 'text/html; charset=utf-8'
|
|
706
409
|
}
|