webspresso 0.0.77 → 0.0.78

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.
Files changed (33) hide show
  1. package/README.md +5 -0
  2. package/bin/commands/db-scaffold.js +81 -0
  3. package/bin/utils/model-migrations.js +211 -0
  4. package/bin/webspresso.js +2 -0
  5. package/core/content/cache.js +64 -0
  6. package/core/content/field-types.js +180 -0
  7. package/core/content/index.js +30 -0
  8. package/core/content/renderer.js +84 -0
  9. package/core/content/schema.js +75 -0
  10. package/core/content/service.js +400 -0
  11. package/core/content/types.js +59 -0
  12. package/index.d.ts +17 -0
  13. package/index.js +7 -0
  14. package/package.json +1 -1
  15. package/plugins/admin-panel/app.js +7 -7
  16. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +41 -0
  17. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +2 -2
  18. package/plugins/admin-panel/index.js +17 -18
  19. package/plugins/admin-panel/modules/menu.js +1 -0
  20. package/plugins/content/admin/content-entries-component.js +291 -0
  21. package/plugins/content/admin/content-types-component.js +250 -0
  22. package/plugins/content/api-handlers.js +157 -0
  23. package/plugins/content/client/inline-edit.css +296 -0
  24. package/plugins/content/client/inline-edit.js +366 -0
  25. package/plugins/content/helpers.js +77 -0
  26. package/plugins/content/index.js +231 -0
  27. package/plugins/content/migration-template.js +54 -0
  28. package/plugins/content/models/content-entry.js +45 -0
  29. package/plugins/content/models/content-type.js +36 -0
  30. package/plugins/index.js +2 -0
  31. package/src/file-router.js +21 -1
  32. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
  33. package/templates/skills/webspresso-usage/SKILL.md +5 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Content type schema validation
