sveltekit-admin 0.2.0 → 0.5.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 +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 +590 -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 +89 -0
- package/dist/server/query/listQuery.js +428 -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 +484 -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/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|

|
|
6
6
|

|
|
7
7
|
|
|
8
|
+
See [CHANGELOG.md](./CHANGELOG.md) for release notes and breaking changes.
|
|
9
|
+
|
|
8
10
|
## Features
|
|
9
11
|
|
|
10
12
|
- 🔍 **Auto-introspection** of Prisma schema
|
|
@@ -75,7 +77,6 @@ createAdminHandler({
|
|
|
75
77
|
// Custom branding
|
|
76
78
|
branding: {
|
|
77
79
|
title: 'My Admin',
|
|
78
|
-
logo: '/logo.svg',
|
|
79
80
|
primaryColor: '#6366f1'
|
|
80
81
|
}
|
|
81
82
|
});
|
|
@@ -106,6 +107,31 @@ const adminHandle = createAdminHandler({
|
|
|
106
107
|
export const handle = sequence(authHandle, adminHandle);
|
|
107
108
|
```
|
|
108
109
|
|
|
110
|
+
### Logout
|
|
111
|
+
|
|
112
|
+
Same philosophy as `authCheck`: the library has no session system of its
|
|
113
|
+
own, so it can't clear one for you. Provide the side effect (clear a
|
|
114
|
+
cookie, invalidate a session, call your auth library's sign-out...) and a
|
|
115
|
+
"Log out" button appears in the sidebar automatically — no button is
|
|
116
|
+
rendered at all if `logout` isn't set.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
const adminHandle = createAdminHandler({
|
|
120
|
+
prisma,
|
|
121
|
+
authCheck: (event) => event.locals.session?.user?.role === 'admin',
|
|
122
|
+
logout: (event) => {
|
|
123
|
+
event.cookies.delete('session', { path: '/' });
|
|
124
|
+
},
|
|
125
|
+
logoutRedirectTo: '/login' // default: '/'
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The button submits a `POST {basePath}/_logout` form (never a bare link —
|
|
130
|
+
logging out must never be triggerable by a GET, unlike a crawler or link
|
|
131
|
+
prefetch would allow), and this route is checked *before* `authCheck`, so
|
|
132
|
+
a user whose session already expired can still use it to clean up
|
|
133
|
+
client-side state instead of being stuck behind a 401 with no way back.
|
|
134
|
+
|
|
109
135
|
## How It Works
|
|
110
136
|
|
|
111
137
|
The admin handler intercepts all requests to `/admin/*` and:
|
|
@@ -140,6 +166,49 @@ models: {
|
|
|
140
166
|
}
|
|
141
167
|
```
|
|
142
168
|
|
|
169
|
+
## Multi-tenant / row-level scoping (`listWhere`) — read this before relying on it
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
models: {
|
|
173
|
+
Post: {
|
|
174
|
+
// Applied to the LIST VIEW ONLY: search, sidebar filters (including
|
|
175
|
+
// the FK filter), and pagination counts. Composed with an AND, never
|
|
176
|
+
// a spread, so it can never be overwritten by a user-supplied filter
|
|
177
|
+
// on the same field.
|
|
178
|
+
listWhere: ({ locals }) => ({ tenantId: locals.tenantId })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**`listWhere` does NOT scope anything except the list view.** The detail
|
|
184
|
+
view, the edit form, the delete action, and the dashboard's per-model
|
|
185
|
+
counts have no equivalent scoping hook in this version and remain fully
|
|
186
|
+
open regardless of this config. Concretely: with only `listWhere` set, a
|
|
187
|
+
user who obtains another tenant's row ID through any other channel (a
|
|
188
|
+
referrer header, a log line, or simple enumeration on a model with an
|
|
189
|
+
`Int` primary key) can still view, edit, and delete that row directly —
|
|
190
|
+
`listWhere` only stops them from *discovering* the ID through the list or
|
|
191
|
+
the FK filter in the first place.
|
|
192
|
+
|
|
193
|
+
If you configure a `relations[field].where` scope for an FK filter (to
|
|
194
|
+
resolve the target's display options/label, see the FK filter docs), it
|
|
195
|
+
is a **separate function** from `listWhere` and is **not** kept in sync
|
|
196
|
+
automatically — you must set both if you want the FK filter's dropdown
|
|
197
|
+
*and* its active-value chip *and* the list rows to all be scoped
|
|
198
|
+
consistently for the same relation.
|
|
199
|
+
|
|
200
|
+
A `listWhere` function that returns `{}` throws instead of silently
|
|
201
|
+
disabling the scope — this is deliberate: an empty object composed into
|
|
202
|
+
an `AND` clause matches every row, which would fail *open* exactly when a
|
|
203
|
+
caller (e.g. one built from a session value that unexpectedly turned out
|
|
204
|
+
to be undefined) most needs protection. Make sure your scope function
|
|
205
|
+
either returns a real condition or isn't called at all for a given
|
|
206
|
+
request.
|
|
207
|
+
|
|
208
|
+
Real per-record scoping (detail/edit/delete) is a known gap, not an
|
|
209
|
+
oversight — track it separately if your application needs it; do not
|
|
210
|
+
assume `listWhere` covers it.
|
|
211
|
+
|
|
143
212
|
## Prisma Schema Introspection
|
|
144
213
|
|
|
145
214
|
The admin automatically parses your Prisma schema and:
|
|
@@ -148,7 +217,16 @@ The admin automatically parses your Prisma schema and:
|
|
|
148
217
|
- Detects field types and generates appropriate form inputs
|
|
149
218
|
- Handles relations (excluded from forms for now)
|
|
150
219
|
- Respects field attributes (@id, @unique, @default, @updatedAt)
|
|
151
|
-
- Auto-hides common sensitive fields
|
|
220
|
+
- Auto-hides common sensitive fields from the list view: any field whose name
|
|
221
|
+
contains `password`, `hash`, `secret` or `token` (case-insensitive), so
|
|
222
|
+
`hashedPassword`, `passwordHash` and `refreshToken` are all covered. Two things
|
|
223
|
+
to know about it:
|
|
224
|
+
- The match is on substrings, so it also catches ordinary names such as
|
|
225
|
+
`hashtag`, `tokenCount` or `secretariat`. Listing a field in
|
|
226
|
+
`models[].listFields` shows it regardless — that is the explicit override,
|
|
227
|
+
and the way to display a column the heuristic gets wrong.
|
|
228
|
+
- It only applies to the list view. Edit forms still render these fields, so
|
|
229
|
+
you can set a value; use `models[].hidden` to remove one everywhere.
|
|
152
230
|
|
|
153
231
|
## Supported Field Types
|
|
154
232
|
|
package/dist/index.d.ts
CHANGED
|
@@ -3,10 +3,5 @@
|
|
|
3
3
|
* Django-like admin panel for SvelteKit + Prisma
|
|
4
4
|
*/
|
|
5
5
|
export { createAdminHandler, type AdminHandlerConfig } from './server/handler.js';
|
|
6
|
-
export {
|
|
7
|
-
export { parsePrismaSchema, parseSchemaContent,
|
|
8
|
-
export { createListOperation, createGetOperation, createCreateOperation, createUpdateOperation, createDeleteOperation, buildSearchWhere, buildFilterWhere, type ListOptions, type ListResult } from './server/crud/operations.js';
|
|
9
|
-
export { createAuthGuard, defaultAdminCheck, type AdminAuthConfig, type AdminSession } from './server/auth/guard.js';
|
|
10
|
-
export { default as AdminLayout } from './components/AdminLayout.svelte';
|
|
11
|
-
export { default as DataTable } from './components/DataTable.svelte';
|
|
12
|
-
export { default as AdminForm } from './components/AdminForm.svelte';
|
|
6
|
+
export { defaultAdminCheck } from './server/auth.js';
|
|
7
|
+
export { parsePrismaSchema, parseSchemaContent, type PrismaSchema, type PrismaModel, type PrismaField } from './server/introspection/parser.js';
|
package/dist/index.js
CHANGED
|
@@ -2,17 +2,6 @@
|
|
|
2
2
|
* SvelteKit Admin
|
|
3
3
|
* Django-like admin panel for SvelteKit + Prisma
|
|
4
4
|
*/
|
|
5
|
-
// Standalone handler (recommended - zero config!)
|
|
6
5
|
export { createAdminHandler } from './server/handler.js';
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
// Prisma introspection utilities
|
|
10
|
-
export { parsePrismaSchema, parseSchemaContent, getDisplayFields, getEditableFields, getInputType, fieldToLabel } from './server/introspection/parser.js';
|
|
11
|
-
// CRUD operations
|
|
12
|
-
export { createListOperation, createGetOperation, createCreateOperation, createUpdateOperation, createDeleteOperation, buildSearchWhere, buildFilterWhere } from './server/crud/operations.js';
|
|
13
|
-
// Auth utilities
|
|
14
|
-
export { createAuthGuard, defaultAdminCheck } from './server/auth/guard.js';
|
|
15
|
-
// Re-export components
|
|
16
|
-
export { default as AdminLayout } from './components/AdminLayout.svelte';
|
|
17
|
-
export { default as DataTable } from './components/DataTable.svelte';
|
|
18
|
-
export { default as AdminForm } from './components/AdminForm.svelte';
|
|
6
|
+
export { defaultAdminCheck } from './server/auth.js';
|
|
7
|
+
export { parsePrismaSchema, parseSchemaContent } from './server/introspection/parser.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-based access check for the admin panel
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Default admin check - looks for role field
|
|
6
|
+
*/
|
|
7
|
+
export function defaultAdminCheck(user, adminRole = 'admin') {
|
|
8
|
+
if (!user || typeof user !== 'object')
|
|
9
|
+
return false;
|
|
10
|
+
const u = user;
|
|
11
|
+
// Check common role field names
|
|
12
|
+
if (u.role === adminRole)
|
|
13
|
+
return true;
|
|
14
|
+
if (u.isAdmin === true)
|
|
15
|
+
return true;
|
|
16
|
+
if (Array.isArray(u.roles) && u.roles.includes(adminRole))
|
|
17
|
+
return true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data access layer - derives Prisma model keys, coerces ids and form payloads,
|
|
3
|
+
* and wraps the CRUD calls the handler orchestrates.
|
|
4
|
+
*/
|
|
5
|
+
import type { PrismaModel } from './introspection/parser.js';
|
|
6
|
+
export declare function toPrismaModel(name: string): string;
|
|
7
|
+
export declare function primaryKeyOf(model: PrismaModel): string;
|
|
8
|
+
/**
|
|
9
|
+
* Coerce l'id issu de l'URL vers le type de la clé primaire du modèle.
|
|
10
|
+
*
|
|
11
|
+
* L'ancienne implémentation appliquait `/^\d+$/.test(id) ? parseInt(id) : id`
|
|
12
|
+
* sans consulter le schéma : une PK String dont la valeur est entièrement
|
|
13
|
+
* numérique partait donc en nombre, et le vrai client Prisma rejetait la requête
|
|
14
|
+
* (`Argument \`id\`: Invalid value provided. Expected String, provided Int.`).
|
|
15
|
+
* Le mock, lui, acceptait n'importe quel type — d'où le test de régression
|
|
16
|
+
* côté intégration.
|
|
17
|
+
*/
|
|
18
|
+
export declare function coerceId(id: string, model: PrismaModel): string | number;
|
|
19
|
+
/** Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés. */
|
|
20
|
+
export declare function formDataToPrisma(formData: FormData, model: PrismaModel): Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Traduit le paramètre `?page=` en fenêtre Prisma. Toute entrée qui n'est pas un
|
|
23
|
+
* entier sûr >= 1 retombe sur la première page : sans ce garde-fou, `?page=abc`
|
|
24
|
+
* envoyait `skip: NaN` et `?page=0` un `skip` négatif directement au client.
|
|
25
|
+
*/
|
|
26
|
+
export declare function paginate(pageParam: string | null, perPage: number): {
|
|
27
|
+
page: number;
|
|
28
|
+
skip: number;
|
|
29
|
+
take: number;
|
|
30
|
+
};
|
|
31
|
+
export declare function listRecords(prisma: any, model: PrismaModel, page: number, perPage: number, where?: Record<string, unknown>): Promise<{
|
|
32
|
+
items: any[];
|
|
33
|
+
total: number;
|
|
34
|
+
}>;
|
|
35
|
+
export declare function getRecord(prisma: any, model: PrismaModel, id: string): Promise<any | null>;
|
|
36
|
+
export declare function createRecord(prisma: any, model: PrismaModel, data: Record<string, unknown>): Promise<void>;
|
|
37
|
+
export declare function updateRecord(prisma: any, model: PrismaModel, id: string, data: Record<string, unknown>): Promise<void>;
|
|
38
|
+
export declare function deleteRecord(prisma: any, model: PrismaModel, id: string): Promise<void>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data access layer - derives Prisma model keys, coerces ids and form payloads,
|
|
3
|
+
* and wraps the CRUD calls the handler orchestrates.
|
|
4
|
+
*/
|
|
5
|
+
export function toPrismaModel(name) {
|
|
6
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
7
|
+
}
|
|
8
|
+
export function primaryKeyOf(model) {
|
|
9
|
+
return model.fields.find((f) => f.isId)?.name || 'id';
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Coerce l'id issu de l'URL vers le type de la clé primaire du modèle.
|
|
13
|
+
*
|
|
14
|
+
* L'ancienne implémentation appliquait `/^\d+$/.test(id) ? parseInt(id) : id`
|
|
15
|
+
* sans consulter le schéma : une PK String dont la valeur est entièrement
|
|
16
|
+
* numérique partait donc en nombre, et le vrai client Prisma rejetait la requête
|
|
17
|
+
* (`Argument \`id\`: Invalid value provided. Expected String, provided Int.`).
|
|
18
|
+
* Le mock, lui, acceptait n'importe quel type — d'où le test de régression
|
|
19
|
+
* côté intégration.
|
|
20
|
+
*/
|
|
21
|
+
export function coerceId(id, model) {
|
|
22
|
+
const pkField = model.fields.find((f) => f.name === primaryKeyOf(model));
|
|
23
|
+
return pkField?.type === 'Int' ? parseInt(id) : id;
|
|
24
|
+
}
|
|
25
|
+
/** Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés. */
|
|
26
|
+
export function formDataToPrisma(formData, model) {
|
|
27
|
+
const data = {};
|
|
28
|
+
for (const field of model.fields) {
|
|
29
|
+
if (field.isId || field.isUpdatedAt || field.isCreatedAt || field.relation)
|
|
30
|
+
continue;
|
|
31
|
+
const value = formData.get(field.name);
|
|
32
|
+
if (value === null) {
|
|
33
|
+
if (field.type === 'Boolean')
|
|
34
|
+
data[field.name] = false;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
switch (field.type) {
|
|
38
|
+
case 'Int':
|
|
39
|
+
case 'BigInt':
|
|
40
|
+
data[field.name] = value ? parseInt(value.toString()) : null;
|
|
41
|
+
break;
|
|
42
|
+
case 'Float':
|
|
43
|
+
case 'Decimal':
|
|
44
|
+
data[field.name] = value ? parseFloat(value.toString()) : null;
|
|
45
|
+
break;
|
|
46
|
+
case 'Boolean':
|
|
47
|
+
data[field.name] = value === 'on' || value === 'true' || value === '1';
|
|
48
|
+
break;
|
|
49
|
+
case 'DateTime':
|
|
50
|
+
data[field.name] = value ? new Date(value.toString()) : null;
|
|
51
|
+
break;
|
|
52
|
+
case 'Json':
|
|
53
|
+
try {
|
|
54
|
+
data[field.name] = value ? JSON.parse(value.toString()) : null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
data[field.name] = null;
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
data[field.name] = value.toString();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Traduit le paramètre `?page=` en fenêtre Prisma. Toute entrée qui n'est pas un
|
|
68
|
+
* entier sûr >= 1 retombe sur la première page : sans ce garde-fou, `?page=abc`
|
|
69
|
+
* envoyait `skip: NaN` et `?page=0` un `skip` négatif directement au client.
|
|
70
|
+
*/
|
|
71
|
+
export function paginate(pageParam, perPage) {
|
|
72
|
+
const parsed = parseInt(pageParam || '1');
|
|
73
|
+
const page = Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1;
|
|
74
|
+
return { page, skip: (page - 1) * perPage, take: perPage };
|
|
75
|
+
}
|
|
76
|
+
export async function listRecords(prisma, model, page, perPage, where) {
|
|
77
|
+
const key = toPrismaModel(model.name);
|
|
78
|
+
const primaryKey = primaryKeyOf(model);
|
|
79
|
+
// Le calcul `(page - 1) * perPage` est dupliqué avec `paginate` DÉLIBÉRÉMENT.
|
|
80
|
+
// Router `listRecords` vers `paginate(String(page), perPage)` serait lossy : la
|
|
81
|
+
// conversion aller-retour passe par `parseInt`, et `String(1e24)` donne
|
|
82
|
+
// `'1e+24'`, que `parseInt` ramène à `1`. `listRecords` reçoit un `number` déjà
|
|
83
|
+
// validé par l'appelant ; ne pas « simplifier » en réintroduisant un parse.
|
|
84
|
+
const [items, total] = await Promise.all([
|
|
85
|
+
prisma[key].findMany({
|
|
86
|
+
where,
|
|
87
|
+
skip: (page - 1) * perPage,
|
|
88
|
+
take: perPage,
|
|
89
|
+
orderBy: { [primaryKey]: 'desc' }
|
|
90
|
+
}),
|
|
91
|
+
prisma[key].count({ where })
|
|
92
|
+
]);
|
|
93
|
+
return { items, total };
|
|
94
|
+
}
|
|
95
|
+
export function getRecord(prisma, model, id) {
|
|
96
|
+
const primaryKey = primaryKeyOf(model);
|
|
97
|
+
return prisma[toPrismaModel(model.name)].findUnique({
|
|
98
|
+
where: { [primaryKey]: coerceId(id, model) }
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export async function createRecord(prisma, model, data) {
|
|
102
|
+
await prisma[toPrismaModel(model.name)].create({ data });
|
|
103
|
+
}
|
|
104
|
+
export async function updateRecord(prisma, model, id, data) {
|
|
105
|
+
const primaryKey = primaryKeyOf(model);
|
|
106
|
+
await prisma[toPrismaModel(model.name)].update({
|
|
107
|
+
where: { [primaryKey]: coerceId(id, model) },
|
|
108
|
+
data
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export async function deleteRecord(prisma, model, id) {
|
|
112
|
+
const primaryKey = primaryKeyOf(model);
|
|
113
|
+
await prisma[toPrismaModel(model.name)].delete({
|
|
114
|
+
where: { [primaryKey]: coerceId(id, model) }
|
|
115
|
+
});
|
|
116
|
+
}
|
package/dist/server/handler.d.ts
CHANGED
|
@@ -11,24 +11,143 @@ export interface AdminHandlerConfig {
|
|
|
11
11
|
basePath?: string;
|
|
12
12
|
/** Authentication check - return true if user can access admin */
|
|
13
13
|
authCheck?: (event: any) => boolean | Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Logout logic — same "bring your own auth" philosophy as `authCheck`:
|
|
16
|
+
* this library has no session system of its own, so it can't know how
|
|
17
|
+
* to clear yours (a cookie, a Lucia/Better-Auth/Auth.js call, whatever).
|
|
18
|
+
* You provide the side effect (clear the cookie, invalidate the
|
|
19
|
+
* session...); the handler wires it to a POST-only route and a sidebar
|
|
20
|
+
* button. No button is rendered at all if this isn't set — an admin
|
|
21
|
+
* with no `logout` configured looks exactly as it did before this
|
|
22
|
+
* option existed.
|
|
23
|
+
*
|
|
24
|
+
* POST-only, never a bare link: logging out must never be triggerable
|
|
25
|
+
* by a GET (a crawler, a link prefetch, `<img src>`), which a plain
|
|
26
|
+
* `<a href="/admin/_logout">` would allow. Runs BEFORE `authCheck` — a
|
|
27
|
+
* user whose session already expired (so `authCheck` would now reject
|
|
28
|
+
* them) must still be able to hit the logout route to clean up
|
|
29
|
+
* client-side state (e.g. clear a stale cookie) without being stuck
|
|
30
|
+
* behind a 401 first.
|
|
31
|
+
*/
|
|
32
|
+
logout?: (event: any) => void | Promise<void>;
|
|
33
|
+
/** Where to redirect after logout (default: '/') */
|
|
34
|
+
logoutRedirectTo?: string;
|
|
14
35
|
/** Per-model configuration */
|
|
15
36
|
models?: Record<string, {
|
|
16
37
|
hidden?: string[];
|
|
17
38
|
readonly?: string[];
|
|
18
39
|
listFields?: string[];
|
|
19
40
|
label?: string;
|
|
20
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Scoping `where` applied to the LIST VIEW ONLY of this model
|
|
43
|
+
* (search, sidebar filters, FK filter, pagination count) — composed
|
|
44
|
+
* via `AND` with active filters, never a spread (docs/design
|
|
45
|
+
* §0.c/§5.2). Deliberately named `listWhere`, not `where`: a bare
|
|
46
|
+
* `where` invites a developer to believe it scopes every operation
|
|
47
|
+
* on the model (detail view, edit, delete, dashboard counts), which
|
|
48
|
+
* it does NOT — those have no equivalent scoping in this version and
|
|
49
|
+
* remain fully open regardless of this config (a real risk found in
|
|
50
|
+
* review: a multi-tenant app that relies on `listWhere` alone gets a
|
|
51
|
+
* false sense of safety while `getRecord`/`updateRecord`/
|
|
52
|
+
* `deleteRecord` stay unscoped for anyone who obtains a row's ID
|
|
53
|
+
* through another channel — a referrer, a log line, or straight
|
|
54
|
+
* enumeration on a model with an Int primary key).
|
|
55
|
+
*
|
|
56
|
+
* Without this scope, the FK filter this feature adds makes
|
|
57
|
+
* cross-tenant row discovery through the list trivial: `?f.authorId=
|
|
58
|
+
* 1..N` used to return another tenant's row (its label stayed
|
|
59
|
+
* protected via `relations[x].where`, §6.3.b, but the row itself did
|
|
60
|
+
* not). This config closes that specific hole for the list — nothing
|
|
61
|
+
* more. If you need every view scoped, you currently have to wire
|
|
62
|
+
* `relations[x].where` for the active-FK-label lookup separately
|
|
63
|
+
* (they are NOT the same function and are NOT automatically kept in
|
|
64
|
+
* sync), and there is no scoping hook at all yet for
|
|
65
|
+
* detail/edit/delete/dashboard — track that as a real gap, not an
|
|
66
|
+
* oversight to work around silently.
|
|
67
|
+
*
|
|
68
|
+
* A scope function that returns `{}` (e.g. because `locals.userId`
|
|
69
|
+
* was undefined after a session expired) is NOT treated as "no
|
|
70
|
+
* scope" — `{}` would silently fail open (an intersection with an
|
|
71
|
+
* empty clause matches everything) exactly when a caller most needs
|
|
72
|
+
* protection. It throws instead: fail loud on a misbehaving scope
|
|
73
|
+
* function, never fail open on a data leak.
|
|
74
|
+
*/
|
|
75
|
+
listWhere?: (ctx: {
|
|
76
|
+
locals?: any;
|
|
77
|
+
}) => Record<string, unknown>;
|
|
78
|
+
relations?: Record<string, {
|
|
79
|
+
widget?: 'select' | 'raw-id' | 'hidden';
|
|
80
|
+
labelTemplate?: string;
|
|
81
|
+
orderBy?: Record<string, 'asc' | 'desc'>;
|
|
82
|
+
where?: (ctx: {
|
|
83
|
+
locals?: any;
|
|
84
|
+
}) => Record<string, unknown>;
|
|
85
|
+
nullLabel?: string;
|
|
86
|
+
}>;
|
|
87
|
+
/**
|
|
88
|
+
* Champs interrogés par la barre de recherche texte libre. Sans
|
|
89
|
+
* config, une heuristique conservatrice reprend `relationDefaults.labelFields`
|
|
90
|
+
* parmi les champs String non sensibles (voir docs/design/list-search-filters.md §2.1).
|
|
91
|
+
* Une config explicite gagne toujours et n'est jamais tronquée par
|
|
92
|
+
* l'heuristique — un champ non filtrable au sens de `isFilterableFieldType`
|
|
93
|
+
* (relation, liste, Json, Bytes) ou sensible (password/hash/secret/token)
|
|
94
|
+
* y est silencieusement ignoré.
|
|
95
|
+
*/
|
|
96
|
+
searchFields?: string[];
|
|
97
|
+
/**
|
|
98
|
+
* Champs filtrables via la sidebar de la liste. Config explicite
|
|
99
|
+
* (forme courte `'published'` ou objet `{ field, label }`) — sinon
|
|
100
|
+
* une heuristique auto-détecte les champs Boolean et enum uniquement
|
|
101
|
+
* (domaine de valeurs connu statiquement, zéro requête pour rendre la
|
|
102
|
+
* sidebar ; voir docs/design/list-search-filters.md §3.5). Une config
|
|
103
|
+
* invalide (champ inexistant, sensible, relation, type non supporté)
|
|
104
|
+
* lève une erreur au démarrage — c'est une erreur de développeur, elle
|
|
105
|
+
* doit échouer fort plutôt que produire un filtre silencieusement mort.
|
|
106
|
+
*/
|
|
107
|
+
listFilter?: import('./query/filterDetection.js').ListFilterConfigEntry[];
|
|
21
108
|
}>;
|
|
22
109
|
/** Models to exclude from admin */
|
|
23
110
|
exclude?: string[];
|
|
111
|
+
/** Hide pivot/junction tables automatically (default: true) */
|
|
112
|
+
hidePivotTables?: boolean;
|
|
113
|
+
/** Relation defaults */
|
|
114
|
+
relationDefaults?: {
|
|
115
|
+
/** Au-delà de ce nombre d'options, une FK est rendue en raw-id (default: 200) */
|
|
116
|
+
selectThreshold?: number;
|
|
117
|
+
/** Champs candidats pour le label, dans l'ordre de préférence */
|
|
118
|
+
labelFields?: string[];
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Défauts pour la sidebar de filtres (listFilter).
|
|
122
|
+
* `linkThreshold`: en dessous ou égal à ce nombre d'options, un filtre FK
|
|
123
|
+
* est rendu en liens dans la sidebar ; au-dessus (et ≤ relationDefaults.selectThreshold),
|
|
124
|
+
* en `<select>` dans un mini form GET (docs/design/list-search-filters.md §3.2).
|
|
125
|
+
* `autoDetect`: auto-détection des champs Boolean/enum quand pas de config
|
|
126
|
+
* `listFilter` explicite (default: true).
|
|
127
|
+
*/
|
|
128
|
+
listFilterDefaults?: {
|
|
129
|
+
linkThreshold?: number;
|
|
130
|
+
autoDetect?: boolean;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Recherche texte libre : configuration globale.
|
|
134
|
+
* `mode`: 'auto' détecte le provider du schéma et n'émet `mode: 'insensitive'`
|
|
135
|
+
* que sur postgresql/cockroachdb/mongodb (les seuls où Prisma le supporte —
|
|
136
|
+
* l'émettre sur sqlite/mysql/sqlserver lève une erreur Prisma). 'insensitive'
|
|
137
|
+
* et 'default' forcent le comportement, pour un provider non détectable
|
|
138
|
+
* (`provider = env(...)`) ou un besoin spécifique (index `citext`, etc.).
|
|
139
|
+
* Voir docs/design/list-search-filters.md §2.5.
|
|
140
|
+
*/
|
|
141
|
+
search?: {
|
|
142
|
+
mode?: 'auto' | 'insensitive' | 'default';
|
|
143
|
+
};
|
|
24
144
|
/** Custom branding */
|
|
25
145
|
branding?: {
|
|
26
146
|
title?: string;
|
|
27
|
-
logo?: string;
|
|
28
147
|
primaryColor?: string;
|
|
29
148
|
};
|
|
30
149
|
}
|
|
31
150
|
export declare function createAdminHandler(config: AdminHandlerConfig): ({ event, resolve }: {
|
|
32
151
|
event: any;
|
|
33
|
-
resolve:
|
|
34
|
-
}) => Promise<
|
|
152
|
+
resolve: (event: any) => Response | Promise<Response>;
|
|
153
|
+
}) => Promise<Response>;
|