@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85

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 (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -1,3 +1,6 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
2
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
3
+ import { useSubmission } from '@nocobase/app-client';
1
4
  import {
2
5
  useSettingsLeave,
3
6
  useSettingsData,
@@ -87,7 +90,12 @@ function RoleEditor(): ReactElement {
87
90
  const [grantsDraft, setGrantsDraft] = useState<readonly PermissionGrant[]>();
88
91
  const [dirty, setDirty] = useState(false);
89
92
  const [editingScope, setEditingScope] = useState<string>();
90
- const [busy, setBusy] = useState(false);
93
+ const { session } = useAuthentication();
94
+ const submission = useSubmission({
95
+ scope: `${session?.user.id}:settings:role:${key}`,
96
+ });
97
+ const [requestBusy, setRequestBusy] = useState(false);
98
+ const busy = requestBusy || submission.submitting;
91
99
  const leave = useSettingsLeave(dirty, busy);
92
100
  const [error, setError] = useState<unknown>();
93
101
 
@@ -127,57 +135,67 @@ function RoleEditor(): ReactElement {
127
135
  pathname: useResolvedPath('..').pathname,
128
136
  search: location.search,
129
137
  };
130
- const save = async (event: React.FormEvent<HTMLFormElement>) => {
138
+ const originalRevisionRef = useRef<string | undefined>(undefined);
139
+ const save = (event: React.FormEvent<HTMLFormElement>) => {
131
140
  event.preventDefault();
132
- if (options.error || !catalog || (!creating && request.error)) return;
133
- setSubmitted(true);
134
- if (!name.trim() || !roleKey.trim()) {
135
- event.currentTarget
136
- .querySelector<HTMLInputElement>('input:invalid')
137
- ?.focus();
138
- return;
139
- }
140
- const incomplete = unconfiguredFilter(grants);
141
- if (incomplete) {
142
- setEditingScope(incomplete);
143
- return;
144
- }
145
- setEditingScope(undefined);
146
- setBusy(true);
147
- setError(undefined);
148
- setKeyConflict(false);
149
- try {
150
- const input = {
151
- key: roleKey,
152
- title:
153
- !creating && initial && nameDraft === undefined
154
- ? initial.title
155
- : name.trim(),
156
- grants,
157
- ...(!creating && request.data?.data.revision
158
- ? { expectedRevision: request.data.data.revision }
159
- : {}),
160
- };
161
- if (creating) await authz.createPermissionSet(input);
162
- else await authz.updatePermissionSet(key, input);
163
- setDirty(false);
164
- parent.reload();
165
- toaster.show({ type: 'success', title: t('settings.saved', { name }) });
166
- leave.allow();
167
- void navigate(closeTo, { replace: true });
168
- } catch (next) {
169
- if (
170
- creating &&
171
- next instanceof Error &&
172
- 'status' in next &&
173
- next.status === 409
174
- ) {
175
- setKeyConflict(true);
176
- document.getElementById('role-key')?.focus();
177
- } else setError(next);
178
- } finally {
179
- setBusy(false);
180
- }
141
+ const form = event.currentTarget;
142
+ return (async () => {
143
+ event.preventDefault();
144
+ if (options.error || !catalog || (!creating && request.error)) return;
145
+ setSubmitted(true);
146
+ if (!name.trim() || !roleKey.trim()) {
147
+ form.querySelector<HTMLInputElement>('input:invalid')?.focus();
148
+ return;
149
+ }
150
+ const incomplete = unconfiguredFilter(grants);
151
+ if (incomplete) {
152
+ setEditingScope(incomplete);
153
+ return;
154
+ }
155
+ setEditingScope(undefined);
156
+ setRequestBusy(true);
157
+ setError(undefined);
158
+ setKeyConflict(false);
159
+ try {
160
+ originalRevisionRef.current ??= request.data?.data.revision;
161
+ const input = {
162
+ key: roleKey,
163
+ title:
164
+ !creating && initial && nameDraft === undefined
165
+ ? initial.title
166
+ : name.trim(),
167
+ grants,
168
+ ...(!creating && request.data?.data.revision
169
+ ? { expectedRevision: originalRevisionRef.current }
170
+ : {}),
171
+ };
172
+ const saved = await submission.run(
173
+ { key, creating, input },
174
+ (idempotencyKey) =>
175
+ creating
176
+ ? authz.createPermissionSet(input, idempotencyKey)
177
+ : authz.updatePermissionSet(key, input, idempotencyKey),
178
+ );
179
+ if (!saved) return;
180
+ setDirty(false);
181
+ parent.reload();
182
+ toaster.show({ type: 'success', title: t('settings.saved', { name }) });
183
+ leave.allow();
184
+ void navigate(closeTo, { replace: true });
185
+ } catch (next) {
186
+ if (
187
+ creating &&
188
+ next instanceof Error &&
189
+ 'status' in next &&
190
+ next.status === 409
191
+ ) {
192
+ setKeyConflict(true);
193
+ document.getElementById('role-key')?.focus();
194
+ } else setError(next);
195
+ } finally {
196
+ setRequestBusy(false);
197
+ }
198
+ })();
181
199
  };
182
200
  const sections = catalog
183
201
  ? configurePermissionScopes(
@@ -215,7 +233,6 @@ function RoleEditor(): ReactElement {
215
233
  />
216
234
  <SettingsError error={request.error} retry={request.reload} />
217
235
  <SettingsError error={options.error} retry={options.reload} />
218
- <SettingsError error={error} />
219
236
  {error instanceof Error &&
220
237
  'status' in error &&
221
238
  error.status === 409 && (
@@ -228,7 +245,10 @@ function RoleEditor(): ReactElement {
228
245
  ) : (
229
246
  !request.error &&
230
247
  !options.error && (
231
- <form
248
+ <FeedbackForm
249
+ submitting={busy}
250
+ error={error}
251
+ errorContent={<SettingsError error={error} inline />}
232
252
  onSubmit={(event) => void save(event)}
233
253
  noValidate
234
254
  className='flex flex-col gap-6'
@@ -238,10 +258,16 @@ function RoleEditor(): ReactElement {
238
258
  <Label htmlFor='role-key'>{t('settings.key')} *</Label>
239
259
  <Input
240
260
  id='role-key'
261
+ name='key'
241
262
  required
242
263
  value={roleKey}
243
264
  disabled={!creating || !allowed || busy}
244
265
  aria-invalid={keyConflict || (submitted && !roleKey.trim())}
266
+ aria-errormessage={
267
+ keyConflict || (submitted && !roleKey.trim())
268
+ ? 'role-key-error'
269
+ : undefined
270
+ }
245
271
  onChange={(event) => {
246
272
  setKeyConflict(false);
247
273
  setKeyDraft(event.target.value);
@@ -249,7 +275,7 @@ function RoleEditor(): ReactElement {
249
275
  }}
250
276
  />
251
277
  {(keyConflict || (submitted && !roleKey.trim())) && (
252
- <p className='text-xs text-destructive'>
278
+ <p id='role-key-error' className='text-xs text-destructive'>
253
279
  {t(
254
280
  keyConflict
255
281
  ? 'settings.keyConflict'
@@ -262,17 +288,24 @@ function RoleEditor(): ReactElement {
262
288
  <Label htmlFor='role-name'>{t('settings.roleName')} *</Label>
263
289
  <Input
264
290
  id='role-name'
291
+ name='title'
265
292
  required
266
293
  value={name}
267
294
  disabled={!allowed || busy}
268
295
  aria-invalid={submitted && !name.trim()}
296
+ aria-errormessage={
297
+ submitted && !name.trim() ? 'role-name-error' : undefined
298
+ }
269
299
  onChange={(event) => {
270
300
  setNameDraft(event.target.value);
271
301
  setDirty(true);
272
302
  }}
273
303
  />
274
304
  {submitted && !name.trim() && (
275
- <p className='text-xs text-destructive'>
305
+ <p
306
+ id='role-name-error'
307
+ className='text-xs text-destructive'
308
+ >
276
309
  {t('settings.required')}
277
310
  </p>
278
311
  )}
@@ -296,6 +329,15 @@ function RoleEditor(): ReactElement {
296
329
  levelFor: (name) => t('settings.levelFor', { name }),
297
330
  search: t('settings.permissionSearch'),
298
331
  enabledOnly: t('settings.enabledOnly'),
332
+ granted: t('settings.permissionGranted'),
333
+ ungranted: t('settings.permissionUngranted'),
334
+ selectAll: t('settings.permissionAll'),
335
+ authorizationStatus: t('settings.permissionStatus'),
336
+ clearSearch: t('settings.permissionClearSearch'),
337
+ collapseAll: t('settings.permissionCollapseAll'),
338
+ expandAll: t('settings.permissionExpandAll'),
339
+ summary: (enabled, total) =>
340
+ t('settings.permissionSummary', { enabled, total }),
299
341
  clear: t('settings.clear'),
300
342
  empty: t('settings.noPermissions'),
301
343
  emptyDescription: t('settings.permissionEmptyHint'),
@@ -342,7 +384,7 @@ function RoleEditor(): ReactElement {
342
384
  </Button>
343
385
  )}
344
386
  </div>
345
- </form>
387
+ </FeedbackForm>
346
388
  )
347
389
  )}
348
390
  {leave.dialog}
@@ -1,33 +1,30 @@
1
+ import { useListLocation } from '#hooks/use-list-location';
1
2
  import { useSettingsData, useRolePermission } from './hooks.js';
2
3
  import { useTranslation } from '@nocobase/i18n/client';
3
4
  import type {
4
5
  PermissionSet,
5
6
  LocalizedText,
6
7
  } from '@nocobase/app-plugin-authorization/client';
8
+ import { resolveListState } from '@nocobase/app-client';
7
9
  import { type ReactElement } from 'react';
8
- import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
9
- import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
10
+ import { Link, Outlet, useLocation } from 'react-router';
11
+ import { PlusIcon } from 'lucide-react';
10
12
  import { PermissionManagementPage } from './permission-management-page.js';
11
13
  import { Input } from '#components/ui/input';
12
14
  import { Button } from '#components/ui/button';
15
+ import { Spinner } from '#components/ui/spinner';
13
16
  import { useTableUrlState } from '#hooks/use-table-url-state';
14
17
  import { RoleList } from '#components/role-list';
15
- import {
16
- Empty,
17
- EmptyTitle,
18
- EmptyDescription,
19
- EmptyContent,
20
- EmptyMedia,
21
- } from '#components/ui/empty';
22
- import { SettingsError, SettingsLoading } from './shared.js';
18
+ import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
23
19
 
24
20
  export default function RolesPage(): ReactElement {
25
21
  const { t, i18n } = useTranslation();
26
- const [params, setParams] = useSearchParams();
22
+ const { params, setParams, active } = useListLocation();
27
23
  const location = useLocation();
28
24
  const search = params.get('q') ?? '';
29
25
  const request = useSettingsData<{ data: readonly PermissionSet[] }>(
30
26
  'authorization/permissionSets',
27
+ { active },
31
28
  );
32
29
  const canCreate = useRolePermission('create');
33
30
  const text = (value: LocalizedText): string =>
@@ -50,12 +47,39 @@ export default function RolesPage(): ReactElement {
50
47
  ),
51
48
  );
52
49
  const tableState = useTableUrlState({
50
+ active,
51
+ revision: request.revision,
53
52
  sortColumns: ['title', 'key'],
54
53
  rowCount:
55
54
  request.data && !request.loading && !request.error
56
55
  ? rows.length
57
56
  : undefined,
58
57
  });
58
+ const listState = resolveListState(
59
+ {
60
+ data: request.data,
61
+ error: tableState.error ?? request.error,
62
+ isPending: request.loading && !request.data,
63
+ },
64
+ rows.length,
65
+ Boolean(search.trim()),
66
+ );
67
+ const canShowCreate =
68
+ canCreate &&
69
+ (listState === 'empty' ||
70
+ listState === 'ready' ||
71
+ listState === 'no-results' ||
72
+ listState === 'refresh-failed');
73
+ const clearSearch = () =>
74
+ setParams(
75
+ (old) => {
76
+ const next = new URLSearchParams(old);
77
+ next.delete('q');
78
+ next.delete('page');
79
+ return next;
80
+ },
81
+ { replace: true },
82
+ );
59
83
  const create = canCreate ? (
60
84
  <Button
61
85
  nativeButton={false}
@@ -69,7 +93,7 @@ export default function RolesPage(): ReactElement {
69
93
  <>
70
94
  <PermissionManagementPage
71
95
  tab='roles'
72
- actions={rows.length || search ? create : undefined}
96
+ actions={canShowCreate ? create : undefined}
73
97
  >
74
98
  <div className='flex items-center gap-3'>
75
99
  <Input
@@ -90,93 +114,43 @@ export default function RolesPage(): ReactElement {
90
114
  );
91
115
  }}
92
116
  />
93
- {search && (
94
- <Button
95
- variant='ghost'
96
- onClick={() => {
97
- setParams(
98
- (old) => {
99
- const next = new URLSearchParams(old);
100
- next.delete('q');
101
- next.delete('page');
102
- return next;
103
- },
104
- { replace: true },
105
- );
106
- }}
107
- >
117
+ {search && listState !== 'no-results' && (
118
+ <Button variant='ghost' onClick={clearSearch}>
108
119
  {t('settings.clear')}
109
120
  </Button>
110
121
  )}
122
+ <ListFeedbackActions
123
+ state={listState}
124
+ onClear={clearSearch}
125
+ onRetry={request.reload}
126
+ busy={request.loading}
127
+ />
128
+ {active && request.loading && request.data ? (
129
+ <Spinner className='text-muted-foreground' />
130
+ ) : null}
111
131
  </div>
112
- <SettingsError error={request.error} retry={request.reload} />
113
- {request.loading && !request.data ? (
114
- <SettingsLoading />
115
- ) : (
116
- !request.error &&
117
- (rows.length ? (
118
- <RoleList
119
- tableState={tableState}
120
- ariaLabel={t('settings.roles')}
121
- roles={rows.map((role) => ({
122
- key: role.key,
123
- title: text(role.title ?? role.key),
124
- to: {
125
- pathname: `${encodeURIComponent(role.key)}/edit`,
126
- search: location.search,
127
- },
128
- protected: Boolean(role.protection),
129
- }))}
130
- labels={{
131
- name: t('settings.roleName'),
132
- key: t('settings.key'),
133
- type: t('settings.roleType'),
134
- protected: t('settings.protected'),
135
- custom: t('settings.custom'),
136
- }}
137
- />
138
- ) : (
139
- <Empty className='min-h-48 border border-dashed'>
140
- <EmptyMedia variant='icon'>
141
- <ShieldCheckIcon />
142
- </EmptyMedia>
143
- <EmptyTitle>
144
- {t(search ? 'settings.noResults' : 'settings.noRoles')}
145
- </EmptyTitle>
146
- <EmptyDescription>
147
- {t(
148
- search
149
- ? 'settings.clearHint'
150
- : canCreate
151
- ? 'settings.rolesDescription'
152
- : 'settings.rolesReadOnlyEmpty',
153
- )}
154
- </EmptyDescription>
155
- <EmptyContent>
156
- {search ? (
157
- <Button
158
- variant='outline'
159
- onClick={() => {
160
- setParams(
161
- (old) => {
162
- const next = new URLSearchParams(old);
163
- next.delete('q');
164
- next.delete('page');
165
- return next;
166
- },
167
- { replace: true },
168
- );
169
- }}
170
- >
171
- {t('settings.clear')}
172
- </Button>
173
- ) : (
174
- create
175
- )}
176
- </EmptyContent>
177
- </Empty>
178
- ))
179
- )}
132
+ <ListFeedback state={listState}>
133
+ <RoleList
134
+ tableState={tableState}
135
+ ariaLabel={t('settings.roles')}
136
+ roles={rows.map((role) => ({
137
+ key: role.key,
138
+ title: text(role.title ?? role.key),
139
+ to: {
140
+ pathname: `${encodeURIComponent(role.key)}/edit`,
141
+ search: location.search,
142
+ },
143
+ protected: Boolean(role.protection),
144
+ }))}
145
+ labels={{
146
+ name: t('settings.roleName'),
147
+ key: t('settings.key'),
148
+ type: t('settings.roleType'),
149
+ protected: t('settings.protected'),
150
+ custom: t('settings.custom'),
151
+ }}
152
+ />
153
+ </ListFeedback>
180
154
  </PermissionManagementPage>
181
155
  <Outlet context={{ reload: request.reload }} />
182
156
  </>
@@ -1,3 +1,4 @@
1
+ import { ListRecoveryError } from '#hooks/use-table-url-state';
1
2
  import { ApiClientError } from '@nocobase/app-client';
2
3
  import { Link } from 'react-router';
3
4
  import { useTranslation } from '@nocobase/i18n/client';
@@ -18,9 +19,11 @@ import {
18
19
  export function SettingsError({
19
20
  error,
20
21
  retry,
22
+ inline = false,
21
23
  }: {
22
24
  readonly error: unknown;
23
25
  readonly retry?: () => void;
26
+ readonly inline?: boolean;
24
27
  }): ReactElement | null {
25
28
  const { t } = useTranslation();
26
29
  if (!error) return null;
@@ -28,40 +31,47 @@ export function SettingsError({
28
31
  typeof error === 'object' && error !== null
29
32
  ? Reflect.get(error, 'status')
30
33
  : undefined;
31
- return (
34
+ const content = (
35
+ <>
36
+ {error instanceof ListRecoveryError
37
+ ? error.message
38
+ : t(
39
+ status === 400
40
+ ? 'settings.invalidRequest'
41
+ : status === 401
42
+ ? 'settings.sessionEnded'
43
+ : status === 403
44
+ ? 'settings.denied'
45
+ : status === 404
46
+ ? 'settings.missing'
47
+ : status === 409
48
+ ? error instanceof ApiClientError &&
49
+ error.reason === 'USER_IDENTITY_CONFLICT'
50
+ ? 'settings.identityConflict'
51
+ : 'settings.conflict'
52
+ : 'settings.failed',
53
+ )}
54
+ {status === 401 && (
55
+ <Button
56
+ variant='outline'
57
+ nativeButton={false}
58
+ render={<Link to='/login' />}
59
+ >
60
+ {t('settings.signInAgain')}
61
+ </Button>
62
+ )}
63
+ {retry && ![400, 401, 403, 404, 409].includes(Number(status)) && (
64
+ <Button variant='outline' size='sm' onClick={retry}>
65
+ {t('settings.retry')}
66
+ </Button>
67
+ )}
68
+ </>
69
+ );
70
+ return inline ? (
71
+ content
72
+ ) : (
32
73
  <Alert variant='destructive'>
33
- <AlertDescription>
34
- {t(
35
- status === 400
36
- ? 'settings.invalidRequest'
37
- : status === 401
38
- ? 'settings.sessionEnded'
39
- : status === 403
40
- ? 'settings.denied'
41
- : status === 404
42
- ? 'settings.missing'
43
- : status === 409
44
- ? error instanceof ApiClientError &&
45
- error.reason === 'USER_IDENTITY_CONFLICT'
46
- ? 'settings.identityConflict'
47
- : 'settings.conflict'
48
- : 'settings.failed',
49
- )}
50
- {status === 401 && (
51
- <Button
52
- variant='outline'
53
- nativeButton={false}
54
- render={<Link to='/login' />}
55
- >
56
- {t('settings.signInAgain')}
57
- </Button>
58
- )}
59
- {retry && ![400, 401, 403, 404, 409].includes(Number(status)) && (
60
- <Button variant='outline' size='sm' onClick={retry}>
61
- {t('settings.retry')}
62
- </Button>
63
- )}
64
- </AlertDescription>
74
+ <AlertDescription>{content}</AlertDescription>
65
75
  </Alert>
66
76
  );
67
77
  }