@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69

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 (87) hide show
  1. package/.agents/rules/components.md +2 -2
  2. package/.agents/rules/shell.md +5 -1
  3. package/AGENTS.md +5 -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/page-header.tsx +1 -1
  13. package/client/components/permission-editor.tsx +589 -0
  14. package/client/components/permission-filter.tsx +455 -0
  15. package/client/components/route-overlay.tsx +26 -13
  16. package/client/components/status-badge.tsx +7 -1
  17. package/client/components/ui/checkbox.tsx +25 -0
  18. package/client/components/ui/sidebar.tsx +1 -4
  19. package/client/components/ui/switch.tsx +30 -0
  20. package/client/components/ui/table.tsx +113 -0
  21. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  22. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  23. package/client/index.tsx +4 -0
  24. package/client/layouts/components/app-sidebar.tsx +6 -6
  25. package/client/layouts/components/layout-header.tsx +1 -1
  26. package/client/layouts/components/navigation-menu.tsx +223 -12
  27. package/client/layouts/components/sidebar-footer.tsx +1 -1
  28. package/client/layouts/components/user-menu.tsx +1 -1
  29. package/client/layouts/use-sidebar-preference.ts +62 -42
  30. package/client/lib/navigation-transform.ts +8 -0
  31. package/client/lib/scrollbar.ts +88 -0
  32. package/client/locales/system/en-US.ts +11 -0
  33. package/client/locales/system/settings-en-US.ts +141 -0
  34. package/client/locales/system/settings-zh-CN.ts +135 -0
  35. package/client/locales/system/zh-CN.ts +22 -12
  36. package/client/pages/auth/auth-page.css +52 -0
  37. package/client/pages/auth/login.tsx +1 -1
  38. package/client/pages/auth/reset-password.tsx +57 -14
  39. package/client/pages/auth/shared.tsx +26 -24
  40. package/client/pages/home/index.tsx +2 -2
  41. package/client/pages/settings/hooks.tsx +137 -0
  42. package/client/pages/settings/permission-filters.tsx +119 -0
  43. package/client/pages/settings/permissions-model.ts +260 -0
  44. package/client/pages/settings/role-form.tsx +426 -0
  45. package/client/pages/settings/roles.tsx +163 -0
  46. package/client/pages/settings/routes.ts +79 -0
  47. package/client/pages/settings/shared.tsx +119 -0
  48. package/client/pages/settings/user-detail.tsx +268 -0
  49. package/client/pages/settings/user-form.tsx +427 -0
  50. package/client/pages/settings/users.tsx +451 -0
  51. package/client/routing/app-router.tsx +4 -1
  52. package/client/routing/route-navigation.ts +10 -1
  53. package/client/routing/session-preparation.tsx +65 -0
  54. package/client/styles.css +257 -24
  55. package/client/theme/theme-settings.tsx +12 -7
  56. package/client/theme/themes/compact.css +9 -14
  57. package/client/theme/themes/default.css +7 -12
  58. package/package.json +26 -25
  59. package/server/app.ts +2 -0
  60. package/server/config/notification.ts +4 -7
  61. package/tests/components/app-layout-routes.test.tsx +14 -7
  62. package/tests/components/auth-i18n.test.tsx +57 -5
  63. package/tests/components/data-table.test.tsx +127 -0
  64. package/tests/components/layout-i18n.test.tsx +10 -3
  65. package/tests/components/navigation-menu.test.tsx +155 -16
  66. package/tests/components/permission-editor.test.tsx +477 -0
  67. package/tests/components/permission-filter-binding.test.tsx +150 -0
  68. package/tests/components/permission-filter.test.tsx +202 -0
  69. package/tests/components/settings.test.tsx +294 -0
  70. package/tests/components/sidebar-permissions.test.tsx +17 -19
  71. package/tests/components/sidebar-preference.test.tsx +209 -14
  72. package/tests/components/status-badge.test.tsx +22 -0
  73. package/tests/components/user-permissions.test.tsx +142 -0
  74. package/tests/fixtures/theme-tokens.tsx +9 -1
  75. package/tests/logic/asset-url-build.test.ts +0 -1
  76. package/tests/logic/client-routes.test.ts +40 -0
  77. package/tests/logic/client-shell.test.tsx +10 -2
  78. package/tests/logic/config-example.test.ts +30 -0
  79. package/tests/logic/dev-client-watch.test.ts +8 -1
  80. package/tests/logic/route-overlay.test.tsx +42 -0
  81. package/tests/logic/scrollbar.test.ts +88 -0
  82. package/tests/logic/session-preparation.test.tsx +223 -0
  83. package/tests/logic/settings-permissions.test.ts +168 -0
  84. package/tests/logic/theme-tokens.test.ts +1 -0
  85. package/vite.config.ts +0 -22
  86. package/vitest.config.ts +1 -0
  87. 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
+ }