@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.77

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 (61) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/default-access-editor.tsx +79 -52
  7. package/client/components/flow-diagram.tsx +645 -83
  8. package/client/components/flow-layout.ts +117 -7
  9. package/client/components/lifecycle-diagram.tsx +175 -37
  10. package/client/components/long-text.tsx +200 -0
  11. package/client/components/permission-editor.tsx +534 -341
  12. package/client/components/record-link.tsx +20 -0
  13. package/client/components/required-role-setup.tsx +93 -0
  14. package/client/components/role-list.tsx +21 -5
  15. package/client/components/setup-readiness-state.ts +135 -0
  16. package/client/components/setup-readiness.tsx +91 -0
  17. package/client/components/ui/combobox.tsx +300 -0
  18. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  19. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  20. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  21. package/client/hooks/use-table-url-state.ts +93 -0
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +51 -4
  26. package/client/locales/system/settings-zh-CN.ts +46 -5
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +14 -2
  29. package/client/pages/settings/default-access-model.ts +6 -2
  30. package/client/pages/settings/default-access.tsx +40 -8
  31. package/client/pages/settings/page-permissions.ts +166 -1
  32. package/client/pages/settings/permission-filters.tsx +9 -2
  33. package/client/pages/settings/permission-workspace.ts +330 -0
  34. package/client/pages/settings/permissions-model.ts +62 -53
  35. package/client/pages/settings/role-form.tsx +31 -95
  36. package/client/pages/settings/roles.tsx +44 -6
  37. package/client/pages/settings/shared.tsx +15 -13
  38. package/client/pages/settings/user-detail.tsx +15 -259
  39. package/client/pages/settings/user-form.tsx +299 -147
  40. package/client/pages/settings/users.tsx +211 -123
  41. package/config.example.yml +6 -5
  42. package/package.json +17 -17
  43. package/server/config/app.ts +1 -1
  44. package/server/config/session.ts +7 -5
  45. package/tests/components/default-access-editor.test.tsx +130 -0
  46. package/tests/components/default-access.test.tsx +141 -2
  47. package/tests/components/permission-editor.test.tsx +613 -572
  48. package/tests/components/permission-filter-binding.test.tsx +20 -0
  49. package/tests/components/settings-continuity.test.tsx +426 -0
  50. package/tests/components/settings-feedback.test.tsx +304 -0
  51. package/tests/components/settings.test.tsx +34 -9
  52. package/tests/components/setup-readiness.test.tsx +370 -0
  53. package/tests/components/table-url-state.test.tsx +95 -0
  54. package/tests/components/user-drawer.test.tsx +526 -0
  55. package/tests/components/user-permissions.test.tsx +47 -13
  56. package/tests/logic/config.test.ts +3 -1
  57. package/tests/logic/page-permission-navigation.test.ts +394 -0
  58. package/tests/logic/permission-workspace.test.ts +570 -0
  59. package/tests/logic/return-to.test.ts +19 -0
  60. package/tests/logic/session-isolation.test.ts +250 -0
  61. package/tsconfig.json +1 -0
@@ -1,3 +1,7 @@
1
+ import { RecordLink } from '#components/record-link';
2
+ import { BackButton } from '#components/back-button';
3
+ import { safeReturnTo } from '../../lib/return-to.js';
4
+ import { useTableUrlState } from '#hooks/use-table-url-state';
1
5
  import { DataTable, DataTableColumnHeader } from '#components/data-table';
2
6
  import type { ColumnDef } from '@tanstack/react-table';
3
7
  import { useSettingsData, useUserPermission } from './hooks.js';
@@ -6,21 +10,29 @@ import { useTranslation } from '@nocobase/i18n/client';
6
10
  import {
7
11
  UsersClient,
8
12
  type ManagedUser,
13
+ type UsersOptions,
9
14
  } from '@nocobase/app-plugin-users/client/user-client';
