@svadmin/ui 0.88.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/app.css +4 -0
  2. package/dist/app.theme.css +4 -0
  3. package/dist/component-registry.svelte.d.ts +2 -2
  4. package/dist/components/AboutSettings.svelte +23 -4
  5. package/dist/components/AdminApp.svelte +6 -4
  6. package/dist/components/AdminApp.svelte.d.ts +3 -3
  7. package/dist/components/ApiSettings.svelte +43 -27
  8. package/dist/components/ApprovalActionCard.svelte +8 -9
  9. package/dist/components/ApprovalCenter.svelte +24 -5
  10. package/dist/components/AuditLogViewer.svelte +2 -2
  11. package/dist/components/AutoComplete.svelte +610 -0
  12. package/dist/components/AutoComplete.svelte.d.ts +25 -0
  13. package/dist/components/AutoForm.svelte +263 -18
  14. package/dist/components/AutoForm.svelte.d.ts +19 -2
  15. package/dist/components/AutoTable.svelte +135 -35
  16. package/dist/components/AutoTable.svelte.d.ts +8 -0
  17. package/dist/components/BatchActionBar.svelte +42 -14
  18. package/dist/components/BatchActionBar.svelte.d.ts +5 -0
  19. package/dist/components/BoundRecordDetailDrawer.svelte +5 -3
  20. package/dist/components/CancelTaskButton.svelte +14 -1
  21. package/dist/components/Cascader.svelte +412 -53
  22. package/dist/components/Cascader.svelte.d.ts +7 -1
  23. package/dist/components/ColorPicker.svelte +1 -1
  24. package/dist/components/ColorPicker.svelte.d.ts +1 -1
  25. package/dist/components/ComboboxField.svelte +270 -34
  26. package/dist/components/ComboboxField.svelte.d.ts +4 -0
  27. package/dist/components/CommandPalette.svelte +4 -4
  28. package/dist/components/DashboardView.svelte +73 -7
  29. package/dist/components/DashboardView.svelte.d.ts +3 -1
  30. package/dist/components/DateRangeInput.svelte +8 -1
  31. package/dist/components/DetailWorkspaceAsyncSection.svelte +107 -0
  32. package/dist/components/DetailWorkspaceAsyncSection.svelte.d.ts +36 -0
  33. package/dist/components/DetailWorkspaceSections.svelte +233 -0
  34. package/dist/components/DetailWorkspaceSections.svelte.d.ts +9 -0
  35. package/dist/components/DrawerForm.svelte +78 -9
  36. package/dist/components/DrawerForm.svelte.d.ts +3 -3
  37. package/dist/components/ErrorBoundary.svelte +1 -1
  38. package/dist/components/ErrorComponent.svelte +19 -6
  39. package/dist/components/ErrorComponent.svelte.d.ts +1 -0
  40. package/dist/components/ErrorSummary.svelte +9 -4
  41. package/dist/components/FieldRenderer.svelte +203 -90
  42. package/dist/components/FieldRenderer.svelte.d.ts +16 -1
  43. package/dist/components/FileUpload.svelte +116 -51
  44. package/dist/components/FileUpload.svelte.d.ts +7 -27
  45. package/dist/components/FileUploadField.svelte +170 -0
  46. package/dist/components/FileUploadField.svelte.d.ts +21 -0
  47. package/dist/components/FilterBuilder.svelte +13 -7
  48. package/dist/components/FilterCollectionInput.svelte +9 -7
  49. package/dist/components/FilterCollectionInput.svelte.d.ts +3 -0
  50. package/dist/components/ForgotPasswordPage.svelte +1 -1
  51. package/dist/components/Header.svelte +1 -1
  52. package/dist/components/ImportWizard.svelte +197 -28
  53. package/dist/components/ImportWizard.svelte.d.ts +5 -1
  54. package/dist/components/InferencerPanel.svelte +4 -2
  55. package/dist/components/InfiniteList.svelte +52 -9
  56. package/dist/components/JsonSchemaForm.svelte +237 -42
  57. package/dist/components/JsonSchemaForm.svelte.d.ts +10 -3
  58. package/dist/components/KanbanBoard.svelte +43 -7
  59. package/dist/components/KanbanBoard.svelte.d.ts +2 -2
  60. package/dist/components/Layout.svelte +1 -1
  61. package/dist/components/LoginPage.svelte +1 -1
  62. package/dist/components/MetricStrip.svelte +8 -3
  63. package/dist/components/MetricStrip.svelte.d.ts +1 -0
  64. package/dist/components/ModalForm.svelte +85 -9
  65. package/dist/components/ModalForm.svelte.d.ts +3 -3
  66. package/dist/components/MoneyInput.svelte +5 -3
  67. package/dist/components/MoneyInput.svelte.d.ts +4 -2
  68. package/dist/components/MultiTabKeepAlive.svelte +502 -43
  69. package/dist/components/MultiTabKeepAlive.svelte.d.ts +40 -6
  70. package/dist/components/NotificationsSettings.svelte +10 -10
  71. package/dist/components/NumberInput.svelte +5 -3
  72. package/dist/components/NumberInput.svelte.d.ts +4 -2
  73. package/dist/components/NumericInput.svelte +277 -25
  74. package/dist/components/NumericInput.svelte.d.ts +3 -1
  75. package/dist/components/PercentInput.svelte +5 -3
  76. package/dist/components/PercentInput.svelte.d.ts +4 -2
  77. package/dist/components/PermissionMatrix.svelte +44 -8
  78. package/dist/components/PermissionMatrix.svelte.d.ts +1 -0
  79. package/dist/components/ProfilePage.svelte +38 -13
  80. package/dist/components/RadioGroup.svelte +203 -4
  81. package/dist/components/RadioGroup.svelte.d.ts +1 -1
  82. package/dist/components/Rate.svelte +3 -3
  83. package/dist/components/Rate.svelte.d.ts +2 -2
  84. package/dist/components/RecordDetailDrawer.svelte +1 -1
  85. package/dist/components/RecordDetailDrawer.svelte.d.ts +1 -1
  86. package/dist/components/RegisterPage.svelte +1 -1
  87. package/dist/components/RelationPicker.svelte +39 -11
  88. package/dist/components/ResourceAccessGuard.svelte +7 -2
  89. package/dist/components/RetryTaskButton.svelte +14 -1
  90. package/dist/components/RolesSettings.svelte +74 -28
  91. package/dist/components/SecuritySettings.svelte +66 -51
  92. package/dist/components/SegmentedControl.svelte +4 -2
  93. package/dist/components/SegmentedControl.svelte.d.ts +1 -1
  94. package/dist/components/Sidebar.svelte +1 -1
  95. package/dist/components/StepsForm.svelte +60 -10
  96. package/dist/components/StepsForm.svelte.d.ts +4 -2
  97. package/dist/components/SvadminLogo.svelte +1 -0
  98. package/dist/components/SvarDataGrid.svelte +42 -24
  99. package/dist/components/SvarDataGrid.svelte.d.ts +10 -10
  100. package/dist/components/SvarResourceRead.svelte +2 -0
  101. package/dist/components/SvarResourceTableView.svelte +4 -2
  102. package/dist/components/TagsInput.svelte +3 -3
  103. package/dist/components/TagsInput.svelte.d.ts +2 -2
  104. package/dist/components/TaskDetails.svelte +22 -16
  105. package/dist/components/TaskDetails.svelte.d.ts +1 -1
  106. package/dist/components/TaskList.svelte +57 -15
  107. package/dist/components/TaskQueueDrawer.svelte +54 -34
  108. package/dist/components/Transfer.svelte +67 -5
  109. package/dist/components/Transfer.svelte.d.ts +4 -0
  110. package/dist/components/TreeSelect.svelte +337 -77
  111. package/dist/components/TreeSelect.svelte.d.ts +4 -0
  112. package/dist/components/TreeTable.svelte +6 -1
  113. package/dist/components/TwoFactorAuthPage.svelte +8 -4
  114. package/dist/components/UpdatePasswordPage.svelte +1 -1
  115. package/dist/components/VersionDiffViewer.svelte +9 -7
  116. package/dist/components/account/CompanyProfilePage.svelte +13 -12
  117. package/dist/components/account/MemberDirectory.svelte +14 -10
  118. package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
  119. package/dist/components/content/AuditTimeline.svelte +8 -6
  120. package/dist/components/content/DataState.svelte +1 -1
  121. package/dist/components/content/FileList.svelte +44 -13
  122. package/dist/components/content/FileList.svelte.d.ts +4 -2
  123. package/dist/components/content/FileListRow.svelte +87 -0
  124. package/dist/components/content/FileListRow.svelte.d.ts +11 -0
  125. package/dist/components/content/ImportDropzone.svelte +109 -12
  126. package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
  127. package/dist/components/content/IntegrationCard.svelte +3 -3
  128. package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
  129. package/dist/components/content/MediaThumbnail.svelte +6 -2
  130. package/dist/components/detail-workspace.d.ts +60 -0
  131. package/dist/components/detail-workspace.js +3 -0
  132. package/dist/components/enterprise/json-schema-form.d.ts +1 -1
  133. package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
  134. package/dist/components/enterprise/recursive-schema-form.js +288 -14
  135. package/dist/components/enterprise/schema-form-references.d.ts +14 -0
  136. package/dist/components/enterprise/schema-form-references.js +316 -0
  137. package/dist/components/fields/ArrayField.svelte +178 -14
  138. package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
  139. package/dist/components/fields/array-field-metadata.d.ts +4 -0
  140. package/dist/components/fields/array-field-metadata.js +36 -0
  141. package/dist/components/file-upload-reference.d.ts +11 -0
  142. package/dist/components/file-upload-reference.js +50 -0
  143. package/dist/components/file-upload-types.d.ts +32 -0
  144. package/dist/components/file-upload-types.js +1 -0
  145. package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
  146. package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
  147. package/dist/components/json-schema-form-state.d.ts +19 -0
  148. package/dist/components/list-deletion.svelte.js +6 -1
  149. package/dist/components/list-state.svelte.d.ts +12 -1
  150. package/dist/components/list-state.svelte.js +56 -16
  151. package/dist/components/numeric-format.d.ts +44 -0
  152. package/dist/components/numeric-format.js +198 -0
  153. package/dist/components/record-detail.svelte.js +2 -0
  154. package/dist/components/saved-list-views.d.ts +9 -2
  155. package/dist/components/saved-list-views.js +42 -11
  156. package/dist/components/saved-list-views.svelte.d.ts +3 -0
  157. package/dist/components/saved-list-views.svelte.js +56 -7
  158. package/dist/components/task-action.svelte.d.ts +1 -0
  159. package/dist/components/task-action.svelte.js +55 -3
  160. package/dist/components/task-utils.d.ts +1 -0
  161. package/dist/components/task-utils.js +18 -0
  162. package/dist/components/ui/input/input.svelte +1 -1
  163. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  164. package/dist/components/ui/sheet/sheet.svelte +4 -1
  165. package/dist/components/version-history.d.ts +14 -0
  166. package/dist/components/version-history.js +32 -0
  167. package/dist/components/workspace-tab-state.d.ts +78 -0
  168. package/dist/components/workspace-tab-state.js +587 -0
  169. package/dist/index.d.ts +13 -1
  170. package/dist/index.js +4 -0
  171. package/package.json +5 -5
