@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.
Files changed (55) hide show
  1. package/README.md +30 -5
  2. package/dist/components/LiteArrayItem.svelte +31 -10
  3. package/dist/components/LiteAuditLog.svelte +8 -17
  4. package/dist/components/LiteAuditLog.svelte.d.ts +5 -13
  5. package/dist/components/LiteForm.svelte +37 -7
  6. package/dist/components/LiteForm.svelte.d.ts +1 -1
  7. package/dist/components/LiteLayout.svelte +12 -7
  8. package/dist/components/LiteLayout.svelte.d.ts +2 -0
  9. package/dist/components/LitePermissionMatrix.svelte +48 -15
  10. package/dist/components/LitePermissionMatrix.svelte.d.ts +10 -9
  11. package/dist/components/LiteShow.svelte +3 -2
  12. package/dist/components/LiteShowField.svelte +5 -3
  13. package/dist/components/LiteTable.svelte +19 -9
  14. package/dist/components/LiteTable.svelte.d.ts +1 -0
  15. package/dist/components/advanced/LiteVirtualTable.svelte +5 -2
  16. package/dist/components/advanced/LiteVirtualTable.svelte.d.ts +1 -0
  17. package/dist/components/buttons/LiteCloneButton.svelte +2 -2
  18. package/dist/components/buttons/LiteCreateButton.svelte +2 -2
  19. package/dist/components/buttons/LiteDeleteButton.svelte +2 -2
  20. package/dist/components/buttons/LiteEditButton.svelte +2 -2
  21. package/dist/components/buttons/LiteExportButton.svelte +2 -2
  22. package/dist/components/buttons/LiteImportButton.svelte +2 -2
  23. package/dist/components/buttons/LiteListButton.svelte +2 -2
  24. package/dist/components/buttons/LiteRefreshButton.svelte +7 -11
  25. package/dist/components/buttons/LiteSaveButton.svelte +2 -2
  26. package/dist/components/buttons/LiteShowButton.svelte +2 -2
  27. package/dist/components/fields/LiteBooleanField.svelte +9 -8
  28. package/dist/components/fields/LiteDateField.svelte +2 -2
  29. package/dist/components/fields/LiteEmailField.svelte +1 -2
  30. package/dist/components/fields/LiteImageField.svelte +20 -11
  31. package/dist/components/fields/LiteNumberField.svelte +1 -2
  32. package/dist/components/fields/LiteRelationField.svelte +15 -7
  33. package/dist/components/fields/LiteSelectField.svelte +2 -1
  34. package/dist/components/fields/LiteTextField.svelte +1 -2
  35. package/dist/components/fields/LiteUrlField.svelte +1 -5
  36. package/dist/components/layout/LiteCatchAllNavigate.svelte +3 -9
  37. package/dist/components/layout/LiteNavigateToResource.svelte +5 -16
  38. package/dist/components/layout/LiteSidebar.svelte +9 -5
  39. package/dist/components/layout/LiteSidebar.svelte.d.ts +1 -0
  40. package/dist/components/pages/LiteEditPage.svelte +6 -4
  41. package/dist/components/pages/LiteListPage.svelte +14 -6
  42. package/dist/components/pages/LiteListPage.svelte.d.ts +1 -0
  43. package/dist/components/pages/LiteShowPage.svelte +6 -4
  44. package/dist/components/widgets/LiteAnomalyBadge.svelte +5 -1
  45. package/dist/index.js +1 -1
  46. package/dist/lite.css +2 -1
  47. package/dist/menu-visibility.d.ts +4 -0
  48. package/dist/menu-visibility.js +34 -0
  49. package/dist/schema-generator.d.ts +1 -1
  50. package/dist/schema-generator.js +112 -51
  51. package/dist/server-adapter.d.ts +53 -4
  52. package/dist/server-adapter.js +235 -67
  53. package/dist/value-normalization.d.ts +2 -0
  54. package/dist/value-normalization.js +21 -0
  55. package/package.json +6 -14
package/README.md CHANGED
@@ -22,7 +22,7 @@ The main `@svadmin/ui` package delivers a premium SPA experience using Svelte 5,
22
22
  | **Delete** | `<details>` confirmation, no JS needed |
23
23
  | **Search** | `<form method="GET">` with `?q=` parameter |
