@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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ComponentProps, ReactElement } from 'react';
|
|
2
|
+
import { Link } from 'react-router';
|
|
3
|
+
import { cn } from 'cn';
|
|
4
|
+
|
|
5
|
+
/** A record name or identifier that opens its route from a list or table. */
|
|
6
|
+
export function RecordLink({
|
|
7
|
+
className,
|
|
8
|
+
...props
|
|
9
|
+
}: ComponentProps<typeof Link>): ReactElement {
|
|
10
|
+
return (
|
|
11
|
+
<Link
|
|
12
|
+
{...props}
|
|
13
|
+
data-slot='record-link'
|
|
14
|
+
className={cn(
|
|
15
|
+
'cursor-pointer font-medium text-foreground no-underline decoration-1 underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useCallback, useMemo, type ReactElement } from 'react';
|
|
2
|
+
import { Link, useLocation } from 'react-router';
|
|
3
|
+
import { useApiClient } from '@nocobase/app-client';
|
|
4
|
+
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
5
|
+
import { UsersClient } from '@nocobase/app-plugin-users/client/user-client';
|
|
6
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
7
|
+
import { Button } from '#components/ui/button';
|
|
8
|
+
import { SetupReadiness } from './setup-readiness.js';
|
|
9
|
+
import { useSetupReadiness } from './setup-readiness-state.js';
|
|
10
|
+
import {
|
|
11
|
+
inspectRequiredRoles,
|
|
12
|
+
requiredRoleSetupId,
|
|
13
|
+
requiredRoleSetupLink,
|
|
14
|
+
type RequiredSetupRole,
|
|
15
|
+
} from '../lib/required-role-setup.js';
|
|
16
|
+
|
|
17
|
+
/** Mount on the dependent feature with its actual required roles, memoized by the host. */
|
|
18
|
+
export function RequiredRoleSetup({
|
|
19
|
+
roles,
|
|
20
|
+
refreshKey = '',
|
|
21
|
+
}: {
|
|
22
|
+
readonly roles: readonly RequiredSetupRole[];
|
|
23
|
+
readonly refreshKey?: string;
|
|
24
|
+
}): ReactElement | null {
|
|
25
|
+
const { t } = useTranslation();
|
|
26
|
+
const api = useApiClient();
|
|
27
|
+
const client = useMemo(() => new UsersClient(api), [api]);
|
|
28
|
+
const location = useLocation();
|
|
29
|
+
const enabled = roles.length > 0;
|
|
30
|
+
const read = useCan(
|
|
31
|
+
{ resource: { type: 'user', id: '*' }, action: 'read' },
|
|
32
|
+
{ enabled },
|
|
33
|
+
);
|
|
34
|
+
const assign = useCan(
|
|
35
|
+
{ resource: { type: 'user', id: '*' }, action: 'assign-role' },
|
|
36
|
+
{ enabled },
|
|
37
|
+
);
|
|
38
|
+
const source = `${location.pathname}${location.search}${location.hash}`;
|
|
39
|
+
const checks = useMemo(
|
|
40
|
+
() => [
|
|
41
|
+
...new Map(
|
|
42
|
+
roles.map((role) => [
|
|
43
|
+
requiredRoleSetupId(role),
|
|
44
|
+
{
|
|
45
|
+
id: requiredRoleSetupId(role),
|
|
46
|
+
title: role.title,
|
|
47
|
+
description: role.description,
|
|
48
|
+
action: assign.can ? (
|
|
49
|
+
<Button
|
|
50
|
+
variant='outline'
|
|
51
|
+
size='sm'
|
|
52
|
+
aria-label={t('settings.setup.manageMembersFor', {
|
|
53
|
+
role: role.title,
|
|
54
|
+
})}
|
|
55
|
+
render={<Link to={requiredRoleSetupLink(role, source)} />}
|
|
56
|
+
>
|
|
57
|
+
{t('settings.setup.manageMembers')}
|
|
58
|
+
</Button>
|
|
59
|
+
) : undefined,
|
|
60
|
+
},
|
|
61
|
+
]),
|
|
62
|
+
).values(),
|
|
63
|
+
],
|
|
64
|
+
[roles, assign.can, source, t],
|
|
65
|
+
);
|
|
66
|
+
const inspect = useCallback(
|
|
67
|
+
() => inspectRequiredRoles(client, roles),
|
|
68
|
+
[client, roles],
|
|
69
|
+
);
|
|
70
|
+
const state = useSetupReadiness(checks, inspect, {
|
|
71
|
+
enabled: read.can,
|
|
72
|
+
refreshKey: `${location.key}:${refreshKey}`,
|
|
73
|
+
});
|
|
74
|
+
if (!read.can) return null;
|
|
75
|
+
return (
|
|
76
|
+
<SetupReadiness
|
|
77
|
+
state={state}
|
|
78
|
+
labels={{
|
|
79
|
+
title: t('settings.setup.title'),
|
|
80
|
+
checking: t('settings.setup.checking'),
|
|
81
|
+
checkAgain: t('settings.setup.checkAgain'),
|
|
82
|
+
ready: t('settings.setup.ready'),
|
|
83
|
+
missing: t('settings.setup.missing'),
|
|
84
|
+
unknown: {
|
|
85
|
+
unavailable: t('settings.setup.unavailable'),
|
|
86
|
+
signIn: t('settings.setup.signIn'),
|
|
87
|
+
forbidden: t('settings.setup.forbidden'),
|
|
88
|
+
invalid: t('settings.setup.invalid'),
|
|
89
|
+
},
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { useMemo, type ReactElement } from 'react';
|
|
2
2
|
import type { ColumnDef } from '@tanstack/react-table';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import type { To } from 'react-router';
|
|
4
|
+
import { RecordLink } from '#components/record-link';
|
|
5
|
+
import {
|
|
6
|
+
DataTable,
|
|
7
|
+
DataTableColumnHeader,
|
|
8
|
+
type DataTableProps,
|
|
9
|
+
} from '#components/data-table';
|
|
5
10
|
import { Badge } from '#components/ui/badge';
|
|
6
11
|
|
|
7
12
|
export interface RoleListRole {
|
|
@@ -21,6 +26,14 @@ export interface RoleListProps {
|
|
|
21
26
|
readonly custom: string;
|
|
22
27
|
};
|
|
23
28
|
readonly ariaLabel?: string;
|
|
29
|
+
readonly tableState?: Pick<
|
|
30
|
+
DataTableProps<RoleListRole>,
|
|
31
|
+
| 'paginationState'
|
|
32
|
+
| 'onPaginationChange'
|
|
33
|
+
| 'sortingState'
|
|
34
|
+
| 'onSortingChange'
|
|
35
|
+
| 'pageSizeOptions'
|
|
36
|
+
>;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
/** Standard role table; the host owns authorization, loading and filtering. */
|
|
@@ -28,6 +41,7 @@ export function RoleList({
|
|
|
28
41
|
roles,
|
|
29
42
|
labels,
|
|
30
43
|
ariaLabel,
|
|
44
|
+
tableState,
|
|
31
45
|
}: RoleListProps): ReactElement {
|
|
32
46
|
const columns = useMemo<ColumnDef<RoleListRole>[]>(
|
|
33
47
|
() => [
|
|
@@ -37,13 +51,13 @@ export function RoleList({
|
|
|
37
51
|
<DataTableColumnHeader column={column} title={labels.name} />
|
|
38
52
|
),
|
|
39
53
|
cell: ({ row }) => (
|
|
40
|
-
<
|
|
54
|
+
<RecordLink
|
|
41
55
|
to={row.original.to}
|
|
42
|
-
className='inline-block max-w-72 truncate align-middle
|
|
56
|
+
className='inline-block max-w-72 truncate align-middle'
|
|
43
57
|
title={row.original.title}
|
|
44
58
|
>
|
|
45
59
|
{row.original.title}
|
|
46
|
-
</
|
|
60
|
+
</RecordLink>
|
|
47
61
|
),
|
|
48
62
|
},
|
|
49
63
|
{
|
|
@@ -62,6 +76,7 @@ export function RoleList({
|
|
|
62
76
|
},
|
|
63
77
|
{
|
|
64
78
|
accessorKey: 'protected',
|
|
79
|
+
enableSorting: false,
|
|
65
80
|
header: labels.type,
|
|
66
81
|
cell: ({ row }) => (
|
|
67
82
|
<Badge variant='secondary'>
|
|
@@ -75,6 +90,7 @@ export function RoleList({
|
|
|
75
90
|
return (
|
|
76
91
|
<div role='region' aria-label={ariaLabel}>
|
|
77
92
|
<DataTable
|
|
93
|
+
{...tableState}
|
|
78
94
|
columns={columns}
|
|
79
95
|
data={roles}
|
|
80
96
|
getRowId={(role) => role.key}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export type SetupUnknownReason =
|
|
5
|
+
'unavailable' | 'signIn' | 'forbidden' | 'invalid';
|
|
6
|
+
export interface SetupInspection {
|
|
7
|
+
readonly status: 'ready' | 'missing' | 'unknown';
|
|
8
|
+
readonly reason?: SetupUnknownReason;
|
|
9
|
+
readonly canConfigure?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SetupCheck {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly title: ReactNode;
|
|
14
|
+
readonly description: ReactNode;
|
|
15
|
+
readonly action?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
export type SetupInspections = Readonly<Record<string, SetupInspection>>;
|
|
18
|
+
export interface SetupReadinessState {
|
|
19
|
+
readonly active: boolean;
|
|
20
|
+
readonly items: readonly (SetupCheck & SetupInspection)[];
|
|
21
|
+
readonly loading: boolean;
|
|
22
|
+
readonly refresh: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep transport errors out of UI copy and never call a failed read missing. */
|
|
26
|
+
export function setupInspectionFailure(error: unknown): SetupInspection {
|
|
27
|
+
const status =
|
|
28
|
+
typeof error === 'object' && error !== null && 'status' in error
|
|
29
|
+
? Number(error.status)
|
|
30
|
+
: undefined;
|
|
31
|
+
return {
|
|
32
|
+
status: 'unknown',
|
|
33
|
+
reason:
|
|
34
|
+
status === 401
|
|
35
|
+
? 'signIn'
|
|
36
|
+
: status === 403
|
|
37
|
+
? 'forbidden'
|
|
38
|
+
: status === 400 || status === 404
|
|
39
|
+
? 'invalid'
|
|
40
|
+
: 'unavailable',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Memoize checks and inspect. The owning feature supplies a real authorized read. */
|
|
45
|
+
export function useSetupReadiness(
|
|
46
|
+
checks: readonly SetupCheck[],
|
|
47
|
+
inspect: () => Promise<SetupInspections>,
|
|
48
|
+
{
|
|
49
|
+
enabled = true,
|
|
50
|
+
refreshKey = '',
|
|
51
|
+
}: { enabled?: boolean; refreshKey?: string } = {},
|
|
52
|
+
): SetupReadinessState {
|
|
53
|
+
const [revision, setRevision] = useState(0);
|
|
54
|
+
const active = enabled && checks.length > 0;
|
|
55
|
+
const request = useMemo(
|
|
56
|
+
() => ({ checks, inspect, refreshKey, revision, active }),
|
|
57
|
+
[checks, inspect, refreshKey, revision, active],
|
|
58
|
+
);
|
|
59
|
+
const [snapshot, setSnapshot] = useState<{
|
|
60
|
+
request: typeof request;
|
|
61
|
+
values: SetupInspections;
|
|
62
|
+
}>();
|
|
63
|
+
const busyRef = useRef(false);
|
|
64
|
+
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const { active, checks, inspect } = request;
|
|
67
|
+
if (!active) return;
|
|
68
|
+
let current = true;
|
|
69
|
+
busyRef.current = true;
|
|
70
|
+
const finish = (values: SetupInspections) => {
|
|
71
|
+
if (!current) return;
|
|
72
|
+
busyRef.current = false;
|
|
73
|
+
setSnapshot({ request, values });
|
|
74
|
+
};
|
|
75
|
+
void Promise.resolve()
|
|
76
|
+
.then(() => (current ? inspect() : {}))
|
|
77
|
+
.then(finish, (error: unknown) =>
|
|
78
|
+
finish(
|
|
79
|
+
Object.fromEntries(
|
|
80
|
+
checks.map(({ id }) => [id, setupInspectionFailure(error)]),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
return () => {
|
|
85
|
+
current = false;
|
|
86
|
+
busyRef.current = false;
|
|
87
|
+
};
|
|
88
|
+
}, [request]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!active) return;
|
|
91
|
+
const onFocus = () => {
|
|
92
|
+
if (!busyRef.current) refresh();
|
|
93
|
+
};
|
|
94
|
+
window.addEventListener('focus', onFocus);
|
|
95
|
+
return () => window.removeEventListener('focus', onFocus);
|
|
96
|
+
}, [active, refresh]);
|
|
97
|
+
const loading = active && snapshot?.request !== request;
|
|
98
|
+
return {
|
|
99
|
+
active,
|
|
100
|
+
items:
|
|
101
|
+
!active || loading
|
|
102
|
+
? []
|
|
103
|
+
: checks.map((check) => {
|
|
104
|
+
const values = snapshot?.values;
|
|
105
|
+
const value =
|
|
106
|
+
values && Object.hasOwn(values, check.id)
|
|
107
|
+
? values[check.id]
|
|
108
|
+
: undefined;
|
|
109
|
+
if (
|
|
110
|
+
!value ||
|
|
111
|
+
!['ready', 'missing', 'unknown'].includes(value.status)
|
|
112
|
+
)
|
|
113
|
+
return {
|
|
114
|
+
...check,
|
|
115
|
+
status: 'unknown' as const,
|
|
116
|
+
reason: 'invalid' as const,
|
|
117
|
+
};
|
|
118
|
+
const reason =
|
|
119
|
+
value.reason &&
|
|
120
|
+
['unavailable', 'signIn', 'forbidden', 'invalid'].includes(
|
|
121
|
+
value.reason,
|
|
122
|
+
)
|
|
123
|
+
? value.reason
|
|
124
|
+
: 'invalid';
|
|
125
|
+
return {
|
|
126
|
+
...check,
|
|
127
|
+
status: value.status,
|
|
128
|
+
reason,
|
|
129
|
+
canConfigure: value.canConfigure !== false,
|
|
130
|
+
};
|
|
131
|
+
}),
|
|
132
|
+
loading,
|
|
133
|
+
refresh,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useId, useState, type ReactElement } from 'react';
|
|
2
|
+
import { Button } from '#components/ui/button';
|
|
3
|
+
import type {
|
|
4
|
+
SetupReadinessState,
|
|
5
|
+
SetupUnknownReason,
|
|
6
|
+
} from './setup-readiness-state.js';
|
|
7
|
+
|
|
8
|
+
export interface SetupReadinessLabels {
|
|
9
|
+
readonly title: string;
|
|
10
|
+
readonly checking: string;
|
|
11
|
+
readonly checkAgain: string;
|
|
12
|
+
readonly ready: string;
|
|
13
|
+
readonly missing: string;
|
|
14
|
+
readonly unknown: Readonly<Record<SetupUnknownReason, string>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A feature mounts this only for its actual prerequisites, never every plugin. */
|
|
18
|
+
export function SetupReadiness({
|
|
19
|
+
state,
|
|
20
|
+
labels,
|
|
21
|
+
}: {
|
|
22
|
+
readonly state: SetupReadinessState;
|
|
23
|
+
readonly labels: SetupReadinessLabels;
|
|
24
|
+
}): ReactElement | null {
|
|
25
|
+
const id = useId();
|
|
26
|
+
const [keepConfirmation, setKeepConfirmation] = useState(false);
|
|
27
|
+
const pending = state.items.filter(({ status }) => status !== 'ready');
|
|
28
|
+
if (!state.active) return null;
|
|
29
|
+
if (!state.loading && pending.length === 0 && !keepConfirmation) return null;
|
|
30
|
+
const canRefresh =
|
|
31
|
+
state.loading ||
|
|
32
|
+
state.items.some(
|
|
33
|
+
({ status, reason }) => status !== 'unknown' || reason === 'unavailable',
|
|
34
|
+
);
|
|
35
|
+
return (
|
|
36
|
+
<section
|
|
37
|
+
aria-labelledby={id}
|
|
38
|
+
aria-busy={state.loading}
|
|
39
|
+
className='flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground'
|
|
40
|
+
>
|
|
41
|
+
<div className='flex flex-wrap items-center justify-between gap-2'>
|
|
42
|
+
<h2 id={id} className='text-base font-medium'>
|
|
43
|
+
{labels.title}
|
|
44
|
+
</h2>
|
|
45
|
+
{(canRefresh || keepConfirmation) && (
|
|
46
|
+
<Button
|
|
47
|
+
type='button'
|
|
48
|
+
variant='outline'
|
|
49
|
+
size='sm'
|
|
50
|
+
disabled={state.loading || !canRefresh}
|
|
51
|
+
onClick={() => {
|
|
52
|
+
setKeepConfirmation(true);
|
|
53
|
+
state.refresh();
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{labels.checkAgain}
|
|
57
|
+
</Button>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
<p role='status' className='text-sm text-muted-foreground'>
|
|
61
|
+
{state.loading
|
|
62
|
+
? labels.checking
|
|
63
|
+
: pending.length
|
|
64
|
+
? labels.missing
|
|
65
|
+
: labels.ready}
|
|
66
|
+
</p>
|
|
67
|
+
{!state.loading && pending.length > 0 && (
|
|
68
|
+
<ul className='divide-y'>
|
|
69
|
+
{pending.map((item) => (
|
|
70
|
+
<li
|
|
71
|
+
key={item.id}
|
|
72
|
+
className='flex flex-wrap items-center justify-between gap-3 py-2 first:pt-0 last:pb-0'
|
|
73
|
+
>
|
|
74
|
+
<div className='min-w-0 flex-1 basis-48 break-words'>
|
|
75
|
+
<p className='text-sm font-medium'>{item.title}</p>
|
|
76
|
+
<p className='text-sm text-muted-foreground'>
|
|
77
|
+
{item.status === 'missing'
|
|
78
|
+
? item.description
|
|
79
|
+
: labels.unknown[item.reason ?? 'invalid']}
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
{item.status === 'missing' &&
|
|
83
|
+
item.canConfigure !== false &&
|
|
84
|
+
item.action}
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
</ul>
|
|
88
|
+
)}
|
|
89
|
+
</section>
|
|
90
|
+
);
|
|
91
|
+
}
|