@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,300 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Combobox as ComboboxPrimitive } from '@base-ui/react';
5
+ import { cn } from 'cn';
6
+
7
+ import { Button } from '#components/ui/button';
8
+ import {
9
+ InputGroup,
10
+ InputGroupAddon,
11
+ InputGroupButton,
12
+ InputGroupInput,
13
+ } from '#components/ui/input-group';
14
+ import { ChevronDownIcon, XIcon, CheckIcon } from 'lucide-react';
15
+
16
+ const Combobox = ComboboxPrimitive.Root;
17
+
18
+ function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
19
+ return <ComboboxPrimitive.Value data-slot='combobox-value' {...props} />;
20
+ }
21
+
22
+ function ComboboxTrigger({
23
+ className,
24
+ children,
25
+ ...props
26
+ }: ComboboxPrimitive.Trigger.Props) {
27
+ return (
28
+ <ComboboxPrimitive.Trigger
29
+ data-slot='combobox-trigger'
30
+ className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
31
+ {...props}
32
+ >
33
+ {children}
34
+ <ChevronDownIcon className='pointer-events-none size-4 text-muted-foreground' />
35
+ </ComboboxPrimitive.Trigger>
36
+ );
37
+ }
38
+
39
+ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
40
+ return (
41
+ <ComboboxPrimitive.Clear
42
+ data-slot='combobox-clear'
43
+ render={<InputGroupButton variant='ghost' size='icon-xs' />}
44
+ className={cn(className)}
45
+ {...props}
46
+ >
47
+ <XIcon className='pointer-events-none' />
48
+ </ComboboxPrimitive.Clear>
49
+ );
50
+ }
51
+
52
+ function ComboboxInput({
53
+ className,
54
+ children,
55
+ disabled = false,
56
+ showTrigger = true,
57
+ showClear = false,
58
+ ...props
59
+ }: ComboboxPrimitive.Input.Props & {
60
+ showTrigger?: boolean;
61
+ showClear?: boolean;
62
+ }) {
63
+ return (
64
+ <InputGroup className={cn('w-auto', className)}>
65
+ <ComboboxPrimitive.Input
66
+ render={<InputGroupInput disabled={disabled} />}
67
+ {...props}
68
+ />
69
+ <InputGroupAddon align='inline-end'>
70
+ {showTrigger && (
71
+ <InputGroupButton
72
+ size='icon-xs'
73
+ variant='ghost'
74
+ render={<ComboboxTrigger />}
75
+ data-slot='input-group-button'
76
+ className='group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent'
77
+ disabled={disabled}
78
+ />
79
+ )}
80
+ {showClear && <ComboboxClear disabled={disabled} />}
81
+ </InputGroupAddon>
82
+ {children}
83
+ </InputGroup>
84
+ );
85
+ }
86
+
87
+ function ComboboxContent({
88
+ className,
89
+ side = 'bottom',
90
+ sideOffset = 6,
91
+ align = 'start',
92
+ alignOffset = 0,
93
+ anchor,
94
+ ...props
95
+ }: ComboboxPrimitive.Popup.Props &
96
+ Pick<
97
+ ComboboxPrimitive.Positioner.Props,
98
+ 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
99
+ >) {
100
+ return (
101
+ <ComboboxPrimitive.Portal>
102
+ <ComboboxPrimitive.Positioner
103
+ side={side}
104
+ sideOffset={sideOffset}
105
+ align={align}
106
+ alignOffset={alignOffset}
107
+ anchor={anchor}
108
+ className='isolate z-50'
109
+ >
110
+ <ComboboxPrimitive.Popup
111
+ data-slot='combobox-content'
112
+ data-chips={!!anchor}
113
+ className={cn(
114
+ 'group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
115
+ className,
116
+ )}
117
+ {...props}
118
+ />
119
+ </ComboboxPrimitive.Positioner>
120
+ </ComboboxPrimitive.Portal>
121
+ );
122
+ }
123
+
124
+ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
125
+ return (
126
+ <ComboboxPrimitive.List
127
+ data-slot='combobox-list'
128
+ className={cn(
129
+ 'no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0',
130
+ className,
131
+ )}
132
+ {...props}
133
+ />
134
+ );
135
+ }
136
+
137
+ function ComboboxItem({
138
+ className,
139
+ children,
140
+ ...props
141
+ }: ComboboxPrimitive.Item.Props) {
142
+ return (
143
+ <ComboboxPrimitive.Item
144
+ data-slot='combobox-item'
145
+ className={cn(
146
+ "relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
147
+ className,
148
+ )}
149
+ {...props}
150
+ >
151
+ {children}
152
+ <ComboboxPrimitive.ItemIndicator
153
+ render={
154
+ <span className='pointer-events-none absolute right-2 flex size-4 items-center justify-center' />
155
+ }
156
+ >
157
+ <CheckIcon className='pointer-events-none' />
158
+ </ComboboxPrimitive.ItemIndicator>
159
+ </ComboboxPrimitive.Item>
160
+ );
161
+ }
162
+
163
+ function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
164
+ return (
165
+ <ComboboxPrimitive.Group
166
+ data-slot='combobox-group'
167
+ className={cn(className)}
168
+ {...props}
169
+ />
170
+ );
171
+ }
172
+
173
+ function ComboboxLabel({
174
+ className,
175
+ ...props
176
+ }: ComboboxPrimitive.GroupLabel.Props) {
177
+ return (
178
+ <ComboboxPrimitive.GroupLabel
179
+ data-slot='combobox-label'
180
+ className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
181
+ {...props}
182
+ />
183
+ );
184
+ }
185
+
186
+ function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
187
+ return (
188
+ <ComboboxPrimitive.Collection data-slot='combobox-collection' {...props} />
189
+ );
190
+ }
191
+
192
+ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
193
+ return (
194
+ <ComboboxPrimitive.Empty
195
+ data-slot='combobox-empty'
196
+ className={cn(
197
+ 'hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex',
198
+ className,
199
+ )}
200
+ {...props}
201
+ />
202
+ );
203
+ }
204
+
205
+ function ComboboxSeparator({
206
+ className,
207
+ ...props
208
+ }: ComboboxPrimitive.Separator.Props) {
209
+ return (
210
+ <ComboboxPrimitive.Separator
211
+ data-slot='combobox-separator'
212
+ className={cn('-mx-1 my-1 h-px bg-border', className)}
213
+ {...props}
214
+ />
215
+ );
216
+ }
217
+
218
+ function ComboboxChips({
219
+ className,
220
+ ...props
221
+ }: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
222
+ ComboboxPrimitive.Chips.Props) {
223
+ return (
224
+ <ComboboxPrimitive.Chips
225
+ data-slot='combobox-chips'
226
+ className={cn(
227
+ 'flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40',
228
+ className,
229
+ )}
230
+ {...props}
231
+ />
232
+ );
233
+ }
234
+
235
+ function ComboboxChip({
236
+ className,
237
+ children,
238
+ showRemove = true,
239
+ ...props
240
+ }: ComboboxPrimitive.Chip.Props & {
241
+ showRemove?: boolean;
242
+ }) {
243
+ return (
244
+ <ComboboxPrimitive.Chip
245
+ data-slot='combobox-chip'
246
+ className={cn(
247
+ 'flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0',
248
+ className,
249
+ )}
250
+ {...props}
251
+ >
252
+ {children}
253
+ {showRemove && (
254
+ <ComboboxPrimitive.ChipRemove
255
+ render={<Button variant='ghost' size='icon-xs' />}
256
+ className='-ml-1 opacity-50 hover:opacity-100'
257
+ data-slot='combobox-chip-remove'
258
+ >
259
+ <XIcon className='pointer-events-none' />
260
+ </ComboboxPrimitive.ChipRemove>
261
+ )}
262
+ </ComboboxPrimitive.Chip>
263
+ );
264
+ }
265
+
266
+ function ComboboxChipsInput({
267
+ className,
268
+ ...props
269
+ }: ComboboxPrimitive.Input.Props) {
270
+ return (
271
+ <ComboboxPrimitive.Input
272
+ data-slot='combobox-chip-input'
273
+ className={cn('min-w-16 flex-1 outline-none', className)}
274
+ {...props}
275
+ />
276
+ );
277
+ }
278
+
279
+ function useComboboxAnchor() {
280
+ return React.useRef<HTMLDivElement | null>(null);
281
+ }
282
+
283
+ export {
284
+ Combobox,
285
+ ComboboxInput,
286
+ ComboboxContent,
287
+ ComboboxList,
288
+ ComboboxItem,
289
+ ComboboxGroup,
290
+ ComboboxLabel,
291
+ ComboboxCollection,
292
+ ComboboxEmpty,
293
+ ComboboxSeparator,
294
+ ComboboxChips,
295
+ ComboboxChip,
296
+ ComboboxChipsInput,
297
+ ComboboxTrigger,
298
+ ComboboxValue,
299
+ useComboboxAnchor,
300
+ };
@@ -63,8 +63,10 @@ The application owns authentication, initial administrator grants, navigation an
63
63
 