24
24
  | **Pagination** | Pure `<a>` page links |
25
- | **Login/Logout** | Cookie-based auth via SvelteKit Form Actions |
25
+ | **Authentication pages** | Login/logout plus optional provider-delegating account actions |
26
26
  | **Auth Guard** | Server hook redirects unauthenticated users |
27
27
  | **UA Detection** | Optional legacy-browser detection hook redirects users to `/lite/` routes |
28
28
  | **i18n** | Uses `@svadmin/core` `t()` translations |
@@ -34,9 +34,12 @@ The main `@svadmin/ui` package delivers a premium SPA experience using Svelte 5,
34
34
  ### 1. Install
35
35
 
36
36
  ```bash
37
- bun add @svadmin/lite
37
+ bun add @svadmin/lite @svadmin/core
38
38
  ```
39
39
 
40
+ Use this inside a Svelte 5/SvelteKit application; those runtimes and `@svadmin/core`
41
+ are required peers rather than optional dependencies.
42
+
40
43
  ### 2. Create a list page
41
44
 
42
45
  ```typescript
@@ -125,6 +128,10 @@ Pass a `menu` prop to `LiteLayout` to replace the auto-generated flat resource l
125
128
  </LiteLayout>
126
129
  ```
127
130
 
131
+ `meta.hidden` is honored recursively. For `meta.resource` / `meta.action`, pass a
132
+ server-computed synchronous `canAccess(resource, action)` callback; permission checks
133
+ that fail or throw are hidden. API and server actions must still enforce authorization.
134
+
128
135
  ### 3. Optionally route legacy browsers to Lite pages
129
136
 