@@ -6,6 +6,8 @@
6
6
  import { createTaskAction } from './task-action.svelte.js';
7
7
 
8
8
  const i18n = useTranslation();
9
+ const instanceId = $props.id();
10
+ const errorId = `${instanceId}-error`;
9
11
 
10
12
  let {
11
13
  taskId,
@@ -37,7 +39,9 @@
37
39
  });
38
40
  </script>
39
41
 
40
- <Button {variant} {size} onclick={action.run} disabled={!action.available || action.pending}>
42
+ <span class="task-action-feedback">
43
+ <Button {variant} {size} onclick={action.run} disabled={!action.available || action.pending}
44
+ aria-describedby={action.failed && !onError ? errorId : undefined}>
41
45
  {#if action.pending}
42
46
  <Loader2 class="svadmin-u-82cc6c6581cd svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-afbdd13a380e" />
43
47
  {:else}
@@ -49,3 +53,12 @@
49
53
  {i18n.t('common.retry')}
50
54
  {/if}
51
55
  </Button>
56
+ {#if action.failed && !onError}
57
+ <span id={errorId} role="alert">{i18n.t('task.actionFailed')}</span>
58
+ {/if}
59
+ </span>
60
+
61
+ <style>
62
+ .task-action-feedback { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; max-width: 16rem; }
63
+ .task-action-feedback [role="alert"] { color: var(--destructive); font-size: 12px; line-height: 16px; white-space: normal; overflow-wrap: anywhere; }
64
+ </style>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { captureAdminContext } from '@svadmin/core';
4
5
  import type { AuthProvider } from '@svadmin/core';
@@ -6,6 +7,8 @@
6
7
  import type { RoleInfo, ResourceInfo, ActionInfo } from '../types.js';
7
8
  import { toast } from '@svadmin/core/toast';
8
9
  import { AlertCircle } from '@lucide/svelte';
10
+ import { Button } from './ui/button/index.js';
11
+ import DataState from './content/DataState.svelte';
9
12
 
10
13
  const i18n = useTranslation();
11
14
  const adminContext = captureAdminContext();
@@ -22,7 +25,8 @@
22
25
  let loadingRoles = $state(true);
23
26
  let loadingPermissions = $state(false);
24
27
  let saving = $state(false);
25
- let error = $state<string | null>(null);
28
+ let error = $state<'roles' | 'unsupported' | null>(null);
29
+ let permissionError = $state(false);
26
30
  let roleRequestEpoch = 0;
27
31
  let permissionRequestEpoch = 0;
28
32
  let mutationRequestEpoch = 0;
@@ -35,7 +39,7 @@
35
39
  }))