64
64
  ## Standard diagram and contextual configuration
65
65
 
66
- Current application templates already contain these parts and the standard React Flow/ELK components. Reuse the installed `LifecycleDiagram` and preserve its default visuals unless the user explicitly asks to customize them. UI Library entries are shadcn registry source, not npm packages; use `shadcn view/add`, never an npm 404 as a fallback decision.
66
+ Current application templates already contain these parts and the standard React Flow/ELK components. Reuse the installed `LifecycleDiagram`; the installable demo opts into `presentation="process"` for compact boundary pills and work cards in both definitions and executions. Supply application-owned `stateKind` semantics and keep configuration and history callbacks intact. Omit `presentation` to retain the classic visuals. UI Library entries are shadcn registry source, not npm packages; use `shadcn view/add`, never an npm 404 as a fallback decision.
67
67
 
68
68
  Connect `LifecycleDiagram.configuration` to the application's existing authorized parameter editor. Native `parameterConfiguration` bindings supply the exact state/transition fields; the component automatically renders bound state controls and sends bound transition selections to `onConfigure`. `canConfigure` is mandatory and controls the affordance only, never server security. The application editor reads current values/revision, preserves other group fields, validates and saves the complete authorized group with CAS, and refreshes the diagram summary from persisted data. Avoid replacing a group with the callback's filtered field subset. Keep historical snapshots read-only.
