@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,
@@ -267,7 +270,12 @@ function UserEditor(): ReactElement {
267
270
  const [confirmRoles, setConfirmRoles] = useState(false);
268
271
  const formRef = useRef<HTMLFormElement>(null);
269
272
  const [dirty, setDirty] = useState(false);
270
- const [busy, setBusy] = useState(false);
273
+ const { session } = useAuthentication();
274
+ const submission = useSubmission({
275
+ scope: `${session?.user.id}:settings:user:${userId}`,
276
+ });
277
+ const [requestBusy, setRequestBusy] = useState(false);
278
+ const busy = requestBusy || submission.submitting;
271
279
  const leave = useSettingsLeave(dirty, busy);
272
280
  const [error, setError] = useState<unknown>();
273
281
  const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
@@ -332,34 +340,43 @@ function UserEditor(): ReactElement {
332
340
  setConfirmRoles(true);
333
341
  return;
334
342
  }
335
- setBusy(true);
343
+ setRequestBusy(true);
336
344
  setError(undefined);
337
345
  setServerErrors({});
338
346
  try {
339
- let updated: ManagedUser;
340
- if (creating)
341
- updated = await client.create({
342
- ...fields,
343
- name: fields.name.trim(),
344
- email: fields.email.trim(),
345
- ...(fields.username.trim()
346
- ? { username: fields.username.trim() }
347
- : { username: undefined }),
348
- roleScopes: roles,
349
- });
350
- else
351
- updated = await client.update(userId, {
352
- ...(canUpdate
353
- ? {
354
- name: fields.name.trim(),
355
- email: fields.email.trim(),
356
- username: fields.username.trim() || null,
357
- }
358
- : {}),
359
- ...(canAssign && Object.keys(changedRoles).length
360
- ? { roleScopes: changedRoles }
361
- : {}),
362
- });
347
+ const input = creating
348
+ ? {
349
+ ...fields,
350
+ name: fields.name.trim(),
351
+ email: fields.email.trim(),
352
+ ...(fields.username.trim()
353
+ ? { username: fields.username.trim() }
354
+ : { username: undefined }),
355
+ roleScopes: roles,
356
+ }
357
+ : {
358
+ ...(canUpdate
359
+ ? {
360
+ name: fields.name.trim(),
361
+ email: fields.email.trim(),
362
+ username: fields.username.trim() || null,
363
+ }
364
+ : {}),
365
+ ...(canAssign && Object.keys(changedRoles).length
366
+ ? { roleScopes: changedRoles }
367
+ : {}),
368
+ };
369
+ const updated = await submission.run(
370
+ { userId, creating, input },
371
+ (idempotencyKey) =>
372
+ creating
373
+ ? client.create(
374
+ input as Parameters<typeof client.create>[0],
375
+ idempotencyKey,
376
+ )
377
+ : client.update(userId, input, idempotencyKey),
378
+ );
379
+ if (!updated) return;
363
380
  setDirty(false);
364
381
  parent.reload(updated);
365
382
  toaster.show({
@@ -395,7 +412,7 @@ function UserEditor(): ReactElement {
395
412
  setError(next);
396
413
  }
397
414
  } finally {
398
- setBusy(false);
415
+ setRequestBusy(false);
399
416
  }
400
417
  };
401
418
  const allowed = creating ? canCreate && canAssign : canUpdate || canAssign;
@@ -453,12 +470,14 @@ function UserEditor(): ReactElement {
453
470
  <>
454
471
  <SettingsError error={data.error} retry={data.reload} />
455
472
  <SettingsError error={options.error} retry={options.reload} />
456
- <SettingsError error={error} />
457
473
  {!creating && data.loading ? (
458
474
  <SettingsLoading />
459
475
  ) : (
460
476
  !data.error && (
461
- <form
477
+ <FeedbackForm
478
+ submitting={busy}
479
+ error={error}
480
+ errorContent={<SettingsError error={error} inline />}
462
481
  id={formId}
463
482
  ref={formRef}
464
483
  onSubmit={(event) => {
@@ -510,6 +529,7 @@ function UserEditor(): ReactElement {
510
529
  />
511
530
  {fieldError(name) && (
512
531
  <p
532
+ data-feedback-field-error
513
533
  id={`${formId}-${name}-error`}
514
534
  className='text-xs text-destructive'
515
535
  >
@@ -536,6 +556,7 @@ function UserEditor(): ReactElement {
536
556
  />
537
557
  {serverErrors[scope.key] && (
538
558
  <p
559
+ data-feedback-field-error
539
560
  id={`${formId}-roles-${encodeURIComponent(scope.key)}-error`}
540
561
  className='text-xs text-destructive'
541
562
  >
@@ -544,7 +565,7 @@ function UserEditor(): ReactElement {
544
565
  )}
545
566
  </div>
546
567
  ))}
547
- </form>
568
+ </FeedbackForm>
548
569
  )
549
570
  )}
550
571
  {leave.dialog}
@@ -1,11 +1,20 @@
1
+ import { useListLocation } from '#hooks/use-list-location';
1
2
  import { RecordLink } from '#components/record-link';
2
3
  import { BackButton } from '#components/back-button';
3
4
  import { safeReturnTo } from '../../lib/return-to.js';
4
- import { useTableUrlState } from '#hooks/use-table-url-state';
5
+ import {
6
+ readTableUrlState,
7
+ useTableUrlState,
8
+ } from '#hooks/use-table-url-state';
5
9
  import { DataTable, DataTableColumnHeader } from '#components/data-table';
6
10
  import type { ColumnDef } from '@tanstack/react-table';
7
11
  import { useSettingsData, useUserPermission } from './hooks.js';
8
- import { useApiClient, useToaster } from '@nocobase/app-client';
12
+ import {
13
+ resolveListState,
14
+ useApiClient,
15
+ useSubmission,
16
+ useToaster,
17
+ } from '@nocobase/app-client';
9
18
  import { useTranslation } from '@nocobase/i18n/client';
10
19
  import {
11
20
  UsersClient,
@@ -20,19 +29,14 @@ import {
20
29
  useEffect,
21
30
  type ReactElement,
22
31
  } from 'react';
23
- import {
24
- Link,
25
- Outlet,
26
- useLocation,
27
- useSearchParams,
28
- useResolvedPath,
29
- } from 'react-router';
30
- import { PlusIcon, UsersIcon } from 'lucide-react';
32
+ import { Link, Outlet, useLocation, useResolvedPath } from 'react-router';
33
+ import { PlusIcon } from 'lucide-react';
31
34
  import { PageContainer } from '#components/page-container';
32
35
  import { PageHeader } from '#components/page-header';
33
36
  import { Button } from '#components/ui/button';
34
37
  import { Input } from '#components/ui/input';
35
38
  import { StatusBadge } from '#components/status-badge';
39
+ import { Spinner } from '#components/ui/spinner';
36
40
  import {
37
41
  Select,
38
42
  SelectTrigger,
@@ -40,14 +44,26 @@ import {
40
44
  SelectContent,
41
45
  SelectItem,
42
46
  } from '#components/ui/select';
43
- import {
44
- Empty,
45
- EmptyMedia,
46
- EmptyTitle,
47
- EmptyDescription,
48
- EmptyContent,
49
- } from '#components/ui/empty';
50
- import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
47
+ import { ConfirmAction, SettingsError } from './shared.js';
48
+
49
+ function UsersRefreshing(): ReactElement | null {
50
+ const { t } = useTranslation();
51
+ const [visible, setVisible] = useState(false);
52
+ useEffect(() => {
53
+ // Fast background reads should not flash feedback on overlay close.
54
+ const timer = setTimeout(() => setVisible(true), 300);
55
+ return () => clearTimeout(timer);
56
+ }, []);
57
+ return visible ? (
58
+ <span
59
+ role='status'
60
+ className='flex items-center gap-2 text-sm text-muted-foreground'
61
+ >
62
+ <Spinner aria-hidden='true' className='size-4' />
63
+ {t('settings.loading')}
64
+ </span>
65
+ ) : null;
66
+ }
51
67
 
52
68
  function UserName({ user }: { readonly user: ManagedUser }): ReactElement {
53
69
  const canUpdate = useUserPermission('update', user.id);
@@ -79,14 +95,21 @@ function UserActions({
79
95
  const [action, setAction] = useState<
80
96
  'disable' | 'enable' | 'revokeSessions'
81
97
  >();
82
- const [busy, setBusy] = useState(false);
98
+ const [requestBusy, setRequestBusy] = useState(false);
83
99
  const [error, setError] = useState<unknown>();
100
+ const submission = useSubmission();
101
+ const busy = requestBusy || submission.submitting;
84
102
  const run = async () => {
85
103
  if (!action) return;
86
- setBusy(true);
104
+ setRequestBusy(true);
87
105
  setError(undefined);
88
106
  try {
89
- const updated = await client[action](user.id);
107
+ const outcome = await submission.run(
108
+ { action, id: user.id },
109
+ async (key) => ({ updated: await client[action](user.id, key) }),
110
+ );
111
+ if (!outcome) return;
112
+ const updated = outcome.updated;
90
113
  toaster.show({
91
114
  type: 'success',
92
115
  title: t(`settings.userActionSaved.${action}`, { name: user.name }),
@@ -96,7 +119,7 @@ function UserActions({
96
119
  } catch (next) {
97
120
  setError(next);
98
121
  } finally {
99
- setBusy(false);
122
+ setRequestBusy(false);
100
123
  }
101
124
  };
102
125
  if (!canRevoke && !(user.disabledAt ? canEnable : canDisable)) return <></>;
@@ -156,7 +179,7 @@ function UserActions({
156
179
 
157
180
  export default function UsersPage(): ReactElement {
158
181
  const { t } = useTranslation();
159
- const [params, setParams] = useSearchParams();
182
+ const { params, setParams, active } = useListLocation();
160
183
  const location = useLocation();
161
184
  const listPath = useResolvedPath('.').pathname;
162
185
  const searchRef = useRef<HTMLInputElement>(null);
@@ -218,16 +241,14 @@ export default function UsersPage(): ReactElement {
218
241
  !roleChoices.some((option) => option.value === selectedRole)
219
242
  )
220
243
  roleChoices.push({ value: selectedRole, label: `${roleScope} · ${role}` });
221
- const page = Math.max(
222
- 1,
223
- Number.isSafeInteger(Number(params.get('page')))
224
- ? Number(params.get('page'))
225
- : 1,
226
- );
244
+ const { paginationState, sortingState } = readTableUrlState(params, {
245
+ defaultPageSize: 20,
246
+ pageSizeOptions: [20, 50, 100],
247
+ sortColumns: ['name'],
248
+ });
249
+ const page = paginationState.pageIndex + 1;
227
250
  const status = params.get('status') ?? 'all';
228
- const pageSize = [20, 50, 100].includes(Number(params.get('pageSize')))
229
- ? Number(params.get('pageSize'))
230
- : 20;
251
+ const pageSize = paginationState.pageSize;
231
252
  const update = (key: string, value: string) => {
232
253
  setParams(
233
254
  (old) => {
@@ -263,20 +284,29 @@ export default function UsersPage(): ReactElement {
263
284
  ...(status !== 'all' ? { status } : {}),
264
285
  ...(roleScope ? { roleScope } : {}),
265
286
  ...(role ? { role } : {}),
266
- ...(params.get('order')
267
- ? { sort: 'name', order: params.get('order')! }
287
+ ...(sortingState[0]
288
+ ? {
289
+ sort: sortingState[0].id,
290
+ order: sortingState[0].desc ? 'desc' : 'asc',
291
+ }
268
292
  : {}),
269
293
  });
294
+ const requestPath = `users?${remote}`;
270
295
  const request = useSettingsData<{
271
296
  data: readonly ManagedUser[];
272
297
  meta: { total: number };
273
- }>(`users?${remote}`, true);
298
+ }>(requestPath, { keepPreviousData: true, active });
299
+ const hasCurrentResponse = request.path === requestPath;
274
300
  const tableState = useTableUrlState({
301
+ active,
302
+ revision: request.revision,
275
303
  defaultPageSize: 20,
276
304
  pageSizeOptions: [20, 50, 100],
277
305
  sortColumns: ['name'],
278
306
  rowCount:
279
- !request.loading && !request.error ? request.data?.meta.total : undefined,
307
+ hasCurrentResponse && !request.loading && !request.error
308
+ ? request.data?.meta.total
309
+ : undefined,
280
310
  });
281
311
  const clearFilters = () => {
282
312
  setQuery('');
@@ -309,6 +339,24 @@ export default function UsersPage(): ReactElement {
309
339
  const canCreate = useUserPermission('create');
310
340
  const canAssign = useUserPermission('assign-role');
311
341
  const rows = request.data?.data ?? [];
342
+ const filtered =
343
+ Boolean(params.get('q') || roleScope || role) || status !== 'all';
344
+ const listState = resolveListState(
345
+ {
346
+ data: hasCurrentResponse ? request.data : undefined,
347
+ error: tableState.error ?? request.error,
348
+ isPending: request.loading && !hasCurrentResponse,
349
+ },
350
+ hasCurrentResponse ? (request.data?.meta.total ?? rows.length) : 0,
351
+ filtered,
352
+ );
353
+ const canShowCreate =
354
+ canCreate &&
355
+ canAssign &&
356
+ (listState === 'empty' ||
357
+ listState === 'ready' ||
358
+ listState === 'no-results' ||
359
+ listState === 'refresh-failed');
312
360
  const columns = useMemo<ColumnDef<ManagedUser>[]>(
313
361
  () => [
314
362
  {
@@ -381,8 +429,6 @@ export default function UsersPage(): ReactElement {
381
429
  {t('settings.newUser')}
382
430
  </Button>
383
431
  ) : null;
384
- const filtered =
385
- Boolean(params.get('q') || roleScope || role) || status !== 'all';
386
432
  return (
387
433
  <>
388
434
  <PageContainer>
@@ -392,7 +438,7 @@ export default function UsersPage(): ReactElement {
392
438
  <PageHeader
393
439
  title={t('settings.users')}
394
440
  description={t('settings.usersDescription')}
395
- actions={rows.length || filtered ? create : undefined}
441
+ actions={canShowCreate ? create : undefined}
396
442
  />
397
443
  <div className='flex flex-wrap items-center gap-3'>
398
444
  <Input
@@ -464,69 +510,37 @@ export default function UsersPage(): ReactElement {
464
510
  ))}
465
511
  </SelectContent>
466
512
  </Select>
467
- {filtered && (
513
+ {filtered && listState !== 'no-results' && (
468
514
  <Button variant='ghost' onClick={clearFilters}>
469
515
  {t('settings.clear')}
470
516
  </Button>
471
517
  )}
518
+ <div className='ml-auto flex h-8 w-28 items-center justify-end'>
519
+ {active && hasCurrentResponse && request.loading && request.data ? (
520
+ <UsersRefreshing />
521
+ ) : null}
522
+ </div>
472
523
  </div>
473
- {request.data && request.loading && (
474
- <span role='status' className='text-sm text-muted-foreground'>
475
- {t('settings.loading')}
476
- </span>
477
- )}
478
524
  {Boolean(roleScope) !== Boolean(role) && (
479
525
  <p role='alert' className='text-sm text-destructive'>
480
526
  {t('settings.roleFilterIncomplete')}
481
527
  </p>
482
528
  )}
483
529
  <SettingsError error={options.error} retry={options.reload} />
484
- <SettingsError error={request.error} retry={request.reload} />
485
- {request.loading && !request.data ? (
486
- <SettingsLoading />
487
- ) : (
488
- !request.error &&
489
- (rows.length ? (
490
- <DataTable<ManagedUser>
491
- columns={columns}
492
- data={rows}
493
- getRowId={(row) => row.id}
494
- manualPagination
495
- rowCount={request.data?.meta.total ?? 0}
496
- {...tableState}
497
- manualSorting
498
- showSelectedCount={false}
499
- loading={request.loading}
500
- />
501
- ) : (
502
- <Empty className='min-h-48 border border-dashed'>
503
- <EmptyMedia variant='icon'>
504
- <UsersIcon />
505
- </EmptyMedia>
506
- <EmptyTitle>
507
- {t(filtered ? 'settings.noResults' : 'settings.noUsers')}
508
- </EmptyTitle>
509
- <EmptyDescription>
510
- {t(
511
- filtered
512
- ? 'settings.clearHint'
513
- : canCreate && canAssign
514
- ? 'settings.usersDescription'
515
- : 'settings.usersReadOnlyEmpty',
516
- )}
517
- </EmptyDescription>
518
- <EmptyContent>
519
- {filtered ? (
520
- <Button variant='outline' onClick={clearFilters}>
521
- {t('settings.clear')}
522
- </Button>
523
- ) : (
524
- create
525
- )}
526
- </EmptyContent>
527
- </Empty>
528
- ))
529
- )}
530
+ <DataTable<ManagedUser>
531
+ columns={columns}
532
+ data={rows}
533
+ getRowId={(row) => row.id}
534
+ manualPagination
535
+ rowCount={request.data?.meta.total ?? 0}
536
+ {...tableState}
537
+ manualSorting
538
+ showSelectedCount={false}
539
+ loading={request.loading}
540
+ listState={listState}
541
+ onRetry={request.reload}
542
+ onClearFilters={clearFilters}
543
+ />
530
544
  {request.data && (
531
545
  <p className='text-sm text-muted-foreground'>
532
546
  {t('settings.total', { count: request.data.meta.total })}
@@ -1,6 +1,11 @@
1
1
  import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
2
2
  import { useTranslation } from '@nocobase/i18n/client';
3
- import { useState, type PropsWithChildren, type ReactElement } from 'react';
3
+ import {
4
+ Fragment,
5
+ useState,
6
+ type PropsWithChildren,
7
+ type ReactElement,
8
+ } from 'react';
4
9
 
5
10
  import { Loading } from '#components/loading';
6
11
  import { Button } from '#components/ui/button';
@@ -9,13 +14,15 @@ import { Button } from '#components/ui/button';
9
14
  export function SessionPreparation({
10
15
  children,
11
16
  }: PropsWithChildren): ReactElement {
12
- const { preparation, retryPreparation, client, refresh } =
17
+ const { preparation, retryPreparation, client, refresh, session } =
13
18
  useAuthentication();
14
19
  const { t } = useTranslation();
15
20
  const [signingOut, setSigningOut] = useState(false);
16
21
  const [signOutFailed, setSignOutFailed] = useState(false);
17
22
 
18
- if (preparation === 'ready') return <>{children}</>;
23
+ // List memory and ancestor queries belong to one signed-in identity only.
24
+ if (preparation === 'ready')
25
+ return <Fragment key={session?.user.id}>{children}</Fragment>;
19
26
  if (preparation === 'preparing') {
20
27
  return <Loading className='min-h-svh' label={t('auth.preparingSession')} />;
21
28
  }
package/client/runtime.ts CHANGED
@@ -5,10 +5,8 @@ import { defineAppRuntime } from '@nocobase/app-client/runtime';
5
5
  import locales from './locales/index.js';
6
6
  import plugins from './plugins.js';
7
7
  import reactProviders from './react-providers.js';
8
- import routeComponentOverrides from './route-overrides.js';
9
8
  import routes from './routes.js';
10
9
  import serviceProviders from './service-provider.js';
11
- import sourceExtensions from './source-extensions.js';
12
10
 
13
11
  const appRuntime = defineAppRuntime({
14
12
  packageName: '@nocobase/app-template-default',
@@ -19,8 +17,6 @@ const appRuntime = defineAppRuntime({
19
17
  reactProviders,
20
18
  routes,
21
19
  plugins,
22
- routeComponentOverrides,
23
- sourceExtensions,
24
20
  });
25
21
 
26
22
  export default appRuntime;
@@ -70,6 +70,26 @@ secrets:
70
70
  key: replace-with-a-unique-secret
71
71
 
72
72
  auth:
73
+ # Authentication owns this sender; notification and mailbox configuration are independent.
74
+ # Set app.publicOrigin (or APP_PUBLIC_ORIGIN) before enabling automatic recovery.
75
+ # passwordReset:
76
+ # enabled: true
77
+ # email:
78
+ # provider: smtp
79
+ # host: smtp.example.com
80
+ # port: 587
81
+ # secure: false
82
+ # auth:
83
+ # user: noreply@example.com
84
+ # pass: replace-with-the-smtp-password
85
+ # from: App <noreply@example.com>
86
+ # replyTo: support@example.com
87
+ # For Resend, replace the entire email block with these fields; do not mix SMTP fields:
88
+ # email:
89
+ # provider: resend
90
+ # apiKey: replace-with-the-resend-api-key
91
+ # from: App <noreply@example.com>
92
+ # AUTH_EMAIL_PROVIDER/HOST/PORT/SECURE/USER/PASSWORD/FROM/REPLY_TO/API_KEY override auth.email.
73
93
  emailAndPassword:
74
94
  enabled: true
75
95
  autoSignIn: false
@@ -146,9 +166,13 @@ snowflake:
146
166
  # process that is killed loses what changed since it started. It serves one process: every other process or instance
147
167
  # would run its own copy. Set default to memory to keep that without the startup warning, or to redis to run several.
148
168
  # Redis must persist its data (AOF or RDB) and use maxmemory-policy noeviction, or schedules can be lost.
169
+ # Install the commercial @nocobase/app-plugin-jobs-redis package and register its Server plugin
170
+ # from @nocobase/app-plugin-jobs-redis/server in server/plugins.ts on every producer and consumer.
171
+ # Upgrading deployments must set adapter: redis explicitly; templates no longer supply Redis defaults.
149
172
  # jobs:
150
173
  # default: redis
151
174
  # redis:
175
+ # adapter: redis
152
176
  # connection:
153
177
  # host: 127.0.0.1
154
178
  # port: 6379
@@ -0,0 +1,19 @@
1
+ import { defineMigration, type MigrationDefinition } from '@nocobase/db';
2
+
3
+ const migration: MigrationDefinition = defineMigration({
4
+ name: '202611020002_create_mutation_receipts',
5
+ async up({ builder }) {
6
+ await builder.createCollection('mutationReceipts', (collection) => {
7
+ collection.string('id', { length: 64 }).notNull();
8
+ collection.string('digest', { length: 64 }).notNull();
9
+ collection.json('result').nullable();
10
+ collection.datetime('expiresAt').notNull();
11
+ collection.primary('id', { name: 'pk_mutation_receipts' });
12
+ collection.index('expiresAt', { name: 'ix_mutation_receipts_expiry' });
13
+ });
14
+ },
15
+ async down({ builder }) {
16
+ await builder.dropCollection('mutationReceipts');
17
+ },
18
+ });
19
+ export default migration;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nocobase/app-template-default",
3
3
  "displayName": "Default Template",
4
- "version": "1.0.0-beta.83",
4
+ "version": "1.0.0-beta.85",
5
5
  "type": "module",
6
6
  "prettier": "@nocobase/dev-config/prettier",
7
7
  "engines": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "nocobase": {
11
11
  "templateKind": "app",
12
- "defaultTemplateVersion": "1.0.0-beta.83"
12
+ "defaultTemplateVersion": "1.0.0-beta.85"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "4.0.131",
@@ -73,9 +73,10 @@
73
73
  "@xyflow/react": "^12.11.5",
74
74
  "elkjs": "^0.10.2",
75
75
  "@types/jsdom": "^30.0.0",
76
- "@nocobase/app-client": "^4.0.0-beta.3",
76
+ "@noble/hashes": "2.3.0",
77
+ "@nocobase/app-client": "^4.0.0-beta.5",
77
78
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
78
- "@nocobase/app-skills": "0.1.0-beta.53",
79
+ "@nocobase/app-skills": "0.1.0-beta.55",
79
80
  "@nocobase/app-testing": "0.1.0-beta.6",
80
81
  "@nocobase/app-websocket": "^0.1.0-beta.1",
81
82
  "@nocobase/dev-config": "0.1.0-beta.25",
@@ -141,38 +142,40 @@
141
142
  "@oclif/core": "^4.14.0",
142
143
  "better-auth": "1.7.5",
143
144
  "hono": "^4.13.2",
144
- "@nocobase/app-plugin-agents": "^1.0.0-beta.27",
145
+ "@nocobase/concurrency": "^0.0.2-beta.0",
146
+ "@nocobase/app-plugin-concurrency": "^0.1.0-beta.0",
147
+ "@nocobase/app-plugin-agents": "^2.0.0-beta.0",
145
148
  "@nocobase/app-plugin-api-keys": "^1.0.0-beta.19",
146
- "@nocobase/app-plugin-authentication": "^2.0.0-beta.15",
147
- "@nocobase/app-plugin-authorization": "^1.0.0-beta.32",
149
+ "@nocobase/app-plugin-authentication": "^2.0.0-beta.16",
150
+ "@nocobase/app-plugin-authorization": "^1.0.0-beta.33",
148
151
  "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.17",
149
152
  "@nocobase/app-plugin-file": "^1.0.0-beta.27",
150
- "@nocobase/app-plugin-foundation": "^0.1.0-beta.9",
153
+ "@nocobase/app-plugin-foundation": "^0.1.0-beta.10",
151
154
  "@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
152
- "@nocobase/app-plugin-knowledge": "^0.1.0-beta.13",
153
- "@nocobase/app-plugin-mail": "^1.0.0-beta.17",
154
- "@nocobase/app-plugin-notification": "^2.0.0-beta.1",
155
+ "@nocobase/app-plugin-knowledge": "^0.1.0-beta.15",
156
+ "@nocobase/app-plugin-mail": "^1.0.0-beta.18",
157
+ "@nocobase/app-plugin-notification": "^2.0.0-beta.2",
155
158
  "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.0",
156
- "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.15",
157
- "@nocobase/app-plugin-scheduler": "^1.0.0-beta.20",
158
- "@nocobase/app-plugin-users": "^2.0.0-beta.10",
159
- "@nocobase/app-server": "^3.0.0-beta.3",
159
+ "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.16",
160
+ "@nocobase/app-plugin-scheduler": "^1.0.0-beta.21",
161
+ "@nocobase/app-plugin-users": "^2.0.0-beta.11",
162
+ "@nocobase/app-server": "^3.0.0-beta.5",
160
163
  "@nocobase/config": "^0.1.0-beta.3",
161
- "@nocobase/db": "^1.0.0-beta.22",
164
+ "@nocobase/db": "^1.0.0-beta.24",
162
165
  "@nocobase/db-sqlite": "^0.1.0-beta.6",
163
166
  "@nocobase/service-provider": "^0.0.2-beta.2",
164
167
  "@nocobase/snowflake": "^1.0.0-beta.4",
165
- "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.16",
168
+ "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.17",
166
169
  "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.16",
167
170
  "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.15",
168
171
  "@nocobase/authorization": "^1.0.0-beta.15",
169
- "@nocobase/queue": "^0.1.0-beta.9",
170
- "@nocobase/jobs": "^0.1.0-beta.4",
171
- "@nocobase/lifecycle": "^0.1.0-beta.3",
172
+ "@nocobase/queue": "^1.0.0-beta.10",
173
+ "@nocobase/jobs": "^1.0.0-beta.5",
174
+ "@nocobase/lifecycle": "^0.1.0-beta.4",
172
175
  "@nocobase/i18n": "^2.0.0-beta.1",
173
176
  "@nocobase/caching": "^0.1.0-beta.3",
174
177
  "@nocobase/repository-input": "^0.1.0-beta.3",
175
- "@nocobase/app-cli": "^1.0.0-beta.29",
178
+ "@nocobase/app-cli": "^1.0.0-beta.30",
176
179
  "@nocobase/realtime": "^0.1.0-beta.3"
177
180
  },
178
181
  "description": "NocoBase 3 application template with the standard product capabilities.",
package/server/app.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { MailerProvider } from '@nocobase/app-server/mailer';
2
1
  import type { Application } from '@nocobase/app-server/application';
3
2
  import { DatabaseProvider } from '@nocobase/app-server/database';
4
3
  import { I18nProvider, i18nHttpMiddleware } from '@nocobase/app-server/i18n';
@@ -34,7 +33,6 @@ export function createApp(runtime: AppRuntimeContext): Application {
34
33
  app.addServiceProvider(SecretsProvider);
35
34
  app.addServiceProvider(SessionProvider);
36
35
  app.addServiceProvider(DriveProvider);
37
- app.addServiceProvider(MailerProvider);
38
36
  app.addServiceProvider(QueueServiceProvider, {
39
37
  nodeEnv: runtime.env.NODE_ENV,
40
38
  });