@svadmin/lite 0.3.12 → 0.3.14
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 +30 -5
- package/dist/components/LiteArrayItem.svelte +31 -10
- package/dist/components/LiteAuditLog.svelte +8 -17
- package/dist/components/LiteAuditLog.svelte.d.ts +5 -13
- package/dist/components/LiteForm.svelte +37 -7
- package/dist/components/LiteForm.svelte.d.ts +1 -1
- package/dist/components/LiteLayout.svelte +12 -7
- package/dist/components/LiteLayout.svelte.d.ts +2 -0
- package/dist/components/LitePermissionMatrix.svelte +48 -15
- package/dist/components/LitePermissionMatrix.svelte.d.ts +10 -9
- package/dist/components/LiteShow.svelte +3 -2
- package/dist/components/LiteShowField.svelte +5 -3
- package/dist/components/LiteTable.svelte +19 -9
- package/dist/components/LiteTable.svelte.d.ts +1 -0
- package/dist/components/advanced/LiteVirtualTable.svelte +5 -2
- package/dist/components/advanced/LiteVirtualTable.svelte.d.ts +1 -0
- package/dist/components/buttons/LiteCloneButton.svelte +2 -2
- package/dist/components/buttons/LiteCreateButton.svelte +2 -2
- package/dist/components/buttons/LiteDeleteButton.svelte +2 -2
- package/dist/components/buttons/LiteEditButton.svelte +2 -2
- package/dist/components/buttons/LiteExportButton.svelte +2 -2
- package/dist/components/buttons/LiteImportButton.svelte +2 -2
- package/dist/components/buttons/LiteListButton.svelte +2 -2
- package/dist/components/buttons/LiteRefreshButton.svelte +7 -11
- package/dist/components/buttons/LiteSaveButton.svelte +2 -2
- package/dist/components/buttons/LiteShowButton.svelte +2 -2
- package/dist/components/fields/LiteBooleanField.svelte +9 -8
- package/dist/components/fields/LiteDateField.svelte +2 -2
- package/dist/components/fields/LiteEmailField.svelte +1 -2
- package/dist/components/fields/LiteImageField.svelte +20 -11
- package/dist/components/fields/LiteNumberField.svelte +1 -2
- package/dist/components/fields/LiteRelationField.svelte +15 -7
- package/dist/components/fields/LiteSelectField.svelte +2 -1
- package/dist/components/fields/LiteTextField.svelte +1 -2
- package/dist/components/fields/LiteUrlField.svelte +1 -5
- package/dist/components/layout/LiteCatchAllNavigate.svelte +3 -9
- package/dist/components/layout/LiteNavigateToResource.svelte +5 -16
- package/dist/components/layout/LiteSidebar.svelte +9 -5
- package/dist/components/layout/LiteSidebar.svelte.d.ts +1 -0
- package/dist/components/pages/LiteEditPage.svelte +6 -4
- package/dist/components/pages/LiteListPage.svelte +14 -6
- package/dist/components/pages/LiteListPage.svelte.d.ts +1 -0
- package/dist/components/pages/LiteShowPage.svelte +6 -4
- package/dist/components/widgets/LiteAnomalyBadge.svelte +5 -1
- package/dist/index.js +1 -1
- package/dist/lite.css +2 -1
- package/dist/menu-visibility.d.ts +4 -0
- package/dist/menu-visibility.js +34 -0
- package/dist/schema-generator.d.ts +1 -1
- package/dist/schema-generator.js +112 -51
- package/dist/server-adapter.d.ts +53 -4
- package/dist/server-adapter.js +241 -67
- package/dist/value-normalization.d.ts +2 -0
- package/dist/value-normalization.js +21 -0
- package/package.json +6 -14
package/dist/server-adapter.js
CHANGED
|
@@ -1,40 +1,65 @@
|
|
|
1
1
|
import { redirect, isRedirect } from '@sveltejs/kit';
|
|
2
2
|
import { resourceToZodSchema } from './schema-generator';
|
|
3
|
+
import { parseExplicitBoolean } from './value-normalization';
|
|
4
|
+
function listRequestState(url, resource) {
|
|
5
|
+
const requestedPage = Number(url.searchParams.get('page'));
|
|
6
|
+
const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
7
|
+
const configuredPageSize = resource.pageSize ?? 10;
|
|
8
|
+
const pageSize = Number.isSafeInteger(configuredPageSize) && configuredPageSize > 0
|
|
9
|
+
? configuredPageSize
|
|
10
|
+
: 10;
|
|
11
|
+
const requestedSort = url.searchParams.get('sort') ?? undefined;
|
|
12
|
+
const sortableField = requestedSort
|
|
13
|
+
? resource.fields.find((field) => field.key === requestedSort && field.sortable !== false)
|
|
14
|
+
: undefined;
|
|
15
|
+
const sort = sortableField?.key ?? resource.defaultSort?.field;
|
|
16
|
+
const requestedOrder = url.searchParams.get('order');
|
|
17
|
+
const order = sortableField
|
|
18
|
+
? requestedOrder === 'asc' || requestedOrder === 'desc' ? requestedOrder : 'asc'
|
|
19
|
+
: resource.defaultSort?.order ?? 'asc';
|
|
20
|
+
return { page, pageSize, sort, order, search: url.searchParams.get('q') ?? undefined };
|
|
21
|
+
}
|
|
22
|
+
function listSearchFilters(resource, search) {
|
|
23
|
+
if (!search)
|
|
24
|
+
return [];
|
|
25
|
+
const fieldFilters = resource.fields
|
|
26
|
+
.filter((field) => field.searchable)
|
|
27
|
+
.map((field) => ({
|
|
28
|
+
field: field.key,
|
|
29
|
+
operator: 'contains',
|
|
30
|
+
value: search,
|
|
31
|
+
}));
|
|
32
|
+
if (fieldFilters.length === 1)
|
|
33
|
+
return fieldFilters;
|
|
34
|
+
return fieldFilters.length > 1 ? [{ operator: 'or', value: fieldFilters }] : [];
|
|
35
|
+
}
|
|
3
36
|
/**
|
|
4
37
|
* Creates a SvelteKit `load` function that fetches a resource list
|
|
5
38
|
* via the DataProvider. All state is driven by URL search params.
|
|
6
39
|
*/
|
|
7
40
|
export function createListLoader(dp, resource) {
|
|
8
41
|
return async ({ url }) => {
|
|
9
|
-
const page
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const order = url.searchParams.get('order') ?? 'asc';
|
|
13
|
-
const search = url.searchParams.get('q') ?? undefined;
|
|
14
|
-
const sorters = sort ? [{ field: sort, order }] :
|
|
15
|
-
resource.defaultSort ? [resource.defaultSort] : [];
|
|
16
|
-
const filters = [];
|
|
17
|
-
if (search) {
|
|
18
|
-
const searchable = resource.fields.find((f) => f.searchable);
|
|
19
|
-
if (searchable) {
|
|
20
|
-
filters.push({ field: searchable.key, operator: 'contains', value: search });
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
const result = await dp.getList({
|
|
42
|
+
const { page, pageSize, sort, order, search } = listRequestState(url, resource);
|
|
43
|
+
const sorters = sort ? [{ field: sort, order }] : [];
|
|
44
|
+
const listResponse = await dp.getList({
|
|
24
45
|
resource: resource.name,
|
|
25
46
|
pagination: { current: page, pageSize },
|
|
26
47
|
sorters,
|
|
27
|
-
filters,
|
|
48
|
+
filters: listSearchFilters(resource, search),
|
|
28
49
|
});
|
|
29
50
|
return {
|
|
30
|
-
records:
|
|
31
|
-
total:
|
|
51
|
+
records: listResponse.data,
|
|
52
|
+
total: listResponse.total,
|
|
32
53
|
page,
|
|
33
54
|
pageSize,
|
|
34
|
-
totalPages: Math.ceil(
|
|
55
|
+
totalPages: Math.ceil(listResponse.total / pageSize),
|
|
35
56
|
sort,
|
|
36
57
|
order,
|
|
37
58
|
search,
|
|
59
|
+
pagination: { page, perPage: pageSize },
|
|
60
|
+
currentSort: sort,
|
|
61
|
+
currentOrder: order,
|
|
62
|
+
currentSearch: search,
|
|
38
63
|
resource,
|
|
39
64
|
};
|
|
40
65
|
};
|
|
@@ -58,6 +83,9 @@ export function createCrudActions(dp, resource) {
|
|
|
58
83
|
const pk = resource.primaryKey ?? 'id';
|
|
59
84
|
return {
|
|
60
85
|
create: async ({ request }) => {
|
|
86
|
+
if (resource.canCreate === false) {
|
|
87
|
+
return { success: false, error: 'Create is disabled for this resource' };
|
|
88
|
+
}
|
|
61
89
|
const formData = await request.formData();
|
|
62
90
|
const submittedValues = formDataToObject(formData, resource.fields);
|
|
63
91
|
const validation = validateFormVariables(resource, 'create', submittedValues);
|
|
@@ -68,15 +96,24 @@ export function createCrudActions(dp, resource) {
|
|
|
68
96
|
const result = await dp.create({ resource: resource.name, variables });
|
|
69
97
|
return { success: true, id: result.data[pk] };
|
|
70
98
|
}
|
|
71
|
-
catch (
|
|
72
|
-
if (isRedirect(
|
|
73
|
-
throw
|
|
74
|
-
return {
|
|
99
|
+
catch (caughtError) {
|
|
100
|
+
if (isRedirect(caughtError))
|
|
101
|
+
throw caughtError;
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
error: 'Create failed',
|
|
105
|
+
values: formValuesForResponse(resource.fields, variables),
|
|
106
|
+
};
|
|
75
107
|
}
|
|
76
108
|
},
|
|
77
109
|
update: async ({ request }) => {
|
|
110
|
+
if (resource.canEdit === false) {
|
|
111
|
+
return { success: false, error: 'Edit is disabled for this resource' };
|
|
112
|
+
}
|
|
78
113
|
const formData = await request.formData();
|
|
79
|
-
const id = formData.get('_id');
|
|
114
|
+
const id = readRecordId(formData.get('_id'));
|
|
115
|
+
if (!id)
|
|
116
|
+
return { success: false, error: 'Missing record id' };
|
|
80
117
|
formData.delete('_id');
|
|
81
118
|
const submittedValues = formDataToObject(formData, resource.fields);
|
|
82
119
|
const validation = validateFormVariables(resource, 'edit', submittedValues);
|
|
@@ -87,26 +124,35 @@ export function createCrudActions(dp, resource) {
|
|
|
87
124
|
await dp.update({ resource: resource.name, id, variables });
|
|
88
125
|
return { success: true };
|
|
89
126
|
}
|
|
90
|
-
catch (
|
|
91
|
-
if (isRedirect(
|
|
92
|
-
throw
|
|
93
|
-
return {
|
|
127
|
+
catch (caughtError) {
|
|
128
|
+
if (isRedirect(caughtError))
|
|
129
|
+
throw caughtError;
|
|
130
|
+
return {
|
|
131
|
+
success: false,
|
|
132
|
+
error: 'Update failed',
|
|
133
|
+
values: formValuesForResponse(resource.fields, variables),
|
|
134
|
+
};
|
|
94
135
|
}
|
|
95
136
|
},
|
|
96
137
|
delete: async ({ request }) => {
|
|
138
|
+
if (resource.canDelete === false) {
|
|
139
|
+
return { success: false, error: 'Delete is disabled for this resource' };
|
|
140
|
+
}
|
|
97
141
|
const formData = await request.formData();
|
|
98
|
-
const id = formData.get('id');
|
|
99
|
-
|
|
142
|
+
const id = readRecordId(formData.get('id'));
|
|
143
|
+
if (!id)
|
|
144
|
+
return { success: false, error: 'Missing record id' };
|
|
145
|
+
const redirectTo = toSafeLocalRedirect(formData.get('redirect'));
|
|
100
146
|
try {
|
|
101
147
|
await dp.deleteOne({ resource: resource.name, id });
|
|
102
148
|
if (redirectTo)
|
|
103
149
|
throw redirect(303, redirectTo);
|
|
104
150
|
return { success: true };
|
|
105
151
|
}
|
|
106
|
-
catch (
|
|
107
|
-
if (isRedirect(
|
|
108
|
-
throw
|
|
109
|
-
return { success: false, error:
|
|
152
|
+
catch (caughtError) {
|
|
153
|
+
if (isRedirect(caughtError))
|
|
154
|
+
throw caughtError;
|
|
155
|
+
return { success: false, error: 'Delete failed' };
|
|
110
156
|
}
|
|
111
157
|
},
|
|
112
158
|
};
|
|
@@ -117,30 +163,32 @@ export function createCrudActions(dp, resource) {
|
|
|
117
163
|
* and redirects unauthenticated users to a login page.
|
|
118
164
|
*/
|
|
119
165
|
export function createAuthGuard(authProvider, loginPath = '/lite/login') {
|
|
166
|
+
const loginSegmentStart = loginPath.lastIndexOf('/');
|
|
167
|
+
const authBasePath = loginSegmentStart > 0
|
|
168
|
+
? loginPath.slice(0, loginSegmentStart)
|
|
169
|
+
: '';
|
|
170
|
+
const allowedPublicPaths = new Set([
|
|
171
|
+
loginPath,
|
|
172
|
+
`${authBasePath}/register`,
|
|
173
|
+
`${authBasePath}/forgot-password`,
|
|
174
|
+
`${authBasePath}/update-password`,
|
|
175
|
+
]);
|
|
120
176
|
return async ({ event, resolve }) => {
|
|
121
|
-
|
|
122
|
-
if (event.url.pathname === loginPath) {
|
|
177
|
+
if (allowedPublicPaths.has(event.url.pathname)) {
|
|
123
178
|
return resolve(event);
|
|
124
179
|
}
|
|
125
|
-
// Local Lite Session Verify
|
|
126
|
-
const session = event.cookies.get('svadmin-session');
|
|
127
|
-
if (!session) {
|
|
128
|
-
return new Response(null, {
|
|
129
|
-
status: 302,
|
|
130
|
-
headers: { Location: loginPath },
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
180
|
try {
|
|
134
181
|
const check = await authProvider.check();
|
|
135
182
|
if (!check.authenticated) {
|
|
136
183
|
event.cookies.delete('svadmin-session', { path: '/' });
|
|
137
184
|
return new Response(null, {
|
|
138
185
|
status: 302,
|
|
139
|
-
headers: { Location: loginPath },
|
|
186
|
+
headers: { Location: toSafeLocalRedirect(check.redirectTo) ?? loginPath },
|
|
140
187
|
});
|
|
141
188
|
}
|
|
142
189
|
}
|
|
143
190
|
catch {
|
|
191
|
+
// Authentication checks fail closed so provider errors never expose a protected page.
|
|
144
192
|
event.cookies.delete('svadmin-session', { path: '/' });
|
|
145
193
|
return new Response(null, {
|
|
146
194
|
status: 302,
|
|
@@ -151,41 +199,98 @@ export function createAuthGuard(authProvider, loginPath = '/lite/login') {
|
|
|
151
199
|
};
|
|
152
200
|
}
|
|
153
201
|
/**
|
|
154
|
-
* Creates
|
|
202
|
+
* Creates the form actions used by all exported Lite authentication pages.
|
|
155
203
|
*/
|
|
156
204
|
export function createAuthActions(authProvider) {
|
|
205
|
+
async function readAuthParams(request) {
|
|
206
|
+
return Object.fromEntries(await request.formData());
|
|
207
|
+
}
|
|
208
|
+
function validatePasswordConfirmation(authParams) {
|
|
209
|
+
const { confirmPassword, ...providerParams } = authParams;
|
|
210
|
+
const password = authParams.password;
|
|
211
|
+
if (typeof password !== 'string'
|
|
212
|
+
|| password.length === 0
|
|
213
|
+
|| typeof confirmPassword !== 'string'
|
|
214
|
+
|| confirmPassword.length === 0) {
|
|
215
|
+
return { valid: false, error: 'Password and confirmation are required' };
|
|
216
|
+
}
|
|
217
|
+
if (password !== confirmPassword) {
|
|
218
|
+
return { valid: false, error: 'Passwords do not match' };
|
|
219
|
+
}
|
|
220
|
+
return { valid: true, providerParams };
|
|
221
|
+
}
|
|
222
|
+
async function runAuthFormAction(providerMethod, authParams, unsupportedMessage, failureMessage) {
|
|
223
|
+
if (!providerMethod)
|
|
224
|
+
return { success: false, error: unsupportedMessage };
|
|
225
|
+
try {
|
|
226
|
+
const authResult = await providerMethod.call(authProvider, authParams);
|
|
227
|
+
if (!authResult.success) {
|
|
228
|
+
return { success: false, error: authResult.error?.message ?? 'Authentication action failed' };
|
|
229
|
+
}
|
|
230
|
+
if (authResult.redirectTo)
|
|
231
|
+
throw redirect(303, authResult.redirectTo);
|
|
232
|
+
return { success: true };
|
|
233
|
+
}
|
|
234
|
+
catch (caughtError) {
|
|
235
|
+
if (isRedirect(caughtError))
|
|
236
|
+
throw caughtError;
|
|
237
|
+
return { success: false, error: failureMessage };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
157
240
|
return {
|
|
158
|
-
login: async ({ request, cookies }) => {
|
|
159
|
-
const
|
|
160
|
-
const params = Object.fromEntries(formData);
|
|
241
|
+
login: async ({ request, cookies, url }) => {
|
|
242
|
+
const authParams = await readAuthParams(request);
|
|
161
243
|
try {
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
// Store a session indicator in a cookie
|
|
244
|
+
const loginResult = await authProvider.login(authParams);
|
|
245
|
+
if (loginResult.success) {
|
|
165
246
|
cookies.set('svadmin-session', 'active', {
|
|
166
247
|
path: '/',
|
|
167
248
|
httpOnly: true,
|
|
168
249
|
sameSite: 'lax',
|
|
250
|
+
secure: url.protocol === 'https:',
|
|
169
251
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
170
252
|
});
|
|
171
|
-
throw redirect(303,
|
|
253
|
+
throw redirect(303, loginResult.redirectTo ?? '/lite');
|
|
172
254
|
}
|
|
173
|
-
return { success: false, error:
|
|
255
|
+
return { success: false, error: loginResult.error?.message ?? 'Login failed' };
|
|
174
256
|
}
|
|
175
|
-
catch (
|
|
176
|
-
if (isRedirect(
|
|
177
|
-
throw
|
|
178
|
-
return { success: false, error:
|
|
257
|
+
catch (caughtError) {
|
|
258
|
+
if (isRedirect(caughtError))
|
|
259
|
+
throw caughtError;
|
|
260
|
+
return { success: false, error: 'Login failed' };
|
|
179
261
|
}
|
|
180
262
|
},
|
|
181
263
|
logout: async ({ cookies }) => {
|
|
264
|
+
let logoutResult;
|
|
182
265
|
try {
|
|
183
|
-
await authProvider.logout();
|
|
266
|
+
logoutResult = await authProvider.logout();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
cookies.delete('svadmin-session', { path: '/' });
|
|
270
|
+
return { success: false, error: 'Logout failed' };
|
|
184
271
|
}
|
|
185
|
-
catch { /* ignore */ }
|
|
186
272
|
cookies.delete('svadmin-session', { path: '/' });
|
|
273
|
+
if (!logoutResult.success) {
|
|
274
|
+
return { success: false, error: logoutResult.error?.message ?? 'Logout failed' };
|
|
275
|
+
}
|
|
276
|
+
if (logoutResult.redirectTo)
|
|
277
|
+
throw redirect(303, logoutResult.redirectTo);
|
|
187
278
|
return { success: true };
|
|
188
279
|
},
|
|
280
|
+
register: async ({ request }) => {
|
|
281
|
+
const confirmation = validatePasswordConfirmation(await readAuthParams(request));
|
|
282
|
+
if (!confirmation.valid)
|
|
283
|
+
return { success: false, error: confirmation.error };
|
|
284
|
+
return runAuthFormAction(authProvider.register, confirmation.providerParams, 'Registration is not supported by this AuthProvider', 'Registration failed');
|
|
285
|
+
},
|
|
286
|
+
forgot_password: async ({ request }) => runAuthFormAction(authProvider.forgotPassword, await readAuthParams(request), 'Password recovery is not supported by this AuthProvider', 'Password recovery failed'),
|
|
287
|
+
update_password: async ({ request }) => {
|
|
288
|
+
const confirmation = validatePasswordConfirmation(await readAuthParams(request));
|
|
289
|
+
if (!confirmation.valid)
|
|
290
|
+
return { success: false, error: confirmation.error };
|
|
291
|
+
return runAuthFormAction(authProvider.updatePassword, confirmation.providerParams, 'Password updates are not supported by this AuthProvider', 'Password update failed');
|
|
292
|
+
},
|
|
293
|
+
update_profile: async ({ request }) => runAuthFormAction((authProvider.updateProfile ?? authProvider.updateIdentity), await readAuthParams(request), 'Profile updates are not supported by this AuthProvider', 'Profile update failed'),
|
|
189
294
|
};
|
|
190
295
|
}
|
|
191
296
|
// ─── UA Detection ─────────────────────────────────────────────
|
|
@@ -201,17 +306,61 @@ export function isLegacyBrowser(userAgent) {
|
|
|
201
306
|
* Consumers remain responsible for their own transpilation and browser support.
|
|
202
307
|
*/
|
|
203
308
|
export function createLegacyRedirectHook(litePrefix = '/lite') {
|
|
309
|
+
const prefixSegments = litePrefix.split('/').filter(Boolean);
|
|
310
|
+
const normalizedPrefix = prefixSegments.length > 0 ? `/${prefixSegments.join('/')}` : '/';
|
|
204
311
|
return async ({ event, resolve }) => {
|
|
205
312
|
const ua = event.request.headers.get('user-agent') ?? '';
|
|
206
|
-
|
|
313
|
+
const acceptsHtml = event.request.headers.get('accept')?.includes('text/html') === true;
|
|
314
|
+
const isDocumentRequest = (event.request.method === 'GET' || event.request.method === 'HEAD')
|
|
315
|
+
&& acceptsHtml;
|
|
316
|
+
const isWithinLite = normalizedPrefix === '/'
|
|
317
|
+
|| event.url.pathname === normalizedPrefix
|
|
318
|
+
|| event.url.pathname.startsWith(`${normalizedPrefix}/`);
|
|
319
|
+
if (isLegacyBrowser(ua) && isDocumentRequest && !isWithinLite) {
|
|
320
|
+
const targetPath = normalizedPrefix === '/'
|
|
321
|
+
? event.url.pathname
|
|
322
|
+
: `${normalizedPrefix}${event.url.pathname}`;
|
|
207
323
|
return new Response(null, {
|
|
208
324
|
status: 302,
|
|
209
|
-
headers: { Location: `${
|
|
325
|
+
headers: { Location: `${targetPath}${event.url.search}` },
|
|
210
326
|
});
|
|
211
327
|
}
|
|
212
328
|
return resolve(event);
|
|
213
329
|
};
|
|
214
330
|
}
|
|
331
|
+
// ─── Utilities ────────────────────────────────────────────────
|
|
332
|
+
function readRecordId(submittedId) {
|
|
333
|
+
if (typeof submittedId !== 'string' || submittedId.trim().length === 0)
|
|
334
|
+
return undefined;
|
|
335
|
+
return submittedId.trim();
|
|
336
|
+
}
|
|
337
|
+
function containsControlCharacter(untrustedRedirect) {
|
|
338
|
+
return Array.from(untrustedRedirect).some((character) => {
|
|
339
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
340
|
+
return codePoint <= 31 || (codePoint >= 127 && codePoint <= 159);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function toSafeLocalRedirect(submittedRedirect) {
|
|
344
|
+
if (typeof submittedRedirect !== 'string'
|
|
345
|
+
|| !submittedRedirect.startsWith('/')
|
|
346
|
+
|| submittedRedirect.startsWith('//')) {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
if (submittedRedirect.includes('\\') || containsControlCharacter(submittedRedirect)) {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
const base = new URL('https://svadmin.local');
|
|
353
|
+
try {
|
|
354
|
+
const target = new URL(submittedRedirect, base);
|
|
355
|
+
if (target.origin !== base.origin)
|
|
356
|
+
return undefined;
|
|
357
|
+
return `${target.pathname}${target.search}${target.hash}`;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Malformed untrusted redirect input is intentionally treated as no redirect.
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
215
364
|
function formatValidationErrors(issues) {
|
|
216
365
|
const errors = {};
|
|
217
366
|
for (const issue of issues) {
|
|
@@ -233,7 +382,7 @@ function validateFormVariables(resource, mode, values) {
|
|
|
233
382
|
failure: {
|
|
234
383
|
success: false,
|
|
235
384
|
error: 'Validation failed',
|
|
236
|
-
values,
|
|
385
|
+
values: formValuesForResponse(resource.fields, values),
|
|
237
386
|
errors: formatValidationErrors(result.error.issues),
|
|
238
387
|
},
|
|
239
388
|
};
|
|
@@ -241,6 +390,28 @@ function validateFormVariables(resource, mode, values) {
|
|
|
241
390
|
function isNativeFile(value) {
|
|
242
391
|
return typeof File !== 'undefined' && value instanceof File;
|
|
243
392
|
}
|
|
393
|
+
function formValuesForResponse(fields, values) {
|
|
394
|
+
const responseValues = {};
|
|
395
|
+
for (const field of fields) {
|
|
396
|
+
if (field.type === 'password' || !(field.key in values))
|
|
397
|
+
continue;
|
|
398
|
+
const value = values[field.key];
|
|
399
|
+
if (field.type === 'array' && Array.isArray(value)) {
|
|
400
|
+
responseValues[field.key] = value.map((row) => {
|
|
401
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row))
|
|
402
|
+
return row;
|
|
403
|
+
return formValuesForResponse(field.subFields ?? [], row);
|
|
404
|
+
});
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (isNativeFile(value))
|
|
408
|
+
continue;
|
|
409
|
+
responseValues[field.key] = Array.isArray(value)
|
|
410
|
+
? value.filter((entry) => !isNativeFile(entry))
|
|
411
|
+
: value;
|
|
412
|
+
}
|
|
413
|
+
return responseValues;
|
|
414
|
+
}
|
|
244
415
|
function isNonEmptyNativeFile(value) {
|
|
245
416
|
return isNativeFile(value) && value.size > 0 && value.name !== '';
|
|
246
417
|
}
|
|
@@ -290,7 +461,7 @@ function formDataToObject(formData, fields) {
|
|
|
290
461
|
}
|
|
291
462
|
break;
|
|
292
463
|
case 'boolean':
|
|
293
|
-
obj[field.key] = strRaw
|
|
464
|
+
obj[field.key] = parseExplicitBoolean(strRaw) ?? strRaw;
|
|
294
465
|
break;
|
|
295
466
|
case 'tags':
|
|
296
467
|
obj[field.key] = strRaw ? strRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
@@ -336,9 +507,12 @@ function parseArrayField(formData, field) {
|
|
|
336
507
|
for (const subField of field.subFields ?? []) {
|
|
337
508
|
const rawValues = entries.get(subField.key) ?? [];
|
|
338
509
|
if (subField.type === 'boolean') {
|
|
339
|
-
const
|
|
510
|
+
const rawValue = rawValues.at(-1);
|
|
511
|
+
const value = rawValue === undefined
|
|
512
|
+
? false
|
|
513
|
+
: parseExplicitBoolean(rawValue) ?? rawValue;
|
|
340
514
|
item[subField.key] = value;
|
|
341
|
-
hasMeaningfulValue ||= value;
|
|
515
|
+
hasMeaningfulValue ||= value !== false;
|
|
342
516
|
continue;
|
|
343
517
|
}
|
|
344
518
|
if (rawValues.length === 0)
|
|
@@ -406,7 +580,7 @@ function coerceFieldValues(field, rawValues) {
|
|
|
406
580
|
return undefined;
|
|
407
581
|
return Number.isNaN(Number(raw)) ? raw : Number(raw);
|
|
408
582
|
case 'boolean':
|
|
409
|
-
return raw
|
|
583
|
+
return parseExplicitBoolean(raw) ?? raw;
|
|
410
584
|
case 'tags':
|
|
411
585
|
return raw ? raw.split(',').map((value) => value.trim()).filter(Boolean) : [];
|
|
412
586
|
case 'json':
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const TRUE_BOOLEAN_STRINGS = new Set(['1', 'true', 'on']);
|
|
2
|
+
const FALSE_BOOLEAN_STRINGS = new Set(['0', 'false', 'off']);
|
|
3
|
+
export function parseExplicitBoolean(rawBoolean) {
|
|
4
|
+
if (typeof rawBoolean === 'boolean')
|
|
5
|
+
return rawBoolean;
|
|
6
|
+
if (rawBoolean === 1)
|
|
7
|
+
return true;
|
|
8
|
+
if (rawBoolean === 0)
|
|
9
|
+
return false;
|
|
10
|
+
if (typeof rawBoolean !== 'string')
|
|
11
|
+
return undefined;
|
|
12
|
+
const normalizedBoolean = rawBoolean.trim().toLowerCase();
|
|
13
|
+
if (TRUE_BOOLEAN_STRINGS.has(normalizedBoolean))
|
|
14
|
+
return true;
|
|
15
|
+
if (FALSE_BOOLEAN_STRINGS.has(normalizedBoolean))
|
|
16
|
+
return false;
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
export function isExplicitBooleanTrue(rawBoolean) {
|
|
20
|
+
return parseExplicitBoolean(rawBoolean) === true;
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/lite",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "SSR-first lightweight admin UI for @svadmin with optional progressive enhancement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -54,10 +54,10 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@lucide/svelte": "^1.30.0",
|
|
57
|
-
"sveltekit-superforms": "^2.30.2",
|
|
58
57
|
"zod": "^4.4.3"
|
|
59
58
|
},
|
|
60
59
|
"devDependencies": {
|
|
60
|
+
"@types/bun": "^1.3.14",
|
|
61
61
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
62
62
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
63
63
|
"@testing-library/dom": "^10.4.1",
|
|
@@ -67,7 +67,10 @@
|
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|
|
69
69
|
"build": "node ../../node_modules/@sveltejs/package/svelte-package.js -i src -o dist && bun ../../scripts/copy-package-assets.ts static/enhance.js dist/enhance.js",
|
|
70
|
-
"test": "
|
|
70
|
+
"test": "bun run test:components && bun run test:server && bun run test:security",
|
|
71
|
+
"test:components": "vitest run --config ./vitest.config.ts",
|
|
72
|
+
"test:server": "bun test ./src/schema-generator.test.ts ./src/server-adapter.test.ts",
|
|
73
|
+
"test:security": "vitest run --config ./src/security.test.config.ts"
|
|
71
74
|
},
|
|
72
75
|
"license": "MIT",
|
|
73
76
|
"publishConfig": {
|
|
@@ -79,16 +82,5 @@
|
|
|
79
82
|
"type": "git",
|
|
80
83
|
"url": "https://github.com/zuohuadong/svadmin.git",
|
|
81
84
|
"directory": "packages/lite"
|
|
82
|
-
},
|
|
83
|
-
"peerDependenciesMeta": {
|
|
84
|
-
"svelte": {
|
|
85
|
-
"optional": true
|
|
86
|
-
},
|
|
87
|
-
"@svadmin/core": {
|
|
88
|
-
"optional": true
|
|
89
|
-
},
|
|
90
|
-
"@sveltejs/kit": {
|
|
91
|
-
"optional": true
|
|
92
|
-
}
|
|
93
85
|
}
|
|
94
86
|
}
|