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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query string parsing and Prisma `where` construction for list search
|
|
3
|
+
* and filters.
|
|
4
|
+
*
|
|
5
|
+
* Design reference: docs/design/list-search-filters.md.
|
|
6
|
+
*
|
|
7
|
+
* Golden rule (§4.3 of the design doc): the query string SELECTS from a
|
|
8
|
+
* finite whitelist of operators derived from the field's type. It never
|
|
9
|
+
* describes a Prisma clause. No operator ever flows from the URL into the
|
|
10
|
+
* `where` object as a key — the operator string from the URL is only ever
|
|
11
|
+
* used to look up a fixed table; the table's value (never the URL's raw
|
|
12
|
+
* string) becomes the Prisma operator key.
|
|
13
|
+
*/
|
|
14
|
+
import type { PrismaModel } from '../introspection/parser.js';
|
|
15
|
+
export type FilterOp = 'equals' | 'contains' | 'startsWith' | 'gte' | 'lte' | 'isnull';
|
|
16
|
+
export interface ActiveFilter {
|
|
17
|
+
field: string;
|
|
18
|
+
op: FilterOp;
|
|
19
|
+
/** Already coerced to the JS type Prisma expects for this field/op. */
|
|
20
|
+
value: unknown;
|
|
21
|
+
/** Original string from the query string, kept to re-render the UI. */
|
|
22
|
+
raw: string;
|
|
23
|
+
}
|
|
24
|
+
export interface IgnoredFilter {
|
|
25
|
+
/** Raw query param key, e.g. "f.passwordHash" or "f.nope__gte". */
|
|
26
|
+
param: string;
|
|
27
|
+
reason: 'unknown-field' | 'not-filterable' | 'bad-operator' | 'bad-value';
|
|
28
|
+
}
|
|
29
|
+
export interface ListQuery {
|
|
30
|
+
q: string | null;
|
|
31
|
+
searchFields: string[];
|
|
32
|
+
filters: ActiveFilter[];
|
|
33
|
+
ignored: IgnoredFilter[];
|
|
34
|
+
}
|
|
35
|
+
/** Field-name candidates used both for relation labels and for the default search heuristic (§2.1). */
|
|
36
|
+
export declare const DEFAULT_LABEL_FIELDS: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Fields eligible for the free-text search box.
|
|
39
|
+
*
|
|
40
|
+
* Explicit `searchFields` config always wins. Otherwise: String fields,
|
|
41
|
+
* not sensitive, not relation/list/id, whose name is in `labelFields`
|
|
42
|
+
* (same list used for relation labels — one heuristic, not two that could
|
|
43
|
+
* drift apart). Empty result means "no search box rendered", never a
|
|
44
|
+
* fallback that scans every String column.
|
|
45
|
+
*
|
|
46
|
+
* `hidden` excludes a field in EVERY case, config included (§3.5): a
|
|
47
|
+
* field hidden from the list/form display must not remain a value-
|
|
48
|
+
* confirmation oracle via `?q=`/`contains` just because a developer
|
|
49
|
+
* explicitly listed it in `searchFields` — that's the exact §0.a leak
|
|
50
|
+
* closed for sensitive-by-name fields, `hidden` is a second independent
|
|
51
|
+
* source that must close the same way (§10).
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveSearchFields(model: PrismaModel, configured: string[] | undefined, labelFields?: string[], hidden?: Set<string>): string[];
|
|
54
|
+
export interface DateRange {
|
|
55
|
+
gte: Date;
|
|
56
|
+
lt: Date;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* DateTime shortcuts, à la Django. Upper bound is always EXCLUSIVE (`lt`),
|
|
60
|
+
* never `lte`: `lte 23:59:59.000` misses the last second's milliseconds,
|
|
61
|
+
* a classic bug invisible in tests unless caught explicitly (§5.5).
|
|
62
|
+
*
|
|
63
|
+
* `now` is injectable so tests are deterministic (not "will break at
|
|
64
|
+
* midnight UTC in CI").
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveDateShortcut(raw: string, now?: () => Date): DateRange | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Parse `?q=` and `?f.*=` into a `ListQuery`. Pure function: no I/O, no
|
|
69
|
+
* Prisma. `filterableFields` is the set of field names the caller allows
|
|
70
|
+
* to be filtered (already validated against config/heuristics + the
|
|
71
|
+
* shared sensitive-name predicate) — this function does not decide
|
|
72
|
+
* *which* fields are filterable, only how to parse a value once a field
|
|
73
|
+
* is known to be eligible.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseListQuery(searchParams: URLSearchParams, model: PrismaModel, enums: Map<string, string[]>, searchFields: string[], filterableFields: Set<string>, now?: () => Date): ListQuery;
|
|
76
|
+
/** A Prisma `where` clause built from a `ListQuery`. Opaque to callers — pass straight to Prisma. */
|
|
77
|
+
export type PrismaWhere = Record<string, unknown>;
|
|
78
|
+
/**
|
|
79
|
+
* Compose the final Prisma `where`: `AND: [scope, ...filters, {OR: search}]`.
|
|
80
|
+
* NEVER a spread — a spread of `{...scope, ...filterWhere}` lets a filter
|
|
81
|
+
* on the same field as the developer's scoping silently overwrite it
|
|
82
|
+
* (docs/design §0.c, the exact IDOR the previous `?filter=` had). Two
|
|
83
|
+
* clauses on the same field inside `AND` intersect; they never merge.
|
|
84
|
+
*
|
|
85
|
+
* Returns `undefined` when nothing is active, so the query shape sent to
|
|
86
|
+
* Prisma is byte-for-byte identical to today's unfiltered call — no
|
|
87
|
+
* regression on existing snapshots/assertions.
|
|
88
|
+
*/
|
|
89
|
+
export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): PrismaWhere | undefined;
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query string parsing and Prisma `where` construction for list search
|
|
3
|
+
* and filters.
|
|
4
|
+
*
|
|
5
|
+
* Design reference: docs/design/list-search-filters.md.
|
|
6
|
+
*
|
|
7
|
+
* Golden rule (§4.3 of the design doc): the query string SELECTS from a
|
|
8
|
+
* finite whitelist of operators derived from the field's type. It never
|
|
9
|
+
* describes a Prisma clause. No operator ever flows from the URL into the
|
|
10
|
+
* `where` object as a key — the operator string from the URL is only ever
|
|
11
|
+
* used to look up a fixed table; the table's value (never the URL's raw
|
|
12
|
+
* string) becomes the Prisma operator key.
|
|
13
|
+
*/
|
|
14
|
+
import { isSensitiveFieldName } from '../introspection/parser.js';
|
|
15
|
+
/** Max length accepted for the free-text search term. Longer input is truncated. */
|
|
16
|
+
const MAX_SEARCH_LENGTH = 200;
|
|
17
|
+
/** Field-name candidates used both for relation labels and for the default search heuristic (§2.1). */
|
|
18
|
+
export const DEFAULT_LABEL_FIELDS = ['name', 'title', 'label', 'email', 'username', 'slug'];
|
|
19
|
+
/** Types eligible for the free-text search heuristic (String only, see §2.1). */
|
|
20
|
+
function isSearchableByHeuristic(field) {
|
|
21
|
+
return (field.type === 'String' &&
|
|
22
|
+
!field.isList &&
|
|
23
|
+
!field.relation &&
|
|
24
|
+
!isSensitiveFieldName(field.name) &&
|
|
25
|
+
!field.isId);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Fields eligible for the free-text search box.
|
|
29
|
+
*
|
|
30
|
+
* Explicit `searchFields` config always wins. Otherwise: String fields,
|
|
31
|
+
* not sensitive, not relation/list/id, whose name is in `labelFields`
|
|
32
|
+
* (same list used for relation labels — one heuristic, not two that could
|
|
33
|
+
* drift apart). Empty result means "no search box rendered", never a
|
|
34
|
+
* fallback that scans every String column.
|
|
35
|
+
*
|
|
36
|
+
* `hidden` excludes a field in EVERY case, config included (§3.5): a
|
|
37
|
+
* field hidden from the list/form display must not remain a value-
|
|
38
|
+
* confirmation oracle via `?q=`/`contains` just because a developer
|
|
39
|
+
* explicitly listed it in `searchFields` — that's the exact §0.a leak
|
|
40
|
+
* closed for sensitive-by-name fields, `hidden` is a second independent
|
|
41
|
+
* source that must close the same way (§10).
|
|
42
|
+
*/
|
|
43
|
+
export function resolveSearchFields(model, configured, labelFields = DEFAULT_LABEL_FIELDS, hidden = new Set()) {
|
|
44
|
+
if (configured) {
|
|
45
|
+
return configured.filter((name) => {
|
|
46
|
+
const field = model.fields.find((f) => f.name === name);
|
|
47
|
+
return field && isFilterableFieldType(field) && !isSensitiveFieldName(name) && !hidden.has(name);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return model.fields
|
|
51
|
+
.filter((f) => isSearchableByHeuristic(f) && labelFields.includes(f.name) && !hidden.has(f.name))
|
|
52
|
+
.map((f) => f.name);
|
|
53
|
+
}
|
|
54
|
+
/** Whether a field's Prisma type can ever appear in a where clause we build (excludes Json/Bytes/relations/lists). */
|
|
55
|
+
function isFilterableFieldType(field) {
|
|
56
|
+
if (field.relation || field.isList)
|
|
57
|
+
return false;
|
|
58
|
+
return !['Json', 'Bytes'].includes(field.type);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whitelist of operators per Prisma scalar type. The URL provides a
|
|
62
|
+
* *string* op name; this table is the only place that turns it into a
|
|
63
|
+
* real Prisma operator. Anything not listed here for the field's type is
|
|
64
|
+
* rejected (§4.3).
|
|
65
|
+
*/
|
|
66
|
+
function allowedOpsFor(field) {
|
|
67
|
+
if (field.isEnum)
|
|
68
|
+
return ['equals'];
|
|
69
|
+
switch (field.type) {
|
|
70
|
+
case 'String':
|
|
71
|
+
return ['equals', 'contains', 'startsWith'];
|
|
72
|
+
case 'Int':
|
|
73
|
+
case 'BigInt':
|
|
74
|
+
case 'Float':
|
|
75
|
+
case 'Decimal':
|
|
76
|
+
return ['equals', 'gte', 'lte'];
|
|
77
|
+
case 'Boolean':
|
|
78
|
+
return ['equals'];
|
|
79
|
+
case 'DateTime':
|
|
80
|
+
return ['equals', 'gte', 'lte'];
|
|
81
|
+
default:
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Coerce a raw query-string value to the JS type Prisma expects, or return
|
|
87
|
+
* `undefined` if it can't be coerced.
|
|
88
|
+
*
|
|
89
|
+
* DateTime `equals`/`gte`/`lte` and enum membership are handled entirely
|
|
90
|
+
* inside `parseOneFilter` and never reach this function; `allowedOpsFor`
|
|
91
|
+
* already returns `[]` for Json/Bytes/relations/lists, so `parseOneFilter`
|
|
92
|
+
* rejects those before reaching this function too. `isnull` DOES reach
|
|
93
|
+
* here (it's field-type-agnostic), which is why it's handled first.
|
|
94
|
+
*/
|
|
95
|
+
function coerceValue(field, op, raw) {
|
|
96
|
+
if (op === 'isnull') {
|
|
97
|
+
if (raw === '1' || raw === 'true')
|
|
98
|
+
return true;
|
|
99
|
+
if (raw === '0' || raw === 'false')
|
|
100
|
+
return false;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
switch (field.type) {
|
|
104
|
+
case 'String':
|
|
105
|
+
return raw;
|
|
106
|
+
case 'Int':
|
|
107
|
+
case 'BigInt': {
|
|
108
|
+
// Strict integer pattern — NOT parseInt: parseInt("12abc") === 12 and
|
|
109
|
+
// parseInt("") is NaN silently. Both are real bugs the previous
|
|
110
|
+
// `?filter=` implementation had (see docs/design §0.b).
|
|
111
|
+
if (!/^-?\d+$/.test(raw))
|
|
112
|
+
return undefined;
|
|
113
|
+
if (field.type === 'BigInt') {
|
|
114
|
+
// `BigInt(str)` never throws once `raw` has already matched the
|
|
115
|
+
// strict integer regex above — no try/catch needed, and a "just
|
|
116
|
+
// in case" catch here would be unreachable code the coverage
|
|
117
|
+
// threshold would force us to fake-test.
|
|
118
|
+
return BigInt(raw);
|
|
119
|
+
}
|
|
120
|
+
const n = Number(raw);
|
|
121
|
+
return Number.isSafeInteger(n) ? n : undefined;
|
|
122
|
+
}
|
|
123
|
+
case 'Float':
|
|
124
|
+
case 'Decimal': {
|
|
125
|
+
if (!/^-?\d+(\.\d+)?$/.test(raw))
|
|
126
|
+
return undefined;
|
|
127
|
+
// Decimal is passed to Prisma as a string to avoid precision loss on
|
|
128
|
+
// a round-trip through JS `Number`; Float uses the numeric value.
|
|
129
|
+
return field.type === 'Decimal' ? raw : Number(raw);
|
|
130
|
+
}
|
|
131
|
+
case 'Boolean':
|
|
132
|
+
default:
|
|
133
|
+
// Boolean is the only remaining branch reachable in practice; the
|
|
134
|
+
// `default` exists only so TypeScript accepts a non-exhaustive
|
|
135
|
+
// switch over `string`, it carries no distinct behaviour.
|
|
136
|
+
if (raw === 'true' || raw === '1')
|
|
137
|
+
return true;
|
|
138
|
+
if (raw === 'false' || raw === '0')
|
|
139
|
+
return false;
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* DateTime shortcuts, à la Django. Upper bound is always EXCLUSIVE (`lt`),
|
|
145
|
+
* never `lte`: `lte 23:59:59.000` misses the last second's milliseconds,
|
|
146
|
+
* a classic bug invisible in tests unless caught explicitly (§5.5).
|
|
147
|
+
*
|
|
148
|
+
* `now` is injectable so tests are deterministic (not "will break at
|
|
149
|
+
* midnight UTC in CI").
|
|
150
|
+
*/
|
|
151
|
+
export function resolveDateShortcut(raw, now = () => new Date()) {
|
|
152
|
+
const today = () => {
|
|
153
|
+
const d = now();
|
|
154
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
155
|
+
};
|
|
156
|
+
const addDays = (d, n) => new Date(d.getTime() + n * 86_400_000);
|
|
157
|
+
if (raw === 'today') {
|
|
158
|
+
const start = today();
|
|
159
|
+
return { gte: start, lt: addDays(start, 1) };
|
|
160
|
+
}
|
|
161
|
+
if (raw === '7d') {
|
|
162
|
+
const end = addDays(today(), 1);
|
|
163
|
+
return { gte: addDays(today(), -6), lt: end };
|
|
164
|
+
}
|
|
165
|
+
if (raw === 'month') {
|
|
166
|
+
const d = now();
|
|
167
|
+
const start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
|
168
|
+
const end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
|
|
169
|
+
return { gte: start, lt: end };
|
|
170
|
+
}
|
|
171
|
+
if (raw === 'year') {
|
|
172
|
+
const d = now();
|
|
173
|
+
const start = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
174
|
+
const end = new Date(Date.UTC(d.getUTCFullYear() + 1, 0, 1));
|
|
175
|
+
return { gte: start, lt: end };
|
|
176
|
+
}
|
|
177
|
+
// A single ISO date (no time component): treat as a day-long interval,
|
|
178
|
+
// never as `equals` — a DateTime stores a time, so `equals` on a bare
|
|
179
|
+
// date never matches anything (§5.5).
|
|
180
|
+
const dayMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
181
|
+
if (dayMatch) {
|
|
182
|
+
const [, y, m, d] = dayMatch;
|
|
183
|
+
const year = Number(y);
|
|
184
|
+
const month = Number(m);
|
|
185
|
+
const day = Number(d);
|
|
186
|
+
// `Date.UTC` silently rolls over out-of-range components (month 13 ->
|
|
187
|
+
// January next year); reject explicitly instead of trusting it.
|
|
188
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
189
|
+
return undefined;
|
|
190
|
+
const start = new Date(Date.UTC(year, month - 1, day));
|
|
191
|
+
if (Number.isNaN(start.getTime()) ||
|
|
192
|
+
start.getUTCFullYear() !== year ||
|
|
193
|
+
start.getUTCMonth() !== month - 1 ||
|
|
194
|
+
start.getUTCDate() !== day) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
return { gte: start, lt: addDays(start, 1) };
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
/** Parse a `gte`/`lte` DateTime bound: full ISO datetime or a bare date. */
|
|
202
|
+
function parseDateBound(raw) {
|
|
203
|
+
const d = new Date(raw);
|
|
204
|
+
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Split a `f.<field>` or `f.<field>__<op>` param name into its parts.
|
|
208
|
+
* Cuts on the FIRST `__` after the `f.` prefix — Prisma identifiers can't
|
|
209
|
+
* contain `__` at all in practice for this parser's purposes, so this is
|
|
210
|
+
* unambiguous.
|
|
211
|
+
*
|
|
212
|
+
* Always called with a key that already starts with `f.` — both call
|
|
213
|
+
* sites in `parseListQuery` guarantee it (one filters on it explicitly,
|
|
214
|
+
* the other builds the string literally for the legacy `?filter=` path) —
|
|
215
|
+
* so there's no `null`-returning guard here to fake-test.
|
|
216
|
+
*/
|
|
217
|
+
function splitFilterParam(key) {
|
|
218
|
+
const rest = key.slice(2);
|
|
219
|
+
const sep = rest.indexOf('__');
|
|
220
|
+
if (sep === -1)
|
|
221
|
+
return { field: rest, op: null };
|
|
222
|
+
return { field: rest.slice(0, sep), op: rest.slice(sep + 2) };
|
|
223
|
+
}
|
|
224
|
+
function parseOneFilter(param, raw, ctx) {
|
|
225
|
+
// Empty value (e.g. the "All" option of a <select> FK/range filter,
|
|
226
|
+
// submitted via a plain GET form) means "no filter" — the design's
|
|
227
|
+
// nominal, expected case (§5.4: "?f.x= -> ignored. Important: it's
|
|
228
|
+
// what a <select> emits for its 'All' option"). This is checked BEFORE
|
|
229
|
+
// any field lookup and produces NEITHER an ActiveFilter NOR an
|
|
230
|
+
// IgnoredFilter: it must never surface as a rendered "unknown field"
|
|
231
|
+
// warning (that was a real regression found in review — classifying
|
|
232
|
+
// this as `ignored` made the UI show a false error banner on the most
|
|
233
|
+
// common sidebar interaction there is).
|
|
234
|
+
if (raw === '') {
|
|
235
|
+
return { skip: true };
|
|
236
|
+
}
|
|
237
|
+
const { field: fieldName, op: opParam } = splitFilterParam(param);
|
|
238
|
+
const field = ctx.model.fields.find((f) => f.name === fieldName);
|
|
239
|
+
// Sensitive field: rejected here too, not just by the caller's
|
|
240
|
+
// `filterableFields` set — defense in depth. Same predicate as
|
|
241
|
+
// `getDisplayFields`/relation labels (isSensitiveFieldName), and the
|
|
242
|
+
// exact fix for the oracle described in docs/design §0.a: a sensitive
|
|
243
|
+
// field is treated EXACTLY like an unknown one, never a distinct
|
|
244
|
+
// "forbidden" message that would confirm its existence.
|
|
245
|
+
if (!field || !ctx.filterableFields.has(fieldName) || isSensitiveFieldName(fieldName)) {
|
|
246
|
+
return { ignored: { param, reason: 'unknown-field' } };
|
|
247
|
+
}
|
|
248
|
+
const allowed = allowedOpsFor(field);
|
|
249
|
+
if (allowed.length === 0) {
|
|
250
|
+
return { ignored: { param, reason: 'not-filterable' } };
|
|
251
|
+
}
|
|
252
|
+
const wantsIsnull = opParam === 'isnull';
|
|
253
|
+
const op = wantsIsnull ? 'isnull' : (opParam || 'equals');
|
|
254
|
+
if (op === 'isnull') {
|
|
255
|
+
if (field.isRequired)
|
|
256
|
+
return { ignored: { param, reason: 'not-filterable' } };
|
|
257
|
+
}
|
|
258
|
+
else if (!allowed.includes(op)) {
|
|
259
|
+
return { ignored: { param, reason: 'bad-operator' } };
|
|
260
|
+
}
|
|
261
|
+
if (field.type === 'DateTime' && op !== 'isnull') {
|
|
262
|
+
if (op === 'equals') {
|
|
263
|
+
const range = resolveDateShortcut(raw, ctx.now);
|
|
264
|
+
if (!range)
|
|
265
|
+
return { ignored: { param, reason: 'bad-value' } };
|
|
266
|
+
// A day/shortcut becomes two filters merged by the caller into one
|
|
267
|
+
// AND pair; represented here as a single filter carrying both
|
|
268
|
+
// bounds so buildWhere can emit `{gte, lt}` from one ActiveFilter.
|
|
269
|
+
return {
|
|
270
|
+
filter: { field: fieldName, op: 'gte', value: range, raw }
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const bound = parseDateBound(raw);
|
|
274
|
+
if (!bound)
|
|
275
|
+
return { ignored: { param, reason: 'bad-value' } };
|
|
276
|
+
return { filter: { field: fieldName, op, value: bound, raw } };
|
|
277
|
+
}
|
|
278
|
+
if (field.isEnum) {
|
|
279
|
+
const members = ctx.enums.get(field.type) ?? [];
|
|
280
|
+
if (!members.includes(raw))
|
|
281
|
+
return { ignored: { param, reason: 'bad-value' } };
|
|
282
|
+
return { filter: { field: fieldName, op: 'equals', value: raw, raw } };
|
|
283
|
+
}
|
|
284
|
+
const value = coerceValue(field, op, raw);
|
|
285
|
+
if (value === undefined)
|
|
286
|
+
return { ignored: { param, reason: 'bad-value' } };
|
|
287
|
+
return { filter: { field: fieldName, op, value, raw } };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Parse `?q=` and `?f.*=` into a `ListQuery`. Pure function: no I/O, no
|
|
291
|
+
* Prisma. `filterableFields` is the set of field names the caller allows
|
|
292
|
+
* to be filtered (already validated against config/heuristics + the
|
|
293
|
+
* shared sensitive-name predicate) — this function does not decide
|
|
294
|
+
* *which* fields are filterable, only how to parse a value once a field
|
|
295
|
+
* is known to be eligible.
|
|
296
|
+
*/
|
|
297
|
+
export function parseListQuery(searchParams, model, enums, searchFields, filterableFields, now) {
|
|
298
|
+
const rawQ = searchParams.get('q');
|
|
299
|
+
const q = rawQ && rawQ.trim() ? rawQ.trim().slice(0, MAX_SEARCH_LENGTH) : null;
|
|
300
|
+
const ctx = { model, enums, searchFields, filterableFields, now };
|
|
301
|
+
const filters = [];
|
|
302
|
+
const ignored = [];
|
|
303
|
+
for (const [key] of searchParams) {
|
|
304
|
+
if (!key.startsWith('f.'))
|
|
305
|
+
continue;
|
|
306
|
+
const raw = searchParams.get(key);
|
|
307
|
+
const result = parseOneFilter(key, raw, ctx);
|
|
308
|
+
if ('filter' in result)
|
|
309
|
+
filters.push(result.filter);
|
|
310
|
+
else if ('ignored' in result)
|
|
311
|
+
ignored.push(result.ignored);
|
|
312
|
+
// else: { skip: true } — empty "All" value, silently dropped, not
|
|
313
|
+
// even recorded in `ignored` (see parseOneFilter's comment).
|
|
314
|
+
}
|
|
315
|
+
// Legacy `?filter=field:value` — routed through the exact same
|
|
316
|
+
// whitelist/coercion path as `f.*`, so it inherits the security fix
|
|
317
|
+
// (docs/design §4.4, §0.a). If both are present for the same field,
|
|
318
|
+
// `f.*` wins (parsed above, so it's already in `filters`); the legacy
|
|
319
|
+
// value is only added if that field has no `f.*` entry.
|
|
320
|
+
const legacy = searchParams.get('filter');
|
|
321
|
+
if (legacy && legacy.includes(':')) {
|
|
322
|
+
const sep = legacy.indexOf(':');
|
|
323
|
+
const legacyField = legacy.slice(0, sep);
|
|
324
|
+
const legacyValue = legacy.slice(sep + 1);
|
|
325
|
+
const alreadyHasField = filters.some((f) => f.field === legacyField);
|
|
326
|
+
if (!alreadyHasField) {
|
|
327
|
+
const result = parseOneFilter(`f.${legacyField}`, legacyValue, ctx);
|
|
328
|
+
if ('filter' in result)
|
|
329
|
+
filters.push(result.filter);
|
|
330
|
+
else if ('ignored' in result)
|
|
331
|
+
ignored.push({ param: 'filter', reason: result.ignored.reason });
|
|
332
|
+
// else: { skip: true } — an empty legacy value (`?filter=field:`)
|
|
333
|
+
// is likewise a no-op, never surfaced as an "ignored" warning.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { q, searchFields, filters, ignored };
|
|
337
|
+
}
|
|
338
|
+
function clauseOf(filter) {
|
|
339
|
+
if (filter.op === 'gte' && filter.value && typeof filter.value === 'object' && 'gte' in filter.value) {
|
|
340
|
+
// Date shortcut carrying both bounds (see parseOneFilter's DateTime branch).
|
|
341
|
+
const range = filter.value;
|
|
342
|
+
return { [filter.field]: { gte: range.gte, lt: range.lt } };
|
|
343
|
+
}
|
|
344
|
+
if (filter.op === 'isnull') {
|
|
345
|
+
return { [filter.field]: filter.value ? { equals: null } : { not: null } };
|
|
346
|
+
}
|
|
347
|
+
if (filter.op === 'equals') {
|
|
348
|
+
return { [filter.field]: filter.value };
|
|
349
|
+
}
|
|
350
|
+
return { [filter.field]: { [filter.op]: filter.value } };
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Compose the final Prisma `where`: `AND: [scope, ...filters, {OR: search}]`.
|
|
354
|
+
* NEVER a spread — a spread of `{...scope, ...filterWhere}` lets a filter
|
|
355
|
+
* on the same field as the developer's scoping silently overwrite it
|
|
356
|
+
* (docs/design §0.c, the exact IDOR the previous `?filter=` had). Two
|
|
357
|
+
* clauses on the same field inside `AND` intersect; they never merge.
|
|
358
|
+
*
|
|
359
|
+
* Returns `undefined` when nothing is active, so the query shape sent to
|
|
360
|
+
* Prisma is byte-for-byte identical to today's unfiltered call — no
|
|
361
|
+
* regression on existing snapshots/assertions.
|
|
362
|
+
*/
|
|
363
|
+
export function buildWhere(query, scope, caseInsensitiveSearch, model) {
|
|
364
|
+
const and = [];
|
|
365
|
+
if (scope)
|
|
366
|
+
and.push(scope);
|
|
367
|
+
for (const f of query.filters)
|
|
368
|
+
and.push(clauseOf(f));
|
|
369
|
+
if (query.q && query.searchFields.length > 0) {
|
|
370
|
+
const or = [];
|
|
371
|
+
for (const fieldName of query.searchFields) {
|
|
372
|
+
const field = model.fields.find((f) => f.name === fieldName);
|
|
373
|
+
const clause = searchClauseFor(field, query.q, caseInsensitiveSearch);
|
|
374
|
+
if (clause)
|
|
375
|
+
or.push({ [fieldName]: clause });
|
|
376
|
+
}
|
|
377
|
+
// Never emit `{OR: []}` — in Prisma that matches nothing, which would
|
|
378
|
+
// silently turn "no searchable field" (or "every clause omitted", §2.4)
|
|
379
|
+
// into "empty result". A no-op search must add nothing to the where.
|
|
380
|
+
if (or.length > 0)
|
|
381
|
+
and.push({ OR: or });
|
|
382
|
+
}
|
|
383
|
+
if (and.length === 0)
|
|
384
|
+
return undefined;
|
|
385
|
+
if (and.length === 1)
|
|
386
|
+
return and[0];
|
|
387
|
+
return { AND: and };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The per-field-type clause for a `searchFields` entry (§2.4):
|
|
391
|
+
* - String @id -> `equals` (a `contains` on a cuid/uuid can't use the
|
|
392
|
+
* index and never makes semantic sense; §2.1 talks ONLY about the id
|
|
393
|
+
* here — an earlier version of this function over-generalized to
|
|
394
|
+
* `@id || @unique`, which silently broke fragment search on the most
|
|
395
|
+
* common real-world case: `email`/`slug` fields are `@unique` in
|
|
396
|
+
* nearly every Prisma schema and are exactly what §2.3's "a title, an
|
|
397
|
+
* email" example means by free-text search. `@unique` alone is NOT a
|
|
398
|
+
* reason to switch to `equals` — only `@id` is).
|
|
399
|
+
* - other String (including @unique) -> `contains` (+ `mode:
|
|
400
|
+
* 'insensitive'` when the provider supports it).
|
|
401
|
+
* - Int/BigInt/Float/Decimal -> `equals` if `q` coerces to that type,
|
|
402
|
+
* otherwise the clause is OMITTED — never `contains` on a numeric
|
|
403
|
+
* column, which Prisma rejects with a hard error (`Unknown argument
|
|
404
|
+
* contains`), turning any legitimate `?q=` into a 500 (§10's known
|
|
405
|
+
* trap, discovered via review — the original implementation searched
|
|
406
|
+
* this exactly wrong).
|
|
407
|
+
* - anything else (enum, Boolean, DateTime, relation, Json/Bytes):
|
|
408
|
+
* omitted. `resolveSearchFields`'s auto heuristic never proposes these,
|
|
409
|
+
* but explicit `searchFields` config isn't type-checked against §2.4 at
|
|
410
|
+
* boot (only against `isFilterableFieldType`), so this is reached in
|
|
411
|
+
* practice for a misconfigured field — degrading to "omitted" here
|
|
412
|
+
* keeps the guarantee that a legitimate URL never 500s, without adding
|
|
413
|
+
* a boot-time validation pass this design doc doesn't ask for.
|
|
414
|
+
*/
|
|
415
|
+
function searchClauseFor(field, q, caseInsensitiveSearch) {
|
|
416
|
+
if (!field)
|
|
417
|
+
return undefined;
|
|
418
|
+
if (field.type === 'String') {
|
|
419
|
+
if (field.isId)
|
|
420
|
+
return { equals: q };
|
|
421
|
+
return caseInsensitiveSearch ? { contains: q, mode: 'insensitive' } : { contains: q };
|
|
422
|
+
}
|
|
423
|
+
if (['Int', 'BigInt', 'Float', 'Decimal'].includes(field.type)) {
|
|
424
|
+
const coerced = coerceValue(field, 'equals', q);
|
|
425
|
+
return coerced === undefined ? undefined : { equals: coerced };
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL helpers shared by every piece of UI that builds a list-view URL:
|
|
3
|
+
* the search box, the filter sidebar links, and pagination.
|
|
4
|
+
*
|
|
5
|
+
* Every list URL MUST go through `buildListUrl` — including pagination,
|
|
6
|
+
* which today builds `?page=N` by hand. A hand-rolled URL bypasses the
|
|
7
|
+
* "always drop `page` on filter change" invariant (docs/design §3.3, §7.1)
|
|
8
|
+
* and risks parameter-order drift, which makes snapshots flaky.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Build a list-view URL from the current one, applying a patch of query
|
|
12
|
+
* params. `null` in the patch removes that key. `page` is ALWAYS dropped
|
|
13
|
+
* unless the patch explicitly sets it (pagination is the one caller that
|
|
14
|
+
* does) — every other caller (search box, filter link) resets to page 1
|
|
15
|
+
* implicitly by omitting `page` from its patch.
|
|
16
|
+
*
|
|
17
|
+
* Keys are sorted before serialization so the resulting URL is
|
|
18
|
+
* deterministic — required for stable snapshot tests, and incidentally
|
|
19
|
+
* nicer to read/bookmark.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildListUrl(currentUrl: URL, patch: Record<string, string | null>): string;
|
|
22
|
+
/**
|
|
23
|
+
* Hidden `<input>` params to re-emit inside a `<form method="GET">` so
|
|
24
|
+
* submitting it doesn't wipe out every other active param — a bare GET
|
|
25
|
+
* form REPLACES the whole query string with just its own fields (docs/design
|
|
26
|
+
* §3.3). `exclude` lists the param(s) the form itself controls (e.g. `q`
|
|
27
|
+
* for the search box, `f.published` for a single-field filter form);
|
|
28
|
+
* `page` is always excluded too, since any new search/filter resets it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function hiddenParams(currentUrl: URL, exclude: string[]): {
|
|
31
|
+
name: string;
|
|
32
|
+
value: string;
|
|
33
|
+
}[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL helpers shared by every piece of UI that builds a list-view URL:
|
|
3
|
+
* the search box, the filter sidebar links, and pagination.
|
|
4
|
+
*
|
|
5
|
+
* Every list URL MUST go through `buildListUrl` — including pagination,
|
|
6
|
+
* which today builds `?page=N` by hand. A hand-rolled URL bypasses the
|
|
7
|
+
* "always drop `page` on filter change" invariant (docs/design §3.3, §7.1)
|
|
8
|
+
* and risks parameter-order drift, which makes snapshots flaky.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Build a list-view URL from the current one, applying a patch of query
|
|
12
|
+
* params. `null` in the patch removes that key. `page` is ALWAYS dropped
|
|
13
|
+
* unless the patch explicitly sets it (pagination is the one caller that
|
|
14
|
+
* does) — every other caller (search box, filter link) resets to page 1
|
|
15
|
+
* implicitly by omitting `page` from its patch.
|
|
16
|
+
*
|
|
17
|
+
* Keys are sorted before serialization so the resulting URL is
|
|
18
|
+
* deterministic — required for stable snapshot tests, and incidentally
|
|
19
|
+
* nicer to read/bookmark.
|
|
20
|
+
*/
|
|
21
|
+
export function buildListUrl(currentUrl, patch) {
|
|
22
|
+
const params = new URLSearchParams(currentUrl.search);
|
|
23
|
+
if (!('page' in patch)) {
|
|
24
|
+
params.delete('page');
|
|
25
|
+
}
|
|
26
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
27
|
+
if (value === null)
|
|
28
|
+
params.delete(key);
|
|
29
|
+
else
|
|
30
|
+
params.set(key, value);
|
|
31
|
+
}
|
|
32
|
+
const sorted = new URLSearchParams();
|
|
33
|
+
for (const key of [...params.keys()].sort()) {
|
|
34
|
+
for (const value of params.getAll(key)) {
|
|
35
|
+
sorted.append(key, value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const qs = sorted.toString();
|
|
39
|
+
return qs ? `${currentUrl.pathname}?${qs}` : currentUrl.pathname;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Hidden `<input>` params to re-emit inside a `<form method="GET">` so
|
|
43
|
+
* submitting it doesn't wipe out every other active param — a bare GET
|
|
44
|
+
* form REPLACES the whole query string with just its own fields (docs/design
|
|
45
|
+
* §3.3). `exclude` lists the param(s) the form itself controls (e.g. `q`
|
|
46
|
+
* for the search box, `f.published` for a single-field filter form);
|
|
47
|
+
* `page` is always excluded too, since any new search/filter resets it.
|
|
48
|
+
*/
|
|
49
|
+
export function hiddenParams(currentUrl, exclude) {
|
|
50
|
+
const excluded = new Set([...exclude, 'page']);
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const [key, value] of currentUrl.searchParams) {
|
|
53
|
+
if (excluded.has(key))
|
|
54
|
+
continue;
|
|
55
|
+
out.push({ name: key, value });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function parseRoute(pathname, basePath) {
|
|
2
|
+
// Le `replace` n'est PAS redondant avec le `filter(Boolean)` plus bas : il est ce
|
|
3
|
+
// qui fait que `/admin/` et `/admin///` donnent un `path` vide, donc le dashboard.
|
|
4
|
+
// Sans lui, `path` vaudrait '/' — truthy — et le chemin tomberait sur `notFound`.
|
|
5
|
+
const path = pathname.slice(basePath.length).replace(/^\/+|\/+$/g, '');
|
|
6
|
+
if (!path) {
|
|
7
|
+
return { view: 'dashboard' };
|
|
8
|
+
}
|
|
9
|
+
const segments = path.split('/').filter(Boolean);
|
|
10
|
+
if (segments.length === 1) {
|
|
11
|
+
if (segments[0] === '_search') {
|
|
12
|
+
return { view: 'search' };
|
|
13
|
+
}
|
|
14
|
+
if (segments[0] === '_logout') {
|
|
15
|
+
return { view: 'logout' };
|
|
16
|
+
}
|
|
17
|
+
return { view: 'list', model: segments[0] };
|
|
18
|
+
}
|
|
19
|
+
if (segments.length === 2) {
|
|
20
|
+
if (segments[1] === 'new') {
|
|
21
|
+
return { view: 'create', model: segments[0] };
|
|
22
|
+
}
|
|
23
|
+
return { view: 'edit', model: segments[0], id: segments[1] };
|
|
24
|
+
}
|
|
25
|
+
// 3 segments ou plus : aucune vue ne correspond.
|
|
26
|
+
return { view: 'notFound' };
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import StatCard from './StatCard.svelte';
|
|
3
|
+
import ModelCard from './ModelCard.svelte';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
models,
|
|
7
|
+
stats,
|
|
8
|
+
basePath
|
|
9
|
+
}: {
|
|
10
|
+
models: Array<{ name: string; label: string; count: number }>;
|
|
11
|
+
stats: { total: number; models: number };
|
|
12
|
+
basePath: string;
|
|
13
|
+
} = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<h1>Dashboard</h1>
|
|
17
|
+
<p class="ska-subtitle">Welcome to your admin panel</p>
|
|
18
|
+
|
|
19
|
+
<div class="ska-stats">
|
|
20
|
+
<StatCard icon="models" value={stats.models} label="Models" />
|
|
21
|
+
<StatCard icon="records" value={stats.total} label="Total Records" />
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<h2>Models</h2>
|
|
25
|
+
<div class="ska-models">
|
|
26
|
+
{#each models as m (m.name)}
|
|
27
|
+
<ModelCard href="{basePath}/{m.name.toLowerCase()}" label={m.label} count={m.count} />
|
|
28
|
+
{/each}
|
|
29
|
+
</div>
|