@svadmin/lite 0.3.12 → 0.3.13
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 +235 -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,59 @@
|
|
|
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 searchableField = resource.fields.find((field) => field.searchable);
|
|
26
|
+
return searchableField
|
|
27
|
+
? [{ field: searchableField.key, operator: 'contains', value: search }]
|
|
28
|
+
: [];
|
|
29
|
+
}
|
|
3
30
|
/**
|
|
4
31
|
* Creates a SvelteKit `load` function that fetches a resource list
|
|
5
32
|
* via the DataProvider. All state is driven by URL search params.
|
|
6
33
|
*/
|
|
7
34
|
export function createListLoader(dp, resource) {
|
|
8
35
|
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({
|
|
36
|
+
const { page, pageSize, sort, order, search } = listRequestState(url, resource);
|
|
37
|
+
const sorters = sort ? [{ field: sort, order }] : [];
|
|
38
|
+
const listResponse = await dp.getList({
|
|
24
39
|
resource: resource.name,
|
|
25
40
|
pagination: { current: page, pageSize },
|
|
26
41
|
sorters,
|
|
27
|
-
filters,
|
|
42
|
+
filters: listSearchFilters(resource, search),
|
|
28
43
|
});
|
|
29
44
|
return {
|
|
30
|
-
records:
|
|
31
|
-
total:
|
|
45
|
+
records: listResponse.data,
|
|
46
|
+
total: listResponse.total,
|
|
32
47
|
page,
|
|
33
48
|
pageSize,
|
|
34
|
-
totalPages: Math.ceil(
|
|
49
|
+
totalPages: Math.ceil(listResponse.total / pageSize),
|
|
35
50
|
sort,
|
|
36
51
|
order,
|
|
37
52
|
search,
|
|
53
|
+
pagination: { page, perPage: pageSize },
|
|
54
|
+
currentSort: sort,
|
|
55
|
+
currentOrder: order,
|
|
56
|
+
currentSearch: search,
|
|
38
57
|
resource,
|
|
39
58
|
};
|
|
40
59
|
};
|
|
@@ -58,6 +77,9 @@ export function createCrudActions(dp, resource) {
|
|
|
58
77
|
const pk = resource.primaryKey ?? 'id';
|
|
59
78
|
return {
|
|
60
79
|
create: async ({ request }) => {
|
|
80
|
+
if (resource.canCreate === false) {
|
|
81
|
+
return { success: false, error: 'Create is disabled for this resource' };
|
|
82
|
+
}
|
|
61
83
|
const formData = await request.formData();
|
|
62
84
|
const submittedValues = formDataToObject(formData, resource.fields);
|
|
63
85
|
const validation = validateFormVariables(resource, 'create', submittedValues);
|
|
@@ -68,15 +90,24 @@ export function createCrudActions(dp, resource) {
|
|
|
68
90
|
const result = await dp.create({ resource: resource.name, variables });
|
|
69
91
|
return { success: true, id: result.data[pk] };
|
|
70
92
|
}
|
|
71
|
-
catch (
|
|
72
|
-
if (isRedirect(
|
|
73
|
-
throw
|
|
74
|
-
return {
|
|
93
|
+
catch (caughtError) {
|
|
94
|
+
if (isRedirect(caughtError))
|
|
95
|
+
throw caughtError;
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
error: 'Create failed',
|
|
99
|
+
values: formValuesForResponse(resource.fields, variables),
|
|
100
|
+
};
|
|
75
101
|
}
|
|
76
102
|
},
|
|
77
103
|
update: async ({ request }) => {
|
|
104
|
+
if (resource.canEdit === false) {
|
|
105
|
+
return { success: false, error: 'Edit is disabled for this resource' };
|
|
106
|
+
}
|
|
78
107
|
const formData = await request.formData();
|
|
79
|
-
const id = formData.get('_id');
|
|
108
|
+
const id = readRecordId(formData.get('_id'));
|
|
109
|
+
if (!id)
|
|
110
|
+
return { success: false, error: 'Missing record id' };
|
|
80
111
|
formData.delete('_id');
|
|
81
112
|
const submittedValues = formDataToObject(formData, resource.fields);
|
|
82
113
|
const validation = validateFormVariables(resource, 'edit', submittedValues);
|
|
@@ -87,26 +118,35 @@ export function createCrudActions(dp, resource) {
|
|
|
87
118
|
await dp.update({ resource: resource.name, id, variables });
|
|
88
119
|
return { success: true };
|
|
89
120
|
}
|
|
90
|
-
catch (
|
|
91
|
-
if (isRedirect(
|
|
92
|
-
throw
|
|
93
|
-
return {
|
|
121
|
+
catch (caughtError) {
|
|
122
|
+
if (isRedirect(caughtError))
|
|
123
|
+
throw caughtError;
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
error: 'Update failed',
|
|
127
|
+
values: formValuesForResponse(resource.fields, variables),
|
|
128
|
+
};
|
|
94
129
|
}
|
|
95
130
|
},
|
|
96
131
|
delete: async ({ request }) => {
|
|
132
|
+
if (resource.canDelete === false) {
|
|
133
|
+
return { success: false, error: 'Delete is disabled for this resource' };
|
|
134
|
+
}
|
|
97
135
|
const formData = await request.formData();
|
|
98
|
-
const id = formData.get('id');
|
|
99
|
-
|
|
136
|
+
const id = readRecordId(formData.get('id'));
|
|
137
|
+
if (!id)
|
|
138
|
+
return { success: false, error: 'Missing record id' };
|
|
139
|
+
const redirectTo = toSafeLocalRedirect(formData.get('redirect'));
|
|
100
140
|
try {
|
|
101
141
|
await dp.deleteOne({ resource: resource.name, id });
|
|
102
142
|
if (redirectTo)
|
|
103
143
|
throw redirect(303, redirectTo);
|
|
104
144
|
return { success: true };
|
|
105
145
|
}
|
|
106
|
-
catch (
|
|
107
|
-
if (isRedirect(
|
|
108
|
-
throw
|
|
109
|
-
return { success: false, error:
|
|
146
|
+
catch (caughtError) {
|
|
147
|
+
if (isRedirect(caughtError))
|
|
148
|
+
throw caughtError;
|
|
149
|
+
return { success: false, error: 'Delete failed' };
|
|
110
150
|
}
|
|
111
151
|
},
|
|
112
152
|
};
|
|
@@ -117,30 +157,32 @@ export function createCrudActions(dp, resource) {
|
|
|
117
157
|
* and redirects unauthenticated users to a login page.
|
|
118
158
|
*/
|
|
119
159
|
export function createAuthGuard(authProvider, loginPath = '/lite/login') {
|
|
160
|
+
const loginSegmentStart = loginPath.lastIndexOf('/');
|
|
161
|
+
const authBasePath = loginSegmentStart > 0
|
|
162
|
+
? loginPath.slice(0, loginSegmentStart)
|
|
163
|
+
: '';
|
|
164
|
+
const allowedPublicPaths = new Set([
|
|
165
|
+
loginPath,
|
|
166
|
+
`${authBasePath}/register`,
|
|
167
|
+
`${authBasePath}/forgot-password`,
|
|
168
|
+
`${authBasePath}/update-password`,
|
|
169
|
+
]);
|
|
120
170
|
return async ({ event, resolve }) => {
|
|
121
|
-
|
|
122
|
-
if (event.url.pathname === loginPath) {
|
|
171
|
+
if (allowedPublicPaths.has(event.url.pathname)) {
|
|
123
172
|
return resolve(event);
|
|
124
173
|
}
|
|
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
174
|
try {
|
|
134
175
|
const check = await authProvider.check();
|
|
135
176
|
if (!check.authenticated) {
|
|
136
177
|
event.cookies.delete('svadmin-session', { path: '/' });
|
|
137
178
|
return new Response(null, {
|
|
138
179
|
status: 302,
|
|
139
|
-
headers: { Location: loginPath },
|
|
180
|
+
headers: { Location: toSafeLocalRedirect(check.redirectTo) ?? loginPath },
|
|
140
181
|
});
|
|
141
182
|
}
|
|
142
183
|
}
|
|
143
184
|
catch {
|
|
185
|
+
// Authentication checks fail closed so provider errors never expose a protected page.
|
|
144
186
|
event.cookies.delete('svadmin-session', { path: '/' });
|
|
145
187
|
return new Response(null, {
|
|
146
188
|
status: 302,
|
|
@@ -151,41 +193,98 @@ export function createAuthGuard(authProvider, loginPath = '/lite/login') {
|
|
|
151
193
|
};
|
|
152
194
|
}
|
|
153
195
|
/**
|
|
154
|
-
* Creates
|
|
196
|
+
* Creates the form actions used by all exported Lite authentication pages.
|
|
155
197
|
*/
|
|
156
198
|
export function createAuthActions(authProvider) {
|
|
199
|
+
async function readAuthParams(request) {
|
|
200
|
+
return Object.fromEntries(await request.formData());
|
|
201
|
+
}
|
|
202
|
+
function validatePasswordConfirmation(authParams) {
|
|
203
|
+
const { confirmPassword, ...providerParams } = authParams;
|
|
204
|
+
const password = authParams.password;
|
|
205
|
+
if (typeof password !== 'string'
|
|
206
|
+
|| password.length === 0
|
|
207
|
+
|| typeof confirmPassword !== 'string'
|
|
208
|
+
|| confirmPassword.length === 0) {
|
|
209
|
+
return { valid: false, error: 'Password and confirmation are required' };
|
|
210
|
+
}
|
|
211
|
+
if (password !== confirmPassword) {
|
|
212
|
+
return { valid: false, error: 'Passwords do not match' };
|
|
213
|
+
}
|
|
214
|
+
return { valid: true, providerParams };
|
|
215
|
+
}
|
|
216
|
+
async function runAuthFormAction(providerMethod, authParams, unsupportedMessage, failureMessage) {
|
|
217
|
+
if (!providerMethod)
|
|
218
|
+
return { success: false, error: unsupportedMessage };
|
|
219
|
+
try {
|
|
220
|
+
const authResult = await providerMethod.call(authProvider, authParams);
|
|
221
|
+
if (!authResult.success) {
|
|
222
|
+
return { success: false, error: authResult.error?.message ?? 'Authentication action failed' };
|
|
223
|
+
}
|
|
224
|
+
if (authResult.redirectTo)
|
|
225
|
+
throw redirect(303, authResult.redirectTo);
|
|
226
|
+
return { success: true };
|
|
227
|
+
}
|
|
228
|
+
catch (caughtError) {
|
|
229
|
+
if (isRedirect(caughtError))
|
|
230
|
+
throw caughtError;
|
|
231
|
+
return { success: false, error: failureMessage };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
157
234
|
return {
|
|
158
|
-
login: async ({ request, cookies }) => {
|
|
159
|
-
const
|
|
160
|
-
const params = Object.fromEntries(formData);
|
|
235
|
+
login: async ({ request, cookies, url }) => {
|
|
236
|
+
const authParams = await readAuthParams(request);
|
|
161
237
|
try {
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
// Store a session indicator in a cookie
|
|
238
|
+
const loginResult = await authProvider.login(authParams);
|
|
239
|
+
if (loginResult.success) {
|
|
165
240
|
cookies.set('svadmin-session', 'active', {
|
|
166
241
|
path: '/',
|
|
167
242
|
httpOnly: true,
|
|
168
243
|
sameSite: 'lax',
|
|
244
|
+
secure: url.protocol === 'https:',
|
|
169
245
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
170
246
|
});
|
|
171
|
-
throw redirect(303,
|
|
247
|
+
throw redirect(303, loginResult.redirectTo ?? '/lite');
|
|
172
248
|
}
|
|
173
|
-
return { success: false, error:
|
|
249
|
+
return { success: false, error: loginResult.error?.message ?? 'Login failed' };
|
|
174
250
|
}
|
|
175
|
-
catch (
|
|
176
|
-
if (isRedirect(
|
|
177
|
-
throw
|
|
178
|
-
return { success: false, error:
|
|
251
|
+
catch (caughtError) {
|
|
252
|
+
if (isRedirect(caughtError))
|
|
253
|
+
throw caughtError;
|
|
254
|
+
return { success: false, error: 'Login failed' };
|
|
179
255
|
}
|
|
180
256
|
},
|
|
181
257
|
logout: async ({ cookies }) => {
|
|
258
|
+
let logoutResult;
|
|
182
259
|
try {
|
|
183
|
-
await authProvider.logout();
|
|
260
|
+
logoutResult = await authProvider.logout();
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
cookies.delete('svadmin-session', { path: '/' });
|
|
264
|
+
return { success: false, error: 'Logout failed' };
|
|
184
265
|
}
|
|
185
|
-
catch { /* ignore */ }
|
|
186
266
|
cookies.delete('svadmin-session', { path: '/' });
|
|
267
|
+
if (!logoutResult.success) {
|
|
268
|
+
return { success: false, error: logoutResult.error?.message ?? 'Logout failed' };
|
|
269
|
+
}
|
|
270
|
+
if (logoutResult.redirectTo)
|
|
271
|
+
throw redirect(303, logoutResult.redirectTo);
|
|
187
272
|
return { success: true };
|
|
188
273
|
},
|
|
274
|
+
register: async ({ request }) => {
|
|
275
|
+
const confirmation = validatePasswordConfirmation(await readAuthParams(request));
|
|
276
|
+
if (!confirmation.valid)
|
|
277
|
+
return { success: false, error: confirmation.error };
|
|
278
|
+
return runAuthFormAction(authProvider.register, confirmation.providerParams, 'Registration is not supported by this AuthProvider', 'Registration failed');
|
|
279
|
+
},
|
|
280
|
+
forgot_password: async ({ request }) => runAuthFormAction(authProvider.forgotPassword, await readAuthParams(request), 'Password recovery is not supported by this AuthProvider', 'Password recovery failed'),
|
|
281
|
+
update_password: async ({ request }) => {
|
|
282
|
+
const confirmation = validatePasswordConfirmation(await readAuthParams(request));
|
|
283
|
+
if (!confirmation.valid)
|
|
284
|
+
return { success: false, error: confirmation.error };
|
|
285
|
+
return runAuthFormAction(authProvider.updatePassword, confirmation.providerParams, 'Password updates are not supported by this AuthProvider', 'Password update failed');
|
|
286
|
+
},
|
|
287
|
+
update_profile: async ({ request }) => runAuthFormAction((authProvider.updateProfile ?? authProvider.updateIdentity), await readAuthParams(request), 'Profile updates are not supported by this AuthProvider', 'Profile update failed'),
|
|
189
288
|
};
|
|
190
289
|
}
|
|
191
290
|
// ─── UA Detection ─────────────────────────────────────────────
|
|
@@ -201,17 +300,61 @@ export function isLegacyBrowser(userAgent) {
|
|
|
201
300
|
* Consumers remain responsible for their own transpilation and browser support.
|
|
202
301
|
*/
|
|
203
302
|
export function createLegacyRedirectHook(litePrefix = '/lite') {
|
|
303
|
+
const prefixSegments = litePrefix.split('/').filter(Boolean);
|
|
304
|
+
const normalizedPrefix = prefixSegments.length > 0 ? `/${prefixSegments.join('/')}` : '/';
|
|
204
305
|
return async ({ event, resolve }) => {
|
|
205
306
|
const ua = event.request.headers.get('user-agent') ?? '';
|
|
206
|
-
|
|
307
|
+
const acceptsHtml = event.request.headers.get('accept')?.includes('text/html') === true;
|
|
308
|
+
const isDocumentRequest = (event.request.method === 'GET' || event.request.method === 'HEAD')
|
|
309
|
+
&& acceptsHtml;
|
|
310
|
+
const isWithinLite = normalizedPrefix === '/'
|
|
311
|
+
|| event.url.pathname === normalizedPrefix
|
|
312
|
+
|| event.url.pathname.startsWith(`${normalizedPrefix}/`);
|
|
313
|
+
if (isLegacyBrowser(ua) && isDocumentRequest && !isWithinLite) {
|
|
314
|
+
const targetPath = normalizedPrefix === '/'
|
|
315
|
+
? event.url.pathname
|
|
316
|
+
: `${normalizedPrefix}${event.url.pathname}`;
|
|
207
317
|
return new Response(null, {
|
|
208
318
|
status: 302,
|
|
209
|
-
headers: { Location: `${
|
|
319
|
+
headers: { Location: `${targetPath}${event.url.search}` },
|
|
210
320
|
});
|
|
211
321
|
}
|
|
212
322
|
return resolve(event);
|
|
213
323
|
};
|
|
214
324
|
}
|
|
325
|
+
// ─── Utilities ────────────────────────────────────────────────
|
|
326
|
+
function readRecordId(submittedId) {
|
|
327
|
+
if (typeof submittedId !== 'string' || submittedId.trim().length === 0)
|
|
328
|
+
return undefined;
|
|
329
|
+
return submittedId.trim();
|
|
330
|
+
}
|
|
331
|
+
function containsControlCharacter(untrustedRedirect) {
|
|
332
|
+
return Array.from(untrustedRedirect).some((character) => {
|
|
333
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
334
|
+
return codePoint <= 31 || (codePoint >= 127 && codePoint <= 159);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function toSafeLocalRedirect(submittedRedirect) {
|
|
338
|
+
if (typeof submittedRedirect !== 'string'
|
|
339
|
+
|| !submittedRedirect.startsWith('/')
|
|
340
|
+
|| submittedRedirect.startsWith('//')) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
if (submittedRedirect.includes('\\') || containsControlCharacter(submittedRedirect)) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
const base = new URL('https://svadmin.local');
|
|
347
|
+
try {
|
|
348
|
+
const target = new URL(submittedRedirect, base);
|
|
349
|
+
if (target.origin !== base.origin)
|
|
350
|
+
return undefined;
|
|
351
|
+
return `${target.pathname}${target.search}${target.hash}`;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Malformed untrusted redirect input is intentionally treated as no redirect.
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
215
358
|
function formatValidationErrors(issues) {
|
|
216
359
|
const errors = {};
|
|
217
360
|
for (const issue of issues) {
|
|
@@ -233,7 +376,7 @@ function validateFormVariables(resource, mode, values) {
|
|
|
233
376
|
failure: {
|
|
234
377
|
success: false,
|
|
235
378
|
error: 'Validation failed',
|
|
236
|
-
values,
|
|
379
|
+
values: formValuesForResponse(resource.fields, values),
|
|
237
380
|
errors: formatValidationErrors(result.error.issues),
|
|
238
381
|
},
|
|
239
382
|
};
|
|
@@ -241,6 +384,28 @@ function validateFormVariables(resource, mode, values) {
|
|
|
241
384
|
function isNativeFile(value) {
|
|
242
385
|
return typeof File !== 'undefined' && value instanceof File;
|
|
243
386
|
}
|
|
387
|
+
function formValuesForResponse(fields, values) {
|
|
388
|
+
const responseValues = {};
|
|
389
|
+
for (const field of fields) {
|
|
390
|
+
if (field.type === 'password' || !(field.key in values))
|
|
391
|
+
continue;
|
|
392
|
+
const value = values[field.key];
|
|
393
|
+
if (field.type === 'array' && Array.isArray(value)) {
|
|
394
|
+
responseValues[field.key] = value.map((row) => {
|
|
395
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row))
|
|
396
|
+
return row;
|
|
397
|
+
return formValuesForResponse(field.subFields ?? [], row);
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (isNativeFile(value))
|
|
402
|
+
continue;
|
|
403
|
+
responseValues[field.key] = Array.isArray(value)
|
|
404
|
+
? value.filter((entry) => !isNativeFile(entry))
|
|
405
|
+
: value;
|
|
406
|
+
}
|
|
407
|
+
return responseValues;
|
|
408
|
+
}
|
|
244
409
|
function isNonEmptyNativeFile(value) {
|
|
245
410
|
return isNativeFile(value) && value.size > 0 && value.name !== '';
|
|
246
411
|
}
|
|
@@ -290,7 +455,7 @@ function formDataToObject(formData, fields) {
|
|
|
290
455
|
}
|
|
291
456
|
break;
|
|
292
457
|
case 'boolean':
|
|
293
|
-
obj[field.key] = strRaw
|
|
458
|
+
obj[field.key] = parseExplicitBoolean(strRaw) ?? strRaw;
|
|
294
459
|
break;
|
|
295
460
|
case 'tags':
|
|
296
461
|
obj[field.key] = strRaw ? strRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
@@ -336,9 +501,12 @@ function parseArrayField(formData, field) {
|
|
|
336
501
|
for (const subField of field.subFields ?? []) {
|
|
337
502
|
const rawValues = entries.get(subField.key) ?? [];
|
|
338
503
|
if (subField.type === 'boolean') {
|
|
339
|
-
const
|
|
504
|
+
const rawValue = rawValues.at(-1);
|
|
505
|
+
const value = rawValue === undefined
|
|
506
|
+
? false
|
|
507
|
+
: parseExplicitBoolean(rawValue) ?? rawValue;
|
|
340
508
|
item[subField.key] = value;
|
|
341
|
-
hasMeaningfulValue ||= value;
|
|
509
|
+
hasMeaningfulValue ||= value !== false;
|
|
342
510
|
continue;
|
|
343
511
|
}
|
|
344
512
|
if (rawValues.length === 0)
|
|
@@ -406,7 +574,7 @@ function coerceFieldValues(field, rawValues) {
|
|
|
406
574
|
return undefined;
|
|
407
575
|
return Number.isNaN(Number(raw)) ? raw : Number(raw);
|
|
408
576
|
case 'boolean':
|
|
409
|
-
return raw
|
|
577
|
+
return parseExplicitBoolean(raw) ?? raw;
|
|
410
578
|
case 'tags':
|
|
411
579
|
return raw ? raw.split(',').map((value) => value.trim()).filter(Boolean) : [];
|
|
412
580
|
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.13",
|
|
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
|
}
|