@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.70

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 (76) hide show
  1. package/.agents/rules/components.md +5 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/AGENTS.md +7 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/flow-diagram-css.d.ts +2 -0
  13. package/client/components/flow-diagram.tsx +674 -0
  14. package/client/components/flow-layout.ts +250 -0
  15. package/client/components/lifecycle-diagram-model.ts +217 -0
  16. package/client/components/lifecycle-diagram.tsx +310 -0
  17. package/client/components/permission-editor.tsx +589 -0
  18. package/client/components/permission-filter.tsx +455 -0
  19. package/client/components/route-overlay.tsx +26 -13
  20. package/client/components/ui/checkbox.tsx +25 -0
  21. package/client/components/ui/sidebar.tsx +1 -4
  22. package/client/components/ui/switch.tsx +30 -0
  23. package/client/components/ui/table.tsx +113 -0
  24. package/client/extensions/nocobase-workflow-management/README.md +70 -0
  25. package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
  26. package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
  27. package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
  28. package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
  29. package/client/layouts/components/app-sidebar.tsx +1 -1
  30. package/client/layouts/use-sidebar-preference.ts +62 -42
  31. package/client/locales/system/en-US.ts +29 -0
  32. package/client/locales/system/settings-en-US.ts +142 -0
  33. package/client/locales/system/settings-zh-CN.ts +136 -0
  34. package/client/locales/system/zh-CN.ts +29 -0
  35. package/client/pages/auth/auth-page.css +15 -0
  36. package/client/pages/auth/login.tsx +1 -1
  37. package/client/pages/auth/shared.tsx +3 -1
  38. package/client/pages/settings/hooks.tsx +137 -0
  39. package/client/pages/settings/page-permission-titles.ts +7 -0
  40. package/client/pages/settings/page-permissions.ts +42 -0
  41. package/client/pages/settings/permission-filters.tsx +119 -0
  42. package/client/pages/settings/permissions-model.ts +260 -0
  43. package/client/pages/settings/role-form.tsx +405 -0
  44. package/client/pages/settings/roles.tsx +163 -0
  45. package/client/pages/settings/routes.ts +79 -0
  46. package/client/pages/settings/shared.tsx +119 -0
  47. package/client/pages/settings/user-detail.tsx +268 -0
  48. package/client/pages/settings/user-form.tsx +427 -0
  49. package/client/pages/settings/users.tsx +451 -0
  50. package/client/routing/app-router.tsx +4 -1
  51. package/client/routing/session-preparation.tsx +65 -0
  52. package/client/styles.css +4 -7
  53. package/package.json +27 -24
  54. package/tests/components/app-layout-routes.test.tsx +14 -7
  55. package/tests/components/data-table.test.tsx +127 -0
  56. package/tests/components/layout-i18n.test.tsx +8 -1
  57. package/tests/components/navigation-menu.test.tsx +24 -14
  58. package/tests/components/permission-editor.test.tsx +477 -0
  59. package/tests/components/permission-filter-binding.test.tsx +150 -0
  60. package/tests/components/permission-filter.test.tsx +202 -0
  61. package/tests/components/settings.test.tsx +294 -0
  62. package/tests/components/sidebar-permissions.test.tsx +12 -4
  63. package/tests/components/sidebar-preference.test.tsx +209 -14
  64. package/tests/components/user-permissions.test.tsx +142 -0
  65. package/tests/fixtures/theme-tokens.tsx +9 -1
  66. package/tests/logic/asset-url-build.test.ts +0 -1
  67. package/tests/logic/client-routes.test.ts +40 -0
  68. package/tests/logic/client-shell.test.tsx +10 -2
  69. package/tests/logic/config-example.test.ts +30 -0
  70. package/tests/logic/dev-client-watch.test.ts +8 -1
  71. package/tests/logic/page-permission-titles.test.ts +223 -0
  72. package/tests/logic/route-overlay.test.tsx +42 -0
  73. package/tests/logic/session-preparation.test.tsx +223 -0
  74. package/tests/logic/settings-permissions.test.ts +168 -0
  75. package/vite.config.ts +0 -22
  76. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,427 @@