130
137
  ```typescript
@@ -166,9 +173,9 @@ export const handle = createLegacyRedirectHook('/lite');
166
173
  | `createDetailLoader(dp, resource)` | SvelteKit `load` function for detail pages |
167
174
  | `createCrudActions(dp, resource)` | SvelteKit form actions for create/update/delete |
168
175
  | `createAuthGuard(authProvider)` | Server hook for authentication |
169
- | `createAuthActions(authProvider)` | Login/logout form actions |
176
+ | `createAuthActions(authProvider)` | Login/logout actions plus optional provider-delegating account actions |
170
177
  | `createLegacyRedirectHook()` | Auto-redirect IE11 to `/lite/` |
171
- | `fieldsToZodSchema(fields)` | Auto-generate Zod schema for superforms |
178
+ | `fieldsToZodSchema(fields)` | Generate the Zod schema used by Lite actions or other Zod consumers |
172
179
 
173
180
  ## CSS
174
181
 
@@ -180,7 +187,7 @@ Import `@svadmin/lite/lite.css` in your layout. It's fully self-contained:
180
187
  - Smooth transitions on all interactive elements
181
188
  - Multi-layer translucent shadows
182
189
  - Print-optimized styles
183
- - ~500 lines, ~14KB unminified
190
+ - ~800 lines, ~19KB unminified
184
191
 
185
192
  This CSS baseline helps older browsers, but the generated Svelte/SvelteKit application and any Tailwind v4 UI used alongside Lite still require the browser targets configured by those tools (Tailwind v4 itself targets modern browsers).
186
193
 
@@ -193,6 +200,24 @@ Copy the exported `@svadmin/lite/enhance.js` asset to your application's static
193
200
 
194
201
  The asset is optional for core server-rendered navigation and form submission; the conveniences listed above require it.
195
202
 
203
+ ## Compatibility notes
204
+
205
+ - Registration, recovery, password, and profile actions delegate the submitted form
206
+ fields to the corresponding optional `AuthProvider` method. Provider-specific recovery
207
+ flows that require tokens, user IDs, secrets, old passwords, or callback URLs need a
208
+ custom route/form; Lite does not infer those values from callback query parameters.
209
+ - `createListLoader` returns both flat URL-state fields (`page`, `pageSize`, `sort`,
210
+ `order`, `search`) and the aliases accepted by `LiteListPage` (`pagination`,
211
+ `currentSort`, `currentOrder`, `currentSearch`). The default page size matches Core: 10.
212
+ - `image` and `images` use the same URL-string values as `@svadmin/ui`; `file` is the
213
+ native upload field. Server validation continues to accept existing Lite image-file
214
+ submissions for migration compatibility.
215
+ - Buttons such as import/export/chat provide SSR UI and route contracts only. Consumers
216
+ must implement the corresponding loader or form action for their backend.
217
+ - IE11 can consume server-rendered HTML and the optional ES5 enhancement layer, with
218
+ graceful CSS/native-element fallbacks. Svelte 5 hydration/runtime execution in IE11
219
+ is not supported or promised by this package.
220
+
196
221
  ## License
197
222
 
198
223
  MIT
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { FieldDefinition } from '@svadmin/core';
3
3
  import { fieldToInputType, fieldToPlaceholder } from '../schema-generator';
4
+ import { isExplicitBooleanTrue } from '../value-normalization';
4
5
 
5
6
  interface Props {
6
7
  subFields: FieldDefinition[];
@@ -39,6 +40,26 @@
39
40
  if (Array.isArray(value)) return value.flatMap(retainedUploadReferences);
40
41
  return [];
41
42
  }
43
+
44
+ function controlValue(field: FieldDefinition, value: unknown): unknown {
45
+ const resolvedValue = value === undefined ? field.defaultValue : value;
46
+ if (field.type === 'relation' && typeof resolvedValue === 'object' && resolvedValue !== null) {
47
+ return (resolvedValue as Record<string, unknown>)[field.optionValue ?? 'id'];
48
+ }
49
+ return resolvedValue;
50
+ }
51
+
52
+ function textareaValue(field: FieldDefinition, value: unknown): string {
53
+ if (field.type === 'images' && Array.isArray(value)) return value.map(String).join('\n');
54
+ if (field.type === 'json' && typeof value !== 'string' && value != null) {
55
+ try {
56
+ return JSON.stringify(value, null, 2);
57
+ } catch {
58
+ return String(value);
59
+ }
60
+ }
61
+ return displayValue(value);
62
+ }
42
63
  </script>
43
64
 
44
65
  <div
@@ -66,12 +87,13 @@
66
87
  </div>
67
88
 
68
89
  {#each subFields as sub (sub.key)}
90
+ {@const value = controlValue(sub, item[sub.key])}
69
91
  <div class="lite-array-subfield">
70
92
  <label for={fieldId(sub.key)}>
71
93
  {sub.label}
72
94
  {#if sub.required}<span class="required">*</span>{/if}
73
95
  </label>
74
- {#if sub.type === 'textarea' || sub.type === 'richtext'}
96
+ {#if sub.type === 'textarea' || sub.type === 'richtext' || sub.type === 'markdown' || sub.type === 'images' || sub.type === 'json'}
75
97
  <textarea
76
98
  id={fieldId(sub.key)}
77
99
  name={fieldName(sub.key)}
@@ -79,7 +101,7 @@
79
101
  rows="3"
80
102
  required={sub.required && !isDraft}
81
103
  placeholder={fieldToPlaceholder(sub)}
82
- >{displayValue(item[sub.key])}</textarea>
104
+ >{textareaValue(sub, value)}</textarea>
83
105
  {:else if sub.type === 'boolean'}
84
106
  <div class="lite-checkbox-group">
85
107
  <input
@@ -87,34 +109,34 @@
87
109
  name={fieldName(sub.key)}
88
110
  type="checkbox"
89
111
  value="1"
90
- checked={Boolean(item[sub.key])}
112
+ checked={isExplicitBooleanTrue(value)}
91
113
  />
92
114
  <label for={fieldId(sub.key)}>{sub.label}</label>
93
115
  </div>
94
- {:else if (sub.type === 'select' || sub.type === 'multiselect') && sub.options}
116
+ {:else if (sub.type === 'select' || sub.type === 'multiselect' || sub.type === 'relation') && sub.options}
95
117
  <select
96
118
  id={fieldId(sub.key)}
97
119
  name={fieldName(sub.key)}
98
120
  class="lite-select"
99
121
  required={sub.required && !isDraft}
100
122
  multiple={sub.type === 'multiselect'}
123
+ value={sub.type === 'multiselect' ? undefined : String(value ?? '')}
101
124
  >
102
125
  {#if sub.type !== 'multiselect'}
103
126
  <option value="">-- Select --</option>
104
127
  {/if}
105
128
  {#each sub.options as opt (opt.value)}
106
- {@const selectedValue = item[sub.key]}
107
129
  <option
108
130
  value={String(opt.value)}
109
131
  selected={sub.type === 'multiselect'
110
- ? Array.isArray(selectedValue) && selectedValue.map(String).includes(String(opt.value))
111
- : String(selectedValue ?? '') === String(opt.value)}
132
+ ? Array.isArray(value) && value.map(String).includes(String(opt.value))
133
+ : undefined}
112
134
  >
113
135
  {opt.label}
114
136
  </option>
115
137
  {/each}
116
138
  </select>
117
- {:else if sub.type === 'file' || sub.type === 'image' || sub.type === 'images'}
139
+ {:else if sub.type === 'file'}
118
140
  {#if mode === 'edit'}
119
141
  {#each retainedUploadReferences(item[sub.key]) as reference, referenceIndex (`${reference}:${referenceIndex}`)}
120
142
  <input type="hidden" name={fieldName(sub.key)} value={reference} />
@@ -128,14 +150,13 @@
128
150
  required={sub.required
129
151
  && !isDraft
130
152
  && (mode === 'create' || retainedUploadReferences(item[sub.key]).length === 0)}
131
- multiple={sub.type === 'images'}
132
153
  />
133
154
  {:else}
134
155
  <input
135
156
  id={fieldId(sub.key)}
136
157
  name={fieldName(sub.key)}
137
158
  type={fieldToInputType(sub)}
138
- value={displayValue(item[sub.key])}
159
+ value={displayValue(value)}
139
160
  class="lite-input"
140
161
  required={sub.required && !isDraft}
141
162
  placeholder={fieldToPlaceholder(sub)}
@@ -3,19 +3,10 @@
3
3
  * LiteAuditLog — SSR-compatible audit log viewer.
4
4
  * Pure HTML table with pagination links. No client-side JS required.
5
5
  */
6
-
7
- interface AuditEntry {
8
- id: string | number;
9
- userName?: string;
10
- action: string;
11
- resource?: string;
12
- createdAt: string;
13
- ipAddress?: string;
14
- details?: string;
15
- }
6
+ import type { AuditLog } from '@svadmin/core';
16
7
 
17
8
  interface Props {
18
- logs: AuditEntry[];
9
+ logs: AuditLog[];
19
10
  total?: number;
20
11
  page?: number;
21
12
  pageSize?: number;
@@ -32,15 +23,15 @@
32
23
 
33
24
  const totalPages = $derived(Math.ceil(total / pageSize) || 1);
34
25
 
35
- function formatDate(d: string) {
36
- return new Date(d).toLocaleString();
26
+ function formatDate(auditTimestamp: string | Date) {
27
+ return new Date(auditTimestamp).toLocaleString();
37
28
  }
38
29
 
39
30
  function actionClass(action: string): string {
40
- const a = action.toLowerCase();
41
- if (a.includes('delete') || a.includes('remove')) return 'lite-badge-danger';
42
- if (a.includes('create') || a.includes('add')) return 'lite-badge-success';
43
- if (a.includes('update') || a.includes('edit')) return 'lite-badge-warning';
31
+ const normalizedAction = action.toLowerCase();
32
+ if (normalizedAction.includes('delete') || normalizedAction.includes('remove')) return 'lite-badge-danger';
33
+ if (normalizedAction.includes('create') || normalizedAction.includes('add')) return 'lite-badge-success';
34
+ if (normalizedAction.includes('update') || normalizedAction.includes('edit')) return 'lite-badge-warning';
44
35
  return 'lite-badge-default';
45
36
  }
46
37
  </script>
@@ -1,18 +1,10 @@
1
1
  /**
2
- * LiteAuditLog — SSR-compatible audit log viewer.
3
- * Pure HTML table with pagination links. No client-side JS required.
4
- */
5
- interface AuditEntry {
6
- id: string | number;
7
- userName?: string;
8
- action: string;
9
- resource?: string;
10
- createdAt: string;
11
- ipAddress?: string;
12
- details?: string;
13
- }
2
+ * LiteAuditLog — SSR-compatible audit log viewer.
3
+ * Pure HTML table with pagination links. No client-side JS required.
4
+ */
5
+ import type { AuditLog } from '@svadmin/core';
14
6
  interface Props {
15
- logs: AuditEntry[];
7
+ logs: AuditLog[];
16
8
  total?: number;
17
9
  page?: number;
18
10
  pageSize?: number;
@@ -2,11 +2,12 @@
2
2
  /**
3
3
  * LiteForm — Server-rendered form driven by FieldDefinitions.
4
4
  * Uses a native <form> with method="POST" for no-JavaScript submission.
5
- * Integrates with sveltekit-superforms for server-side validation.
5
+ * Uses the generated Zod schema for server-side validation.
6
6
  */
7
7
  import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
8
8
  import { t } from '@svadmin/core/i18n';
9
9
  import { fieldToInputType, fieldToPlaceholder } from '../schema-generator';
10
+ import { isExplicitBooleanTrue } from '../value-normalization';
10
11
  import LiteArrayField from './LiteArrayField.svelte';
11
12
 
12
13
  interface Props {
@@ -40,6 +41,7 @@
40
41
 
41
42
  const formFields = $derived(
42
43
  fields.filter(f => {
44
+ if (f.key === (resource?.primaryKey ?? 'id')) return false;
43
45
  if (f.showInForm === false) return false;
44
46
  if (mode === 'create' && f.showInCreate === false) return false;
45
47
  if (mode === 'edit' && f.showInEdit === false) return false;
@@ -55,6 +57,29 @@
55
57
  && value.size > 0
56
58
  && value.name !== '';
57
59
  }
60
+
61
+ function fieldValue(field: FieldDefinition): unknown {
62
+ const value = Object.prototype.hasOwnProperty.call(values, field.key)
63
+ ? values[field.key]
64
+ : field.defaultValue;
65
+ if (field.type === 'relation' && typeof value === 'object' && value !== null) {
66
+ return (value as Record<string, unknown>)[field.optionValue ?? 'id'];
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function textareaValue(field: FieldDefinition, value: unknown): string {
72
+ if (field.type === 'images' && Array.isArray(value)) return value.map(String).join('\n');
73
+ if (field.type === 'json' && typeof value !== 'string' && value != null) {
74
+ try {
75
+ return JSON.stringify(value, null, 2);
76
+ } catch {
77
+ return String(value);
78
+ }
79
+ }
80
+ return String(value ?? '');
81
+ }
82
+
58
83
  </script>
59
84
 
60
85
  <form method="POST" action={action || undefined} class="lite-card" enctype="multipart/form-data">
@@ -68,7 +93,7 @@
68
93
  {#each formFields as field, _i (_i)}
69
94
  {@const inputType = fieldToInputType(field)}
70
95
  {@const placeholder = fieldToPlaceholder(field)}
71
- {@const value = values[field.key]}
96
+ {@const value = fieldValue(field)}
72
97
  {@const fieldErrors = errors[field.key]}
73
98
  {@const hasError = fieldErrors && fieldErrors.length > 0}
74
99
 
@@ -87,7 +112,7 @@
87
112
  type="checkbox"
88
113
  name={field.key}
89
114
  id={field.key}
90
- checked={!!value}
115
+ checked={isExplicitBooleanTrue(value)}
91
116
  />
92
117
  <label for={field.key}>{field.label}</label>
93
118
  </div>
@@ -103,12 +128,15 @@
103
128
  id={field.key}
104
129
  class="lite-input {hasError ? 'lite-input-error' : ''}"
105
130
  placeholder={placeholder}
106
- >{value ?? ''}</textarea>
131
+ required={field.required}
132
+ >{textareaValue(field, value)}</textarea>
107
133
  {:else if inputType === 'select'}
108
134
  <select
109
135
  name={field.key}
110
136
  id={field.key}
111
137
  class="lite-select {hasError ? 'lite-input-error' : ''}"
138
+ required={field.required}
139
+ value={field.type === 'multiselect' ? undefined : String(value ?? '')}
112
140
  {...field.type === 'multiselect' ? { multiple: true } : {}}
113
141
  >
114
142
  {#if field.type !== 'multiselect'}
@@ -116,9 +144,11 @@
116
144
  {/if}
117
145
  {#if field.options}
118
146
  {#each field.options as opt, _i (_i)}
119
- <option
120
- value={String(opt.value)}
121
- selected={field.type === 'multiselect' ? (Array.isArray(value) && value.includes(opt.value)) : value === opt.value}
147
+ <option
148
+ value={String(opt.value)}
149
+ selected={field.type === 'multiselect'
150
+ ? Array.isArray(value) && value.map(String).includes(String(opt.value))
151
+ : undefined}
122
152
  >
123
153
  {opt.label}
124
154
  </option>
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * LiteForm — Server-rendered form driven by FieldDefinitions.
3
3
  * Uses a native <form> with method="POST" for no-JavaScript submission.
4
- * Integrates with sveltekit-superforms for server-side validation.
4
+ * Uses the generated Zod schema for server-side validation.
5
5
  */
6
6
  import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
7
7
  interface Props {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { ResourceDefinition, MenuItem } from '@svadmin/core';
7
7
  import type { Snippet } from 'svelte';
8
+ import { filterVisibleMenu, filterVisibleResources } from '../menu-visibility';
8
9
 
9
10
  interface Props {
10
11
  resources: ResourceDefinition[];
@@ -14,6 +15,8 @@
14
15
  basePath?: string;
15
16
  /** Optional multi-level menu configuration */
16
17
  menu?: MenuItem[];
18
+ /** Optional server-computed permission check for menu metadata. */
19
+ canAccess?: (resource: string, action: string) => boolean;
17
20
  children: Snippet;
18
21
  }
19
22
 
@@ -24,15 +27,17 @@
24
27
  userName = '',
25
28
  basePath = '/lite',
26
29
  menu,
30
+ canAccess,
27
31
  children,
28
32
  }: Props = $props();
29
33
 
30
- const menuResources = $derived(
31
- resources.filter((r: ResourceDefinition) => r.showInMenu !== false)
32
- );
34
+ const menuResources = $derived(filterVisibleResources(resources, canAccess));
35
+
36
+ const hasCustomMenu = $derived(menu !== undefined);
37
+ const visibleMenu = $derived(menu ? filterVisibleMenu(menu, canAccess) : []);
33
38
 
34
39
  const mobileItems = $derived.by(() => {
35
- if (!menu?.length) {
40
+ if (!hasCustomMenu) {
36
41
  return menuResources.map((resource): MenuItem => ({
37
42
  name: resource.name,
38
43
  label: resource.label,
@@ -47,7 +52,7 @@
47
52
  if (item.children?.length) visit(item.children);
48
53
  }
49
54
  };
50
- visit(menu);
55
+ visit(visibleMenu);
51
56
  return result;
52
57
  });
53
58
 
@@ -61,8 +66,8 @@
61
66
  <!-- Sidebar -->
62
67
  <nav class="lite-sidebar">
63
68
  <div class="lite-sidebar-brand">{brandName}</div>
64
- {#if menu && menu.length > 0}
65
- {#each menu as item, _i (_i)}
69
+ {#if hasCustomMenu}
70
+ {#each visibleMenu as item, _i (_i)}
66
71
  {#if item.children && item.children.length > 0}
67
72
  <details class="lite-menu-group">
68
73
  <summary class="lite-menu-parent">{item.label ?? item.name}</summary>
@@ -12,6 +12,8 @@ interface Props {
12
12
  basePath?: string;
13
13
  /** Optional multi-level menu configuration */
14
14
  menu?: MenuItem[];
15
+ /** Optional server-computed permission check for menu metadata. */
16
+ canAccess?: (resource: string, action: string) => boolean;
15
17
  children: Snippet;
16
18
  }
17
19
  declare const LiteLayout: import("svelte").Component<Props, {}, "">;
@@ -4,8 +4,9 @@
4
4
  * Pure HTML table with checkbox inputs. No client-side JS required.
5
5
  * Works with form POST actions for state changes.
6
6
  */
7
+ import type { ResourceDefinition, Role } from '@svadmin/core';
7
8
 
8
- interface LiteRole {
9
+ interface LegacyLiteRole {
9
10
  code: string;
10
11
  name: string;
11
12
  }
@@ -22,11 +23,11 @@
22
23
  }
23
24
 
24
25
  interface Props {
25
- roles: LiteRole[];
26
- resources: LiteResource[];
26
+ roles: Array<Role | LegacyLiteRole>;
27
+ resources: Array<ResourceDefinition | LiteResource>;
27
28
  actions: LiteAction[];
28
- /** Current permissions map: { "resource:action": true } */
29
- permissions: Record<string, boolean>;
29
+ /** Core matrix ({ resource: [actions] }) or legacy flat boolean map. */
30
+ permissions: Record<string, string[]> | Record<string, boolean>;
30
31
  selectedRole?: string;
31
32
  /** Form POST action URL */
32
33
  actionUrl?: string;
@@ -38,13 +39,44 @@
38
39
  resources,
39
40
  actions,
40
41
  permissions,
41
- selectedRole = roles[0]?.code || '',
42
+ selectedRole = '',
42
43
  actionUrl = '?/updatePermissions',
43
44
  disabled = false,
44
45
  }: Props = $props();
45
46
 
47
+ function isCoreRole(role: Role | LegacyLiteRole): role is Role {
48
+ return 'id' in role && typeof role.id === 'string';
49
+ }
50
+
51
+ function isCoreResource(resource: ResourceDefinition | LiteResource): resource is ResourceDefinition {
52
+ return 'fields' in resource && Array.isArray(resource.fields);
53
+ }
54
+
55
+ function roleId(role: Role | LegacyLiteRole | undefined): string {
56
+ if (!role) return '';
57
+ return isCoreRole(role) ? role.id : role.code;
58
+ }
59
+
60
+ function resourceCode(resource: ResourceDefinition | LiteResource): string {
61
+ return isCoreResource(resource) ? resource.name : resource.code;
62
+ }
63
+
64
+ function resourceLabel(resource: ResourceDefinition | LiteResource): string {
65
+ return isCoreResource(resource) ? resource.label : resource.name;
66
+ }
67
+
68
+ function resourceSection(resource: ResourceDefinition | LiteResource | undefined): string | undefined {
69
+ if (!resource) return undefined;
70
+ return isCoreResource(resource) ? resource.group : resource.section;
71
+ }
72
+
73
+ const activeRole = $derived(selectedRole || roleId(roles[0]));
74
+
46
75
  function isGranted(resource: string, action: string): boolean {
47
- return permissions[`${resource}:${action}`] === true;
76
+ const flatPermission = permissions[`${resource}:${action}`];
77
+ if (typeof flatPermission === 'boolean') return flatPermission;
78
+ const resourcePermissions = permissions[resource];
79
+ return Array.isArray(resourcePermissions) && resourcePermissions.includes(action);
48
80
  }
49
81
  </script>
50
82
 
@@ -53,8 +85,8 @@
53
85
  <div class="lite-role-tabs">
54
86
  {#each roles as role, _i (_i)}
55
87
  <a
56
- href="?role={role.code}"
57
- class="lite-role-tab {selectedRole === role.code ? 'active' : ''}"
88
+ href="?role={roleId(role)}"
89
+ class="lite-role-tab {activeRole === roleId(role) ? 'active' : ''}"
58
90
  >
59
91
  {role.name}
60
92
  </a>
@@ -63,7 +95,7 @@
63
95
 
64
96
  <!-- Matrix Table -->
65
97
  <form method="POST" action={actionUrl}>
66
- <input type="hidden" name="role" value={selectedRole} />
98
+ <input type="hidden" name="role" value={activeRole} />
67
99
 
68
100
  <table class="lite-table" style="margin-top:0;">
69
101
  <thead>
@@ -76,21 +108,22 @@
76
108
  </thead>
77
109
  <tbody>
78
110
  {#each resources as resource, i (i)}
79
- {#if resource.section && (i === 0 || resource.section !== resources[i-1].section)}
111
+ {@const section = resourceSection(resource)}
112
+ {#if section && (i === 0 || section !== resourceSection(resources[i-1]))}
80
113
  <tr>
81
114
  <td colspan={actions.length + 1} style="background:#f1f5f9;font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#475569;">
82
- {resource.section}
115
+ {section}
83
116
  </td>
84
117
  </tr>
85
118
  {/if}
86
119
  <tr>
87
- <td><strong>{resource.name}</strong><br/><small style="color:#94a3b8;">{resource.code}</small></td>
120
+ <td><strong>{resourceLabel(resource)}</strong><br/><small style="color:#94a3b8;">{resourceCode(resource)}</small></td>
88
121
  {#each actions as action, _i (_i)}
89
122
  <td style="text-align:center;">
90
123
  <input
91
124
  type="checkbox"
92
- name="perm_{resource.code}_{action.code}"
93
- checked={isGranted(resource.code, action.code)}
125
+ name="perm_{resourceCode(resource)}_{action.code}"
126
+ checked={isGranted(resourceCode(resource), action.code)}
94
127
  {disabled}
95
128
  style="width:18px;height:18px;cursor:{disabled ? 'not-allowed' : 'pointer'};"
96
129
  />
@@ -1,9 +1,10 @@
1
1
  /**
2
- * LitePermissionMatrix — SSR-compatible permission matrix.
3
- * Pure HTML table with checkbox inputs. No client-side JS required.
4
- * Works with form POST actions for state changes.
5
- */
6
- interface LiteRole {
2
+ * LitePermissionMatrix — SSR-compatible permission matrix.
3
+ * Pure HTML table with checkbox inputs. No client-side JS required.
4
+ * Works with form POST actions for state changes.
5
+ */
6
+ import type { ResourceDefinition, Role } from '@svadmin/core';
7
+ interface LegacyLiteRole {
7
8
  code: string;
8
9
  name: string;
9
10
  }
@@ -17,11 +18,11 @@ interface LiteAction {
17
18
  name: string;
18
19
  }
19
20
  interface Props {
20
- roles: LiteRole[];
21
- resources: LiteResource[];
21
+ roles: Array<Role | LegacyLiteRole>;
22
+ resources: Array<ResourceDefinition | LiteResource>;
22
23
  actions: LiteAction[];
23
- /** Current permissions map: { "resource:action": true } */
24
- permissions: Record<string, boolean>;
24
+ /** Core matrix ({ resource: [actions] }) or legacy flat boolean map. */
25
+ permissions: Record<string, string[]> | Record<string, boolean>;
25
26
  selectedRole?: string;
26
27
  /** Form POST action URL */
27
28
  actionUrl?: string;
@@ -18,11 +18,12 @@
18
18
  record,
19
19
  resource,
20
20
  basePath = '/lite',
21
- canEdit = true,
21
+ canEdit,
22
22
  }: Props = $props();
23
23
 
24
24
  let pk = $derived(resource.primaryKey ?? 'id');
25
25
  let id = $derived(record[pk]);
26
+ const showEdit = $derived(canEdit ?? resource.canEdit !== false);
26
27
  const showFields = $derived(
27
28
  resource.fields.filter(f => f.showInShow !== false)
28
29
  );
@@ -32,7 +33,7 @@
32
33
  <div class="lite-header">
33
34
  <h1>{resource.label} #{id}</h1>
34
35
  <div>
35
- {#if canEdit}
36
+ {#if showEdit}
36
37
  <a href={`${basePath}/${resource.name}/edit/${id}`} class="lite-btn lite-btn-primary">
37
38
  {t('common.edit') || 'Edit'}
38
39
  </a>
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { FieldDefinition } from '@svadmin/core';
7
7
  import { toSafeHref, toSafeText } from '../security';
8
+ import { isExplicitBooleanTrue } from '../value-normalization';
8
9
 
9
10
  interface Props {
10
11
  field: FieldDefinition;
@@ -19,7 +20,7 @@
19
20
  try { return new Date(v as string).toLocaleString(); } catch { return String(v); }
20
21
  }
21
22
  if (f.type === 'select' && f.options) {
22
- const opt = f.options.find(o => o.value === v);
23
+ const opt = f.options.find(o => String(o.value) === String(v));
23
24
  return opt?.label ?? String(v);
24
25
  }
25
26
  if (f.type === 'url') return String(v);
@@ -33,8 +34,9 @@
33
34
  </script>
34
35
 
35
36
  {#if field.type === 'boolean'}
36
- <span class="lite-bool {value ? 'lite-bool-true' : ''}"></span>
37
- {value ? '✓ Yes' : '✗ No'}
37
+ {@const checked = isExplicitBooleanTrue(value)}
38
+ <span class="lite-bool {checked ? 'lite-bool-true' : ''}"></span>
39
+ {checked ? '✓ Yes' : '✗ No'}
38
40
  {:else if field.type === 'url' && value}
39
41
  {@const href = toSafeHref(value)}
40
42
  {#if href}