10
15
  import {
11
16
  useCallback,
17
+ useRef,
12
18
  useMemo,
13
19
  useState,
14
20
  useEffect,
15
21
  type ReactElement,
16
22
  } from 'react';
17
- import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
18
- import { MoreHorizontalIcon, PlusIcon, UsersIcon } from 'lucide-react';
23
+ import {
24
+ Link,
25
+ Outlet,
26
+ useLocation,
27
+ useSearchParams,
28
+ useResolvedPath,
29
+ } from 'react-router';
30
+ import { PlusIcon, UsersIcon } from 'lucide-react';
19
31
  import { PageContainer } from '#components/page-container';
20
32
  import { PageHeader } from '#components/page-header';
21
33
  import { Button } from '#components/ui/button';
22
34
  import { Input } from '#components/ui/input';
23
- import { Badge } from '#components/ui/badge';
35
+ import { StatusBadge } from '#components/status-badge';
24
36
  import {
25
37
  Select,
26
38
  SelectTrigger,
@@ -28,13 +40,6 @@ import {
28
40
  SelectContent,
29
41
  SelectItem,
30
42
  } from '#components/ui/select';
31
- import {
32
- DropdownMenu,
33
- DropdownMenuTrigger,
34
- DropdownMenuContent,
35
- DropdownMenuItem,
36
- DropdownMenuSeparator,
37
- } from '#components/ui/dropdown-menu';
38
43
  import {
39
44
  Empty,
40
45
  EmptyMedia,
@@ -44,6 +49,19 @@ import {
44
49
  } from '#components/ui/empty';
45
50
  import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
46
51
 
52
+ function UserName({ user }: { readonly user: ManagedUser }): ReactElement {
53
+ const canUpdate = useUserPermission('update', user.id);
54
+ const canAssign = useUserPermission('assign-role', user.id);
55
+ const location = useLocation();
56
+ return canUpdate || canAssign ? (
57
+ <RecordLink to={{ pathname: `${user.id}/edit`, search: location.search }}>
58
+ {user.name}
59
+ </RecordLink>
60
+ ) : (
61
+ <span className='font-medium'>{user.name}</span>
62
+ );
63
+ }
64
+
47
65
  function UserActions({
48
66
  user,
49
67
  reload,
@@ -55,11 +73,9 @@ function UserActions({
55
73
  const api = useApiClient();
56
74
  const toaster = useToaster();
57
75
  const client = useMemo(() => new UsersClient(api), [api]);
58
- const canUpdate = useUserPermission('update', user.id);
59
76
  const canDisable = useUserPermission('disable', user.id);
60
77
  const canEnable = useUserPermission('enable', user.id);
61
78
  const canRevoke = useUserPermission('revoke-sessions', user.id);
62
- const location = useLocation();
63
79
  const [action, setAction] = useState<
64
80
  'disable' | 'enable' | 'revokeSessions'
65
81
  >();
@@ -73,7 +89,7 @@ function UserActions({
73
89
  const updated = await client[action](user.id);
74
90
  toaster.show({
75
91
  type: 'success',
76
- title: t('settings.actionSaved', { name: user.name }),
92
+ title: t(`settings.userActionSaved.${action}`, { name: user.name }),
77
93
  });
78
94
  setAction(undefined);
79
95
  reload(updated ?? undefined);
@@ -83,56 +99,32 @@ function UserActions({
83
99
  setBusy(false);
84
100
  }
85
101
  };
102
+ if (!canRevoke && !(user.disabledAt ? canEnable : canDisable)) return <></>;
86
103
  return (
87
- <>
88
- <DropdownMenu>
89
- <DropdownMenuTrigger
90
- render={
91
- <Button
92
- size='icon-sm'
93
- variant='ghost'
94
- aria-label={t('settings.actionsFor', { name: user.name })}
95
- >
96
- <MoreHorizontalIcon />
97
- </Button>
98
- }
99
- />
100
- <DropdownMenuContent align='end' className='w-auto min-w-40'>
101
- {canUpdate && (
102
- <DropdownMenuItem
103
- render={
104
- <Link
105
- to={{ pathname: `${user.id}/edit`, search: location.search }}
106
- />
107
- }
108
- >
109
- {t('settings.edit')}
110
- </DropdownMenuItem>
111
- )}
112
- {(canDisable || canEnable || canRevoke) && <DropdownMenuSeparator />}
113
- {!user.disabledAt && canDisable && (
114
- <DropdownMenuItem
115
- variant='destructive'
116
- onClick={() => setAction('disable')}
117
- >
118
- {t('settings.disable')}
119
- </DropdownMenuItem>
120
- )}
121
- {user.disabledAt && canEnable && (
122
- <DropdownMenuItem onClick={() => setAction('enable')}>
123
- {t('settings.enable')}
124
- </DropdownMenuItem>
125
- )}
126
- {canRevoke && (
127
- <DropdownMenuItem
128
- variant='destructive'
129
- onClick={() => setAction('revokeSessions')}
130
- >
131
- {t('settings.revokeSessions')}
132
- </DropdownMenuItem>
133
- )}
134
- </DropdownMenuContent>
135
- </DropdownMenu>
104
+ <div className='flex items-center justify-end gap-2 whitespace-nowrap'>
105
+ {!user.disabledAt && canDisable && (
106
+ <Button
107
+ variant='outline'
108
+ size='xs'
109
+ onClick={() => setAction('disable')}
110
+ >
111
+ {t('settings.disable')}
112
+ </Button>
113
+ )}
114
+ {user.disabledAt && canEnable && (
115
+ <Button variant='outline' size='xs' onClick={() => setAction('enable')}>
116
+ {t('settings.enable')}
117
+ </Button>
118
+ )}
119
+ {canRevoke && (
120
+ <Button
121
+ variant='outline'
122
+ size='xs'
123
+ onClick={() => setAction('revokeSessions')}
124
+ >
125
+ {t('settings.revokeSessions')}
126
+ </Button>
127
+ )}
136
128
  <ConfirmAction
137
129
  open={Boolean(action)}
138
130
  title={t(
@@ -158,7 +150,7 @@ function UserActions({
158
150
  onCancel={() => setAction(undefined)}
159
151
  onConfirm={() => void run()}
160
152
  />
161
- </>
153
+ </div>
162
154
  );
163
155
  }
164
156
 
@@ -166,6 +158,24 @@ export default function UsersPage(): ReactElement {
166
158
  const { t } = useTranslation();
167
159
  const [params, setParams] = useSearchParams();
168
160
  const location = useLocation();
161
+ const listPath = useResolvedPath('.').pathname;
162
+ const searchRef = useRef<HTMLInputElement>(null);
163
+ const previousPathRef = useRef(location.pathname);
164
+ useEffect(() => {
165
+ const returned =
166
+ previousPathRef.current !== location.pathname &&
167
+ location.pathname === listPath;
168
+ previousPathRef.current = location.pathname;
169
+ if (!returned) return;
170
+ const frame = requestAnimationFrame(() => {
171
+ if (
172
+ document.activeElement === document.body ||
173
+ !document.activeElement?.isConnected
174
+ )
175
+ searchRef.current?.focus();
176
+ });
177
+ return () => cancelAnimationFrame(frame);
178
+ }, [listPath, location.pathname]);
169
179
  const [queryDraft, setQueryDraft] = useState<{
170
180
  key: string;
171
181
  value: string;
@@ -177,7 +187,43 @@ export default function UsersPage(): ReactElement {
177
187
  const setQuery = (value: string) =>
178
188
  setQueryDraft({ key: location.key, value });
179
189
  const [composing, setComposing] = useState(false);
180
- const page = Math.max(1, Number(params.get('page')) || 1);
190
+ const options = useSettingsData<{ data: UsersOptions }>('users/options');
191
+ const roleScopes = useMemo(
192
+ () => options.data?.data.roleScopes ?? [],
193
+ [options.data],
194
+ );
195
+ const returnTo = safeReturnTo(params.get('returnTo'));
196
+ const roleScope = params.get('roleScope') ?? '';
197
+ const role = params.get('role') ?? '';
198
+ const roleText = useCallback(
199
+ (option: { label: string; labelI18nKey?: string; labelI18nNs?: string }) =>
200
+ option.labelI18nKey
201
+ ? t(option.labelI18nKey, {
202
+ ns: option.labelI18nNs,
203
+ defaultValue: option.label,
204
+ })
205
+ : option.label,
206
+ [t],
207
+ );
208
+ const roleChoices = roleScopes.flatMap((scope) =>
209
+ scope.options.map((option) => ({
210
+ value: JSON.stringify([scope.key, option.value]),
211
+ label: `${roleText(scope)} · ${roleText(option)}`,
212
+ })),
213
+ );
214
+ const selectedRole =
215
+ roleScope && role ? JSON.stringify([roleScope, role]) : 'all';
216
+ if (
217
+ selectedRole !== 'all' &&
218
+ !roleChoices.some((option) => option.value === selectedRole)
219
+ )
220
+ 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
+ );
181
227
  const status = params.get('status') ?? 'all';
182
228
  const pageSize = [20, 50, 100].includes(Number(params.get('pageSize')))
183
229
  ? Number(params.get('pageSize'))
@@ -215,6 +261,8 @@ export default function UsersPage(): ReactElement {
215
261
  pageSize: String(pageSize),
216
262
  ...(params.get('q') ? { q: params.get('q')! } : {}),
217
263
  ...(status !== 'all' ? { status } : {}),
264
+ ...(roleScope ? { roleScope } : {}),
265
+ ...(role ? { role } : {}),
218
266
  ...(params.get('order')
219
267
  ? { sort: 'name', order: params.get('order')! }
220
268
  : {}),
@@ -223,6 +271,25 @@ export default function UsersPage(): ReactElement {
223
271
  data: readonly ManagedUser[];
224
272
  meta: { total: number };
225
273
  }>(`users?${remote}`, true);
274
+ const tableState = useTableUrlState({
275
+ defaultPageSize: 20,
276
+ pageSizeOptions: [20, 50, 100],
277
+ sortColumns: ['name'],
278
+ rowCount:
279
+ !request.loading && !request.error ? request.data?.meta.total : undefined,
280
+ });
281
+ const clearFilters = () => {
282
+ setQuery('');
283
+ setParams(
284
+ (old) => {
285
+ const next = new URLSearchParams(old);
286
+ for (const key of ['q', 'status', 'roleScope', 'role', 'page'])
287
+ next.delete(key);
288
+ return next;
289
+ },
290
+ { replace: true },
291
+ );
292
+ };
226
293
  const { data: pageData, replaceData, reload: refreshUsers } = request;
227
294
  const reload = useCallback(
228
295
  (updated?: ManagedUser) => {
@@ -234,8 +301,10 @@ export default function UsersPage(): ReactElement {
234
301
  ),
235
302
  });
236
303
  refreshUsers();
304
+ if (updated && location.pathname === listPath)
305
+ requestAnimationFrame(() => searchRef.current?.focus());
237
306
  },
238
- [pageData, replaceData, refreshUsers],
307
+ [pageData, replaceData, refreshUsers, location.pathname, listPath],
239
308
  );
240
309
  const canCreate = useUserPermission('create');
241
310
  const canAssign = useUserPermission('assign-role');
@@ -248,17 +317,7 @@ export default function UsersPage(): ReactElement {
248
317
  <DataTableColumnHeader column={column} title={t('settings.name')} />
249
318
  ),
250
319
  enableHiding: false,
251
- cell: ({ row }) => (
252
- <Link
253
- className='font-medium text-foreground underline-offset-4 hover:underline'
254
- to={{
255
- pathname: row.original.id,
256
- search: location.search,
257
- }}
258
- >
259
- {row.original.name}
260
- </Link>
261
- ),
320
+ cell: ({ row }) => <UserName user={row.original} />,
262
321
  },
263
322
  {
264
323
  accessorKey: 'email',
@@ -275,13 +334,13 @@ export default function UsersPage(): ReactElement {
275
334
  header: t('settings.status'),
276
335
  enableSorting: false,
277
336
  cell: ({ row }) => (
278
- <Badge variant='secondary'>
337
+ <StatusBadge tone={row.original.disabledAt ? 'neutral' : 'success'}>
279
338
  {t(
280
339
  row.original.disabledAt
281
340
  ? 'settings.disabled'
282
341
  : 'settings.enabled',
283
342
  )}
284
- </Badge>
343
+ </StatusBadge>
285
344
  ),
286
345
  },
287
346
  {
@@ -289,7 +348,19 @@ export default function UsersPage(): ReactElement {
289
348
  header: t('settings.assignedRoles'),
290
349
  enableSorting: false,
291
350
  cell: ({ row }) =>
292
- Object.values(row.original.roleScopes).flat().join(', ') || '—',
351
+ Object.entries(row.original.roleScopes)
352
+ .flatMap(([scopeKey, value]) => {
353
+ const scope = roleScopes.find((entry) => entry.key === scopeKey);
354
+ return (typeof value === 'string' ? [value] : value).map(
355
+ (key) => {
356
+ const option = scope?.options.find(
357
+ (entry) => entry.value === key,
358
+ );
359
+ return option ? roleText(option) : key;
360
+ },
361
+ );
362
+ })
363
+ .join(', ') || '—',
293
364
  },
294
365
  {
295
366
  id: 'actions',
@@ -298,7 +369,7 @@ export default function UsersPage(): ReactElement {
298
369
  cell: ({ row }) => <UserActions user={row.original} reload={reload} />,
299
370
  },
300
371
  ],
301
- [t, location.search, reload],
372
+ [t, reload, roleScopes, roleText],
302
373
  );
303
374
  const create =
304
375
  canCreate && canAssign ? (
@@ -310,10 +381,14 @@ export default function UsersPage(): ReactElement {
310
381
  {t('settings.newUser')}
311
382
  </Button>
312
383
  ) : null;
313
- const filtered = Boolean(params.get('q')) || status !== 'all';
384
+ const filtered =
385
+ Boolean(params.get('q') || roleScope || role) || status !== 'all';
314
386
  return (
315
387
  <>
316
388
  <PageContainer>
389
+ {returnTo && (
390
+ <BackButton to={returnTo}>{t('settings.backToSource')}</BackButton>
391
+ )}
317
392
  <PageHeader
318
393
  title={t('settings.users')}
319
394
  description={t('settings.usersDescription')}
@@ -321,6 +396,7 @@ export default function UsersPage(): ReactElement {
321
396
  />
322
397
  <div className='flex flex-wrap items-center gap-3'>
323
398
  <Input
399
+ ref={searchRef}
324
400
  className='w-full sm:max-w-xs'
325
401
  value={query}
326
402
  aria-label={t('settings.userSearch')}
@@ -348,14 +424,48 @@ export default function UsersPage(): ReactElement {
348
424
  ))}
349
425
  </SelectContent>
350
426
  </Select>
427
+ <Select
428
+ items={[
429
+ { value: 'all', label: t('settings.allRoles') },
430
+ ...roleChoices,
431
+ ]}
432
+ value={selectedRole}
433
+ onValueChange={(value) => {
434
+ if (!value) return;
435
+ setParams(
436
+ (old) => {
437
+ const next = new URLSearchParams(old);
438
+ next.delete('page');
439
+ if (value === 'all') {
440
+ next.delete('roleScope');
441
+ next.delete('role');
442
+ } else {
443
+ const [scope, key] = JSON.parse(value) as [string, string];
444
+ next.set('roleScope', scope);
445
+ next.set('role', key);
446
+ }
447
+ return next;
448
+ },
449
+ { replace: true },
450
+ );
451
+ }}
452
+ >
453
+ <SelectTrigger aria-label={t('settings.roleFilter')}>
454
+ <SelectValue />
455
+ </SelectTrigger>
456
+ <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
457
+ {[
458
+ { value: 'all', label: t('settings.allRoles') },
459
+ ...roleChoices,
460
+ ].map((option) => (
461
+ <SelectItem key={option.value} value={option.value}>
462
+ {option.label}
463
+ </SelectItem>
464
+ ))}
465
+ </SelectContent>
466
+ </Select>
351
467
  {filtered && (
352
- <Button
353
- variant='ghost'
354
- onClick={() => {
355
- setQuery('');
356
- setParams({});
357
- }}
358
- >
468
+ <Button variant='ghost' onClick={clearFilters}>
359
469
  {t('settings.clear')}
360
470
  </Button>
361
471
  )}
@@ -365,6 +475,12 @@ export default function UsersPage(): ReactElement {
365
475
  {t('settings.loading')}
366
476
  </span>
367
477
  )}
478
+ {Boolean(roleScope) !== Boolean(role) && (
479
+ <p role='alert' className='text-sm text-destructive'>
480
+ {t('settings.roleFilterIncomplete')}
481
+ </p>
482
+ )}
483
+ <SettingsError error={options.error} retry={options.reload} />
368
484
  <SettingsError error={request.error} retry={request.reload} />
369
485
  {request.loading && !request.data ? (
370
486
  <SettingsLoading />
@@ -377,34 +493,8 @@ export default function UsersPage(): ReactElement {
377
493
  getRowId={(row) => row.id}
378
494
  manualPagination
379
495
  rowCount={request.data?.meta.total ?? 0}
380
- paginationState={{ pageIndex: page - 1, pageSize }}
381
- onPaginationChange={(updater) => {
382
- const next =
383
- typeof updater === 'function'
384
- ? updater({ pageIndex: page - 1, pageSize })
385
- : updater;
386
- setParams((old) => {
387
- const result = new URLSearchParams(old);
388
- result.set('page', String(next.pageIndex + 1));
389
- result.set('pageSize', String(next.pageSize));
390
- return result;
391
- });
392
- }}
496
+ {...tableState}
393
497
  manualSorting
394
- sortingState={
395
- params.get('order')
396
- ? [{ id: 'name', desc: params.get('order') === 'desc' }]
397
- : []
398
- }
399
- onSortingChange={(updater) => {
400
- const previous = params.get('order')
401
- ? [{ id: 'name', desc: params.get('order') === 'desc' }]
402
- : [];
403
- const next =
404
- typeof updater === 'function' ? updater(previous) : updater;
405
- update('order', next[0] ? (next[0].desc ? 'desc' : 'asc') : '');
406
- }}
407
- pageSizeOptions={[20, 50, 100]}
408
498
  showSelectedCount={false}
409
499
  loading={request.loading}
410
500
  />
@@ -418,18 +508,16 @@ export default function UsersPage(): ReactElement {
418
508
  </EmptyTitle>
419
509
  <EmptyDescription>
420
510
  {t(
421
- filtered ? 'settings.clearHint' : 'settings.usersDescription',
511
+ filtered
512
+ ? 'settings.clearHint'
513
+ : canCreate && canAssign
514
+ ? 'settings.usersDescription'
515
+ : 'settings.usersReadOnlyEmpty',
422
516
  )}
423
517
  </EmptyDescription>
424
518
  <EmptyContent>
425
519
  {filtered ? (
426
- <Button
427
- variant='outline'
428
- onClick={() => {
429
- setQuery('');
430
- setParams({});
431
- }}
432
- >
520
+ <Button variant='outline' onClick={clearFilters}>
433
521
  {t('settings.clear')}
434
522
  </Button>
435
523
  ) : (
@@ -77,15 +77,16 @@ auth:
77
77
  storeSessionInDatabase: true
78
78
 
79
79
  notification:
80
+ # provider remains a compatibility alias for type; both must match when supplied.
80
81
  channels:
81
82
  inbox:
82
- provider: in-app
83
+ type: in-app
83
84
 
84
85
  # Uncomment and configure independent email Channels.
85
86
  # Values are read as written: ${NAME} is not expanded. To take a secret from the environment instead, map a
86
87
  # variable onto its path in env of server/config/notification.ts, such as SMTP_PASSWORD.
87
88
  # system-email:
88
- # provider: smtp
89
+ # type: smtp
89
90
  # host: smtp.example.com
90
91
  # port: 587
91
92
  # auth:
@@ -93,15 +94,15 @@ notification:
93
94
  # pass: replace-with-the-smtp-password
94
95
  # from: notifications@example.com
95
96
  # marketing-email:
96
- # provider: resend
97
+ # type: resend
97
98
  # apiKey: replace-with-the-resend-api-key
98
99
  # from: marketing@example.com
99
100
 
100
101
  # ops-feishu:
101
- # provider: feishu-webhook
102
+ # type: feishu-webhook
102
103
  # webhookUrl: https://open.feishu.cn/open-apis/bot/v2/hook/replace-with-the-token
103
104
  # ops-dingtalk:
104
- # provider: dingtalk-webhook
105
+ # type: dingtalk-webhook
105
106
  # webhookUrl: https://oapi.dingtalk.com/robot/send?access_token=replace-with-the-token
106
107
 
107
108
  # DB_DIALECT, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_SSL and DB_FILENAME override the main
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.75",
4
+ "version": "1.0.0-beta.77",
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.75"
12
+ "defaultTemplateVersion": "1.0.0-beta.77"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "4.0.131",
@@ -72,9 +72,9 @@
72
72
  "tar": "^7.5.22",
73
73
  "@xyflow/react": "^12.11.5",
74
74
  "elkjs": "^0.10.2",
75
- "@nocobase/app-client": "^4.0.0-beta.1",
75
+ "@nocobase/app-client": "^4.0.0-beta.2",
76
76
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
77
- "@nocobase/app-skills": "0.1.0-beta.43",
77
+ "@nocobase/app-skills": "0.1.0-beta.46",
78
78
  "@nocobase/app-testing": "0.1.0-beta.6",
79
79
  "@nocobase/app-websocket": "^0.1.0-beta.1",
80
80
  "@nocobase/dev-config": "0.1.0-beta.24",
@@ -139,23 +139,24 @@
139
139
  "@oclif/core": "^4.14.0",
140
140
  "better-auth": "1.7.5",
141
141
  "hono": "^4.13.2",
142
- "@nocobase/app-plugin-agents": "^1.0.0-beta.17",
142
+ "@nocobase/app-plugin-agents": "^1.0.0-beta.19",
143
143
  "@nocobase/app-plugin-api-keys": "^1.0.0-beta.18",
144
- "@nocobase/app-plugin-authentication": "^2.0.0-beta.12",
145
- "@nocobase/app-plugin-authorization": "^1.0.0-beta.30",
144
+ "@nocobase/app-plugin-authorization": "^1.0.0-beta.31",
145
+ "@nocobase/app-plugin-authentication": "^2.0.0-beta.13",
146
146
  "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.16",
147
147
  "@nocobase/app-plugin-file": "^1.0.0-beta.26",
148
- "@nocobase/app-plugin-foundation": "^0.1.0-beta.0",
148
+ "@nocobase/app-plugin-foundation": "^0.1.0-beta.1",
149
149
  "@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
150
- "@nocobase/app-plugin-knowledge": "^0.1.0-beta.9",
151
- "@nocobase/app-plugin-notification": "^1.0.0-beta.29",
152
- "@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.27",
153
- "@nocobase/app-plugin-notification-providers": "^0.2.0-beta.14",
150
+ "@nocobase/app-plugin-knowledge": "^0.1.0-beta.10",
151
+ "@nocobase/app-plugin-mail": "^1.0.0-beta.16",
152
+ "@nocobase/app-plugin-notification": "^2.0.0-beta.0",
153
+ "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.0",
154
+ "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.15",
154
155
  "@nocobase/app-plugin-scheduler": "^1.0.0-beta.19",
155
- "@nocobase/app-plugin-users": "^2.0.0-beta.8",
156
- "@nocobase/app-server": "^3.0.0-beta.1",
156
+ "@nocobase/app-plugin-users": "^2.0.0-beta.9",
157
+ "@nocobase/app-server": "^3.0.0-beta.3",
157
158
  "@nocobase/config": "^0.1.0-beta.3",
158
- "@nocobase/db": "^1.0.0-beta.21",
159
+ "@nocobase/db": "^1.0.0-beta.22",
159
160
  "@nocobase/db-sqlite": "^0.1.0-beta.6",
160
161
  "@nocobase/service-provider": "^0.0.2-beta.2",
161
162
  "@nocobase/snowflake": "^1.0.0-beta.4",
@@ -169,8 +170,7 @@
169
170
  "@nocobase/i18n": "^2.0.0-beta.1",
170
171
  "@nocobase/caching": "^0.1.0-beta.3",
171
172
  "@nocobase/repository-input": "^0.1.0-beta.3",
172
- "@nocobase/app-cli": "^1.0.0-beta.26",
173
- "@nocobase/app-plugin-mail": "^1.0.0-beta.16",
173
+ "@nocobase/app-cli": "^1.0.0-beta.27",
174
174
  "@nocobase/realtime": "^0.1.0-beta.3"
175
175
  },
176
176
  "description": "NocoBase 3 application template with the standard product capabilities.",
@@ -27,7 +27,7 @@ const app: AppConfigFactory<AppIdentityConfig> = defineAppConfig({
27
27
  },
28
28
  defaults: (runtime) => {
29
29
  const { routing } = runtime;
30
- const publicBasePath = normalizeBasePath(routing.publicBasePath || '/main');
30
+ const publicBasePath = normalizeBasePath(routing.publicBasePath);
31
31
  return {
32
32
  name: routing.name || resolveAppNameFromBasePath(publicBasePath, 'main'),
33
33
  publicBasePath,
@@ -4,7 +4,10 @@ import {
4
4
  type AppConfigFactory,
5
5
  } from '@nocobase/app-server/config';
6
6
  import type { AppSessionConfigInput } from '@nocobase/app-server/session';
7
- import { normalizeBasePath } from '@nocobase/app-server/support';
7
+ import {
8
+ createAppCookieScope,
9
+ normalizeBasePath,
10
+ } from '@nocobase/app-server/support';
8
11
 
9
12
  const session: AppConfigFactory<AppSessionConfigInput> = defineAppConfig({
10
13
  env: {
@@ -19,10 +22,9 @@ const session: AppConfigFactory<AppSessionConfigInput> = defineAppConfig({
19
22
  enabled: true,
20
23
  default: 'memory',
21
24
  cookie: {
22
- name: 'nocobase_session',
23
- // The application's own public base path, as `app.publicBasePath` resolves it: applications sharing an origin
24
- // under different paths (`/crm`, `/acme`) each keep their own session cookie instead of overwriting one another's.
25
- path: normalizeBasePath(routing.publicBasePath || '/main'),
25
+ name: `nocobase_session_${createAppCookieScope(routing.publicBasePath)}`,
26
+ // A distinct name also keeps a child application from reading a parent's cookie sent under an overlapping path.
27
+ path: normalizeBasePath(routing.publicBasePath) || '/',
26
28
  secure: env.NODE_ENV === 'production',
27
29
  httpOnly: true,
28
30
  sameSite: 'lax' as const,