@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,119 @@
1
+ import { ApiClientError } from '@nocobase/app-client';
2
+ import { Link } from 'react-router';
3
+ import { useTranslation } from '@nocobase/i18n/client';
4
+ import type { ReactElement, ReactNode } from 'react';
5
+ import { Button } from '#components/ui/button';
6
+ import { Alert, AlertDescription } from '#components/ui/alert';
7
+ import { Skeleton } from '#components/ui/skeleton';
8
+ import {
9
+ AlertDialog,
10
+ AlertDialogContent,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ AlertDialogDescription,
14
+ AlertDialogFooter,
15
+ AlertDialogCancel,
16
+ } from '#components/ui/alert-dialog';
17
+
18
+ export function SettingsError({
19
+ error,
20
+ retry,
21
+ }: {
22
+ readonly error: unknown;
23
+ readonly retry?: () => void;
24
+ }): ReactElement | null {
25
+ const { t } = useTranslation();
26
+ if (!error) return null;
27
+ const status: unknown =
28
+ typeof error === 'object' && error !== null
29
+ ? Reflect.get(error, 'status')
30
+ : undefined;
31
+ return (
32
+ <Alert variant='destructive'>
33
+ <AlertDescription>
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',
47
+ )}
48
+ {status === 401 && (
49
+ <Button
50
+ variant='outline'
51
+ nativeButton={false}
52
+ render={<Link to='/login' />}
53
+ >
54
+ {t('settings.signInAgain')}
55
+ </Button>
56
+ )}
57
+ {retry && ![401, 403, 404, 409].includes(Number(status)) && (
58
+ <Button variant='outline' size='sm' onClick={retry}>
59
+ {t('settings.retry')}
60
+ </Button>
61
+ )}
62
+ </AlertDescription>
63
+ </Alert>
64
+ );
65
+ }
66
+ export function SettingsLoading(): ReactElement {
67
+ const { t } = useTranslation();
68
+ return (
69
+ <div aria-label={t('settings.loading')} className='flex flex-col gap-3'>
70
+ <Skeleton className='h-10 w-full' />
71
+ <Skeleton className='h-20 w-full' />
72
+ <Skeleton className='h-20 w-full' />
73
+ </div>
74
+ );
75
+ }
76
+ export function ConfirmAction({
77
+ open,
78
+ title,
79
+ description,
80
+ confirmLabel,
81
+ busy,
82
+ onCancel,
83
+ onConfirm,
84
+ }: {
85
+ readonly open: boolean;
86
+ readonly title: string;
87
+ readonly description: ReactNode;
88
+ readonly confirmLabel?: string;
89
+ readonly busy: boolean;
90
+ readonly onCancel: () => void;
91
+ readonly onConfirm: () => void;
92
+ }): ReactElement {
93
+ const { t } = useTranslation();
94
+ return (
95
+ <AlertDialog
96
+ open={open}
97
+ onOpenChange={(value) => {
98
+ if (!value && !busy) onCancel();
99
+ }}
100
+ >
101
+ <AlertDialogContent>
102
+ <AlertDialogHeader>
103
+ <AlertDialogTitle>{title}</AlertDialogTitle>
104
+ <AlertDialogDescription>{description}</AlertDialogDescription>
105
+ </AlertDialogHeader>
106
+ <AlertDialogFooter>
107
+ <AlertDialogCancel disabled={busy}>
108
+ {t('settings.cancel')}
109
+ </AlertDialogCancel>
110
+ <Button variant='destructive' disabled={busy} onClick={onConfirm}>
111
+ {busy
112
+ ? t('settings.saving')
113
+ : (confirmLabel ?? t('settings.confirm'))}
114
+ </Button>
115
+ </AlertDialogFooter>
116
+ </AlertDialogContent>
117
+ </AlertDialog>
118
+ );
119
+ }
@@ -0,0 +1,268 @@
1
+ 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,
19
+ useLocation,
20
+ useOutletContext,
21
+ useParams,
22
+ } 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';
36
+
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();
176
+ 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
+ };
200
+ 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>
262
+ );
263
+ }
264
+
265
+ export default function UserDetail(): ReactElement {
266
+ const { userId } = useParams();
267
+ return <UserDetailView key={userId} />;
268
+ }