3
+ * @module core/content/schema
4
+ */
5
+
6
+ const { z } = require('zod');
7
+ const { isValidFieldType } = require('./field-types');
8
+
9
+ const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+
11
+ const fieldDefinitionSchema = z.object({
12
+ name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/i, 'Field name must be alphanumeric'),
13
+ type: z.string().refine(isValidFieldType, 'Invalid field type'),
14
+ label: z.string().max(255).optional(),
15
+ required: z.boolean().optional(),
16
+ options: z.array(z.string()).optional(),
17
+ fields: z.lazy(() => z.array(fieldDefinitionSchema)).optional(),
18
+ });
19
+
20
+ const contentTypeSchemaSchema = z.object({
21
+ fields: z.array(fieldDefinitionSchema).min(0),
22
+ });
23
+
24
+ /**
25
+ * @param {string} slug
26
+ * @returns {boolean}
27
+ */
28
+ function isValidSlug(slug) {
29
+ return typeof slug === 'string' && SLUG_REGEX.test(slug);
30
+ }
31
+
32
+ /**
33
+ * @param {unknown} schema
34
+ * @returns {import('./types').ContentTypeSchema}
35
+ */
36
+ function parseContentTypeSchema(schema) {
37
+ const parsed = contentTypeSchemaSchema.parse(schema);
38
+ const names = new Set();
39
+ for (const field of parsed.fields) {
40
+ if (names.has(field.name)) {
41
+ throw new Error(`Duplicate field name "${field.name}"`);
42
+ }
43
+ names.add(field.name);
44
+ if (field.type === 'select' && (!field.options || field.options.length === 0)) {
45
+ throw new Error(`Select field "${field.name}" requires options`);
46
+ }
47
+ if (field.type === 'repeater') {
48
+ if (!field.fields || field.fields.length === 0) {
49
+ throw new Error(`Repeater field "${field.name}" requires nested fields`);
50
+ }
51
+ parseContentTypeSchema({ fields: field.fields });
52
+ }
53
+ }
54
+ return parsed;
55
+ }
56
+
57
+ /**
58
+ * @param {import('./types').ContentTypeSchema} schema
59
+ * @param {Record<string, unknown>} data
60
+ * @param {{ sanitizeRichHtml?: (v: string) => string }} [options]
61
+ * @returns {Record<string, unknown>}
62
+ */
63
+ function validateEntryData(schema, data, options = {}) {
64
+ const { normalizeEntryData } = require('./field-types');
65
+ return normalizeEntryData(data, schema.fields, options);
66
+ }
67
+
68
+ module.exports = {
69
+ SLUG_REGEX,
70
+ isValidSlug,
71
+ parseContentTypeSchema,
72
+ validateEntryData,
73
+ fieldDefinitionSchema,
74
+ contentTypeSchemaSchema,
75
+ };
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Content service — CRUD for content types and entries.
3
+ * @module core/content/service
4
+ */
5
+
6
+ const { isValidSlug, parseContentTypeSchema, validateEntryData } = require('./schema');
7
+ const { createContentCache } = require('./cache');
8
+
9
+ /**
10
+ * @param {import('../orm').Database} db
11
+ * @param {Object} [options]
12
+ * @param {number|null} [options.cacheTtlMs] - null = in-memory until write invalidation
13
+ * @param {(html: string) => string} [options.sanitizeRichHtml]
14
+ */
15
+ function createContentService(db, options = {}) {
16
+ if (!db) {
17
+ throw new Error('createContentService requires a database instance');
18
+ }
19
+
20
+ const TypeRepo = () => db.getRepository('ContentType');
21
+ const EntryRepo = () => db.getRepository('ContentEntry');
22
+ const cache = createContentCache(
23
+ options.cacheTtlMs !== undefined ? options.cacheTtlMs : null
24
+ );
25
+ const sanitizeRichHtml = options.sanitizeRichHtml;
26
+
27
+ function entryCacheKey(typeSlug, entrySlug, locale, includeDraft) {
28
+ return `entry:${typeSlug}:${entrySlug}:${locale ?? ''}:${includeDraft ? 'all' : 'pub'}`;
29
+ }
30
+
31
+ function invalidateType(typeSlug, typeId) {
32
+ if (typeSlug) {
33
+ cache.invalidatePrefix(`entry:${typeSlug}:`);
34
+ cache.invalidatePrefix(`entries:${typeSlug}:`);
35
+ cache.del(`type:slug:${typeSlug}`);
36
+ }
37
+ if (typeId != null) {
38
+ cache.del(`type:id:${typeId}`);
39
+ }
40
+ cache.invalidatePrefix('types:');
41
+ }
42
+
43
+ function invalidateEntry(entry, type) {
44
+ if (entry?.id != null) {
45
+ cache.del(`entry:id:${entry.id}`);
46
+ }
47
+ if (type?.slug && entry?.slug != null) {
48
+ cache.invalidatePrefix(`entry:${type.slug}:${entry.slug}:`);
49
+ }
50
+ if (type?.slug) {
51
+ cache.invalidatePrefix(`entries:${type.slug}:`);
52
+ }
53
+ }
54
+
55
+ function cacheTypeRow(row) {
56
+ if (!row) return;
57
+ cache.set(`type:slug:${row.slug}`, row);
58
+ cache.set(`type:id:${row.id}`, row);
59
+ }
60
+
61
+ /**
62
+ * @returns {Promise<import('./types').ContentTypeRecord[]>}
63
+ */
64
+ async function listTypes() {
65
+ const cached = cache.get('types:all');
66
+ if (cached) return cached;
67
+
68
+ const rows = await db.query('ContentType').orderBy('name', 'asc').list();
69
+ cache.set('types:all', rows);
70
+ for (const row of rows) {
71
+ cacheTypeRow(row);
72
+ }
73
+ return rows;
74
+ }
75
+
76
+ /**
77
+ * @param {number|string} id
78
+ */
79
+ async function getTypeById(id) {
80
+ const key = `type:id:${id}`;
81
+ const cached = cache.get(key);
82
+ if (cached) return cached;
83
+
84
+ const row = await TypeRepo().findById(id);
85
+ cacheTypeRow(row);
86
+ return row;
87
+ }
88
+
89
+ /**
90
+ * @param {string} slug
91
+ */
92
+ async function getTypeBySlug(slug) {
93
+ const key = `type:slug:${slug}`;
94
+ const cached = cache.get(key);
95
+ if (cached) return cached;
96
+
97
+ const row = await TypeRepo().findOne({ slug });
98
+ cacheTypeRow(row);
99
+ return row;
100
+ }
101
+
102
+ /**
103
+ * @param {Object} input
104
+ * @param {string} input.slug
105
+ * @param {string} input.name
106
+ * @param {string} [input.description]
107
+ * @param {import('./types').ContentTypeSchema} input.schema
108
+ * @param {Object} [input.settings]
109
+ */
110
+ async function createType(input) {
111
+ if (!isValidSlug(input.slug)) {
112
+ throw new Error('Invalid content type slug');
113
+ }
114
+ const schema = parseContentTypeSchema(input.schema);
115
+ const existing = await getTypeBySlug(input.slug);
116
+ if (existing) {
117
+ throw new Error(`Content type "${input.slug}" already exists`);
118
+ }
119
+
120
+ const row = await TypeRepo().create({
121
+ slug: input.slug,
122
+ name: input.name,
123
+ description: input.description ?? null,
124
+ schema,
125
+ settings: input.settings ?? null,
126
+ });
127
+
128
+ cache.invalidateAll();
129
+ return row;
130
+ }
131
+
132
+ /**
133
+ * @param {number|string} id
134
+ * @param {Object} input
135
+ */
136
+ async function updateType(id, input) {
137
+ const existing = await getTypeById(id);
138
+ if (!existing) {
139
+ throw new Error('Content type not found');
140
+ }
141
+
142
+ /** @type {Record<string, unknown>} */
143
+ const patch = {};
144
+
145
+ if (input.slug !== undefined) {
146
+ if (!isValidSlug(input.slug)) {
147
+ throw new Error('Invalid content type slug');
148
+ }
149
+ if (input.slug !== existing.slug) {
150
+ const conflict = await getTypeBySlug(input.slug);
151
+ if (conflict) {
152
+ throw new Error(`Content type "${input.slug}" already exists`);
153
+ }
154
+ }
155
+ patch.slug = input.slug;
156
+ }
157
+ if (input.name !== undefined) patch.name = input.name;
158
+ if (input.description !== undefined) patch.description = input.description;
159
+ if (input.settings !== undefined) patch.settings = input.settings;
160
+ if (input.schema !== undefined) {
161
+ patch.schema = parseContentTypeSchema(input.schema);
162
+ }
163
+
164
+ const row = await TypeRepo().update(id, patch);
165
+ invalidateType(existing.slug, existing.id);
166
+ if (patch.slug && patch.slug !== existing.slug) {
167
+ invalidateType(String(patch.slug), existing.id);
168
+ }
169
+ return row;
170
+ }
171
+
172
+ /**
173
+ * @param {number|string} id
174
+ */
175
+ async function deleteType(id) {
176
+ const existing = await getTypeById(id);
177
+ if (!existing) {
178
+ throw new Error('Content type not found');
179
+ }
180
+
181
+ const entries = await db.query('ContentEntry').where('content_type_id', existing.id).list();
182
+ for (const entry of entries) {
183
+ invalidateEntry(entry, existing);
184
+ await EntryRepo().delete(entry.id);
185
+ }
186
+
187
+ await TypeRepo().delete(id);
188
+ invalidateType(existing.slug, existing.id);
189
+ return { success: true };
190
+ }
191
+
192
+ /**
193
+ * @param {string} typeSlug
194
+ * @param {Object} [filters]
195
+ */
196
+ async function listEntries(typeSlug, filters = {}) {
197
+ const status = filters.status ?? '';
198
+ const localeKey = filters.locale !== undefined ? String(filters.locale) : '';
199
+ const listKey = `entries:${typeSlug}:${status}:${localeKey}`;
200
+ const cached = cache.get(listKey);
201
+ if (cached) return cached;
202
+
203
+ const type = await getTypeBySlug(typeSlug);
204
+ if (!type) {
205
+ throw new Error(`Content type "${typeSlug}" not found`);
206
+ }
207
+
208
+ /** @type {Record<string, unknown>} */
209
+ const where = { content_type_id: type.id };
210
+ if (filters.status) where.status = filters.status;
211
+ if (filters.locale !== undefined) where.locale = filters.locale;
212
+
213
+ const rows = await db.query('ContentEntry')
214
+ .where(where)
215
+ .orderBy('updated_at', 'desc')
216
+ .list();
217
+
218
+ cache.set(listKey, rows);
219
+ return rows;
220
+ }
221
+
222
+ /**
223
+ * @param {string} typeSlug
224
+ * @param {string} entrySlug
225
+ * @param {import('./types').GetEntryOptions} [opts]
226
+ * @returns {Promise<import('./types').ContentEntryResult|null>}
227
+ */
228
+ async function getEntry(typeSlug, entrySlug, opts = {}) {
229
+ const locale = opts.locale ?? null;
230
+ const includeDraft = Boolean(opts.includeDraft);
231
+ const key = entryCacheKey(typeSlug, entrySlug, locale, includeDraft);
232
+ const cached = cache.get(key);
233
+ if (cached !== undefined) return cached;
234
+
235
+ const type = await getTypeBySlug(typeSlug);
236
+ if (!type) return null;
237
+
238
+ /** @type {Record<string, unknown>} */
239
+ const where = {
240
+ content_type_id: type.id,
241
+ slug: entrySlug,
242
+ locale,
243
+ };
244
+
245
+ if (!includeDraft) {
246
+ where.status = 'published';
247
+ }
248
+
249
+ const entry = await EntryRepo().findOne(where);
250
+ if (!entry) {
251
+ cache.set(key, null);
252
+ return null;
253
+ }
254
+
255
+ const result = {
256
+ data: entry.data || {},
257
+ meta: {
258
+ id: entry.id,
259
+ slug: entry.slug,
260
+ title: entry.title,
261
+ status: entry.status,
262
+ locale: entry.locale,
263
+ revision: entry.revision,
264
+ contentTypeId: type.id,
265
+ typeSlug: type.slug,
266
+ updatedAt: entry.updated_at,
267
+ },
268
+ type,
269
+ };
270
+
271
+ cache.set(key, result);
272
+ cache.set(`entry:id:${entry.id}`, { entry, type });
273
+ return result;
274
+ }
275
+
276
+ /**
277
+ * @param {number|string} id
278
+ */
279
+ async function getEntryById(id) {
280
+ const key = `entry:id:${id}`;
281
+ const cached = cache.get(key);
282
+ if (cached) return cached;
283
+
284
+ const entry = await EntryRepo().findById(id);
285
+ if (!entry) return null;
286
+
287
+ const type = await getTypeById(entry.content_type_id);
288
+ const bundle = { entry, type };
289
+ cache.set(key, bundle);
290
+ return bundle;
291
+ }
292
+
293
+ /**
294
+ * @param {string} typeSlug
295
+ * @param {Object} input
296
+ */
297
+ async function createEntry(typeSlug, input) {
298
+ const type = await getTypeBySlug(typeSlug);
299
+ if (!type) {
300
+ throw new Error(`Content type "${typeSlug}" not found`);
301
+ }
302
+ if (!isValidSlug(input.slug)) {
303
+ throw new Error('Invalid entry slug');
304
+ }
305
+
306
+ const locale = input.locale ?? null;
307
+ const existing = await EntryRepo().findOne({
308
+ content_type_id: type.id,
309
+ slug: input.slug,
310
+ locale,
311
+ });
312
+ if (existing) {
313
+ throw new Error(`Entry "${input.slug}" already exists for this content type`);
314
+ }
315
+
316
+ const data = validateEntryData(type.schema, input.data || {}, { sanitizeRichHtml });
317
+
318
+ const row = await EntryRepo().create({
319
+ content_type_id: type.id,
320
+ slug: input.slug,
321
+ title: input.title ?? null,
322
+ data,
323
+ status: input.status === 'draft' ? 'draft' : 'published',
324
+ locale,
325
+ revision: 1,
326
+ });
327
+
328
+ invalidateType(type.slug, type.id);
329
+ return row;
330
+ }
331
+
332
+ /**
333
+ * @param {number|string} id
334
+ * @param {Object} input
335
+ */
336
+ async function updateEntry(id, input) {
337
+ const bundle = await getEntryById(id);
338
+ if (!bundle) {
339
+ throw new Error('Content entry not found');
340
+ }
341
+ const { entry, type } = bundle;
342
+
343
+ /** @type {Record<string, unknown>} */
344
+ const patch = {};
345
+
346
+ if (input.slug !== undefined) {
347
+ if (!isValidSlug(input.slug)) {
348
+ throw new Error('Invalid entry slug');
349
+ }
350
+ patch.slug = input.slug;
351
+ }
352
+ if (input.title !== undefined) patch.title = input.title;
353
+ if (input.status !== undefined) {
354
+ patch.status = input.status === 'draft' ? 'draft' : 'published';
355
+ }
356
+ if (input.locale !== undefined) patch.locale = input.locale;
357
+ if (input.data !== undefined && type) {
358
+ patch.data = validateEntryData(type.schema, input.data, { sanitizeRichHtml });
359
+ }
360
+
361
+ patch.revision = (entry.revision || 1) + 1;
362
+
363
+ const row = await EntryRepo().update(id, patch);
364
+ invalidateEntry(entry, type);
365
+ if (type) invalidateType(type.slug, type.id);
366
+ return row;
367
+ }
368
+
369
+ /**
370
+ * @param {number|string} id
371
+ */
372
+ async function deleteEntry(id) {
373
+ const bundle = await getEntryById(id);
374
+ if (!bundle) {
375
+ throw new Error('Content entry not found');
376
+ }
377
+
378
+ await EntryRepo().delete(id);
379
+ invalidateEntry(bundle.entry, bundle.type);
380
+ if (bundle.type) invalidateType(bundle.type.slug, bundle.type.id);
381
+ return { success: true };
382
+ }
383
+
384
+ return {
385
+ listTypes,
386
+ getTypeById,
387
+ getTypeBySlug,
388
+ createType,
389
+ updateType,
390
+ deleteType,
391
+ listEntries,
392
+ getEntry,
393
+ getEntryById,
394
+ createEntry,
395
+ updateEntry,
396
+ deleteEntry,
397
+ };
398
+ }
399
+
400
+ module.exports = { createContentService };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @typedef {'text'|'textarea'|'rich-text'|'number'|'boolean'|'image'|'url'|'date'|'select'|'repeater'} ContentFieldType
3
+ */
4
+
5
+ /**
6
+ * @typedef {Object} ContentFieldDefinition
7
+ * @property {string} name
8
+ * @property {ContentFieldType} type
9
+ * @property {string} [label]
10
+ * @property {boolean} [required]
11
+ * @property {string[]} [options] - for select
12
+ * @property {ContentFieldDefinition[]} [fields] - for repeater
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} ContentTypeSchema
17
+ * @property {ContentFieldDefinition[]} fields
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} ContentTypeRecord
22
+ * @property {number} id
23
+ * @property {string} slug
24
+ * @property {string} name
25
+ * @property {string|null} [description]
26
+ * @property {ContentTypeSchema} schema
27
+ * @property {Object|null} [settings]
28
+ * @property {string} [created_at]
29
+ * @property {string} [updated_at]
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} ContentEntryRecord
34
+ * @property {number} id
35
+ * @property {number} content_type_id
36
+ * @property {string} slug
37
+ * @property {string|null} [title]
38
+ * @property {Record<string, unknown>} data
39
+ * @property {'published'|'draft'} status
40
+ * @property {string|null} [locale]
41
+ * @property {number} [revision]
42
+ * @property {string} [created_at]
43
+ * @property {string} [updated_at]
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} ContentEntryResult
48
+ * @property {Record<string, unknown>} data
49
+ * @property {Object} meta
50
+ * @property {ContentTypeRecord|null} [type]
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} GetEntryOptions
55
+ * @property {string|null} [locale]
56
+ * @property {boolean} [includeDraft]
57
+ */
58
+
59
+ module.exports = {};
package/index.d.ts CHANGED
@@ -595,6 +595,23 @@ export interface RateLimitPluginOptions {
595
595
 
596
596
  export function rateLimitPlugin(options?: RateLimitPluginOptions): WebspressoPlugin;
597
597
 
598
+ export interface ContentPluginOptions {
599
+ db: import('./index').Database;
600
+ adminPath?: string;
601
+ publicApiPath?: string;
602
+ inlineEdit?: boolean;
603
+ cacheTtlMs?: number | null;
604
+ }
605
+
606
+ export function contentPlugin(options: ContentPluginOptions): WebspressoPlugin;
607
+
608
+ export const content: {
609
+ createContentService: typeof import('./core/content').createContentService;
610
+ parseContentTypeSchema: typeof import('./core/content').parseContentTypeSchema;
611
+ validateEntryData: typeof import('./core/content').validateEntryData;
612
+ wrapEditable: typeof import('./core/content').wrapEditable;
613
+ };
614
+
598
615
  export interface RestResourcePluginOptions {
599
616
  path?: string;
600
617
  middleware?: RequestHandler[];
package/index.js CHANGED
@@ -62,8 +62,11 @@ const {
62
62
  dataExchangePlugin,
63
63
  redirectPlugin,
64
64
  rateLimitPlugin,
65
+ contentPlugin,
65
66
  } = require('./plugins');
66
67
 
68
+ const content = require('./core/content');
69
+
67
70
  module.exports = {
68
71
  // Main API
69
72
  createApp,
@@ -129,4 +132,8 @@ module.exports = {
129
132
  dataExchangePlugin,
130
133
  redirectPlugin,
131
134
  rateLimitPlugin,
135
+ contentPlugin,
136
+
137
+ // Schema-driven CMS core (framework-agnostic)
138
+ content,
132
139
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.77",
3
+ "version": "0.0.78",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -45,7 +45,7 @@ function getUserManagementModel() {
45
45
  async function guardUserManagementRoutes() {
46
46
  var isAuth = await checkAuth();
47
47
  if (!isAuth) {
48
- m.route.set('/login');
48
+ redirectToLogin();
49
49
  return false;
50
50
  }
51
51
  if (!getUserManagementModel()) {
@@ -135,7 +135,7 @@ var routes = {
135
135
  onmatch: async () => {
136
136
  const isAuth = await checkAuth();
137
137
  if (isAuth) {
138
- m.route.set('/');
138
+ m.route.set(consumeIntendedRoute('/'));
139
139
  return;
140
140
  }
141
141
  return LoginForm;
@@ -155,7 +155,7 @@ var routes = {
155
155
  onmatch: async () => {
156
156
  const isAuth = await checkAuth();
157
157
  if (!isAuth) {
158
- m.route.set('/login');
158
+ redirectToLogin();
159
159
  return;
160
160
  }
161
161
  return SettingsPage;
@@ -193,7 +193,7 @@ var routes = {
193
193
  onmatch: async () => {
194
194
  const isAuth = await checkAuth();
195
195
  if (!isAuth) {
196
- m.route.set('/login');
196
+ redirectToLogin();
197
197
  return;
198
198
  }
199
199
  return RecordList;
@@ -203,7 +203,7 @@ var routes = {
203
203
  onmatch: async () => {
204
204
  const isAuth = await checkAuth();
205
205
  if (!isAuth) {
206
- m.route.set('/login');
206
+ redirectToLogin();
207
207
  return;
208
208
  }
209
209
  return RecordForm;
@@ -213,7 +213,7 @@ var routes = {
213
213
  onmatch: async () => {
214
214
  const isAuth = await checkAuth();
215
215
  if (!isAuth) {
216
- m.route.set('/login');
216
+ redirectToLogin();
217
217
  return;
218
218
  }
219
219
  return RecordForm;
@@ -230,7 +230,7 @@ if (config && config.pages) {
230
230
  onmatch: async () => {
231
231
  const isAuth = await checkAuth();
232
232
  if (!isAuth) {
233
- m.route.set('/login');
233
+ redirectToLogin();
234
234
  return;
235
235
  }
236
236
  if (window.__customPages && window.__customPages[page.id]) {
@@ -15,6 +15,9 @@ const api = {
15
15
 
16
16
  if (!response.ok) {
17
17
  const error = await response.json().catch(() => ({ error: 'Request failed' }));
18
+ if (response.status === 401 && path.indexOf('/auth/') !== 0) {
19
+ redirectToLogin();
20
+ }
18
21
  throw new Error(error.error || 'Request failed');
19
22
  }
20
23
 
@@ -101,6 +104,44 @@ const state = {
101
104
  selectAllMode: false, // true = all records selected (not just current page)
102
105
  };
103
106
 
107
+ var INTENDED_ROUTE_KEY = 'webspresso.admin.intendedRoute';
108
+
109
+ function isSafeIntendedRoute(path) {
110
+ if (!path || typeof path !== 'string') return false;
111
+ if (path.charAt(0) !== '/') return false;
112
+ if (path === '/login' || path === '/setup') return false;
113
+ return true;
114
+ }
115
+
116
+ function saveIntendedRoute() {
117
+ try {
118
+ var path = m.route.get();
119
+ if (!isSafeIntendedRoute(path)) return;
120
+ sessionStorage.setItem(INTENDED_ROUTE_KEY, path);
121
+ } catch (e) {}
122
+ }
123
+
124
+ function clearIntendedRoute() {
125
+ try {
126
+ sessionStorage.removeItem(INTENDED_ROUTE_KEY);
127
+ } catch (e) {}
128
+ }
129
+
130
+ function consumeIntendedRoute(defaultPath) {
131
+ defaultPath = defaultPath || '/';
132
+ try {
133
+ var path = sessionStorage.getItem(INTENDED_ROUTE_KEY);
134
+ sessionStorage.removeItem(INTENDED_ROUTE_KEY);
135
+ if (isSafeIntendedRoute(path)) return path;
136
+ } catch (e) {}
137
+ return defaultPath;
138
+ }
139
+
140
+ function redirectToLogin() {
141
+ saveIntendedRoute();
142
+ m.route.set('/login');
143
+ }
144
+
104
145
  // Breadcrumb Component
105
146
  const Breadcrumb = {
106
147
  view: (vnode) => {