@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.
Files changed (61) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/default-access-editor.tsx +79 -52
  7. package/client/components/flow-diagram.tsx +645 -83
  8. package/client/components/flow-layout.ts +117 -7
  9. package/client/components/lifecycle-diagram.tsx +175 -37
  10. package/client/components/long-text.tsx +200 -0
  11. package/client/components/permission-editor.tsx +534 -341
  12. package/client/components/record-link.tsx +20 -0
  13. package/client/components/required-role-setup.tsx +93 -0
  14. package/client/components/role-list.tsx +21 -5
  15. package/client/components/setup-readiness-state.ts +135 -0
  16. package/client/components/setup-readiness.tsx +91 -0
  17. package/client/components/ui/combobox.tsx +300 -0
  18. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  19. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  20. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  21. package/client/hooks/use-table-url-state.ts +93 -0
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +51 -4
  26. package/client/locales/system/settings-zh-CN.ts +46 -5
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +14 -2
  29. package/client/pages/settings/default-access-model.ts +6 -2
  30. package/client/pages/settings/default-access.tsx +40 -8
  31. package/client/pages/settings/page-permissions.ts +166 -1
  32. package/client/pages/settings/permission-filters.tsx +9 -2
  33. package/client/pages/settings/permission-workspace.ts +330 -0
  34. package/client/pages/settings/permissions-model.ts +62 -53
  35. package/client/pages/settings/role-form.tsx +31 -95
  36. package/client/pages/settings/roles.tsx +44 -6
  37. package/client/pages/settings/shared.tsx +15 -13
  38. package/client/pages/settings/user-detail.tsx +15 -259
  39. package/client/pages/settings/user-form.tsx +299 -147
  40. package/client/pages/settings/users.tsx +211 -123
  41. package/config.example.yml +6 -5
  42. package/package.json +17 -17
  43. package/server/config/app.ts +1 -1
  44. package/server/config/session.ts +7 -5
  45. package/tests/components/default-access-editor.test.tsx +130 -0
  46. package/tests/components/default-access.test.tsx +141 -2
  47. package/tests/components/permission-editor.test.tsx +613 -572
  48. package/tests/components/permission-filter-binding.test.tsx +20 -0
  49. package/tests/components/settings-continuity.test.tsx +426 -0
  50. package/tests/components/settings-feedback.test.tsx +304 -0
  51. package/tests/components/settings.test.tsx +34 -9
  52. package/tests/components/setup-readiness.test.tsx +370 -0
  53. package/tests/components/table-url-state.test.tsx +95 -0
  54. package/tests/components/user-drawer.test.tsx +526 -0
  55. package/tests/components/user-permissions.test.tsx +47 -13
  56. package/tests/logic/config.test.ts +3 -1
  57. package/tests/logic/page-permission-navigation.test.ts +394 -0
  58. package/tests/logic/permission-workspace.test.ts +570 -0
  59. package/tests/logic/return-to.test.ts +19 -0
  60. package/tests/logic/session-isolation.test.ts +250 -0
  61. 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 { Link, type To } from 'react-router';
4
- import { DataTable, DataTableColumnHeader } from '#components/data-table';
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
- <Link
54
+ <RecordLink
41
55
  to={row.original.to}
42
- className='inline-block max-w-72 truncate align-middle font-medium text-foreground'
56
+ className='inline-block max-w-72 truncate align-middle'
43
57
  title={row.original.title}
44
58
  >
45
59
  {row.original.title}
46
- </Link>
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
+ }