69
69
 
70
70
  Omit `WorkflowDefinitionDetail.parameters` when the editor opens contextually so the diagram spans the available width; keep the slot when the same editor is visible beside the diagram. No editor, API, sample business registration or menu is installed by this block. Definition and execution lists use the same TanStack DataTable as the templates, with no second local pagination over a server page.
71
+
72
+ Ordering is optional: omit `onSort` when activity ordering is not supported so the header stays plain text. Omit a row's optional `name` when its linked `title` already supplies the complete display name. Hosts own filtering, pagination and their URL state; keep that state when opening and returning from a definition or execution.
@@ -11,7 +11,7 @@ import { DataTable } from '#components/data-table/data-table';
11
11
 
12
12
  export interface WorkflowDefinitionRow {
13
13
  id: string;
14
- name: string;
14
+ name?: string;
15
15
  title: ReactNode;
16
16
  activity: ReactNode;
17
17
  }
@@ -30,7 +30,7 @@ export interface WorkflowDefinitionListProps {
30
30
  executionActivity: string;
31
31
  };
32
32
  sort?: { ascending: boolean };
33
- onSort: () => void;
33
+ onSort?: () => void;
34
34
  onRefresh: () => void;
35
35
  }
36
36
 
@@ -91,7 +91,7 @@ export function WorkflowDefinitionList({
91
91
  {
92
92
  id: 'activity',
93
93
  accessorFn: (row) => row.activity,
94
- enableSorting: true,
94
+ enableSorting: Boolean(onSort),
95
95
  meta: activityMeta,
96
96
  header: DefinitionActivityHeader,
97
97
  cell: DefinitionActivityCell,
@@ -118,7 +118,9 @@ function DefinitionTitleCell({
118
118
  return (
119
119
  <>
120
120
  {row.original.title}
121
- <p className='text-xs text-muted-foreground'>{row.original.name}</p>
121
+ {row.original.name && row.original.name !== row.original.title && (
122
+ <p className='text-xs text-muted-foreground'>{row.original.name}</p>
123
+ )}
122
124
  </>
123
125
  );
124
126
  }
@@ -134,15 +136,19 @@ function DefinitionActivityHeader({
134
136
  .meta as DefinitionActivityMeta;
135
137
  return (
136
138
  <div className='text-right'>
137
- <Button
138
- variant='ghost'
139
- size='sm'
140
- title={labels.executionCount}
141
- onClick={onSort}
142
- >
143
- {labels.executionActivity}
144
- {sort && (sort.ascending ? <ArrowUp /> : <ArrowDown />)}
145
- </Button>
139
+ {onSort ? (
140
+ <Button
141
+ variant='ghost'
142
+ size='sm'
143
+ title={labels.executionCount}
144
+ onClick={onSort}
145
+ >
146
+ {labels.executionActivity}
147
+ {sort && (sort.ascending ? <ArrowUp /> : <ArrowDown />)}
148
+ </Button>
149
+ ) : (
150
+ <span>{labels.executionActivity}</span>
151
+ )}
146
152
  </div>
147
153
  );
148
154
  }
@@ -68,7 +68,7 @@ export function WorkflowDefinitionDetail({
68
68
  className={
69
69
  parameters == null
70
70
  ? 'min-w-0'
71
- : 'grid min-w-0 grid-cols-1 items-start gap-6 lg:grid-cols-2'
71
+ : 'grid min-w-0 grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]'
72
72
  }
73
73
  >
74
74
  <Card className='min-w-0' role='region' aria-label={diagramTitle}>
@@ -0,0 +1,93 @@
1
+ import { useEffect } from 'react';
2
+ import { useSearchParams } from 'react-router';
3
+ import type {
4
+ OnChangeFn,
5
+ PaginationState,
6
+ SortingState,
7
+ } from '@tanstack/react-table';
8
+
9
+ const sizes = [10, 20, 50, 100] as const;
10
+
11
+ /** Keep list position shareable across overlays, refresh and browser history. */
12
+ export function useTableUrlState({
13
+ defaultPageSize = 10,
14
+ pageSizeOptions = sizes,
15
+ sortColumns,
16
+ rowCount,
17
+ }: {
18
+ defaultPageSize?: number;
19
+ pageSizeOptions?: readonly number[];
20
+ sortColumns: readonly string[];
21
+ rowCount?: number;
22
+ }) {
23
+ const [params, setParams] = useSearchParams();
24
+ const requestedPage = Number(params.get('page'));
25
+ const pageIndex =
26
+ Number.isSafeInteger(requestedPage) && requestedPage > 0
27
+ ? requestedPage - 1
28
+ : 0;
29
+ const requestedSize = Number(params.get('pageSize'));
30
+ const pageSize = pageSizeOptions.includes(requestedSize)
31
+ ? requestedSize
32
+ : defaultPageSize;
33
+ const paginationState = { pageIndex, pageSize };
34
+ const sort = params.get('sort') ?? sortColumns[0];
35
+ const order = params.get('order');
36
+ const sortingState: SortingState =
37
+ sort && sortColumns.includes(sort) && (order === 'asc' || order === 'desc')
38
+ ? [{ id: sort, desc: order === 'desc' }]
39
+ : [];
40
+ const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
41
+ const next =
42
+ typeof updater === 'function' ? updater(paginationState) : updater;
43
+ setParams(
44
+ (old) => {
45
+ const result = new URLSearchParams(old);
46
+ result.set('page', String(next.pageIndex + 1));
47
+ result.set('pageSize', String(next.pageSize));
48
+ return result;
49
+ },
50
+ { replace: false },
51
+ );
52
+ };
53
+ const onSortingChange: OnChangeFn<SortingState> = (updater) => {
54
+ const next =
55
+ typeof updater === 'function' ? updater(sortingState) : updater;
56
+ setParams(
57
+ (old) => {
58
+ const result = new URLSearchParams(old);
59
+ result.delete('page');
60
+ if (next[0] && sortColumns.includes(next[0].id)) {
61
+ result.set('sort', next[0].id);
62
+ result.set('order', next[0].desc ? 'desc' : 'asc');
63
+ } else {
64
+ result.delete('sort');
65
+ result.delete('order');
66
+ }
67
+ return result;
68
+ },
69
+ { replace: true },
70
+ );
71
+ };
72
+ // Only pass a count for the current, successfully loaded query.
73
+ useEffect(() => {
74
+ if (rowCount === undefined) return;
75
+ const lastPage = Math.max(0, Math.ceil(rowCount / pageSize) - 1);
76
+ if (pageIndex <= lastPage) return;
77
+ setParams(
78
+ (old) => {
79
+ const result = new URLSearchParams(old);
80
+ result.set('page', String(lastPage + 1));
81
+ return result;
82
+ },
83
+ { replace: true },
84
+ );
85
+ }, [rowCount, pageSize, pageIndex, setParams]);
86
+ return {
87
+ paginationState,
88
+ onPaginationChange,
89
+ sortingState,
90
+ onSortingChange,
91
+ pageSizeOptions,
92
+ };
93
+ }
@@ -0,0 +1,98 @@
1
+ import type { UsersClient } from '@nocobase/app-plugin-users/client/user-client';
2
+ import {
3
+ setupInspectionFailure,
4
+ type SetupInspection,
5
+ type SetupInspections,
6
+ } from '../components/setup-readiness-state.js';
7
+ import { safeReturnTo } from './return-to.js';
8
+
9
+ function isReadonlyArray<T>(
10
+ value: readonly T[] | undefined,
11
+ ): value is readonly T[] {
12
+ return Array.isArray(value);
13
+ }
14
+
15
+ export interface RequiredSetupRole {
16
+ readonly scope: string;
17
+ readonly role: string;
18
+ /** A localized business label; never render the role key as a title. */
19
+ readonly title: string;
20
+ readonly description: string;
21
+ }
22
+ export function requiredRoleSetupId(
23
+ role: Pick<RequiredSetupRole, 'scope' | 'role'>,
24
+ ): string {
25
+ return JSON.stringify([role.scope, role.role]);
26
+ }
27
+ export function requiredRoleSetupLink(
28
+ role: Pick<RequiredSetupRole, 'scope' | 'role'>,
29
+ returnTo: string,
30
+ ): string {
31
+ const query = new URLSearchParams({
32
+ roleScope: role.scope,
33
+ role: role.role,
34
+ status: 'enabled',
35
+ });
36
+ const source = safeReturnTo(returnTo);
37
+ if (source) query.set('returnTo', source);
38
+ return `/settings/users?${query}`;
39
+ }
40
+
41
+ /** One metadata read and one bounded read per distinct required role. No writes. */
42
+ export async function inspectRequiredRoles(
43
+ client: Pick<UsersClient, 'options' | 'list'>,
44
+ requirements: readonly RequiredSetupRole[],
45
+ ): Promise<SetupInspections> {
46
+ const unique = [
47
+ ...new Map(
48
+ requirements.map((role) => [requiredRoleSetupId(role), role]),
49
+ ).values(),
50
+ ];
51
+ if (unique.length === 0) return {};
52
+ const options = await client.options();
53
+ const roleScopes = options?.roleScopes;
54
+ if (!isReadonlyArray(roleScopes))
55
+ return Object.fromEntries(
56
+ unique.map((role) => [
57
+ requiredRoleSetupId(role),
58
+ { status: 'unknown', reason: 'invalid' },
59
+ ]),
60
+ );
61
+ const entries = await Promise.all(
62
+ unique.map(
63
+ async (requirement): Promise<readonly [string, SetupInspection]> => {
64
+ const id = requiredRoleSetupId(requirement);
65
+ const scope = roleScopes.find(
66
+ (scope) => scope?.key === requirement.scope,
67
+ );
68
+ const role = isReadonlyArray(scope?.options)
69
+ ? scope.options.find((role) => role?.value === requirement.role)
70
+ : undefined;
71
+ // Metadata offers direct selectable roles; authenticated defaults apply separately.
72
+ if (!scope || !role)
73
+ return [id, { status: 'unknown', reason: 'invalid' }];
74
+ try {
75
+ const page = await client.list({
76
+ roleScope: requirement.scope,
77
+ role: requirement.role,
78
+ status: 'enabled',
79
+ page: 1,
80
+ pageSize: 1,
81
+ });
82
+ if (!Number.isSafeInteger(page?.total) || page.total < 0)
83
+ return [id, { status: 'unknown', reason: 'invalid' }];
84
+ return [
85
+ id,
86
+ {
87
+ status: page.total > 0 ? 'ready' : 'missing',
88
+ canConfigure: role.assignable !== false,
89
+ },
90
+ ];
91
+ } catch (error: unknown) {
92
+ return [id, setupInspectionFailure(error)];
93
+ }
94
+ },
95
+ ),
96
+ );
97
+ return Object.fromEntries(entries);
98
+ }
@@ -0,0 +1,19 @@
1
+ /** Accept only an application-relative destination, resolved by the app router's basename. */
2
+ export function safeReturnTo(value: string | null): string | undefined {
3
+ if (
4
+ !value ||
5
+ !value.startsWith('/') ||
6
+ value.startsWith('//') ||
7
+ [...value].some(
8
+ (character) =>
9
+ character === '\\' ||
10
+ character.charCodeAt(0) <= 32 ||
11
+ character.charCodeAt(0) === 127,
12
+ )
13
+ )
14
+ return;
15
+ const base = 'https://app.invalid';
16
+ const url = new URL(value, base);
17
+ if (url.origin !== base || url.pathname.startsWith('//')) return;
18
+ return `${url.pathname}${url.search}${url.hash}`;
19
+ }
@@ -9,6 +9,16 @@ import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
9
9
  */
10
10
  const systemEnUS = {
11
11
  flowDiagram: {
12
+ controls: 'Diagram controls',
13
+ fitView: 'Fit to view',
14
+ fullscreen: 'Fullscreen',
15
+ exitFullscreen: 'Exit fullscreen',
16
+ escapeHint: 'Press Escape to exit',
17
+ leavesTo: 'Leaves to',
18
+ arrivesFrom: 'Arrives from',
19
+ otherRoutes: 'Other routes',
20
+ minimap: 'Diagram overview',
21
+
12
22
  configure: 'Configure',
13
23
  overview: 'Diagram overview',
14
24
  actualSize: 'Actual size',