1
+ import {
2
+ useSettingsLeave,
3
+ useSettingsData,
4
+ useUserPermission,
5
+ } from './hooks.js';
6
+ import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
7
+ import {
8
+ UsersClient,
9
+ type ManagedUser,
10
+ type UsersOptions,
11
+ type UserRoleValue,
12
+ type UserRoleScopeOption,
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
+ useLocation,
18
+ useNavigate,
19
+ useOutletContext,
20
+ useParams,
21
+ } from 'react-router';
22
+ import { RouteDialog } from '#components/route-dialog';
23
+ import { Button } from '#components/ui/button';
24
+ import { Input } from '#components/ui/input';
25
+ import { Label } from '#components/ui/label';
26
+ import { Switch } from '#components/ui/switch';
27
+ import {
28
+ Select,
29
+ SelectContent,
30
+ SelectItem,
31
+ SelectTrigger,
32
+ SelectValue,
33
+ } from '#components/ui/select';
34
+ import { SettingsError, SettingsLoading } from './shared.js';
35
+
36
+ export function RoleSelection({
37
+ scope,
38
+ value,
39
+ onChange,
40
+ disabled = false,
41
+ }: {
42
+ readonly scope: UserRoleScopeOption;
43
+ readonly value: UserRoleValue;
44
+ readonly onChange: (value: UserRoleValue) => void;
45
+ readonly disabled?: boolean;
46
+ }): ReactElement {
47
+ const { t } = useTranslation();
48
+ const label = scope.labelI18nKey
49
+ ? t(scope.labelI18nKey, {
50
+ ns: scope.labelI18nNs,
51
+ defaultValue: scope.label,
52
+ })
53
+ : scope.label;
54
+ const text = (option: UserRoleScopeOption['options'][number]) =>
55
+ option.labelI18nKey
56
+ ? t(option.labelI18nKey, {
57
+ ns: option.labelI18nNs,
58
+ defaultValue: option.label,
59
+ })
60
+ : option.label;
61
+ return (
62
+ <div className='flex flex-col gap-2'>
63
+ <Label>
64
+ {label}
65
+ {scope.requiredOnCreate ? ' *' : ''}
66
+ </Label>
67
+ {scope.selection === 'single' ? (
68
+ <Select
69
+ items={scope.options.map((option) => ({
70
+ value: option.value,
71
+ label: text(option),
72
+ }))}
73
+ value={typeof value === 'string' ? value : ''}
74
+ disabled={
75
+ disabled ||
76
+ scope.options.some(
77
+ (option) => option.value === value && option.removable === false,
78
+ )
79
+ }
80
+ onValueChange={(next) => {
81
+ if (next !== null) onChange(next);
82
+ }}
83
+ >
84
+ <SelectTrigger aria-label={label} className='w-full'>
85
+ <SelectValue />
86
+ </SelectTrigger>
87
+ <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'>
88
+ {scope.options.map((option) => (
89
+ <SelectItem
90
+ key={option.value}
91
+ value={option.value}
92
+ disabled={option.assignable === false && value !== option.value}
93
+ >
94
+ {text(option)}
95
+ </SelectItem>
96
+ ))}
97
+ </SelectContent>
98
+ </Select>
99
+ ) : (
100
+ <div className='flex flex-col gap-2 rounded-lg border p-3'>
101
+ {scope.options.map((option) => {
102
+ const current = typeof value === 'string' ? [] : value;
103
+ const checked = current.includes(option.value);
104
+ return (
105
+ <label
106
+ key={option.value}
107
+ className='flex items-center gap-3 text-sm'
108
+ >
109
+ <Switch
110
+ checked={checked}
111
+ disabled={
112
+ disabled ||
113
+ (checked
114
+ ? option.removable === false
115
+ : option.assignable === false)
116
+ }
117
+ onCheckedChange={(next) =>
118
+ onChange(
119
+ next
120
+ ? [...current, option.value]
121
+ : current.filter((item) => item !== option.value),
122
+ )
123
+ }
124
+ />
125
+ {text(option)}
126
+ </label>
127
+ );
128
+ })}
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function UserEditor(): ReactElement {
136
+ const { userId } = useParams();
137
+ const creating = !userId;
138
+ const { t } = useTranslation();
139
+ const api = useApiClient();
140
+ const toaster = useToaster();
141
+ const client = useMemo(() => new UsersClient(api), [api]);
142
+ const navigate = useNavigate();
143
+ const location = useLocation();
144
+ const parent = useOutletContext<{
145
+ reload: (updated?: ManagedUser) => void;
146
+ }>();
147
+ const data = useSettingsData<{ data: ManagedUser }>(
148
+ userId ? `users/${encodeURIComponent(userId)}` : 'users/options',
149
+ );
150
+ const missingRef = useRef<unknown>(undefined);
151
+ useEffect(() => {
152
+ if (
153
+ data.error instanceof ApiClientError &&
154
+ data.error.status === 404 &&
155
+ missingRef.current !== data.error
156
+ ) {
157
+ missingRef.current = data.error;
158
+ parent.reload();
159
+ }
160
+ }, [data.error, parent]);
161
+ const options = useSettingsData<{ data: UsersOptions }>('users/options');
162
+ const canCreate = useUserPermission('create');
163
+ const canAssign = useUserPermission('assign-role', userId ?? '*');
164
+ const canUpdate = useUserPermission('update', userId ?? '*');
165
+ const [draftFields, setDraftFields] = useState<{
166
+ name: string;
167
+ username: string;
168
+ email: string;
169
+ password: string;
170
+ }>();
171
+ const initial = !creating ? data.data?.data : undefined;
172
+ const fields = draftFields ?? {
173
+ name: initial?.name ?? '',
174
+ username: initial?.username ?? '',
175
+ email: initial?.email ?? '',
176
+ password: '',
177
+ };
178
+ const [roles, setRoles] = useState<Record<string, UserRoleValue>>({});
179
+ const [dirty, setDirty] = useState(false);
180
+ const [busy, setBusy] = useState(false);
181
+ const leave = useSettingsLeave(dirty, busy);
182
+ const [error, setError] = useState<unknown>();
183
+ const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
184
+ const [submitted, setSubmitted] = useState(false);
185
+
186
+ const closeTo = {
187
+ pathname: creating
188
+ ? '/settings/users'
189
+ : `/settings/users/${encodeURIComponent(userId)}`,
190
+ search: location.search,
191
+ };
192
+ const fieldError = (name: keyof typeof fields): string | undefined => {
193
+ if (!submitted) return;
194
+ if (serverErrors[name]) return serverErrors[name];
195
+ if (
196
+ (name === 'name' || (name === 'password' && creating)) &&
197
+ !fields[name].trim()
198
+ )
199
+ return t('settings.required');
200
+ if (name === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email))
201
+ return t('settings.invalidEmail');
202
+ return;
203
+ };
204
+ const save = async (event: React.FormEvent<HTMLFormElement>) => {
205
+ event.preventDefault();
206
+ setSubmitted(true);
207
+ const form = event.currentTarget;
208
+ if (
209
+ !fields.name.trim() ||
210
+ !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email) ||
211
+ (creating && !fields.password.trim())
212
+ ) {
213
+ form.querySelector<HTMLInputElement>('input:invalid')?.focus();
214
+ return;
215
+ }
216
+ if (data.error || (creating && (options.error || !options.data))) return;
217
+ if (creating) {
218
+ const missing = (options.data?.data.roleScopes ?? []).filter(
219
+ (scope) => scope.requiredOnCreate && !roles[scope.key]?.length,
220
+ );
221
+ if (missing.length) {
222
+ setServerErrors(
223
+ Object.fromEntries(
224
+ missing.map((scope) => [scope.key, t('settings.required')]),
225
+ ),
226
+ );
227
+ return;
228
+ }
229
+ }
230
+ setBusy(true);
231
+ setError(undefined);
232
+ setServerErrors({});
233
+ try {
234
+ let updated: ManagedUser;
235
+ if (creating)
236
+ updated = await client.create({
237
+ ...fields,
238
+ name: fields.name.trim(),
239
+ email: fields.email.trim(),
240
+ ...(fields.username.trim()
241
+ ? { username: fields.username.trim() }
242
+ : { username: undefined }),
243
+ roleScopes: roles,
244
+ });
245
+ else
246
+ updated = await client.update(userId, {
247
+ name: fields.name.trim(),
248
+ email: fields.email.trim(),
249
+ username: fields.username.trim() || null,
250
+ });
251
+ setDirty(false);
252
+ parent.reload(updated);
253
+ toaster.show({
254
+ type: 'success',
255
+ title: t(creating ? 'settings.created' : 'settings.saved', {
256
+ name: fields.name,
257
+ }),
258
+ });
259
+ leave.allow();
260
+ void navigate(closeTo);
261
+ } catch (next) {
262
+ if (
263
+ next instanceof ApiClientError &&
264
+ [
265
+ 'PASSWORD_TOO_SHORT',
266
+ 'PASSWORD_TOO_LONG',
267
+ 'USER_IDENTITY_CONFLICT',
268
+ ].includes(next.reason ?? '')
269
+ ) {
270
+ const field =
271
+ next.reason === 'USER_IDENTITY_CONFLICT' ? 'email' : 'password';
272
+ setServerErrors({
273
+ [field]: t(
274
+ field === 'password'
275
+ ? 'settings.passwordPolicy'
276
+ : 'settings.identityConflict',
277
+ ),
278
+ });
279
+ form.querySelector<HTMLInputElement>(`[name="${field}"]`)?.focus();
280
+ } else {
281
+ setError(next);
282
+ }
283
+ } finally {
284
+ setBusy(false);
285
+ }
286
+ };
287
+ const allowed = creating ? canCreate && canAssign : canUpdate;
288
+ const formId = creating ? 'new-user' : 'edit-user';
289
+ return (
290
+ <RouteDialog
291
+ className={creating ? undefined : 'sm:max-w-md'}
292
+ title={t(creating ? 'settings.newUser' : 'settings.editUser')}
293
+ closeTo={closeTo}
294
+ beforeClose={leave.guard}
295
+ footer={
296
+ <>
297
+ <Button
298
+ variant='outline'
299
+ disabled={busy}
300
+ onClick={() => void navigate(closeTo)}
301
+ >
302
+ {t('settings.cancel')}
303
+ </Button>
304
+ {allowed && (
305
+ <Button
306
+ form={formId}
307
+ type='submit'
308
+ disabled={
309
+ busy ||
310
+ Boolean(data.error) ||
311
+ (!creating && data.loading) ||
312
+ (creating &&
313
+ (options.loading || Boolean(options.error) || !options.data))
314
+ }
315
+ >
316
+ {t(
317
+ busy
318
+ ? 'settings.saving'
319
+ : creating
320
+ ? 'settings.create'
321
+ : 'settings.save',
322
+ )}
323
+ </Button>
324
+ )}
325
+ </>
326
+ }
327
+ >
328
+ <SettingsError error={data.error} retry={data.reload} />
329
+ {creating && (
330
+ <SettingsError error={options.error} retry={options.reload} />
331
+ )}
332
+ <SettingsError error={error} />
333
+ {!creating && data.loading ? (
334
+ <SettingsLoading />
335
+ ) : (
336
+ !data.error && (
337
+ <form
338
+ id={formId}
339
+ onSubmit={(event) => void save(event)}
340
+ noValidate
341
+ className='flex flex-col gap-4'
342
+ >
343
+ {(
344
+ [
345
+ 'name',
346
+ 'username',
347
+ 'email',
348
+ ...(creating ? ['password'] : []),
349
+ ] as (keyof typeof fields)[]
350
+ ).map((name) => (
351
+ <div key={name} className='flex flex-col gap-2'>
352
+ <Label htmlFor={`${formId}-${name}`}>
353
+ {t(`settings.${name}`)}
354
+ {name !== 'username' ? ' *' : ''}
355
+ </Label>
356
+ <Input
357
+ id={`${formId}-${name}`}
358
+ name={name}
359
+ required={name !== 'username'}
360
+ type={
361
+ name === 'password'
362
+ ? 'password'
363
+ : name === 'email'
364
+ ? 'email'
365
+ : 'text'
366
+ }
367
+ autoComplete={name === 'password' ? 'new-password' : 'off'}
368
+ disabled={!allowed || busy}
369
+ value={fields[name]}
370
+ aria-invalid={Boolean(fieldError(name))}
371
+ aria-describedby={
372
+ fieldError(name) ? `${formId}-${name}-error` : undefined
373
+ }
374
+ onChange={(event) => {
375
+ setDraftFields(() => ({
376
+ ...fields,
377
+ [name]: event.target.value,
378
+ }));
379
+ setServerErrors((old) => ({ ...old, [name]: '' }));
380
+ setDirty(true);
381
+ }}
382
+ />
383
+ {fieldError(name) && (
384
+ <p
385
+ id={`${formId}-${name}-error`}
386
+ className='text-xs text-destructive'
387
+ >
388
+ {fieldError(name)}
389
+ </p>
390
+ )}
391
+ </div>
392
+ ))}
393
+ {creating &&
394
+ options.data?.data.roleScopes.map((scope) => (
395
+ <div key={scope.key}>
396
+ <RoleSelection
397
+ scope={scope}
398
+ disabled={!allowed || busy}
399
+ value={
400
+ roles[scope.key] ??
401
+ (scope.selection === 'single' ? '' : [])
402
+ }
403
+ onChange={(value) => {
404
+ setRoles((old) => ({ ...old, [scope.key]: value }));
405
+ setServerErrors((old) => ({ ...old, [scope.key]: '' }));
406
+ setDirty(true);
407
+ }}
408
+ />
409
+ {serverErrors[scope.key] && (
410
+ <p className='text-xs text-destructive'>
411
+ {serverErrors[scope.key]}
412
+ </p>
413
+ )}
414
+ </div>
415
+ ))}
416
+ </form>
417
+ )
418
+ )}
419
+ {leave.dialog}
420
+ </RouteDialog>
421
+ );
422
+ }
423
+
424
+ export default function UserForm(): ReactElement {
425
+ const { userId } = useParams();
426
+ return <UserEditor key={userId ?? '$new'} />;
427
+ }