36
40
  );
37
41
 
38
- let matrixActions = $state<ActionInfo[]>([
42
+ let matrixActions = $derived<ActionInfo[]>([
39
43
  { code: 'create', name: i18n.t('common.create') ?? 'Create' },
40
44
  { code: 'read', name: i18n.t('common.detail') ?? 'Read' },
41
45
  { code: 'update', name: i18n.t('common.edit') ?? 'Update' },
@@ -45,7 +49,7 @@
45
49
  async function loadRoles(scopedProvider: AuthProvider | null, epoch: number) {
46
50
  if (!scopedProvider?.getRoles) {
47
51
  if (epoch !== roleRequestEpoch) return;
48
- error = i18n.t('settings.rbacNotSupported') ?? 'RBAC not supported by AuthProvider';
52
+ error = 'unsupported';
49
53
  loadingRoles = false;
50
54
  return;
51
55
  }
@@ -54,8 +58,8 @@
54
58
  if (epoch !== roleRequestEpoch) return;
55
59
  roles = fetchedRoles.map(r => ({ code: r.id, ...r }));
56
60
  selectedRoleCode = roles[0]?.code ?? '';
57
- } catch (e) {
58
- if (epoch === roleRequestEpoch) error = (e as Error).message;
61
+ } catch {
62
+ if (epoch === roleRequestEpoch) error = 'roles';
59
63
  } finally {
60
64
  if (epoch === roleRequestEpoch) loadingRoles = false;
61
65
  }
@@ -63,15 +67,20 @@
63
67
 
64
68
  async function loadPermissionsForRole(roleId: string) {
65
69
  const scopedProvider = authProvider;
66
- const epoch = permissionRequestEpoch;
67
- if (!scopedProvider?.getRolePermissions || permissionsCache[roleId]) return;
70
+ const epoch = ++permissionRequestEpoch;
71
+ if (permissionsCache[roleId]) return;
72
+ if (!scopedProvider?.getRolePermissions) {
73
+ permissionError = true;
74
+ return;
75
+ }
68
76
 
69
77
  loadingPermissions = true;
78
+ permissionError = false;
70
79
  try {
71
80
  const perms = await scopedProvider.getRolePermissions(roleId);
72
81
  if (epoch === permissionRequestEpoch) permissionsCache[roleId] = perms;
73
- } catch (e) {
74
- if (epoch === permissionRequestEpoch) toast.error((e as Error).message);
82
+ } catch {
83
+ if (epoch === permissionRequestEpoch) permissionError = true;
75
84
  } finally {
76
85
  if (epoch === permissionRequestEpoch) loadingPermissions = false;
77
86
  }
@@ -79,9 +88,13 @@
79
88
 
80
89
  // Load permissions whenever the selected role changes
81
90
  $effect(() => {
82
- if (selectedRoleCode && !permissionsCache[selectedRoleCode] && !loadingPermissions) {
83
- loadPermissionsForRole(selectedRoleCode);
84
- }
91
+ const roleId = selectedRoleCode;
92
+ untrack(() => {
93
+ permissionRequestEpoch += 1;
94
+ loadingPermissions = false;
95
+ permissionError = false;
96
+ if (roleId) void loadPermissionsForRole(roleId);
97
+ });
85
98
  });
86
99
 
87
100
  $effect(() => {
@@ -94,6 +107,7 @@
94
107
  selectedRoleCode = '';
95
108
  permissionsCache = {};
96
109
  error = null;
110
+ permissionError = false;
97
111
  loadingRoles = true;
98
112
  loadingPermissions = false;
99
113
  saving = false;
@@ -115,7 +129,8 @@
115
129
 
116
130
  async function handleToggle(roleCode: string, resourceCode: string, actionCode: string, grant: boolean) {
117
131
  const scopedProvider = authProvider;
118
- if (!scopedProvider?.updateRolePermissions) return;
132
+ if (!scopedProvider?.updateRolePermissions || saving || loadingRoles || loadingPermissions
133
+ || permissionError || roleCode !== selectedRoleCode || !permissionsCache[roleCode]) return;
119
134
  const mutationEpoch = ++mutationRequestEpoch;
120
135
 
121
136
  // Copy state
@@ -141,14 +156,14 @@
141
156
  if (result.success) {
142
157
  // Success silently
143
158
  } else {
144
- throw new Error(result.error?.message ?? i18n.t('common.operationFailed'));
159
+ throw new Error(i18n.t('common.operationFailed'));
145
160
  }
146
- } catch (e) {
161
+ } catch {
147
162
  if (mutationEpoch !== mutationRequestEpoch) return;
148
- toast.error((e as Error).message);
163
+ toast.error(i18n.t('common.operationFailed'));
149
164
  // Rollback on fail
150
165
  delete permissionsCache[roleCode]; // eslint-disable-line @typescript-eslint/no-dynamic-delete
151
- void loadPermissionsForRole(roleCode);
166
+ if (selectedRoleCode === roleCode) void loadPermissionsForRole(roleCode);
152
167
  } finally {
153
168
  if (mutationEpoch === mutationRequestEpoch) saving = false;
154
169
  }
@@ -156,21 +171,52 @@
156
171
  </script>
157
172
 
158
173
  {#if error}
159
- <div class="svadmin-u-668b21aa5409 svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-86843cf1e227">
174
+ <div role="alert" class="svadmin-u-668b21aa5409 svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-86843cf1e227">
160
175
  <div class="svadmin-u-9794ab45094d svadmin-u-0478c89a150f svadmin-u-ca6bcd4b6f3f svadmin-u-f0c1e65bd6f2 svadmin-u-43928fcc832f svadmin-u-811148b13d1e svadmin-u-5f22e64f2282 svadmin-u-60fbb7713999 svadmin-u-8dddea0773ed svadmin-u-3960ffc248d9 svadmin-u-1004c0c3954c svadmin-u-ca6bf63030aa">
161
176
  <AlertCircle class="svadmin-u-ed8a5df7b2fb svadmin-u-2bbcfc3b5179" />
162
177
  <h3 class="svadmin-u-69450ef1487e svadmin-u-42536e69e639">{i18n.t('common.error') ?? 'Error'}</h3>
163
- <p>{error}</p>
178
+ <p>{error === 'unsupported' ? i18n.t('settings.rbacNotSupported') : i18n.t('common.operationFailed')}</p>
179
+ {#if authProvider?.getRoles}<Button size="sm" variant="outline" onclick={() => {
180
+ const provider = authProvider;
181
+ const epoch = ++roleRequestEpoch;
182
+ error = null;
183
+ loadingRoles = true;
184
+ roles = [];
185
+ selectedRoleCode = '';
186
+ permissionsCache = {};
187
+ permissionError = false;
188
+ void loadRoles(provider, epoch);
189
+ }}>{i18n.t('common.retry')}</Button>{/if}
164
190
  </div>
165
191
  </div>
166
192
  {:else}
167
- <PermissionMatrix
168
- {roles}
169
- resources={matrixResources}
170
- actions={matrixActions}
171
- isGranted={checkGrant}
172
- bind:selectedRole={selectedRoleCode}
173
- loading={loadingRoles || loadingPermissions || saving}
174
- onToggle={handleToggle}
175
- />
193
+ {#if loadingRoles}
194
+ <DataState state="loading" loadingLabel={i18n.t('common.loading')} />
195
+ {/if}
196
+ {#snippet permissionsTableContent()}
197
+ {#if !permissionError}
198
+ <DataState state="loading" loadingLabel={i18n.t('common.loading')} />
199
+ {:else if permissionError}
200
+ <div role="alert" class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4 svadmin-u-811148b13d1e">
201
+ <span>{i18n.t(authProvider?.getRolePermissions ? 'common.operationFailed' : 'settings.rbacNotSupported')}</span>
202
+ {#if authProvider?.getRolePermissions}
203
+ <Button size="sm" variant="outline" onclick={() => { if (selectedRoleCode) void loadPermissionsForRole(selectedRoleCode); }}>{i18n.t('common.retry')}</Button>
204
+ {/if}
205
+ </div>
206
+ {/if}
207
+ {/snippet}
208
+ {#if !loadingRoles}
209
+ <div aria-busy={saving}>
210
+ <PermissionMatrix
211
+ {roles}
212
+ resources={matrixResources}
213
+ actions={matrixActions}
214
+ isGranted={checkGrant}
215
+ bind:selectedRole={selectedRoleCode}
216
+ tableContent={loadingPermissions || permissionError || (selectedRoleCode && !permissionsCache[selectedRoleCode]) ? permissionsTableContent : undefined}
217
+ loading={loadingRoles || loadingPermissions || saving || permissionError || !permissionsCache[selectedRoleCode] || !authProvider?.updateRolePermissions}
218
+ onToggle={handleToggle}
219
+ />
220
+ </div>
221
+ {/if}
176
222
  {/if}
@@ -21,50 +21,53 @@
21
21
  const sessionProvider = $derived(adminContext.sessionProvider);
22
22
  const isZh = $derived(i18n.locale === 'zh-CN');
23
23
 
24
- let is2faEnabled = $state(false);
24
+ let is2faEnabled = $state<boolean | null>(null);
25
25
  let sessions = $state.raw<SessionInfo[]>([]);
26
26
  let loadingSessions = $state(false);
27
- let sessionError = $state<string | null>(null);
27
+ let sessionError = $state(false);
28
28
  let updatingMfa = $state(false);
29
29
  let revoking = $state<string | null>(null);
30
30
  let currentPassword = $state('');
31
31
  let newPassword = $state('');
32
32
  let confirmPassword = $state('');
33
33
  let passwordErrorMessage = $state('');
34
+ let passwordChangedRecently = $state(false);
35
+ let passwordRequestFailed = $state(false);
34
36
  let changingPassword = $state(false);
35
- let requestId = 0;
37
+ let scopeId = 0;
38
+ let securityReadId = 0;
36
39
  let pendingDanger = $state<{ label: string; run: () => Promise<void> } | null>(null);
37
40
  function requestDanger(label: string, run: () => Promise<void>): void {
38
- const epoch = requestId;
39
- pendingDanger = { label, run: async () => { if (epoch === requestId) await run(); } };
41
+ const epoch = scopeId;
42
+ pendingDanger = { label, run: async () => { if (epoch === scopeId) await run(); } };
40
43
  }
41
44
 
42
- function message(error: unknown): string {
43
- return error instanceof Error ? error.message : String(error);
45
+ function failureMessage(): string {
46
+ return i18n.t('common.operationFailed');
44
47
  }
45
48
 
46
49
  async function loadSecurity(): Promise<void> {
47
- const currentRequest = ++requestId;
50
+ const currentRequest = ++securityReadId;
48
51
  const provider = sessionProvider;
49
52
  const context = adminContext.enterpriseRequestContext;
50
53
  sessions = [];
51
- is2faEnabled = false;
52
- sessionError = null;
54
+ is2faEnabled = null;
55
+ sessionError = false;
53
56
  loadingSessions = false;
54
57
  if (!provider) return;
55
58
  loadingSessions = true;
56
59
  try {
57
60
  const [nextSessions, mfa] = await Promise.all([
58
61
  provider.listSessions(context),
59
- provider.getMfaState?.(context) ?? { enabled: false },
62
+ provider.getMfaState?.(context) ?? null,
60
63
  ]);
61
- if (currentRequest !== requestId) return;
64
+ if (currentRequest !== securityReadId) return;
62
65
  sessions = nextSessions;
63
- is2faEnabled = mfa.enabled;
64
- } catch (caught) {
65
- if (currentRequest === requestId) sessionError = message(caught);
66
+ is2faEnabled = typeof mfa?.enabled === 'boolean' ? mfa.enabled : null;
67
+ } catch {
68
+ if (currentRequest === securityReadId) sessionError = true;
66
69
  } finally {
67
- if (currentRequest === requestId) loadingSessions = false;
70
+ if (currentRequest === securityReadId) loadingSessions = false;
68
71
  }
69
72
  }
70
73
 
@@ -76,41 +79,49 @@
76
79
  newPassword = '';
77
80
  confirmPassword = '';
78
81
  passwordErrorMessage = '';
82
+ passwordChangedRecently = false;
83
+ passwordRequestFailed = false;
79
84
  changingPassword = false;
80
85
  updatingMfa = false;
81
86
  revoking = null;
82
87
  pendingDanger = null;
83
88
  void loadSecurity();
84
- return () => { requestId += 1; };
89
+ return () => {
90
+ scopeId += 1;
91
+ securityReadId += 1;
92
+ };
85
93
  });
86
94
 
87
95
  async function handleMfaChange(enabled: boolean, confirmed = false): Promise<void> {
96
+ if (is2faEnabled === null || loadingSessions || sessionError || updatingMfa) return;
88
97
  if (!enabled && !confirmed) {
89
98
  requestDanger(isZh ? '关闭两步验证' : 'Disable two-factor authentication', () => handleMfaChange(false, true));
90
99
  return;
91
100
  }
92
101
  const provider = sessionProvider;
93
- const currentRequest = requestId;
102
+ const currentRequest = scopeId;
94
103
  const context = adminContext.enterpriseRequestContext;
95
104
  if (!provider?.setMfaEnabled) return;
96
105
  updatingMfa = true;
97
106
  try {
98
107
  const result = await provider.setMfaEnabled(enabled, context);
99
- if (currentRequest !== requestId) return;
108
+ if (currentRequest !== scopeId) return;
100
109
  is2faEnabled = result.enabled;
101
- } catch (caught) {
102
- if (currentRequest !== requestId) return;
103
- notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
110
+ } catch {
111
+ if (currentRequest !== scopeId) return;
112
+ notifyWithProvider({ type: 'error', message: failureMessage() }, adminContext.notificationProvider);
104
113
  } finally {
105
- if (currentRequest === requestId) updatingMfa = false;
114
+ if (currentRequest === scopeId) updatingMfa = false;
106
115
  }
107
116
  }
108
117
 
109
118
  async function handlePasswordChange(event: SubmitEvent): Promise<void> {
110
119
  event.preventDefault();
120
+ if (changingPassword) return;
111
121
  passwordErrorMessage = '';
122
+ passwordRequestFailed = false;
112
123
  const provider = authProvider;
113
- const currentRequest = requestId;
124
+ const currentRequest = scopeId;
114
125
  if (!provider?.updatePassword) return;
115
126
  if (!currentPassword) passwordErrorMessage = i18n.t('profile.currentPassword');
116
127
  else if (newPassword.length < 8) passwordErrorMessage = i18n.t('validation.minLength', { min: 8 });
@@ -120,63 +131,66 @@
120
131
  changingPassword = true;
121
132
  try {
122
133
  const result = await provider.updatePassword({ currentPassword, password: newPassword, confirmPassword });
123
- if (!result.success) throw new Error(result.error?.message ?? (isZh ? '密码修改失败' : 'Password update failed'));
124
- if (currentRequest !== requestId) return;
134
+ if (!result.success) throw new Error('Password update failed');
135
+ if (currentRequest !== scopeId) return;
125
136
  notifyWithProvider({ type: 'success', message: i18n.t('profile.passwordChanged') }, adminContext.notificationProvider);
137
+ passwordChangedRecently = true;
126
138
  currentPassword = '';
127
139
  newPassword = '';
128
140
  confirmPassword = '';
129
- } catch (caught) {
130
- if (currentRequest !== requestId) return;
131
- passwordErrorMessage = message(caught);
141
+ } catch {
142
+ if (currentRequest !== scopeId) return;
143
+ passwordRequestFailed = true;
132
144
  } finally {
133
- if (currentRequest === requestId) changingPassword = false;
145
+ if (currentRequest === scopeId) changingPassword = false;
134
146
  }
135
147
  }
136
148
 
137
149
  async function revokeSession(id: string, confirmed = false): Promise<void> {
150
+ if (revoking !== null) return;
138
151
  if (!confirmed) {
139
152
  requestDanger(`${i18n.t('security.revokeSession')}: ${id}`, () => revokeSession(id, true));
140
153
  return;
141
154
  }
142
155
  const provider = sessionProvider;
143
- const currentRequest = requestId;
156
+ const currentRequest = scopeId;
144
157
  const context = adminContext.enterpriseRequestContext;
145
158
  if (!provider) return;
146
159
  revoking = id;
147
160
  try {
148
161
  const result = await provider.revokeSession(id, context);
149
- if (!result.success) throw new Error(result.error?.message ?? (isZh ? '会话撤销失败' : 'Session revocation failed'));
150
- if (currentRequest !== requestId) return;
162
+ if (!result.success) throw new Error('Session revocation failed');
163
+ if (currentRequest !== scopeId) return;
151
164
  sessions = sessions.filter((session) => session.id !== id);
152
- } catch (caught) {
153
- if (currentRequest !== requestId) return;
154
- notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
165
+ } catch {
166
+ if (currentRequest !== scopeId) return;
167
+ notifyWithProvider({ type: 'error', message: failureMessage() }, adminContext.notificationProvider);
155
168
  } finally {
156
- if (currentRequest === requestId) revoking = null;
169
+ if (currentRequest === scopeId) revoking = null;
157
170
  }
158
171
  }
159
172
 
160
173
  async function revokeAllOthers(confirmed = false): Promise<void> {
174
+ if (revoking !== null) return;
161
175
  if (!confirmed) {
162
176
  requestDanger(i18n.t('security.revokeOthers'), () => revokeAllOthers(true));
163
177
  return;
164
178
  }
165
179
  const provider = sessionProvider;
166
- const currentRequest = requestId;
180
+ const currentRequest = scopeId;
167
181
  const context = adminContext.enterpriseRequestContext;
168
182
  if (!provider) return;
169
183
  revoking = '*';
170
184
  try {
171
185
  const result = await provider.revokeOtherSessions(context);
172
- if (!result.success) throw new Error(result.error?.message ?? (isZh ? '会话撤销失败' : 'Session revocation failed'));
173
- if (currentRequest !== requestId) return;
186
+ if (!result.success) throw new Error('Session revocation failed');
187
+ if (currentRequest !== scopeId) return;
174
188
  sessions = sessions.filter((session) => session.current);
175
- } catch (caught) {
176
- if (currentRequest !== requestId) return;
177
- notifyWithProvider({ type: 'error', message: message(caught) }, adminContext.notificationProvider);
189
+ } catch {
190
+ if (currentRequest !== scopeId) return;
191
+ notifyWithProvider({ type: 'error', message: failureMessage() }, adminContext.notificationProvider);
178
192
  } finally {
179
- if (currentRequest === requestId) revoking = null;
193
+ if (currentRequest === scopeId) revoking = null;
180
194
  }
181
195
  }
182
196
  </script>
@@ -187,17 +201,18 @@
187
201
  <FeedbackNotice tone="warning" message={isZh ? '未配置 SessionProvider。会话和 MFA 设置不会使用模拟数据。' : 'SessionProvider is not configured. Session and MFA controls do not use simulated data.'} />
188
202
  {/if}
189
203
  <SettingsGroup title={i18n.t('security.twoFactorAuth')} description={i18n.t('security.twoFactorDescription')} headingLevel={headingLevel === 'h1' ? 'h2' : 'h3'}>
190
- <SettingsFieldRow controlId={`${controlId}-mfa`} label={i18n.t('security.enable2fa')} description={is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}>
191
- {#snippet control()}<Switch id={`${controlId}-mfa`} bind:checked={() => is2faEnabled, (enabled) => { void handleMfaChange(enabled); }} disabled={!sessionProvider?.setMfaEnabled || updatingMfa} />{/snippet}
204
+ <SettingsFieldRow controlId={`${controlId}-mfa`} label={i18n.t('security.enable2fa')} description={is2faEnabled === null ? i18n.t('security.twoFactorUnknown') : (is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive'))}>
205
+ {#snippet control()}<Switch id={`${controlId}-mfa`} bind:checked={() => is2faEnabled === true, (enabled) => { void handleMfaChange(enabled); }} disabled={!sessionProvider?.setMfaEnabled || is2faEnabled === null || updatingMfa} />{/snippet}
192
206
  </SettingsFieldRow>
193
207
  </SettingsGroup>
194
208
  <SettingsGroup title={i18n.t('profile.changePassword')} description={isZh ? '密码修改由 AuthProvider 持久化。' : 'Password changes are persisted by AuthProvider.'} headingLevel={headingLevel === 'h1' ? 'h2' : 'h3'}>
195
209
  {#if !authProvider?.updatePassword}<FeedbackNotice tone="warning" message={isZh ? '当前 AuthProvider 不支持修改密码。' : 'The current AuthProvider does not support password updates.'} />{/if}
196
210
  <form onsubmit={handlePasswordChange} class="svadmin-u-6199866f612f svadmin-u-3e7ce58d64fa">
197
- {#if passwordErrorMessage}<Alert.Root variant="destructive"><AlertTriangle class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" /><Alert.Description>{passwordErrorMessage}</Alert.Description></Alert.Root>{/if}
198
- <PasswordInput id="security-current-password" label={i18n.t('profile.currentPassword')} bind:value={currentPassword} autocomplete="current-password" disabled={!authProvider?.updatePassword || changingPassword} />
199
- <PasswordInput id="security-new-password" label={i18n.t('profile.newPassword')} bind:value={newPassword} autocomplete="new-password" showStrength disabled={!authProvider?.updatePassword || changingPassword} />
200
- <PasswordInput id="security-confirm-password" label={i18n.t('auth.confirmPassword')} bind:value={confirmPassword} autocomplete="new-password" disabled={!authProvider?.updatePassword || changingPassword} />
211
+ {#if passwordRequestFailed || passwordErrorMessage}<Alert.Root variant="destructive"><AlertTriangle class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" /><Alert.Description>{passwordRequestFailed ? failureMessage() : passwordErrorMessage}</Alert.Description></Alert.Root>{/if}
212
+ <PasswordInput id={`${controlId}-security-current-password`} label={i18n.t('profile.currentPassword')} bind:value={currentPassword} autocomplete="current-password" disabled={!authProvider?.updatePassword || changingPassword} />
213
+ <PasswordInput id={`${controlId}-security-new-password`} label={i18n.t('profile.newPassword')} bind:value={newPassword} autocomplete="new-password" showStrength disabled={!authProvider?.updatePassword || changingPassword} />
214
+ <PasswordInput id={`${controlId}-security-confirm-password`} label={i18n.t('auth.confirmPassword')} bind:value={confirmPassword} autocomplete="new-password" disabled={!authProvider?.updatePassword || changingPassword} />
215
+ {#if passwordChangedRecently}<p class="text-xs text-muted-foreground">{i18n.t('profile.passwordChangedRecently')}</p>{/if}
201
216
  <Button type="submit" disabled={!authProvider?.updatePassword || changingPassword}>{changingPassword ? (isZh ? '更新中...' : 'Updating...') : i18n.t('profile.updatePassword')}</Button>
202
217
  </form>
203
218
  </SettingsGroup>
@@ -206,7 +221,7 @@
206
221
  {#if loadingSessions}
207
222
  <DataState state="loading" />
208
223
  {:else if sessionError}
209
- <DataState state="error" description={sessionError} retry={loadSecurity} />
224
+ <DataState state="error" description={failureMessage()} retry={loadSecurity} />
210
225
  {:else if sessions.length === 0}
211
226
  <DataState state="empty" title={i18n.t('security.noSessions')} description={sessionProvider ? (isZh ? 'Provider 未返回活跃会话。' : 'The provider returned no active sessions.') : (isZh ? '配置 SessionProvider 后可管理真实会话。' : 'Configure SessionProvider to manage real sessions.')} />
212
227
  {:else}
@@ -9,7 +9,7 @@
9
9
 
10
10
  interface Props<T extends string = string> {
11
11
  options: readonly SegmentedControlOption<T>[];
12
- value?: T;
12
+ value?: T | undefined;
13
13
  disabled?: boolean;
14
14
  ariaLabel: string;
15
15
  dir?: 'ltr' | 'rtl';
@@ -52,7 +52,9 @@
52
52
  }
53
53
 
54
54
  function handleKeydown(event: KeyboardEvent, option: SegmentedControlOption<T>, index: number): void {
55
- if (disabled || option.disabled) return;
55
+ const current = event.currentTarget;
56
+ if (disabled || option.disabled || event.altKey || event.ctrlKey || event.metaKey
57
+ || !(current instanceof HTMLButtonElement) || current.matches(':disabled')) return;
56
58
  const rtl = getComputedStyle(root).direction === 'rtl' || dir === 'rtl';
57
59
  if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
58
60
  event.preventDefault();
@@ -5,7 +5,7 @@ export interface SegmentedControlOption<T extends string = string> {
5
5
  }
6
6
  interface Props<T extends string = string> {
7
7
  options: readonly SegmentedControlOption<T>[];
8
- value?: T;
8
+ value?: T | undefined;
9
9
  disabled?: boolean;
10
10
  ariaLabel: string;
11
11
  dir?: 'ltr' | 'rtl';
@@ -463,7 +463,7 @@
463
463
  />
464
464
  <div class="svadmin-u-36e579c0b41c svadmin-u-7e0b7cdf1a94">
465
465
  <p class="svadmin-u-f283ea9bea0e svadmin-u-a14daebf7748 svadmin-u-2689f3958069 svadmin-u-a7a63217e098">{identity.name}</p>
466
- <p class="svadmin-u-f283ea9bea0e svadmin-u-d058ca6de60f svadmin-u-5f1ff8fe8768">{((identity as Record<string, unknown>)['role'] || (identity as Record<string, unknown>)['roleName']) ?? 'User'}</p>
466
+ <p class="svadmin-u-f283ea9bea0e svadmin-u-d058ca6de60f svadmin-u-5f1ff8fe8768">{((identity as Record<string, unknown>)['role'] || (identity as Record<string, unknown>)['roleName']) ?? i18n.t('common.user')}</p>
467
467
  </div>
468
468
  <button
469
469
  class="svadmin-u-d0a52b312f7d svadmin-u-cbbf90f9a828 svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-86843cf1e227 svadmin-u-421ac2be5045 svadmin-u-3e3534b4c5df svadmin-u-646e10356266 svadmin-u-0b48b877be2a svadmin-u-ceb69a6b0e5f"
@@ -15,6 +15,7 @@
15
15
  import FieldRenderer from './FieldRenderer.svelte';
16
16
  import ConfirmDialog from './ConfirmDialog.svelte';
17
17
  import { Skeleton } from './ui/skeleton/index.js';
18
+ import { withoutArrayFieldMetadata } from './fields/array-field-metadata.js';
18
19
 
19
20
  const i18n = useTranslation();
20
21
 
@@ -25,7 +26,7 @@
25
26
 
26
27
  interface Props {
27
28
  resourceName: string;
28
- id?: string | number;
29
+ id?: string | number | undefined;
29
30
  mode?: 'create' | 'edit';
30
31
  steps: StepDef[];
31
32
  onSuccess?: () => void;
@@ -102,6 +103,7 @@
102
103
  let element = $state.raw<HTMLFormElement>();
103
104
  let submitError = $state<string | null>(null);
104
105
  let submission = $state.raw<object>();
106
+ const instanceId = $props.id();
105
107
  const scope = $derived({
106
108
  contract: binding.resource, id, mode, allowed, configurationValid,
107
109
  steps: JSON.stringify(steps), provider: adminContext.providers?.[binding.dataProviderName],
@@ -110,6 +112,14 @@
110
112
  session: captureAuthSession(adminContext.authProvider),
111
113
  });
112
114
  const rendered = $derived({ scope, step: form.steps.currentStep });
115
+ const authoritativeDraft = $derived({ scope, values: form.values });
116
+ let acceptedDraft = $state.raw<{
117
+ snapshot: typeof authoritativeDraft;
118
+ values: Record<string, unknown>;
119
+ }>();
120
+ // 仅回显本次写入已确认的 UI 身份;新的 Core 快照使缓存立即失效。
121
+ const renderedValues = $derived(acceptedDraft?.snapshot === authoritativeDraft
122
+ ? acceptedDraft.values : authoritativeDraft.values);
113
123
  let mounted = true;
114
124
  let confirmation = $state.raw<typeof rendered>();
115
125
  const currentRendered = (origin: typeof rendered) => mounted && origin === rendered &&
@@ -122,11 +132,35 @@
122
132
  confirmation = undefined;
123
133
  });
124
134
  $effect(() => () => { mounted = false; submission = undefined; confirmation = undefined; });
125
- function errorId(field: string): string { return `${resourceName}-step-${field}-error`; }
135
+ function errorId(field: string): string { return `${instanceId}-${resourceName}-step-${field}-error`; }
136
+ const focusableFieldSelector = [
137
+ 'input:not([type="hidden"])', 'textarea', 'select', 'button', '[tabindex]',
138
+ ].join(', ');
139
+ function insideDisabledFieldset(candidate: HTMLElement): boolean {
140
+ for (let parent = candidate.parentElement; parent; parent = parent.parentElement) {
141
+ if (!(parent instanceof HTMLFieldSetElement) || !parent.disabled) continue;
142
+ const legend = Array.from(parent.children).find(child => child instanceof HTMLLegendElement);
143
+ if (!legend?.contains(candidate)) return true;
144
+ }
145
+ return false;
146
+ }
147
+ function focusFirstActionableControl(field: HTMLElement): boolean {
148
+ for (const candidate of field.querySelectorAll<HTMLElement>(focusableFieldSelector)) {
149
+ if (candidate.matches(':disabled, [disabled], input[type="hidden"]')) continue;
150
+ if (candidate.closest('[hidden], [inert], [aria-disabled="true"]')) continue;
151
+ if (insideDisabledFieldset(candidate)) continue;
152
+ candidate.focus();
153
+ if (document.activeElement === candidate) return true;
154
+ }
155
+ return false;
156
+ }
126
157
  async function focusInvalid(origin: typeof scope) {
127
158
  await tick();
128
- if (mounted && scope === origin && origin.session.isCurrent() && element?.isConnected) {
129
- element.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
159
+ if (!mounted || scope !== origin || !origin.session.isCurrent() || !element?.isConnected) return;
160
+ // 嵌套数组的子字段键不属于当前步骤的顶层错误。
161
+ for (const field of element.querySelectorAll<HTMLElement>(':scope > [data-svadmin-field]')) {
162
+ const key = field.getAttribute('data-svadmin-field-key');
163
+ if (key && form.errors[key] && focusFirstActionableControl(field)) return;
130
164
  }
131
165
  }
132
166
  const changeStep = $derived.by(() => {
@@ -140,7 +174,15 @@
140
174
  const fieldChange = $derived.by(() => {
141
175
  const origin = rendered;
142
176
  return (key: string) => (value: unknown) => {
143
- if (currentRendered(origin)) form.setFieldValue(key, value);
177
+ if (!currentRendered(origin)) return;
178
+ const previous = renderedValues;
179
+ form.setFieldValue(key, withoutArrayFieldMetadata(value));
180
+ if (currentRendered(origin)) {
181
+ acceptedDraft = {
182
+ snapshot: authoritativeDraft,
183
+ values: { ...previous, [key]: value },
184
+ };
185
+ }
144
186
  };
145
187
  });
146
188
  function leave(origin: typeof rendered) {
@@ -176,6 +218,16 @@
176
218
  };
177
219
  });
178
220
 
221
+ // 显式 reset 入口:宿主通过 bind:this 调用,恢复 Core 草稿、步骤导航和本实例
222
+ // 提交提示;不触发导航,也不改变未授权状态。
223
+ export function reset(): void {
224
+ if (!allowed) return;
225
+ submitError = null;
226
+ submission = undefined;
227
+ confirmation = undefined;
228
+ form.reset();
229
+ }
230
+
179
231
  const handleSubmit = $derived.by(() => {
180
232
  const origin = rendered;
181
233
  return async () => {
@@ -267,7 +319,7 @@
267
319
  {#each currentFields as field (field.key)}
268
320
  <FieldRenderer
269
321
  {field}
270
- value={form.values[field.key]}
322
+ value={renderedValues[field.key]}
271
323
  onchange={fieldChange(field.key)}
272
324
  invalid={!!form.errors[field.key]}
273
325
  {...definedOptions({ errorId: form.errors[field.key] ? errorId(field.key) : undefined })}
@@ -306,7 +358,5 @@
306
358
  {/if}
307
359
  </div>
308
360
 
309
- {#key confirmation}
310
- <ConfirmDialog open={confirmOpen} message={i18n.t('common.unsavedChanges')}
311
- onconfirm={confirmLeave} oncancel={cancelLeave} />
312
- {/key}
361
+ <ConfirmDialog open={confirmOpen} message={i18n.t('common.unsavedChanges')}
362
+ onconfirm={confirmLeave} oncancel={cancelLeave} />