sveltekit-admin 0.2.1 → 0.5.3
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/LICENSE +21 -0
- package/README.md +80 -2
- package/dist/index.d.ts +2 -7
- package/dist/index.js +2 -13
- package/dist/server/auth.d.ts +7 -0
- package/dist/server/auth.js +19 -0
- package/dist/server/data.d.ts +38 -0
- package/dist/server/data.js +116 -0
- package/dist/server/handler.d.ts +123 -4
- package/dist/server/handler.js +600 -819
- package/dist/server/introspection/parser.d.ts +19 -12
- package/dist/server/introspection/parser.js +71 -61
- package/dist/server/introspection/relations.d.ts +49 -0
- package/dist/server/introspection/relations.js +128 -0
- package/dist/server/query/filterDetection.d.ts +71 -0
- package/dist/server/query/filterDetection.js +153 -0
- package/dist/server/query/listQuery.d.ts +92 -0
- package/dist/server/query/listQuery.js +442 -0
- package/dist/server/query/urls.d.ts +33 -0
- package/dist/server/query/urls.js +58 -0
- package/dist/server/router.d.ts +6 -0
- package/dist/server/router.js +27 -0
- package/dist/server/views/Dashboard.svelte +29 -0
- package/dist/server/views/Dashboard.svelte.d.ts +15 -0
- package/dist/server/views/FieldInput.svelte +63 -0
- package/dist/server/views/FieldInput.svelte.d.ts +9 -0
- package/dist/server/views/Form.svelte +127 -0
- package/dist/server/views/Form.svelte.d.ts +12 -0
- package/dist/server/views/Layout.svelte +78 -0
- package/dist/server/views/Layout.svelte.d.ts +13 -0
- package/dist/server/views/List.svelte +240 -0
- package/dist/server/views/List.svelte.d.ts +27 -0
- package/dist/server/views/ListFilters.svelte +257 -0
- package/dist/server/views/ListFilters.svelte.d.ts +18 -0
- package/dist/server/views/ModelCard.svelte +11 -0
- package/dist/server/views/ModelCard.svelte.d.ts +8 -0
- package/dist/server/views/NotFound.svelte +7 -0
- package/dist/server/views/NotFound.svelte.d.ts +7 -0
- package/dist/server/views/RelatedBlock.svelte +74 -0
- package/dist/server/views/RelatedBlock.svelte.d.ts +11 -0
- package/dist/server/views/RelationCheckboxes.svelte +53 -0
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +53 -0
- package/dist/server/views/RelationSelect.svelte.d.ts +12 -0
- package/dist/server/views/StatCard.svelte +18 -0
- package/dist/server/views/StatCard.svelte.d.ts +8 -0
- package/dist/server/views/html.d.ts +5 -0
- package/dist/server/views/html.js +41 -0
- package/dist/server/views/theme.d.ts +1 -0
- package/dist/server/views/theme.js +537 -0
- package/dist/server/views/types.d.ts +57 -0
- package/dist/server/views/types.js +1 -0
- package/package.json +24 -26
- package/dist/admin.d.ts +0 -227
- package/dist/admin.js +0 -369
- package/dist/components/AdminForm.svelte +0 -423
- package/dist/components/AdminForm.svelte.d.ts +0 -30
- package/dist/components/AdminLayout.svelte +0 -328
- package/dist/components/AdminLayout.svelte.d.ts +0 -20
- package/dist/components/DataTable.svelte +0 -573
- package/dist/components/DataTable.svelte.d.ts +0 -25
- package/dist/components/index.d.ts +0 -3
- package/dist/components/index.js +0 -3
- package/dist/server/auth/guard.d.ts +0 -36
- package/dist/server/auth/guard.js +0 -38
- package/dist/server/auth/index.d.ts +0 -1
- package/dist/server/auth/index.js +0 -1
- package/dist/server/crud/index.d.ts +0 -1
- package/dist/server/crud/index.js +0 -1
- package/dist/server/crud/operations.d.ts +0 -87
- package/dist/server/crud/operations.js +0 -276
- package/dist/server/introspection/index.d.ts +0 -1
- package/dist/server/introspection/index.js +0 -1
package/dist/server/handler.js
CHANGED
|
@@ -2,716 +2,396 @@
|
|
|
2
2
|
* SvelteKit Admin - Standalone Handler
|
|
3
3
|
* Zero files needed in routes - everything handled via hook
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return { view: 'edit', model: segments[0], id: segments[1] };
|
|
20
|
-
}
|
|
21
|
-
return { view: 'dashboard' };
|
|
22
|
-
}
|
|
23
|
-
function toLabel(name) {
|
|
24
|
-
return name.replace(/([A-Z])/g, ' $1').trim();
|
|
25
|
-
}
|
|
26
|
-
function toPrismaModel(name) {
|
|
27
|
-
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
28
|
-
}
|
|
29
|
-
// ============================================
|
|
30
|
-
// HTML Templates
|
|
31
|
-
// ============================================
|
|
32
|
-
function baseLayout(content, config, models, currentModel) {
|
|
33
|
-
const { branding = {} } = config;
|
|
34
|
-
const title = branding.title || 'Admin';
|
|
35
|
-
const primaryColor = branding.primaryColor || '#6366f1';
|
|
36
|
-
const basePath = config.basePath || '/admin';
|
|
37
|
-
return `<!DOCTYPE html>
|
|
38
|
-
<html lang="en">
|
|
39
|
-
<head>
|
|
40
|
-
<meta charset="UTF-8">
|
|
41
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
42
|
-
<title>${title}</title>
|
|
43
|
-
<style>
|
|
44
|
-
:root {
|
|
45
|
-
--ska-primary: ${primaryColor};
|
|
46
|
-
--ska-primary-hover: ${adjustColor(primaryColor, -15)};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
50
|
-
|
|
51
|
-
body {
|
|
52
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
53
|
-
background: #f8fafc;
|
|
54
|
-
color: #1e293b;
|
|
55
|
-
line-height: 1.5;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
.ska-layout {
|
|
59
|
-
display: flex;
|
|
60
|
-
min-height: 100vh;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
.ska-sidebar {
|
|
64
|
-
width: 260px;
|
|
65
|
-
background: white;
|
|
66
|
-
border-right: 1px solid #e2e8f0;
|
|
67
|
-
padding: 1.5rem;
|
|
68
|
-
position: fixed;
|
|
69
|
-
height: 100vh;
|
|
70
|
-
overflow-y: auto;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.ska-logo {
|
|
74
|
-
font-size: 1.25rem;
|
|
75
|
-
font-weight: 700;
|
|
76
|
-
color: var(--ska-primary);
|
|
77
|
-
text-decoration: none;
|
|
78
|
-
display: block;
|
|
79
|
-
margin-bottom: 2rem;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.ska-nav { list-style: none; }
|
|
83
|
-
|
|
84
|
-
.ska-nav__item {
|
|
85
|
-
margin-bottom: 0.25rem;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.ska-nav__link {
|
|
89
|
-
display: flex;
|
|
90
|
-
align-items: center;
|
|
91
|
-
gap: 0.75rem;
|
|
92
|
-
padding: 0.625rem 0.875rem;
|
|
93
|
-
color: #64748b;
|
|
94
|
-
text-decoration: none;
|
|
95
|
-
border-radius: 0.375rem;
|
|
96
|
-
font-size: 0.875rem;
|
|
97
|
-
transition: all 0.15s;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
.ska-nav__link:hover {
|
|
101
|
-
background: #f1f5f9;
|
|
102
|
-
color: #1e293b;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
.ska-nav__link--active {
|
|
106
|
-
background: #eef2ff;
|
|
107
|
-
color: var(--ska-primary);
|
|
108
|
-
font-weight: 500;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
.ska-main {
|
|
112
|
-
flex: 1;
|
|
113
|
-
margin-left: 260px;
|
|
114
|
-
padding: 2rem;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
.ska-card {
|
|
118
|
-
background: white;
|
|
119
|
-
border: 1px solid #e2e8f0;
|
|
120
|
-
border-radius: 0.5rem;
|
|
121
|
-
padding: 1.5rem;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
.ska-btn {
|
|
125
|
-
display: inline-flex;
|
|
126
|
-
align-items: center;
|
|
127
|
-
gap: 0.5rem;
|
|
128
|
-
padding: 0.5rem 1rem;
|
|
129
|
-
font-size: 0.875rem;
|
|
130
|
-
font-weight: 500;
|
|
131
|
-
border-radius: 0.375rem;
|
|
132
|
-
border: none;
|
|
133
|
-
cursor: pointer;
|
|
134
|
-
text-decoration: none;
|
|
135
|
-
transition: all 0.15s;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
.ska-btn--primary {
|
|
139
|
-
background: var(--ska-primary);
|
|
140
|
-
color: white;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
.ska-btn--primary:hover {
|
|
144
|
-
background: var(--ska-primary-hover);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
.ska-btn--secondary {
|
|
148
|
-
background: #f1f5f9;
|
|
149
|
-
color: #475569;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
.ska-btn--secondary:hover {
|
|
153
|
-
background: #e2e8f0;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
.ska-btn--danger {
|
|
157
|
-
background: #fef2f2;
|
|
158
|
-
color: #dc2626;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
.ska-btn--danger:hover {
|
|
162
|
-
background: #fee2e2;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
.ska-btn--sm {
|
|
166
|
-
padding: 0.375rem 0.75rem;
|
|
167
|
-
font-size: 0.75rem;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; }
|
|
171
|
-
h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }
|
|
172
|
-
|
|
173
|
-
.ska-subtitle { color: #64748b; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
|
174
|
-
|
|
175
|
-
/* Table styles */
|
|
176
|
-
.ska-table-wrap { overflow-x: auto; }
|
|
177
|
-
|
|
178
|
-
.ska-table {
|
|
179
|
-
width: 100%;
|
|
180
|
-
border-collapse: collapse;
|
|
181
|
-
font-size: 0.875rem;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
.ska-table th {
|
|
185
|
-
text-align: left;
|
|
186
|
-
padding: 0.75rem 1rem;
|
|
187
|
-
background: #f8fafc;
|
|
188
|
-
border-bottom: 1px solid #e2e8f0;
|
|
189
|
-
font-weight: 600;
|
|
190
|
-
color: #64748b;
|
|
191
|
-
font-size: 0.75rem;
|
|
192
|
-
text-transform: uppercase;
|
|
193
|
-
letter-spacing: 0.05em;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
.ska-table td {
|
|
197
|
-
padding: 0.75rem 1rem;
|
|
198
|
-
border-bottom: 1px solid #e2e8f0;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
.ska-table tr:hover {
|
|
202
|
-
background: #f8fafc;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
.ska-table__actions {
|
|
206
|
-
display: flex;
|
|
207
|
-
gap: 0.5rem;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/* Form styles */
|
|
211
|
-
.ska-form { max-width: 600px; }
|
|
212
|
-
|
|
213
|
-
.ska-field {
|
|
214
|
-
margin-bottom: 1.25rem;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
.ska-label {
|
|
218
|
-
display: block;
|
|
219
|
-
font-size: 0.875rem;
|
|
220
|
-
font-weight: 500;
|
|
221
|
-
color: #374151;
|
|
222
|
-
margin-bottom: 0.375rem;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
.ska-input {
|
|
226
|
-
width: 100%;
|
|
227
|
-
padding: 0.625rem 0.875rem;
|
|
228
|
-
font-size: 0.875rem;
|
|
229
|
-
border: 1px solid #d1d5db;
|
|
230
|
-
border-radius: 0.375rem;
|
|
231
|
-
transition: all 0.15s;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
.ska-input:focus {
|
|
235
|
-
outline: none;
|
|
236
|
-
border-color: var(--ska-primary);
|
|
237
|
-
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
.ska-input[readonly] {
|
|
241
|
-
background: #f9fafb;
|
|
242
|
-
color: #6b7280;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
.ska-checkbox-wrap {
|
|
246
|
-
display: flex;
|
|
247
|
-
align-items: center;
|
|
248
|
-
gap: 0.5rem;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
.ska-checkbox {
|
|
252
|
-
width: 1rem;
|
|
253
|
-
height: 1rem;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
.ska-form__actions {
|
|
257
|
-
display: flex;
|
|
258
|
-
gap: 0.75rem;
|
|
259
|
-
margin-top: 1.5rem;
|
|
260
|
-
padding-top: 1.5rem;
|
|
261
|
-
border-top: 1px solid #e2e8f0;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/* Stats grid */
|
|
265
|
-
.ska-stats {
|
|
266
|
-
display: grid;
|
|
267
|
-
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
268
|
-
gap: 1rem;
|
|
269
|
-
margin-bottom: 2rem;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
.ska-stat {
|
|
273
|
-
background: white;
|
|
274
|
-
border: 1px solid #e2e8f0;
|
|
275
|
-
border-radius: 0.5rem;
|
|
276
|
-
padding: 1.25rem;
|
|
277
|
-
display: flex;
|
|
278
|
-
align-items: center;
|
|
279
|
-
gap: 1rem;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
.ska-stat__icon {
|
|
283
|
-
width: 3rem;
|
|
284
|
-
height: 3rem;
|
|
285
|
-
background: #eef2ff;
|
|
286
|
-
border-radius: 0.5rem;
|
|
287
|
-
display: flex;
|
|
288
|
-
align-items: center;
|
|
289
|
-
justify-content: center;
|
|
290
|
-
color: var(--ska-primary);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
.ska-stat__value {
|
|
294
|
-
font-size: 1.5rem;
|
|
295
|
-
font-weight: 700;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
.ska-stat__label {
|
|
299
|
-
font-size: 0.875rem;
|
|
300
|
-
color: #64748b;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/* Models grid */
|
|
304
|
-
.ska-models {
|
|
305
|
-
display: grid;
|
|
306
|
-
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
307
|
-
gap: 1rem;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
.ska-model-card {
|
|
311
|
-
background: white;
|
|
312
|
-
border: 1px solid #e2e8f0;
|
|
313
|
-
border-radius: 0.5rem;
|
|
314
|
-
padding: 1.25rem;
|
|
315
|
-
text-decoration: none;
|
|
316
|
-
transition: all 0.15s;
|
|
317
|
-
display: flex;
|
|
318
|
-
flex-direction: column;
|
|
319
|
-
justify-content: space-between;
|
|
320
|
-
min-height: 100px;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
.ska-model-card:hover {
|
|
324
|
-
border-color: var(--ska-primary);
|
|
325
|
-
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
.ska-model-card__name {
|
|
329
|
-
font-weight: 600;
|
|
330
|
-
color: #1e293b;
|
|
331
|
-
margin-bottom: 0.25rem;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
.ska-model-card__count {
|
|
335
|
-
font-size: 0.75rem;
|
|
336
|
-
color: #64748b;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
.ska-model-card__footer {
|
|
340
|
-
color: var(--ska-primary);
|
|
341
|
-
font-size: 0.875rem;
|
|
342
|
-
font-weight: 500;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/* Header with actions */
|
|
346
|
-
.ska-header {
|
|
347
|
-
display: flex;
|
|
348
|
-
justify-content: space-between;
|
|
349
|
-
align-items: flex-start;
|
|
350
|
-
margin-bottom: 1.5rem;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/* Pagination */
|
|
354
|
-
.ska-pagination {
|
|
355
|
-
display: flex;
|
|
356
|
-
align-items: center;
|
|
357
|
-
gap: 0.5rem;
|
|
358
|
-
margin-top: 1rem;
|
|
359
|
-
padding-top: 1rem;
|
|
360
|
-
border-top: 1px solid #e2e8f0;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
.ska-pagination__info {
|
|
364
|
-
font-size: 0.875rem;
|
|
365
|
-
color: #64748b;
|
|
366
|
-
margin-right: auto;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/* Search */
|
|
370
|
-
.ska-search {
|
|
371
|
-
margin-bottom: 1rem;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
.ska-search__input {
|
|
375
|
-
padding: 0.5rem 1rem;
|
|
376
|
-
border: 1px solid #e2e8f0;
|
|
377
|
-
border-radius: 0.375rem;
|
|
378
|
-
font-size: 0.875rem;
|
|
379
|
-
width: 300px;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/* Back link */
|
|
383
|
-
.ska-back {
|
|
384
|
-
display: inline-flex;
|
|
385
|
-
align-items: center;
|
|
386
|
-
gap: 0.25rem;
|
|
387
|
-
color: #64748b;
|
|
388
|
-
text-decoration: none;
|
|
389
|
-
font-size: 0.875rem;
|
|
390
|
-
margin-bottom: 0.5rem;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
.ska-back:hover { color: #475569; }
|
|
394
|
-
|
|
395
|
-
/* Alert */
|
|
396
|
-
.ska-alert {
|
|
397
|
-
padding: 1rem;
|
|
398
|
-
border-radius: 0.375rem;
|
|
399
|
-
margin-bottom: 1rem;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
.ska-alert--error {
|
|
403
|
-
background: #fef2f2;
|
|
404
|
-
color: #dc2626;
|
|
405
|
-
border: 1px solid #fecaca;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
.ska-alert--success {
|
|
409
|
-
background: #f0fdf4;
|
|
410
|
-
color: #16a34a;
|
|
411
|
-
border: 1px solid #bbf7d0;
|
|
412
|
-
}
|
|
413
|
-
</style>
|
|
414
|
-
</head>
|
|
415
|
-
<body>
|
|
416
|
-
<div class="ska-layout">
|
|
417
|
-
<aside class="ska-sidebar">
|
|
418
|
-
<a href="${basePath}" class="ska-logo">${title}</a>
|
|
419
|
-
<nav>
|
|
420
|
-
<ul class="ska-nav">
|
|
421
|
-
<li class="ska-nav__item">
|
|
422
|
-
<a href="${basePath}" class="ska-nav__link ${!currentModel ? 'ska-nav__link--active' : ''}">
|
|
423
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
424
|
-
Dashboard
|
|
425
|
-
</a>
|
|
426
|
-
</li>
|
|
427
|
-
${models.map(m => `
|
|
428
|
-
<li class="ska-nav__item">
|
|
429
|
-
<a href="${basePath}/${m.name.toLowerCase()}" class="ska-nav__link ${currentModel?.toLowerCase() === m.name.toLowerCase() ? 'ska-nav__link--active' : ''}">
|
|
430
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>
|
|
431
|
-
${m.label}
|
|
432
|
-
</a>
|
|
433
|
-
</li>
|
|
434
|
-
`).join('')}
|
|
435
|
-
</ul>
|
|
436
|
-
</nav>
|
|
437
|
-
</aside>
|
|
438
|
-
<main class="ska-main">
|
|
439
|
-
${content}
|
|
440
|
-
</main>
|
|
441
|
-
</div>
|
|
442
|
-
</body>
|
|
443
|
-
</html>`;
|
|
444
|
-
}
|
|
445
|
-
function adjustColor(hex, percent) {
|
|
446
|
-
const num = parseInt(hex.replace('#', ''), 16);
|
|
447
|
-
const amt = Math.round(2.55 * percent);
|
|
448
|
-
const R = Math.max(0, Math.min(255, (num >> 16) + amt));
|
|
449
|
-
const G = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amt));
|
|
450
|
-
const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt));
|
|
451
|
-
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
|
|
452
|
-
}
|
|
453
|
-
function dashboardView(models, stats, basePath) {
|
|
454
|
-
return `
|
|
455
|
-
<h1>Dashboard</h1>
|
|
456
|
-
<p class="ska-subtitle">Welcome to your admin panel</p>
|
|
457
|
-
|
|
458
|
-
<div class="ska-stats">
|
|
459
|
-
<div class="ska-stat">
|
|
460
|
-
<div class="ska-stat__icon">
|
|
461
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/></svg>
|
|
462
|
-
</div>
|
|
463
|
-
<div>
|
|
464
|
-
<div class="ska-stat__value">${stats.models}</div>
|
|
465
|
-
<div class="ska-stat__label">Models</div>
|
|
466
|
-
</div>
|
|
467
|
-
</div>
|
|
468
|
-
<div class="ska-stat">
|
|
469
|
-
<div class="ska-stat__icon">
|
|
470
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
|
471
|
-
</div>
|
|
472
|
-
<div>
|
|
473
|
-
<div class="ska-stat__value">${stats.total}</div>
|
|
474
|
-
<div class="ska-stat__label">Total Records</div>
|
|
475
|
-
</div>
|
|
476
|
-
</div>
|
|
477
|
-
</div>
|
|
478
|
-
|
|
479
|
-
<h2>Models</h2>
|
|
480
|
-
<div class="ska-models">
|
|
481
|
-
${models.map(m => `
|
|
482
|
-
<a href="${basePath}/${m.name.toLowerCase()}" class="ska-model-card">
|
|
483
|
-
<div>
|
|
484
|
-
<div class="ska-model-card__name">${m.label}</div>
|
|
485
|
-
<div class="ska-model-card__count">${m.count} records</div>
|
|
486
|
-
</div>
|
|
487
|
-
<div class="ska-model-card__footer">Manage →</div>
|
|
488
|
-
</a>
|
|
489
|
-
`).join('')}
|
|
490
|
-
</div>
|
|
491
|
-
`;
|
|
492
|
-
}
|
|
493
|
-
function listView(model, items, pagination, basePath, config) {
|
|
494
|
-
const modelConfig = config.models?.[model.name] || {};
|
|
495
|
-
const hidden = modelConfig.hidden || [];
|
|
496
|
-
const listFields = modelConfig.listFields;
|
|
497
|
-
let displayFields = model.fields.filter(f => !hidden.includes(f.name) &&
|
|
498
|
-
!f.relation &&
|
|
499
|
-
!['Json', 'Bytes'].includes(f.type));
|
|
500
|
-
if (listFields?.length) {
|
|
501
|
-
displayFields = displayFields.filter(f => listFields.includes(f.name));
|
|
502
|
-
}
|
|
503
|
-
displayFields = displayFields.slice(0, 6);
|
|
504
|
-
const totalPages = Math.ceil(pagination.total / pagination.perPage);
|
|
505
|
-
return `
|
|
506
|
-
<div class="ska-header">
|
|
507
|
-
<div>
|
|
508
|
-
<h1>${model.label}</h1>
|
|
509
|
-
<p class="ska-subtitle">${pagination.total} records</p>
|
|
510
|
-
</div>
|
|
511
|
-
<a href="${basePath}/${model.name.toLowerCase()}/new" class="ska-btn ska-btn--primary">
|
|
512
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
|
513
|
-
Add ${model.label}
|
|
514
|
-
</a>
|
|
515
|
-
</div>
|
|
516
|
-
|
|
517
|
-
<div class="ska-card">
|
|
518
|
-
<div class="ska-table-wrap">
|
|
519
|
-
<table class="ska-table">
|
|
520
|
-
<thead>
|
|
521
|
-
<tr>
|
|
522
|
-
${displayFields.map(f => `<th>${toLabel(f.name)}</th>`).join('')}
|
|
523
|
-
<th>Actions</th>
|
|
524
|
-
</tr>
|
|
525
|
-
</thead>
|
|
526
|
-
<tbody>
|
|
527
|
-
${items.length === 0 ? `
|
|
528
|
-
<tr><td colspan="${displayFields.length + 1}" style="text-align: center; color: #64748b; padding: 2rem;">No records found</td></tr>
|
|
529
|
-
` : items.map(item => `
|
|
530
|
-
<tr>
|
|
531
|
-
${displayFields.map(f => `<td>${formatValue(item[f.name], f.type)}</td>`).join('')}
|
|
532
|
-
<td class="ska-table__actions">
|
|
533
|
-
<a href="${basePath}/${model.name.toLowerCase()}/${item[model.primaryKey]}" class="ska-btn ska-btn--secondary ska-btn--sm">Edit</a>
|
|
534
|
-
<form method="POST" action="${basePath}/${model.name.toLowerCase()}/${item[model.primaryKey]}" style="display:inline" onsubmit="return confirm('Delete this item?')">
|
|
535
|
-
<input type="hidden" name="_action" value="delete">
|
|
536
|
-
<button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete</button>
|
|
537
|
-
</form>
|
|
538
|
-
</td>
|
|
539
|
-
</tr>
|
|
540
|
-
`).join('')}
|
|
541
|
-
</tbody>
|
|
542
|
-
</table>
|
|
543
|
-
</div>
|
|
544
|
-
|
|
545
|
-
${totalPages > 1 ? `
|
|
546
|
-
<div class="ska-pagination">
|
|
547
|
-
<span class="ska-pagination__info">
|
|
548
|
-
Showing ${(pagination.page - 1) * pagination.perPage + 1} to ${Math.min(pagination.page * pagination.perPage, pagination.total)} of ${pagination.total}
|
|
549
|
-
</span>
|
|
550
|
-
${pagination.page > 1 ? `<a href="?page=${pagination.page - 1}" class="ska-btn ska-btn--secondary ska-btn--sm">Previous</a>` : ''}
|
|
551
|
-
${pagination.page < totalPages ? `<a href="?page=${pagination.page + 1}" class="ska-btn ska-btn--secondary ska-btn--sm">Next</a>` : ''}
|
|
552
|
-
</div>
|
|
553
|
-
` : ''}
|
|
554
|
-
</div>
|
|
555
|
-
`;
|
|
556
|
-
}
|
|
557
|
-
function createView(model, basePath, config, error) {
|
|
558
|
-
const modelConfig = config.models?.[model.name] || {};
|
|
559
|
-
const hidden = modelConfig.hidden || [];
|
|
560
|
-
const formFields = model.fields.filter(f => !hidden.includes(f.name) &&
|
|
561
|
-
!f.isId &&
|
|
562
|
-
!f.isCreatedAt &&
|
|
563
|
-
!f.isUpdatedAt &&
|
|
564
|
-
!f.relation &&
|
|
565
|
-
!f.hasDefault);
|
|
566
|
-
return `
|
|
567
|
-
<a href="${basePath}/${model.name.toLowerCase()}" class="ska-back">← Back to list</a>
|
|
568
|
-
<h1>Create ${model.label}</h1>
|
|
569
|
-
|
|
570
|
-
${error ? `<div class="ska-alert ska-alert--error">${error}</div>` : ''}
|
|
571
|
-
|
|
572
|
-
<div class="ska-card">
|
|
573
|
-
<form method="POST" class="ska-form">
|
|
574
|
-
<input type="hidden" name="_action" value="create">
|
|
575
|
-
${formFields.map(f => fieldInput(f, null, false)).join('')}
|
|
576
|
-
<div class="ska-form__actions">
|
|
577
|
-
<button type="submit" class="ska-btn ska-btn--primary">Create</button>
|
|
578
|
-
<a href="${basePath}/${model.name.toLowerCase()}" class="ska-btn ska-btn--secondary">Cancel</a>
|
|
579
|
-
</div>
|
|
580
|
-
</form>
|
|
581
|
-
</div>
|
|
582
|
-
`;
|
|
583
|
-
}
|
|
584
|
-
function editView(model, item, basePath, config, error) {
|
|
585
|
-
const modelConfig = config.models?.[model.name] || {};
|
|
586
|
-
const hidden = modelConfig.hidden || [];
|
|
587
|
-
const readonly = modelConfig.readonly || [];
|
|
588
|
-
const formFields = model.fields.filter(f => !hidden.includes(f.name) &&
|
|
589
|
-
!f.relation);
|
|
590
|
-
const id = item[model.primaryKey];
|
|
591
|
-
return `
|
|
592
|
-
<a href="${basePath}/${model.name.toLowerCase()}" class="ska-back">← Back to list</a>
|
|
593
|
-
<h1>Edit ${model.label}</h1>
|
|
594
|
-
<p class="ska-subtitle">ID: ${id}</p>
|
|
595
|
-
|
|
596
|
-
${error ? `<div class="ska-alert ska-alert--error">${error}</div>` : ''}
|
|
597
|
-
|
|
598
|
-
<div class="ska-card">
|
|
599
|
-
<form method="POST" class="ska-form">
|
|
600
|
-
<input type="hidden" name="_action" value="update">
|
|
601
|
-
${formFields.map(f => fieldInput(f, item[f.name], f.isId || f.isCreatedAt || f.isUpdatedAt || readonly.includes(f.name))).join('')}
|
|
602
|
-
<div class="ska-form__actions">
|
|
603
|
-
<button type="submit" class="ska-btn ska-btn--primary">Save Changes</button>
|
|
604
|
-
<a href="${basePath}/${model.name.toLowerCase()}" class="ska-btn ska-btn--secondary">Cancel</a>
|
|
605
|
-
</div>
|
|
606
|
-
</form>
|
|
607
|
-
</div>
|
|
608
|
-
`;
|
|
609
|
-
}
|
|
610
|
-
function fieldInput(field, value, isReadonly) {
|
|
611
|
-
const label = toLabel(field.name);
|
|
612
|
-
const required = field.isRequired && !field.hasDefault && !isReadonly;
|
|
613
|
-
if (field.type === 'Boolean') {
|
|
614
|
-
return `
|
|
615
|
-
<div class="ska-field">
|
|
616
|
-
<label class="ska-checkbox-wrap">
|
|
617
|
-
<input type="checkbox" name="${field.name}" class="ska-checkbox" ${value ? 'checked' : ''} ${isReadonly ? 'disabled' : ''}>
|
|
618
|
-
<span class="ska-label">${label}</span>
|
|
619
|
-
</label>
|
|
620
|
-
</div>
|
|
621
|
-
`;
|
|
622
|
-
}
|
|
623
|
-
let inputType = 'text';
|
|
624
|
-
let inputValue = value ?? '';
|
|
625
|
-
switch (field.type) {
|
|
626
|
-
case 'Int':
|
|
627
|
-
case 'Float':
|
|
628
|
-
case 'Decimal':
|
|
629
|
-
case 'BigInt':
|
|
630
|
-
inputType = 'number';
|
|
631
|
-
break;
|
|
632
|
-
case 'DateTime':
|
|
633
|
-
inputType = 'datetime-local';
|
|
634
|
-
if (value) {
|
|
635
|
-
inputValue = new Date(value).toISOString().slice(0, 16);
|
|
636
|
-
}
|
|
637
|
-
break;
|
|
638
|
-
case 'Json':
|
|
639
|
-
return `
|
|
640
|
-
<div class="ska-field">
|
|
641
|
-
<label class="ska-label">${label}${required ? ' *' : ''}</label>
|
|
642
|
-
<textarea name="${field.name}" class="ska-input" rows="4" ${isReadonly ? 'readonly' : ''} ${required ? 'required' : ''}>${value ? JSON.stringify(value, null, 2) : ''}</textarea>
|
|
643
|
-
</div>
|
|
644
|
-
`;
|
|
645
|
-
}
|
|
646
|
-
// Handle String fields that might be long
|
|
647
|
-
if (field.type === 'String' && (field.name.includes('description') || field.name.includes('content') || field.name.includes('body'))) {
|
|
648
|
-
return `
|
|
649
|
-
<div class="ska-field">
|
|
650
|
-
<label class="ska-label">${label}${required ? ' *' : ''}</label>
|
|
651
|
-
<textarea name="${field.name}" class="ska-input" rows="4" ${isReadonly ? 'readonly' : ''} ${required ? 'required' : ''}>${inputValue}</textarea>
|
|
652
|
-
</div>
|
|
653
|
-
`;
|
|
654
|
-
}
|
|
655
|
-
return `
|
|
656
|
-
<div class="ska-field">
|
|
657
|
-
<label class="ska-label">${label}${required ? ' *' : ''}</label>
|
|
658
|
-
<input type="${inputType}" name="${field.name}" value="${escapeHtml(String(inputValue))}" class="ska-input" ${isReadonly ? 'readonly' : ''} ${required ? 'required' : ''}>
|
|
659
|
-
</div>
|
|
660
|
-
`;
|
|
661
|
-
}
|
|
662
|
-
function formatValue(value, type) {
|
|
663
|
-
if (value === null || value === undefined)
|
|
664
|
-
return '<span style="color:#94a3b8">—</span>';
|
|
665
|
-
if (type === 'DateTime') {
|
|
666
|
-
return new Date(value).toLocaleString();
|
|
667
|
-
}
|
|
668
|
-
if (type === 'Boolean') {
|
|
669
|
-
return value ? '✓' : '✗';
|
|
670
|
-
}
|
|
671
|
-
const str = String(value);
|
|
672
|
-
if (str.length > 50) {
|
|
673
|
-
return escapeHtml(str.slice(0, 50)) + '...';
|
|
674
|
-
}
|
|
675
|
-
return escapeHtml(str);
|
|
676
|
-
}
|
|
677
|
-
function escapeHtml(str) {
|
|
678
|
-
return str
|
|
679
|
-
.replace(/&/g, '&')
|
|
680
|
-
.replace(/</g, '<')
|
|
681
|
-
.replace(/>/g, '>')
|
|
682
|
-
.replace(/"/g, '"');
|
|
683
|
-
}
|
|
684
|
-
function notFoundView(message) {
|
|
685
|
-
return `
|
|
686
|
-
<h1>Not Found</h1>
|
|
687
|
-
<p class="ska-subtitle">${message}</p>
|
|
688
|
-
<a href="" class="ska-btn ska-btn--secondary">← Back to Dashboard</a>
|
|
689
|
-
`;
|
|
690
|
-
}
|
|
5
|
+
import { render } from 'svelte/server';
|
|
6
|
+
import { parsePrismaSchema, isSensitiveFieldName } from './introspection/parser.js';
|
|
7
|
+
import { buildRelationGraph } from './introspection/relations.js';
|
|
8
|
+
import { parseRoute } from './router.js';
|
|
9
|
+
import { primaryKeyOf, toPrismaModel, coerceId, formDataToPrisma, paginate, listRecords, getRecord, createRecord, updateRecord, deleteRecord } from './data.js';
|
|
10
|
+
import { parseListQuery, buildWhere, resolveSearchFields } from './query/listQuery.js';
|
|
11
|
+
import { resolveListFilters, validateListFilterConfig, findFkEdge } from './query/filterDetection.js';
|
|
12
|
+
import { escapeHtml, toLabel } from './views/html.js';
|
|
13
|
+
import NotFound from './views/NotFound.svelte';
|
|
14
|
+
import Layout from './views/Layout.svelte';
|
|
15
|
+
import Dashboard from './views/Dashboard.svelte';
|
|
16
|
+
import Form from './views/Form.svelte';
|
|
17
|
+
import List from './views/List.svelte';
|
|
18
|
+
const PER_PAGE = 20;
|
|
691
19
|
// ============================================
|
|
692
20
|
// Main Handler
|
|
693
21
|
// ============================================
|
|
694
22
|
export function createAdminHandler(config) {
|
|
695
|
-
const { prisma, prismaSchemaPath = './prisma/schema.prisma', basePath = '/admin', authCheck, exclude = [],
|
|
23
|
+
const { prisma, prismaSchemaPath = './prisma/schema.prisma', basePath = '/admin', authCheck, logout, logoutRedirectTo = '/', exclude = [], hidePivotTables = true, models: modelsConfig = {} } = config;
|
|
696
24
|
// Parse schema once at startup
|
|
697
25
|
let schema = null;
|
|
26
|
+
let relationGraph = null;
|
|
698
27
|
try {
|
|
699
28
|
schema = parsePrismaSchema(prismaSchemaPath);
|
|
29
|
+
relationGraph = buildRelationGraph(schema);
|
|
30
|
+
for (const d of relationGraph.diagnostics) {
|
|
31
|
+
console.warn(`[sveltekit-admin] ${d}`);
|
|
32
|
+
}
|
|
700
33
|
}
|
|
701
34
|
catch (e) {
|
|
702
35
|
console.warn('[sveltekit-admin] Could not parse Prisma schema:', e);
|
|
703
36
|
}
|
|
704
|
-
const filteredModels = schema?.models.filter(
|
|
705
|
-
|
|
37
|
+
const filteredModels = schema?.models.filter((m) => {
|
|
38
|
+
// Exclude explicitly excluded models
|
|
39
|
+
if (exclude.includes(m.name))
|
|
40
|
+
return false;
|
|
41
|
+
// Exclude pivot tables if option is enabled
|
|
42
|
+
if (hidePivotTables && m.isPivotTable)
|
|
43
|
+
return false;
|
|
44
|
+
return true;
|
|
45
|
+
}) || [];
|
|
46
|
+
// Valider `listFilter` au démarrage : une config invalide (champ
|
|
47
|
+
// inexistant, sensible, relation, type non supporté) doit échouer fort
|
|
48
|
+
// ici plutôt que produire silencieusement un filtre mort à chaque rendu
|
|
49
|
+
// de liste (docs/design §8, même politique que le groupe ambigu de
|
|
50
|
+
// relations.ts).
|
|
51
|
+
const hiddenFieldsOf = (m) => new Set(modelsConfig[m.name]?.hidden ?? []);
|
|
52
|
+
for (const m of filteredModels) {
|
|
53
|
+
const entries = modelsConfig[m.name]?.listFilter;
|
|
54
|
+
// Non-null par construction : `filteredModels` n'existe que si le schéma
|
|
55
|
+
// a été parsé, et le graphe est construit dans la même branche de boot.
|
|
56
|
+
if (entries)
|
|
57
|
+
validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
|
|
58
|
+
}
|
|
59
|
+
const labelOf = (m) => modelsConfig[m.name]?.label || toLabel(m.name);
|
|
60
|
+
const modelList = filteredModels.map((m) => ({ name: m.name, label: labelOf(m) }));
|
|
61
|
+
const findModel = (name) => filteredModels.find((m) => m.name.toLowerCase() === name?.toLowerCase());
|
|
62
|
+
const viewModel = (m) => ({
|
|
706
63
|
name: m.name,
|
|
707
|
-
label:
|
|
708
|
-
|
|
64
|
+
label: labelOf(m),
|
|
65
|
+
fields: m.fields,
|
|
66
|
+
primaryKey: primaryKeyOf(m),
|
|
67
|
+
// Non-null par construction : `m` vient toujours de `filteredModels`,
|
|
68
|
+
// dérivé du schéma qu'on vient de parser avec succès.
|
|
69
|
+
relationGraph: relationGraph
|
|
70
|
+
});
|
|
71
|
+
const redirectToList = (model) => new Response(null, {
|
|
72
|
+
status: 303,
|
|
73
|
+
headers: { Location: `${basePath}/${model.toLowerCase()}` }
|
|
74
|
+
});
|
|
75
|
+
const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
|
|
76
|
+
const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
|
|
77
|
+
const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
|
|
78
|
+
'name', 'title', 'label', 'email', 'username', 'slug'
|
|
79
|
+
];
|
|
80
|
+
// `mode: 'insensitive'` n'est supporté par Prisma que sur
|
|
81
|
+
// postgresql/cockroachdb/mongodb — l'émettre sur sqlite/mysql/sqlserver
|
|
82
|
+
// lève une erreur Prisma dure. Détection auto via le provider extrait du
|
|
83
|
+
// schéma ; `search.mode` permet de forcer le comportement (provider non
|
|
84
|
+
// littéral dans le schéma, index citext, etc.). Voir docs/design §2.5.
|
|
85
|
+
const searchMode = config.search?.mode ?? 'auto';
|
|
86
|
+
const caseInsensitiveSearch = searchMode === 'insensitive' ||
|
|
87
|
+
(searchMode === 'auto' &&
|
|
88
|
+
['postgresql', 'cockroachdb', 'mongodb'].includes(schema?.provider ?? ''));
|
|
89
|
+
/**
|
|
90
|
+
* Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
|
|
91
|
+
* champ scalaire non-liste, non-relation, de type filtrable
|
|
92
|
+
* (String/Int/Float/Decimal/BigInt/Boolean/DateTime/enum — donc pas
|
|
93
|
+
* Json/Bytes), non sensible, et non listé dans `hidden` pour ce modèle.
|
|
94
|
+
* Sans ce dernier point, `hidden: ['internalNotes']` ne fait que masquer
|
|
95
|
+
* l'affichage : le champ reste un oracle de confirmation de valeur via
|
|
96
|
+
* `?f.internalNotes=...contains...`, exactement la faille §0.a fermée
|
|
97
|
+
* ailleurs pour les champs sensibles par nom — `hidden` et le prédicat
|
|
98
|
+
* de sensibilité sont deux sources distinctes, toutes deux doivent
|
|
99
|
+
* fermer l'oracle (docs/design §10, "deux sources, un seul prédicat
|
|
100
|
+
* partagé, sinon divergence garantie"). Défense en profondeur :
|
|
101
|
+
* `listQuery.ts` revérifie lui-même la sensibilité par nom, ce set est
|
|
102
|
+
* la première passe et la seule à connaître la config `hidden`.
|
|
103
|
+
*/
|
|
104
|
+
const resolveFilterableFields = (model) => {
|
|
105
|
+
const hidden = hiddenFieldsOf(model);
|
|
106
|
+
const out = new Set();
|
|
107
|
+
for (const f of model.fields) {
|
|
108
|
+
if (f.relation || f.isList)
|
|
109
|
+
continue;
|
|
110
|
+
if (['Json', 'Bytes'].includes(f.type))
|
|
111
|
+
continue;
|
|
112
|
+
if (isSensitiveFieldName(f.name))
|
|
113
|
+
continue;
|
|
114
|
+
if (hidden.has(f.name))
|
|
115
|
+
continue;
|
|
116
|
+
out.add(f.name);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Résout le label BRUT (non échappé) d'une ligne : premier champ String
|
|
122
|
+
* candidat présent, sinon template `{a} {b}` si configuré, sinon la PK.
|
|
123
|
+
* Déterministe. Svelte échappe automatiquement à l'interpolation dans les
|
|
124
|
+
* composants — pas besoin d'échapper ici.
|
|
125
|
+
*/
|
|
126
|
+
const resolveLabel = (targetModel, row, labelTemplate) => {
|
|
127
|
+
if (labelTemplate) {
|
|
128
|
+
return labelTemplate.replace(/\{(\w+)\}/g, (_, k) => String(row[k] ?? ''));
|
|
129
|
+
}
|
|
130
|
+
for (const candidate of labelFieldCandidates) {
|
|
131
|
+
const field = targetModel.fields.find((f) => f.name === candidate);
|
|
132
|
+
if (field && field.type === 'String' && row[candidate] != null) {
|
|
133
|
+
return String(row[candidate]);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return String(row[primaryKeyOf(targetModel)]);
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Charge les options pour toutes les arêtes to-one-owning et m2m-implicite
|
|
140
|
+
* d'un modèle. Une requête COUNT par relation avant le findMany : évite de
|
|
141
|
+
* charger 10k lignes pour découvrir qu'il y en a 10k.
|
|
142
|
+
*/
|
|
143
|
+
const loadRelationOptions = async (model, ctx, currentId) => {
|
|
144
|
+
const edges = [...relationGraph.edges.values()].filter((edge) => {
|
|
145
|
+
if (edge.model !== model.name)
|
|
146
|
+
return false;
|
|
147
|
+
if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m-implicit')
|
|
148
|
+
return false;
|
|
149
|
+
if (edge.unsupported)
|
|
150
|
+
return false;
|
|
151
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
152
|
+
return relConfig?.widget !== 'hidden';
|
|
153
|
+
});
|
|
154
|
+
// Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
|
|
155
|
+
// qu'en série (un modèle avec N relations ne doit pas payer N
|
|
156
|
+
// aller-retours DB empilés pour afficher un seul formulaire).
|
|
157
|
+
const entries = await Promise.all(edges.map(async (edge) => {
|
|
158
|
+
const key = `${edge.model}.${edge.field}`;
|
|
159
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
160
|
+
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
161
|
+
const where = relConfig?.where ? relConfig.where(ctx) : undefined;
|
|
162
|
+
const prismaKey = toPrismaModel(edge.target);
|
|
163
|
+
try {
|
|
164
|
+
const total = await prisma[prismaKey].count({ where });
|
|
165
|
+
if (total > selectThreshold || relConfig?.widget === 'raw-id') {
|
|
166
|
+
const selectedIds = edge.kind === 'm2m-implicit' && currentId
|
|
167
|
+
? await loadSelectedIds(model, edge, currentId, targetModel)
|
|
168
|
+
: undefined;
|
|
169
|
+
return [key, { tooMany: true, options: [], selectedIds }];
|
|
170
|
+
}
|
|
171
|
+
const rows = await prisma[prismaKey].findMany({
|
|
172
|
+
where,
|
|
173
|
+
orderBy: relConfig?.orderBy
|
|
174
|
+
});
|
|
175
|
+
const options = rows.map((row) => ({
|
|
176
|
+
id: row[primaryKeyOf(targetModel)],
|
|
177
|
+
label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
178
|
+
}));
|
|
179
|
+
const selectedIds = edge.kind === 'm2m-implicit' && currentId
|
|
180
|
+
? await loadSelectedIds(model, edge, currentId, targetModel)
|
|
181
|
+
: undefined;
|
|
182
|
+
return [key, { tooMany: false, options, selectedIds }];
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Cible absente de la base ou client incomplet : repli raw-id pour
|
|
186
|
+
// garder le champ éditable plutôt que de faire échouer tout le form.
|
|
187
|
+
return [key, { tooMany: true, options: [] }];
|
|
188
|
+
}
|
|
189
|
+
}));
|
|
190
|
+
return new Map(entries);
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Options d'un filtre FK : charge et scope les valeurs possibles pour la
|
|
194
|
+
* sidebar, ET résout le label du chip actif. Doctrine IDOR (docs/design
|
|
195
|
+
* §6.3) : les options ET le label du chip passent par le `where` de
|
|
196
|
+
* scoping de la relation — un chip forgé avec un ID hors scope affiche
|
|
197
|
+
* l'ID brut, jamais le label (sinon c'est un oracle sur le nom d'un
|
|
198
|
+
* enregistrement d'un autre tenant).
|
|
199
|
+
*/
|
|
200
|
+
const resolveFkFilterOptions = async (model, fkFieldName, label, ctx, activeRawValue) => {
|
|
201
|
+
// Appelé uniquement pour un filtre `kind: 'fk'` retourné par
|
|
202
|
+
// resolveListFilters avec CE MÊME graphe : graphe et arête existent donc
|
|
203
|
+
// par construction. Garder des gardes here masquerait une incohérence
|
|
204
|
+
// interne et ajouterait du code mort (coverage artificielle).
|
|
205
|
+
const edge = findFkEdge(relationGraph, model.name, fkFieldName);
|
|
206
|
+
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
207
|
+
// Non-null par construction : `edge` vient du graphe dérivé du même
|
|
208
|
+
// schéma parsé avec succès — une arête ne peut pas cibler un modèle qui
|
|
209
|
+
// n'existe pas dans `schema.models`.
|
|
210
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
211
|
+
const scope = relConfig?.where ? relConfig.where(ctx) : undefined;
|
|
212
|
+
const prismaKey = toPrismaModel(edge.target);
|
|
213
|
+
// Options de la sidebar (comptées puis chargées si sous le seuil) et
|
|
214
|
+
// label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
|
|
215
|
+
// ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
|
|
216
|
+
// série.
|
|
217
|
+
const loadOptions = async () => {
|
|
218
|
+
try {
|
|
219
|
+
const total = await prisma[prismaKey].count({ where: scope });
|
|
220
|
+
if (total > selectThreshold) {
|
|
221
|
+
return { options: [], tooMany: true };
|
|
222
|
+
}
|
|
223
|
+
const rows = await prisma[prismaKey].findMany({
|
|
224
|
+
where: scope,
|
|
225
|
+
orderBy: relConfig?.orderBy
|
|
226
|
+
});
|
|
227
|
+
const options = rows.map((row) => ({
|
|
228
|
+
id: row[primaryKeyOf(targetModel)],
|
|
229
|
+
label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
230
|
+
}));
|
|
231
|
+
return { options, tooMany: false };
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return { options: [], tooMany: true };
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
// Un ID hors scope retourne null ici → le composant affiche l'ID brut,
|
|
238
|
+
// jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
|
|
239
|
+
// d'un autre tenant).
|
|
240
|
+
const loadActiveLabel = async () => {
|
|
241
|
+
if (activeRawValue === undefined)
|
|
242
|
+
return undefined;
|
|
243
|
+
const activeId = coerceId(activeRawValue, targetModel);
|
|
244
|
+
try {
|
|
245
|
+
const row = await prisma[prismaKey].findFirst({
|
|
246
|
+
where: scope ? { AND: [{ [primaryKeyOf(targetModel)]: activeId }, scope] } : { [primaryKeyOf(targetModel)]: activeId }
|
|
247
|
+
});
|
|
248
|
+
return row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
|
|
255
|
+
return {
|
|
256
|
+
field: fkFieldName,
|
|
257
|
+
label,
|
|
258
|
+
relationField: edge.field,
|
|
259
|
+
targetModel: edge.target,
|
|
260
|
+
options,
|
|
261
|
+
mode: tooMany ? 'raw-id' : options.length <= filterLinkThreshold ? 'links' : 'select',
|
|
262
|
+
tooMany,
|
|
263
|
+
activeLabel,
|
|
264
|
+
// Une cible exclue/masquée n'a pas de page admin : le chip reste du
|
|
265
|
+
// texte, jamais un lien mort (docs/design §6.4).
|
|
266
|
+
activeHref: activeLabel && findModel(edge.target)
|
|
267
|
+
? `${basePath}/${edge.target.toLowerCase()}/${encodeURIComponent(activeRawValue)}`
|
|
268
|
+
: undefined
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
/** IDs liés côté N-N implicite, via une requête sur le join field Prisma. */
|
|
272
|
+
const loadSelectedIds = async (model, edge, currentId, targetModel) => {
|
|
273
|
+
try {
|
|
274
|
+
const current = await prisma[toPrismaModel(model.name)].findUnique({
|
|
275
|
+
where: { [primaryKeyOf(model)]: coerceId(currentId, model) },
|
|
276
|
+
include: { [edge.field]: true }
|
|
277
|
+
});
|
|
278
|
+
const linked = current?.[edge.field] ?? [];
|
|
279
|
+
return linked.map((row) => row[primaryKeyOf(targetModel)]);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return [];
|
|
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
|
+
try {
|
|
302
|
+
const count = await prisma[toPrismaModel(edge.target)].count({
|
|
303
|
+
where: { [scalarName]: coerceId(currentId, model) }
|
|
304
|
+
});
|
|
305
|
+
return [key, count];
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return [key, 0];
|
|
309
|
+
}
|
|
310
|
+
}));
|
|
311
|
+
return new Map(entries.filter((e) => e !== undefined));
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
|
|
315
|
+
* Sert les options d'une relation to-one-owning ou m2m-implicite en JSON
|
|
316
|
+
* paginé — la voie prévue pour un futur widget autocomplete côté client
|
|
317
|
+
* quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
|
|
318
|
+
* de scoping configuré sur la relation, comme le select et la validation
|
|
319
|
+
* POST : même garantie anti-IDOR sur les trois chemins.
|
|
320
|
+
*/
|
|
321
|
+
const handleSearch = async (event) => {
|
|
322
|
+
const relParam = event.url.searchParams.get('rel') ?? '';
|
|
323
|
+
const [modelName, fieldName] = relParam.split('.');
|
|
324
|
+
const q = event.url.searchParams.get('q') ?? '';
|
|
325
|
+
const { page } = paginate(event.url.searchParams.get('page'), PER_PAGE);
|
|
326
|
+
const model = findModel(modelName);
|
|
327
|
+
const edge = model && relationGraph
|
|
328
|
+
? relationGraph.edges.get(`${model.name}.${fieldName}`)
|
|
329
|
+
: undefined;
|
|
330
|
+
if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m-implicit') || edge.unsupported) {
|
|
331
|
+
return new Response(JSON.stringify({ error: 'unknown relation' }), {
|
|
332
|
+
status: 404,
|
|
333
|
+
headers: { 'Content-Type': 'application/json' }
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
337
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
338
|
+
const configWhere = relConfig?.where ? relConfig.where({ locals: event.locals }) : {};
|
|
339
|
+
// Recherche sur le premier champ String candidat du modèle cible — le
|
|
340
|
+
// même champ que celui utilisé pour construire le label par défaut.
|
|
341
|
+
const searchField = labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
|
|
342
|
+
const where = {
|
|
343
|
+
...configWhere,
|
|
344
|
+
...(q && searchField ? { [searchField]: { contains: q } } : {})
|
|
345
|
+
};
|
|
346
|
+
const prismaKey = toPrismaModel(edge.target);
|
|
347
|
+
try {
|
|
348
|
+
const [total, rows] = await Promise.all([
|
|
349
|
+
prisma[prismaKey].count({ where }),
|
|
350
|
+
prisma[prismaKey].findMany({
|
|
351
|
+
where,
|
|
352
|
+
skip: (page - 1) * PER_PAGE,
|
|
353
|
+
take: PER_PAGE,
|
|
354
|
+
orderBy: relConfig?.orderBy
|
|
355
|
+
})
|
|
356
|
+
]);
|
|
357
|
+
const options = rows.map((row) => ({
|
|
358
|
+
id: row[primaryKeyOf(targetModel)],
|
|
359
|
+
label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
360
|
+
}));
|
|
361
|
+
return new Response(JSON.stringify({ options, total, page }), {
|
|
362
|
+
headers: { 'Content-Type': 'application/json' }
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return new Response(JSON.stringify({ error: 'search failed' }), {
|
|
367
|
+
status: 500,
|
|
368
|
+
headers: { 'Content-Type': 'application/json' }
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
};
|
|
709
372
|
return async ({ event, resolve }) => {
|
|
710
373
|
const { pathname } = event.url;
|
|
711
374
|
// Only handle admin routes
|
|
712
375
|
if (!pathname.startsWith(basePath)) {
|
|
713
376
|
return resolve(event);
|
|
714
377
|
}
|
|
378
|
+
const route = parseRoute(pathname, basePath);
|
|
379
|
+
// Logout: dispatched BEFORE authCheck, deliberately. A user whose
|
|
380
|
+
// session already expired (authCheck would now reject them) must
|
|
381
|
+
// still be able to hit this route to clear client-side state (a
|
|
382
|
+
// stale cookie, for instance) instead of being stuck behind a 401
|
|
383
|
+
// with no way to reach the very thing that would let them log back
|
|
384
|
+
// in cleanly. POST-only: a GET (crawler, link prefetch, <img src>)
|
|
385
|
+
// must never be able to trigger a logout side effect.
|
|
386
|
+
if (route.view === 'logout') {
|
|
387
|
+
if (event.request.method !== 'POST') {
|
|
388
|
+
return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } });
|
|
389
|
+
}
|
|
390
|
+
if (logout) {
|
|
391
|
+
await logout(event);
|
|
392
|
+
}
|
|
393
|
+
return new Response(null, { status: 303, headers: { Location: logoutRedirectTo } });
|
|
394
|
+
}
|
|
715
395
|
// Auth check
|
|
716
396
|
if (authCheck) {
|
|
717
397
|
const allowed = await authCheck(event);
|
|
@@ -719,172 +399,273 @@ export function createAdminHandler(config) {
|
|
|
719
399
|
return new Response('Unauthorized', { status: 401 });
|
|
720
400
|
}
|
|
721
401
|
}
|
|
722
|
-
const route = parseRoute(pathname, basePath);
|
|
723
402
|
let content = '';
|
|
724
403
|
let currentModel;
|
|
404
|
+
if (route.view === 'search') {
|
|
405
|
+
return handleSearch(event);
|
|
406
|
+
}
|
|
725
407
|
try {
|
|
726
|
-
// Handle POST requests (create, update, delete)
|
|
408
|
+
// Handle POST requests (create, update, delete). Unrecognised actions fall
|
|
409
|
+
// through to the GET rendering below, as they always have.
|
|
727
410
|
if (event.request.method === 'POST') {
|
|
728
411
|
const formData = await event.request.formData();
|
|
729
412
|
const action = formData.get('_action');
|
|
730
413
|
if (route.model) {
|
|
731
|
-
const
|
|
732
|
-
if (!
|
|
414
|
+
const model = findModel(route.model);
|
|
415
|
+
if (!model) {
|
|
733
416
|
throw new Error(`Model "${route.model}" not found`);
|
|
734
417
|
}
|
|
735
|
-
const prismaModelName = toPrismaModel(schemaModel.name);
|
|
736
|
-
const primaryKey = schemaModel.fields.find(f => f.isId)?.name || 'id';
|
|
737
418
|
if (action === 'delete' && route.id) {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
where: { [primaryKey]: parsedId }
|
|
741
|
-
});
|
|
742
|
-
return new Response(null, {
|
|
743
|
-
status: 303,
|
|
744
|
-
headers: { Location: `${basePath}/${route.model.toLowerCase()}` }
|
|
745
|
-
});
|
|
419
|
+
await deleteRecord(prisma, model, route.id);
|
|
420
|
+
return redirectToList(route.model);
|
|
746
421
|
}
|
|
747
422
|
if (action === 'create' || action === 'update') {
|
|
748
|
-
const data =
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
if (
|
|
755
|
-
|
|
423
|
+
const data = formDataToPrisma(formData, model);
|
|
424
|
+
// Validation des FK owning : coercion + existence + self-ref.
|
|
425
|
+
// Rejoue le `where` de scoping : un ID hors du where est rejeté,
|
|
426
|
+
// pas seulement caché du select (IDOR par POST forgé).
|
|
427
|
+
if (relationGraph) {
|
|
428
|
+
for (const edge of relationGraph.edges.values()) {
|
|
429
|
+
if (edge.model !== model.name || edge.kind !== 'to-one-owning')
|
|
430
|
+
continue;
|
|
431
|
+
if (edge.unsupported)
|
|
432
|
+
continue;
|
|
433
|
+
const scalarName = edge.scalarFields[0];
|
|
434
|
+
// Lu directement depuis le FormData plutôt que `data` :
|
|
435
|
+
// `formDataToPrisma` omet la clé pour un scalaire required
|
|
436
|
+
// laissé vide, donc `data[scalarName]` ne suffirait pas ici.
|
|
437
|
+
const raw = formData.get(scalarName);
|
|
438
|
+
if (raw === null)
|
|
439
|
+
continue;
|
|
440
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
441
|
+
// Vide sur relation optionnelle → null (disconnect).
|
|
442
|
+
if (raw === '' || raw === undefined || raw === null) {
|
|
443
|
+
if (edge.isRequired) {
|
|
444
|
+
throw new Error(`${edge.field} is required`);
|
|
445
|
+
}
|
|
446
|
+
data[scalarName] = null;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
// Coercion vers le type de la PK cible. `targetModel` existe
|
|
450
|
+
// toujours : le graphe n'aurait pas produit d'arête sinon.
|
|
451
|
+
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
452
|
+
const pkField = targetModel.fields.find((f) => f.isId);
|
|
453
|
+
const coerced = pkField?.type === 'Int' ? parseInt(String(raw)) : String(raw);
|
|
454
|
+
if (pkField?.type === 'Int' && !Number.isSafeInteger(coerced)) {
|
|
455
|
+
throw new Error(`${edge.field}: invalid id`);
|
|
456
|
+
}
|
|
457
|
+
// Self-ref : la ligne courante ne peut pas être sa propre cible.
|
|
458
|
+
if (edge.selfReferential && route.id && String(coerced) === String(coerceId(route.id, model))) {
|
|
459
|
+
throw new Error(`${edge.field}: cannot reference itself`);
|
|
460
|
+
}
|
|
461
|
+
// Existence + scoping. findFirst et non findUnique : le where
|
|
462
|
+
// peut porter des conditions arbitraires (scoping multi-tenant).
|
|
463
|
+
// Si le client ne sait pas répondre, on ne bloque pas l'écriture.
|
|
464
|
+
try {
|
|
465
|
+
const where = {
|
|
466
|
+
[primaryKeyOf(targetModel)]: coerced,
|
|
467
|
+
...(relConfig?.where ? relConfig.where({ locals: event.locals }) : {})
|
|
468
|
+
};
|
|
469
|
+
const found = await prisma[toPrismaModel(edge.target)].findFirst({ where });
|
|
470
|
+
if (!found) {
|
|
471
|
+
throw new Error(`${edge.field}: invalid value`);
|
|
472
|
+
}
|
|
756
473
|
}
|
|
757
|
-
|
|
474
|
+
catch (e) {
|
|
475
|
+
if (e?.message?.includes('invalid value'))
|
|
476
|
+
throw e;
|
|
477
|
+
// Client incapable de vérifier (mock partiel, etc.) : on laisse passer.
|
|
478
|
+
}
|
|
479
|
+
data[scalarName] = coerced;
|
|
758
480
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
481
|
+
// N-N implicite : lit `__rel__<field>` (valeurs cochées) et
|
|
482
|
+
// `__rel_present__<field>` (sentinelle). Sans le sentinelle,
|
|
483
|
+
// le champ est absent du form (readonly/exclu) → no-op.
|
|
484
|
+
// Avec le sentinelle mais zéro valeur cochée → vider la
|
|
485
|
+
// relation (`set: []` / rien à connecter en création).
|
|
486
|
+
for (const edge of relationGraph.edges.values()) {
|
|
487
|
+
if (edge.model !== model.name || edge.kind !== 'm2m-implicit')
|
|
488
|
+
continue;
|
|
489
|
+
// Pas de garde `edge.unsupported` ici : par construction du
|
|
490
|
+
// graphe, `unsupported` n'est jamais posé sur une arête
|
|
491
|
+
// m2m-implicite (seulement sur to-one-owning / groupes
|
|
492
|
+
// ambigus, qui retombent toujours en to-one-owning).
|
|
493
|
+
const present = formData.get(`__rel_present__${edge.field}`);
|
|
494
|
+
if (present === null)
|
|
495
|
+
continue;
|
|
496
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
497
|
+
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
498
|
+
const targetPk = primaryKeyOf(targetModel);
|
|
499
|
+
const pkIsInt = targetModel.fields.find((f) => f.isId)?.type === 'Int';
|
|
500
|
+
const submitted = formData.getAll(`__rel__${edge.field}`).map(String);
|
|
501
|
+
const rawIds = submitted.length === 1 && submitted[0].includes(',')
|
|
502
|
+
? submitted[0].split(',').map((s) => s.trim()).filter(Boolean)
|
|
503
|
+
: submitted;
|
|
504
|
+
const ids = rawIds.map((v) => pkIsInt ? parseInt(v) : v);
|
|
505
|
+
if (pkIsInt && ids.some((v) => !Number.isSafeInteger(v))) {
|
|
506
|
+
throw new Error(`${edge.field}: invalid id`);
|
|
507
|
+
}
|
|
508
|
+
// Existence + scoping en une requête, sur l'ensemble des IDs
|
|
509
|
+
// soumis. Un compte différent = au moins un ID invalide ou
|
|
510
|
+
// hors scoping — IDOR bloqué au même titre que pour les FK.
|
|
511
|
+
if (ids.length > 0) {
|
|
512
|
+
const where = {
|
|
513
|
+
[targetPk]: { in: ids },
|
|
514
|
+
...(relConfig?.where ? relConfig.where({ locals: event.locals }) : {})
|
|
515
|
+
};
|
|
775
516
|
try {
|
|
776
|
-
|
|
517
|
+
const found = await prisma[toPrismaModel(edge.target)].findMany({ where });
|
|
518
|
+
if (found.length !== new Set(ids.map(String)).size) {
|
|
519
|
+
throw new Error(`${edge.field}: invalid value`);
|
|
520
|
+
}
|
|
777
521
|
}
|
|
778
|
-
catch {
|
|
779
|
-
|
|
522
|
+
catch (e) {
|
|
523
|
+
if (e?.message?.includes('invalid value'))
|
|
524
|
+
throw e;
|
|
525
|
+
// Client incapable de vérifier : on laisse passer.
|
|
780
526
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
527
|
+
}
|
|
528
|
+
const idRefs = ids.map((id) => ({ [targetPk]: id }));
|
|
529
|
+
data[edge.field] =
|
|
530
|
+
action === 'create' ? { connect: idRefs } : { set: idRefs };
|
|
784
531
|
}
|
|
785
532
|
}
|
|
786
533
|
if (action === 'create') {
|
|
787
|
-
await prisma
|
|
534
|
+
await createRecord(prisma, model, data);
|
|
788
535
|
}
|
|
789
536
|
else if (route.id) {
|
|
790
|
-
|
|
791
|
-
await prisma[prismaModelName].update({
|
|
792
|
-
where: { [primaryKey]: parsedId },
|
|
793
|
-
data
|
|
794
|
-
});
|
|
537
|
+
await updateRecord(prisma, model, route.id, data);
|
|
795
538
|
}
|
|
796
|
-
return
|
|
797
|
-
status: 303,
|
|
798
|
-
headers: { Location: `${basePath}/${route.model.toLowerCase()}` }
|
|
799
|
-
});
|
|
539
|
+
return redirectToList(route.model);
|
|
800
540
|
}
|
|
801
541
|
}
|
|
802
542
|
}
|
|
803
543
|
// GET requests - render views
|
|
804
|
-
if (route.view === '
|
|
544
|
+
if (route.view === 'notFound') {
|
|
545
|
+
content = render(NotFound, { props: { message: 'Page not found', basePath } }).body;
|
|
546
|
+
}
|
|
547
|
+
else if (route.view === 'dashboard') {
|
|
805
548
|
const modelsWithCounts = await Promise.all(filteredModels.map(async (m) => {
|
|
806
|
-
const prismaModelName = toPrismaModel(m.name);
|
|
807
549
|
let count = 0;
|
|
808
550
|
try {
|
|
809
|
-
count = await prisma[
|
|
551
|
+
count = await prisma[toPrismaModel(m.name)].count();
|
|
810
552
|
}
|
|
811
|
-
catch
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
count
|
|
816
|
-
};
|
|
553
|
+
catch {
|
|
554
|
+
// model absent from the database
|
|
555
|
+
}
|
|
556
|
+
return { name: m.name, label: labelOf(m), count };
|
|
817
557
|
}));
|
|
818
558
|
const totalRecords = modelsWithCounts.reduce((sum, m) => sum + m.count, 0);
|
|
819
|
-
content =
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
}
|
|
827
|
-
else {
|
|
828
|
-
const prismaModelName = toPrismaModel(schemaModel.name);
|
|
829
|
-
const primaryKey = schemaModel.fields.find(f => f.isId)?.name || 'id';
|
|
830
|
-
const page = parseInt(event.url.searchParams.get('page') || '1');
|
|
831
|
-
const perPage = 20;
|
|
832
|
-
const [items, total] = await Promise.all([
|
|
833
|
-
prisma[prismaModelName].findMany({
|
|
834
|
-
skip: (page - 1) * perPage,
|
|
835
|
-
take: perPage,
|
|
836
|
-
orderBy: { [primaryKey]: 'desc' }
|
|
837
|
-
}),
|
|
838
|
-
prisma[prismaModelName].count()
|
|
839
|
-
]);
|
|
840
|
-
content = listView({
|
|
841
|
-
name: schemaModel.name,
|
|
842
|
-
label: modelsConfig[schemaModel.name]?.label || toLabel(schemaModel.name),
|
|
843
|
-
fields: schemaModel.fields,
|
|
844
|
-
primaryKey
|
|
845
|
-
}, items, { page, perPage, total }, basePath, config);
|
|
846
|
-
}
|
|
559
|
+
content = render(Dashboard, {
|
|
560
|
+
props: {
|
|
561
|
+
models: modelsWithCounts,
|
|
562
|
+
stats: { total: totalRecords, models: modelsWithCounts.length },
|
|
563
|
+
basePath
|
|
564
|
+
}
|
|
565
|
+
}).body;
|
|
847
566
|
}
|
|
848
|
-
else if (route.
|
|
567
|
+
else if (route.model) {
|
|
849
568
|
currentModel = route.model;
|
|
850
|
-
const
|
|
851
|
-
if (!
|
|
852
|
-
content =
|
|
569
|
+
const model = findModel(route.model);
|
|
570
|
+
if (!model) {
|
|
571
|
+
content = render(NotFound, {
|
|
572
|
+
props: { message: `Model "${route.model}" not found`, basePath }
|
|
573
|
+
}).body;
|
|
853
574
|
}
|
|
854
|
-
else {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
575
|
+
else if (route.view === 'list') {
|
|
576
|
+
const { page } = paginate(event.url.searchParams.get('page'), PER_PAGE);
|
|
577
|
+
const modelSearchConfig = modelsConfig[model.name]?.searchFields;
|
|
578
|
+
const searchFields = resolveSearchFields(model, modelSearchConfig, labelFieldCandidates, hiddenFieldsOf(model));
|
|
579
|
+
const filterableFields = resolveFilterableFields(model);
|
|
580
|
+
const listQuery = parseListQuery(event.url.searchParams, model, schema.enums, searchFields, filterableFields);
|
|
581
|
+
const listScope = modelsConfig[model.name]?.listWhere?.({ locals: event.locals });
|
|
582
|
+
// A scope function returning `{}` (falsy-looking but truthy as
|
|
583
|
+
// an object) would otherwise silently fail OPEN — `{}` composed
|
|
584
|
+
// into an AND matches every row, exactly the opposite of what a
|
|
585
|
+
// caller configuring listWhere expects (real gap found in
|
|
586
|
+
// review: `locals.userId` undefined after a session expires is
|
|
587
|
+
// a realistic way to hit this). Fail loud instead: a scope
|
|
588
|
+
// function is either omitted entirely, or must return at least
|
|
589
|
+
// one condition every time it runs.
|
|
590
|
+
if (listScope && Object.keys(listScope).length === 0) {
|
|
591
|
+
throw new Error(`[sveltekit-admin] models.${model.name}.listWhere returned an empty object ({}), ` +
|
|
592
|
+
`which would silently disable list scoping (fail-open). Return undefined/omit the ` +
|
|
593
|
+
`scope entirely if there is genuinely nothing to scope by for this request, or a ` +
|
|
594
|
+
`condition that actually restricts rows otherwise.`);
|
|
595
|
+
}
|
|
596
|
+
const where = buildWhere(listQuery, listScope, caseInsensitiveSearch, model);
|
|
597
|
+
const { items, total } = await listRecords(prisma, model, page, PER_PAGE, where);
|
|
598
|
+
const listFilters = resolveListFilters(model, schema.enums, modelsConfig[model.name]?.listFilter, toLabel, relationGraph, hiddenFieldsOf(model), config.listFilterDefaults?.autoDetect ?? true);
|
|
599
|
+
// Un filtre FK ne dépend pas de l'autre : résolus en parallèle
|
|
600
|
+
// plutôt qu'un par un (même raisonnement que loadRelationOptions).
|
|
601
|
+
const fkFilterEntries = await Promise.all(listFilters
|
|
602
|
+
.filter((filter) => filter.kind === 'fk')
|
|
603
|
+
.map(async (filter) => {
|
|
604
|
+
const activeRawValue = listQuery.filters.find((f) => f.field === filter.field && f.op === 'equals')?.raw;
|
|
605
|
+
const meta = await resolveFkFilterOptions(model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
|
|
606
|
+
return [filter.field, meta];
|
|
607
|
+
}));
|
|
608
|
+
const fkFilterMeta = new Map(fkFilterEntries);
|
|
609
|
+
content = render(List, {
|
|
610
|
+
props: {
|
|
611
|
+
model: viewModel(model),
|
|
612
|
+
items,
|
|
613
|
+
pagination: { page, perPage: PER_PAGE, total },
|
|
614
|
+
basePath,
|
|
615
|
+
config,
|
|
616
|
+
query: listQuery,
|
|
617
|
+
currentUrl: event.url,
|
|
618
|
+
listFilters,
|
|
619
|
+
fkFilterMeta
|
|
620
|
+
}
|
|
621
|
+
}).body;
|
|
862
622
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
623
|
+
else if (route.view === 'create') {
|
|
624
|
+
const relationOptions = await loadRelationOptions(model, { locals: event.locals });
|
|
625
|
+
// Pré-remplissage FK depuis la query string (`?authorId=3`), posé
|
|
626
|
+
// par le lien "Ajouter" du bloc de liaisons inverses.
|
|
627
|
+
const prefill = {};
|
|
628
|
+
for (const edge of relationGraph.edges.values()) {
|
|
629
|
+
if (edge.model !== model.name || edge.kind !== 'to-one-owning')
|
|
630
|
+
continue;
|
|
631
|
+
const scalarName = edge.scalarFields[0];
|
|
632
|
+
const value = event.url.searchParams.get(scalarName);
|
|
633
|
+
if (value !== null)
|
|
634
|
+
prefill[scalarName] = value;
|
|
635
|
+
}
|
|
636
|
+
const itemPrefill = Object.keys(prefill).length > 0 ? prefill : undefined;
|
|
637
|
+
content = render(Form, {
|
|
638
|
+
props: {
|
|
639
|
+
mode: 'create',
|
|
640
|
+
model: { ...viewModel(model), relationOptions },
|
|
641
|
+
basePath,
|
|
642
|
+
config,
|
|
643
|
+
item: itemPrefill
|
|
644
|
+
}
|
|
645
|
+
}).body;
|
|
869
646
|
}
|
|
870
647
|
else {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
648
|
+
// `route.id!` s'appuie sur un invariant de `parseRoute` : les seules vues
|
|
649
|
+
// qui portent un `model` sont 'list', 'create' et 'edit', et seule 'edit'
|
|
650
|
+
// atteint ce `else` — or 'edit' est la branche à 2 segments, donc `id` y est
|
|
651
|
+
// toujours défini. La variante 'notFound' ne porte pas de `model` : elle est
|
|
652
|
+
// interceptée en amont et ne peut pas arriver ici.
|
|
653
|
+
const item = await getRecord(prisma, model, route.id);
|
|
654
|
+
const relationOptions = await loadRelationOptions(model, { locals: event.locals }, route.id);
|
|
655
|
+
const relatedCounts = item ? await loadRelatedCounts(model, route.id) : undefined;
|
|
656
|
+
content = item
|
|
657
|
+
? render(Form, {
|
|
658
|
+
props: {
|
|
659
|
+
mode: 'edit',
|
|
660
|
+
model: { ...viewModel(model), relationOptions, relatedCounts },
|
|
661
|
+
basePath,
|
|
662
|
+
config,
|
|
663
|
+
item
|
|
664
|
+
}
|
|
665
|
+
}).body
|
|
666
|
+
: render(NotFound, {
|
|
667
|
+
props: { message: `${model.name} with ID "${route.id}" not found`, basePath }
|
|
668
|
+
}).body;
|
|
888
669
|
}
|
|
889
670
|
}
|
|
890
671
|
}
|
|
@@ -892,7 +673,7 @@ export function createAdminHandler(config) {
|
|
|
892
673
|
console.error('[sveltekit-admin] Error:', e);
|
|
893
674
|
content = `<div class="ska-alert ska-alert--error">Error: ${escapeHtml(e.message || 'Unknown error')}</div>`;
|
|
894
675
|
}
|
|
895
|
-
const html =
|
|
676
|
+
const html = render(Layout, { props: { content, config, modelList, currentModel } }).body;
|
|
896
677
|
return new Response(html, {
|
|
897
678
|
headers: {
|
|
898
679
|
'Content-Type': 'text/html; charset=utf-8'
|