@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.
- package/.agents/rules/build-and-deploy.md +4 -0
- package/.agents/rules/components.md +3 -1
- package/README.MD +2 -2
- package/client/components/data-table/data-table.tsx +2 -0
- package/client/components/data-table/pagination.tsx +6 -6
- package/client/components/default-access-editor.tsx +79 -52
- package/client/components/flow-diagram.tsx +645 -83
- package/client/components/flow-layout.ts +117 -7
- package/client/components/lifecycle-diagram.tsx +175 -37
- package/client/components/long-text.tsx +200 -0
- package/client/components/permission-editor.tsx +534 -341
- package/client/components/record-link.tsx +20 -0
- package/client/components/required-role-setup.tsx +93 -0
- package/client/components/role-list.tsx +21 -5
- package/client/components/setup-readiness-state.ts +135 -0
- package/client/components/setup-readiness.tsx +91 -0
- package/client/components/ui/combobox.tsx +300 -0
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
- package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
- package/client/hooks/use-table-url-state.ts +93 -0
- package/client/lib/required-role-setup.ts +98 -0
- package/client/lib/return-to.ts +19 -0
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +51 -4
- package/client/locales/system/settings-zh-CN.ts +46 -5
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +14 -2
- package/client/pages/settings/default-access-model.ts +6 -2
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/page-permissions.ts +166 -1
- package/client/pages/settings/permission-filters.tsx +9 -2
- package/client/pages/settings/permission-workspace.ts +330 -0
- package/client/pages/settings/permissions-model.ts +62 -53
- package/client/pages/settings/role-form.tsx +31 -95
- package/client/pages/settings/roles.tsx +44 -6
- package/client/pages/settings/shared.tsx +15 -13
- package/client/pages/settings/user-detail.tsx +15 -259
- package/client/pages/settings/user-form.tsx +299 -147
- package/client/pages/settings/users.tsx +211 -123
- package/config.example.yml +6 -5
- package/package.json +17 -17
- package/server/config/app.ts +1 -1
- package/server/config/session.ts +7 -5
- package/tests/components/default-access-editor.test.tsx +130 -0
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/permission-editor.test.tsx +613 -572
- package/tests/components/permission-filter-binding.test.tsx +20 -0
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +34 -9
- package/tests/components/setup-readiness.test.tsx +370 -0
- package/tests/components/table-url-state.test.tsx +95 -0
- package/tests/components/user-drawer.test.tsx +526 -0
- package/tests/components/user-permissions.test.tsx +47 -13
- package/tests/logic/config.test.ts +3 -1
- package/tests/logic/page-permission-navigation.test.ts +394 -0
- package/tests/logic/permission-workspace.test.ts +570 -0
- package/tests/logic/return-to.test.ts +19 -0
- package/tests/logic/session-isolation.test.ts +250 -0
- 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(
|
|
74
|
-
|
|
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(
|
|
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 ===
|
|
36
|
-
? 'settings.
|
|
37
|
-
: status ===
|
|
38
|
-
? 'settings.
|
|
39
|
-
: status ===
|
|
40
|
-
? 'settings.
|
|
41
|
-
: status ===
|
|
42
|
-
?
|
|
43
|
-
|
|
44
|
-
?
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
useResolvedPath,
|
|
22
8
|
} from 'react-router';
|
|
23
|
-
import {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
178
|
-
const
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
}
|