@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
@@ -10,6 +10,7 @@ import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
10
10
  import { PermissionManagementPage } from './permission-management-page.js';
11
11
  import { Input } from '#components/ui/input';
12
12
  import { Button } from '#components/ui/button';
13
+ import { useTableUrlState } from '#hooks/use-table-url-state';
13
14
  import { RoleList } from '#components/role-list';
14
15
  import {
15
16
  Empty,
@@ -48,6 +49,13 @@ export default function RolesPage(): ReactElement {
48
49
  i18n.language,
49
50
  ),
50
51
  );
52
+ const tableState = useTableUrlState({
53
+ sortColumns: ['title', 'key'],
54
+ rowCount:
55
+ request.data && !request.loading && !request.error
56
+ ? rows.length
57
+ : undefined,
58
+ });
51
59
  const create = canCreate ? (
52
60
  <Button
53
61
  nativeButton={false}
@@ -70,16 +78,31 @@ export default function RolesPage(): ReactElement {
70
78
  placeholder={t('settings.roleSearch')}
71
79
  aria-label={t('settings.roleSearch')}
72
80
  onChange={(event) => {
73
- setParams(event.target.value ? { q: event.target.value } : {}, {
74
- replace: true,
75
- });
81
+ setParams(
82
+ (old) => {
83
+ const next = new URLSearchParams(old);
84
+ next.delete('page');
85
+ if (event.target.value) next.set('q', event.target.value);
86
+ else next.delete('q');
87
+ return next;
88
+ },
89
+ { replace: true },
90
+ );
76
91
  }}
77
92
  />
78
93
  {search && (
79
94
  <Button
80
95
  variant='ghost'
81
96
  onClick={() => {
82
- setParams({});
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
+ );
83
106
  }}
84
107
  >
85
108
  {t('settings.clear')}
@@ -93,6 +116,7 @@ export default function RolesPage(): ReactElement {
93
116
  !request.error &&
94
117
  (rows.length ? (
95
118
  <RoleList
119
+ tableState={tableState}
96
120
  ariaLabel={t('settings.roles')}
97
121
  roles={rows.map((role) => ({
98
122
  key: role.key,
@@ -120,14 +144,28 @@ export default function RolesPage(): ReactElement {
120
144
  {t(search ? 'settings.noResults' : 'settings.noRoles')}
121
145
  </EmptyTitle>
122
146
  <EmptyDescription>
123
- {t(search ? 'settings.clearHint' : 'settings.rolesDescription')}
147
+ {t(
148
+ search
149
+ ? 'settings.clearHint'
150
+ : canCreate
151
+ ? 'settings.rolesDescription'
152
+ : 'settings.rolesReadOnlyEmpty',
153
+ )}
124
154
  </EmptyDescription>
125
155
  <EmptyContent>
126
156
  {search ? (
127
157
  <Button
128
158
  variant='outline'
129
159
  onClick={() => {
130
- setParams({});
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
+ );
131
169
  }}
132
170
  >
133
171
  {t('settings.clear')}
@@ -32,18 +32,20 @@ export function SettingsError({
32
32
  <Alert variant='destructive'>
33
33
  <AlertDescription>
34
34
  {t(
35
- status === 401
36
- ? 'settings.sessionEnded'
37
- : status === 403
38
- ? 'settings.denied'
39
- : status === 404
40
- ? 'settings.missing'
41
- : status === 409
42
- ? error instanceof ApiClientError &&
43
- error.reason === 'USER_IDENTITY_CONFLICT'
44
- ? 'settings.identityConflict'
45
- : 'settings.conflict'
46
- : 'settings.failed',
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',
47
49
  )}
48
50
  {status === 401 && (
49
51
  <Button
@@ -54,7 +56,7 @@ export function SettingsError({
54
56
  {t('settings.signInAgain')}
55
57
  </Button>
56
58
  )}
57
- {retry && ![401, 403, 404, 409].includes(Number(status)) && (
59
+ {retry && ![400, 401, 403, 404, 409].includes(Number(status)) && (
58
60
  <Button variant='outline' size='sm' onClick={retry}>
59
61
  {t('settings.retry')}
60
62
  </Button>
@@ -1,268 +1,24 @@
1
+ import type { ReactElement } from 'react';
1
2
  import {
2
- useSettingsData,
3
- useUserPermission,
4
- useSettingsLeave,
5
- } from './hooks.js';
6
- import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
7
- import {
8
- UsersClient,
9
- type ManagedUser,
10
- type UserRoleScopeOption,
11
- type UserRoleValue,
12
- type UsersOptions,
13
- } from '@nocobase/app-plugin-users/client/user-client';
14
- import { useTranslation } from '@nocobase/i18n/client';
15
- import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
16
- import {
17
- Link,
18
- Outlet,
3
+ Navigate,
19
4
  useLocation,
5
+ useOutlet,
20
6
  useOutletContext,
21
- useParams,
7
+ useResolvedPath,
22
8
  } from 'react-router';
23
- import { RouteDrawer } from '#components/route-drawer';
24
- import { Button } from '#components/ui/button';
25
- import { Badge } from '#components/ui/badge';
26
- import {
27
- Card,
28
- CardHeader,
29
- CardTitle,
30
- CardDescription,
31
- CardContent,
32
- CardFooter,
33
- } from '#components/ui/card';
34
- import { RoleSelection } from './user-form.js';
35
- import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
9
+ import type { ManagedUser } from '@nocobase/app-plugin-users/client/user-client';
36
10
 
37
- function ScopeCard({
38
- scope,
39
- user,
40
- reload,
41
- onDirtyChange,
42
- }: {
43
- readonly scope: UserRoleScopeOption;
44
- readonly user: ManagedUser;
45
- readonly reload: (updated?: ManagedUser) => void;
46
- readonly onDirtyChange: (dirty: boolean, busy: boolean) => void;
47
- }): ReactElement {
48
- const { t } = useTranslation();
49
- const api = useApiClient();
50
- const toaster = useToaster();
51
- const client = useMemo(() => new UsersClient(api), [api]);
52
- const allowed = useUserPermission('assign-role', user.id);
53
- const current =
54
- user.roleScopes[scope.key] ?? (scope.selection === 'single' ? '' : []);
55
- const key = JSON.stringify(current);
56
- const [draftValue, setDraftValue] = useState<UserRoleValue>();
57
- const value = draftValue ?? current;
58
- const [busy, setBusy] = useState(false);
59
- const [error, setError] = useState<unknown>();
60
- const [confirm, setConfirm] = useState(false);
61
- const changed = JSON.stringify(value) !== key;
62
- const removed = (typeof current === 'string' ? [current] : current).filter(
63
- (item) => !(typeof value === 'string' ? [value] : value).includes(item),
64
- );
65
- const save = async () => {
66
- setBusy(true);
67
- onDirtyChange(changed, true);
68
- setError(undefined);
69
- let saved = false;
70
- try {
71
- const updated = await client.replaceRoleScope(user.id, scope.key, value);
72
- saved = true;
73
- onDirtyChange(false, false);
74
- reload(updated);
75
- toaster.show({
76
- type: 'success',
77
- title: t('settings.rolesSaved', { name: user.name }),
78
- });
79
- setConfirm(false);
80
- } catch (next) {
81
- setError(next);
82
- } finally {
83
- setBusy(false);
84
- onDirtyChange(!saved && changed, false);
85
- }
86
- };
87
- return (
88
- <Card>
89
- <CardHeader>
90
- <CardTitle>
91
- {scope.labelI18nKey
92
- ? t(scope.labelI18nKey, {
93
- ns: scope.labelI18nNs,
94
- defaultValue: scope.label,
95
- })
96
- : scope.label}
97
- </CardTitle>
98
- <CardDescription>{t('settings.rolesDescription')}</CardDescription>
99
- </CardHeader>
100
- <CardContent>
101
- <RoleSelection
102
- scope={scope}
103
- value={value}
104
- onChange={(next) => {
105
- setDraftValue(next);
106
- onDirtyChange(JSON.stringify(next) !== key, false);
107
- }}
108
- disabled={!allowed || busy}
109
- />
110
- <SettingsError error={error} />
111
- </CardContent>
112
- {allowed && (
113
- <CardFooter className='justify-end'>
114
- <Button
115
- disabled={!changed || busy}
116
- onClick={() => (removed.length ? setConfirm(true) : void save())}
117
- >
118
- {t(busy ? 'settings.saving' : 'settings.save')}
119
- </Button>
120
- </CardFooter>
121
- )}
122
- <ConfirmAction
123
- open={confirm}
124
- confirmLabel={t('settings.revokeRoles')}
125
- title={t('settings.revokeRoleTitle', { name: user.name })}
126
- description={
127
- <>
128
- <p>{t('settings.revokeRoleHint')}</p>
129
- <p>
130
- {scope.labelI18nKey
131
- ? t(scope.labelI18nKey, {
132
- ns: scope.labelI18nNs,
133
- defaultValue: scope.label,
134
- })
135
- : scope.label}
136
- :{' '}
137
- {removed
138
- .map((key) => {
139
- const option = scope.options.find(
140
- (item) => item.value === key,
141
- );
142
- return option?.labelI18nKey
143
- ? t(option.labelI18nKey, {
144
- ns: option.labelI18nNs,
145
- defaultValue: option.label,
146
- })
147
- : (option?.label ?? key);
148
- })
149
- .join(', ')}
150
- </p>
151
- <SettingsError error={error} />
152
- </>
153
- }
154
- busy={busy}
155
- onCancel={() => setConfirm(false)}
156
- onConfirm={() => void save()}
157
- />
158
- </Card>
159
- );
160
- }
161
- function UserDetailView(): ReactElement {
162
- const { userId = '' } = useParams();
163
- const [draftRevision, setDraftRevision] = useState(0);
164
- const [scopeState, setScopeState] = useState<
165
- Record<string, { dirty: boolean; busy: boolean }>
166
- >({});
167
- const leave = useSettingsLeave(
168
- Object.values(scopeState).some((state) => state.dirty),
169
- Object.values(scopeState).some((state) => state.busy),
170
- () => {
171
- setScopeState({});
172
- setDraftRevision((value) => value + 1);
173
- },
174
- );
175
- const { t } = useTranslation();
11
+ /** Keep stored route identifiers and old detail links while using one editor. */
12
+ export default function UserDetail(): ReactElement {
13
+ const parent = useOutletContext<{
14
+ reload: (updated?: ManagedUser) => void;
15
+ }>();
176
16
  const location = useLocation();
177
- const parent = useOutletContext<{ reload: () => void }>();
178
- const request = useSettingsData<{ data: ManagedUser }>(
179
- `users/${encodeURIComponent(userId)}`,
180
- );
181
- const missingRef = useRef<unknown>(undefined);
182
- useEffect(() => {
183
- if (
184
- request.error instanceof ApiClientError &&
185
- request.error.status === 404 &&
186
- missingRef.current !== request.error
187
- ) {
188
- missingRef.current = request.error;
189
- parent.reload();
190
- }
191
- }, [request.error, parent]);
192
- const options = useSettingsData<{ data: UsersOptions }>('users/options');
193
- const canUpdate = useUserPermission('update', userId);
194
- const user = request.data?.data;
195
- const reload = (updated?: ManagedUser) => {
196
- if (updated) request.replaceData({ data: updated });
197
- request.reload();
198
- parent.reload();
199
- };
17
+ const listPath = useResolvedPath('..').pathname;
18
+ const outlet = useOutlet({ ...parent, listPath });
200
19
  return (
201
- <RouteDrawer
202
- beforeClose={leave.guard}
203
- title={user?.name ?? t('settings.users')}
204
- closeTo={{ pathname: '/settings/users', search: location.search }}
205
- footer={
206
- canUpdate && user ? (
207
- <Button
208
- variant='outline'
209
- nativeButton={false}
210
- render={<Link to={{ pathname: 'edit', search: location.search }} />}
211
- >
212
- {t('settings.edit')}
213
- </Button>
214
- ) : undefined
215
- }
216
- >
217
- <SettingsError error={request.error} retry={reload} />
218
- {request.loading && !user ? (
219
- <SettingsLoading />
220
- ) : (
221
- user && (
222
- <div className='flex flex-col gap-6'>
223
- <dl className='grid grid-cols-[8rem_minmax(0,1fr)] gap-3 text-sm'>
224
- {(['name', 'username', 'email'] as const).map((field) => (
225
- <div key={field} className='contents'>
226
- <dt className='text-muted-foreground'>
227
- {t(`settings.${field}`)}
228
- </dt>
229
- <dd className='min-w-0 break-words'>{user[field] || '—'}</dd>
230
- </div>
231
- ))}
232
- <dt className='text-muted-foreground'>{t('settings.status')}</dt>
233
- <dd>
234
- <Badge variant='secondary'>
235
- {t(
236
- user.disabledAt ? 'settings.disabled' : 'settings.enabled',
237
- )}
238
- </Badge>
239
- </dd>
240
- </dl>
241
- <SettingsError error={options.error} retry={options.reload} />
242
- {options.data?.data.roleScopes.map((scope) => (
243
- <ScopeCard
244
- key={`${draftRevision}:${scope.key}:${JSON.stringify(user.roleScopes[scope.key])}`}
245
- scope={scope}
246
- user={user}
247
- reload={reload}
248
- onDirtyChange={(dirty, busy) =>
249
- setScopeState((old) => ({
250
- ...old,
251
- [scope.key]: { dirty, busy },
252
- }))
253
- }
254
- />
255
- ))}
256
- </div>
257
- )
258
- )}
259
- {leave.dialog}
260
- <Outlet context={{ reload }} />
261
- </RouteDrawer>
20
+ outlet ?? (
21
+ <Navigate to={{ pathname: 'edit', search: location.search }} replace />
22
+ )
262
23
  );
263
24
  }
264
-
265
- export default function UserDetail(): ReactElement {
266
- const { userId } = useParams();
267
- return <UserDetailView key={userId} />;
268
- }