@nocobase/app-template-default 1.0.0-beta.66 → 1.0.0-beta.68
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 +45 -0
- package/.agents/rules/components.md +34 -0
- package/.agents/rules/shell.md +31 -0
- package/.prettierignore +3 -1
- package/AGENTS.md +42 -366
- package/CLAUDE.md +0 -8
- package/client/components/page-header.tsx +1 -1
- package/client/components/status-badge.tsx +7 -1
- package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
- package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
- package/client/index.tsx +4 -0
- package/client/layouts/components/app-sidebar.tsx +5 -5
- package/client/layouts/components/layout-header.tsx +1 -1
- package/client/layouts/components/navigation-menu.tsx +223 -12
- package/client/layouts/components/sidebar-footer.tsx +1 -1
- package/client/layouts/components/user-menu.tsx +1 -1
- package/client/lib/navigation-transform.ts +8 -0
- package/client/lib/scrollbar.ts +88 -0
- package/client/locales/system/en-US.ts +4 -0
- package/client/locales/system/zh-CN.ts +15 -12
- package/client/pages/auth/auth-page.css +37 -0
- package/client/pages/auth/reset-password.tsx +57 -14
- package/client/pages/auth/shared.tsx +24 -24
- package/client/pages/home/index.tsx +2 -2
- package/client/routing/route-navigation.ts +10 -1
- package/client/styles.css +253 -17
- package/client/theme/theme-settings.tsx +12 -7
- package/client/theme/themes/compact.css +9 -14
- package/client/theme/themes/default.css +7 -12
- package/package.json +10 -9
- package/server/app.ts +2 -0
- package/server/config/notification.ts +4 -7
- package/tests/components/auth-i18n.test.tsx +57 -5
- package/tests/components/layout-i18n.test.tsx +2 -2
- package/tests/components/navigation-menu.test.tsx +133 -4
- package/tests/components/sidebar-permissions.test.tsx +5 -15
- package/tests/components/status-badge.test.tsx +22 -0
- package/tests/logic/agents-md.test.ts +108 -0
- package/tests/logic/scrollbar.test.ts +88 -0
- package/tests/logic/theme-tokens.test.ts +1 -0
- package/vitest.config.ts +1 -0
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
2
|
import { ChevronRight } from 'lucide-react';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
} from 'react';
|
|
4
10
|
import { Link } from 'react-router';
|
|
5
11
|
|
|
6
12
|
import {
|
|
@@ -17,8 +23,10 @@ import {
|
|
|
17
23
|
import {
|
|
18
24
|
SidebarGroup,
|
|
19
25
|
SidebarGroupContent,
|
|
26
|
+
SidebarGroupLabel,
|
|
20
27
|
SidebarMenu,
|
|
21
28
|
SidebarMenuAction,
|
|
29
|
+
SidebarMenuBadge,
|
|
22
30
|
SidebarMenuButton,
|
|
23
31
|
SidebarMenuItem,
|
|
24
32
|
SidebarMenuSub,
|
|
@@ -38,35 +46,164 @@ const SELECTED =
|
|
|
38
46
|
'data-active:bg-sidebar-primary data-active:text-sidebar-primary-foreground data-active:hover:bg-sidebar-primary data-active:hover:text-sidebar-primary-foreground';
|
|
39
47
|
|
|
40
48
|
/**
|
|
41
|
-
* A
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* A count beside a menu entry. A number is what waits on the viewer, such as unread messages or requests to decide;
|
|
50
|
+
* `live` is work under way, such as runs in progress, with `hint` as its words for the tooltip and screen readers. Zero
|
|
51
|
+
* shows nothing.
|
|
52
|
+
*/
|
|
53
|
+
export type NavigationBadge =
|
|
54
|
+
| number
|
|
55
|
+
| { readonly count: number; readonly live: true; readonly hint: string };
|
|
56
|
+
|
|
57
|
+
/** Counts keyed by the route name of the entry they stand beside. */
|
|
58
|
+
export type NavigationBadges = Readonly<Record<string, NavigationBadge>>;
|
|
59
|
+
|
|
60
|
+
const NavigationBadgesContext = createContext<NavigationBadges>({});
|
|
61
|
+
|
|
62
|
+
function useNavigationBadge(item: RouteNavigationItem): {
|
|
63
|
+
readonly count: number;
|
|
64
|
+
readonly live: boolean;
|
|
65
|
+
readonly text: string;
|
|
66
|
+
} {
|
|
67
|
+
const badge = useContext(NavigationBadgesContext)[item.route.name];
|
|
68
|
+
if (typeof badge === 'object')
|
|
69
|
+
return { count: badge.count, live: true, text: badge.hint };
|
|
70
|
+
const count = badge ?? 0;
|
|
71
|
+
return { count, live: false, text: String(count) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A permission-filtered route tree as the sidebar's menu, drawn with shadcn's Sidebar primitives. A top-level group
|
|
76
|
+
* with no page of its own is a section: a sidebar group under a small heading, its entries always shown. Nested groups
|
|
77
|
+
* and parents with a page are collapsible sub-menus. In the desktop icon mode the labels and headings hide, every entry
|
|
78
|
+
* carries its label as a tooltip, and an entry with children opens them in a popover.
|
|
44
79
|
*/
|
|
45
80
|
export function NavigationMenu({
|
|
46
81
|
items,
|
|
47
82
|
label,
|
|
48
83
|
selectedKey,
|
|
84
|
+
sections = true,
|
|
85
|
+
badges = {},
|
|
49
86
|
}: {
|
|
50
87
|
readonly items: readonly RouteNavigationItem[];
|
|
51
88
|
readonly label: string;
|
|
52
89
|
readonly selectedKey: string | undefined;
|
|
90
|
+
/** `false` draws top-level groups as collapsible sub-menus too. */
|
|
91
|
+
readonly sections?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Counts beside entries, keyed by route name, for a page entry that is not a sub-menu's: a top-level entry or a
|
|
94
|
+
* section's. They change while the page is open, so the layout passes them rather than the route declaring them.
|
|
95
|
+
*/
|
|
96
|
+
readonly badges?: NavigationBadges;
|
|
97
|
+
}): ReactElement {
|
|
98
|
+
// A group already divided into `section`s by the navigation stays one collapsible entry over them.
|
|
99
|
+
const isSection = (item: RouteNavigationItem): boolean =>
|
|
100
|
+
sections &&
|
|
101
|
+
item.children.length > 0 &&
|
|
102
|
+
!item.route.componentLoader &&
|
|
103
|
+
!item.children.some((child) => child.section);
|
|
104
|
+
// Consecutive plain entries share one sidebar group; each section is a group of its own.
|
|
105
|
+
const runs: (RouteNavigationItem | RouteNavigationItem[])[] = [];
|
|
106
|
+
for (const item of items) {
|
|
107
|
+
const last = runs.at(-1);
|
|
108
|
+
if (isSection(item)) runs.push(item);
|
|
109
|
+
else if (Array.isArray(last)) last.push(item);
|
|
110
|
+
else runs.push([item]);
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
<NavigationBadgesContext value={badges}>
|
|
114
|
+
<nav aria-label={label}>
|
|
115
|
+
{runs.map((run) =>
|
|
116
|
+
Array.isArray(run) ? (
|
|
117
|
+
<SidebarGroup key={routeKey(run[0].route)}>
|
|
118
|
+
<SidebarGroupContent>
|
|
119
|
+
<SidebarMenu>
|
|
120
|
+
{run.map((item) => (
|
|
121
|
+
<NavigationItem
|
|
122
|
+
key={routeKey(item.route)}
|
|
123
|
+
item={item}
|
|
124
|
+
selectedKey={selectedKey}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
</SidebarMenu>
|
|
128
|
+
</SidebarGroupContent>
|
|
129
|
+
</SidebarGroup>
|
|
130
|
+
) : (
|
|
131
|
+
<NavigationGroup
|
|
132
|
+
key={routeKey(run.route)}
|
|
133
|
+
item={run}
|
|
134
|
+
selectedKey={selectedKey}
|
|
135
|
+
/>
|
|
136
|
+
),
|
|
137
|
+
)}
|
|
138
|
+
</nav>
|
|
139
|
+
</NavigationBadgesContext>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A top-level group with no page of its own, as a sidebar group under a heading, its entries always shown. A section
|
|
145
|
+
* the navigation marks deeper in the tree (`section`) is a `NavigationSection` inside its menu instead.
|
|
146
|
+
*/
|
|
147
|
+
function NavigationGroup({
|
|
148
|
+
item,
|
|
149
|
+
selectedKey,
|
|
150
|
+
}: {
|
|
151
|
+
readonly item: RouteNavigationItem;
|
|
152
|
+
readonly selectedKey: string | undefined;
|
|
53
153
|
}): ReactElement {
|
|
154
|
+
const label = useItemLabel(item);
|
|
54
155
|
return (
|
|
55
|
-
<
|
|
56
|
-
<
|
|
156
|
+
<SidebarGroup aria-label={label}>
|
|
157
|
+
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
|
158
|
+
<SidebarGroupContent>
|
|
159
|
+
<SidebarMenu>
|
|
160
|
+
{item.children.map((child) => (
|
|
161
|
+
<NavigationItem
|
|
162
|
+
key={routeKey(child.route)}
|
|
163
|
+
item={child}
|
|
164
|
+
selectedKey={selectedKey}
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
</SidebarMenu>
|
|
168
|
+
</SidebarGroupContent>
|
|
169
|
+
</SidebarGroup>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Navigation sections are labels with direct links, rather than another collapsible menu level. */
|
|
174
|
+
function NavigationSection({
|
|
175
|
+
item,
|
|
176
|
+
selectedKey,
|
|
177
|
+
onNavigate,
|
|
178
|
+
inPopover = false,
|
|
179
|
+
}: ItemProps): ReactElement {
|
|
180
|
+
const label = useItemLabel(item);
|
|
181
|
+
return (
|
|
182
|
+
<SidebarMenuItem>
|
|
183
|
+
<SidebarGroup role='group' aria-label={label} className='p-0'>
|
|
184
|
+
<SidebarGroupLabel
|
|
185
|
+
className={
|
|
186
|
+
inPopover
|
|
187
|
+
? 'text-sidebar-foreground/50 group-data-[collapsible=icon]:mt-0 group-data-[collapsible=icon]:opacity-100'
|
|
188
|
+
: 'text-sidebar-foreground/50'
|
|
189
|
+
}
|
|
190
|
+
>
|
|
191
|
+
{label}
|
|
192
|
+
</SidebarGroupLabel>
|
|
57
193
|
<SidebarGroupContent>
|
|
58
194
|
<SidebarMenu>
|
|
59
|
-
{
|
|
60
|
-
<
|
|
61
|
-
key={routeKey(
|
|
62
|
-
item={
|
|
195
|
+
{item.children.map((child) => (
|
|
196
|
+
<NavigationSubItem
|
|
197
|
+
key={routeKey(child.route)}
|
|
198
|
+
item={child}
|
|
63
199
|
selectedKey={selectedKey}
|
|
200
|
+
onNavigate={onNavigate}
|
|
64
201
|
/>
|
|
65
202
|
))}
|
|
66
203
|
</SidebarMenu>
|
|
67
204
|
</SidebarGroupContent>
|
|
68
205
|
</SidebarGroup>
|
|
69
|
-
</
|
|
206
|
+
</SidebarMenuItem>
|
|
70
207
|
);
|
|
71
208
|
}
|
|
72
209
|
|
|
@@ -107,10 +244,22 @@ function NavigationItem({
|
|
|
107
244
|
const isSelected = routeKey(item.route) === selectedKey;
|
|
108
245
|
const children = item.children;
|
|
109
246
|
const disclosure = useDisclosure(item, selectedKey);
|
|
247
|
+
const badge = useNavigationBadge(item);
|
|
248
|
+
const counted = badge.count > 0 ? `${label} · ${badge.text}` : null;
|
|
110
249
|
const tooltip = inPopover
|
|
111
250
|
? undefined
|
|
112
251
|
: { children: label, role: 'tooltip', sideOffset: 8 };
|
|
113
252
|
|
|
253
|
+
if (item.section)
|
|
254
|
+
return (
|
|
255
|
+
<NavigationSection
|
|
256
|
+
item={item}
|
|
257
|
+
selectedKey={selectedKey}
|
|
258
|
+
onNavigate={navigate}
|
|
259
|
+
inPopover={inPopover}
|
|
260
|
+
/>
|
|
261
|
+
);
|
|
262
|
+
|
|
114
263
|
if (children.length > 0 && state === 'collapsed' && !isMobile && !inPopover)
|
|
115
264
|
return (
|
|
116
265
|
<SidebarMenuItem>
|
|
@@ -138,12 +287,22 @@ function NavigationItem({
|
|
|
138
287
|
<SidebarMenuButton
|
|
139
288
|
render={link}
|
|
140
289
|
isActive={isSelected}
|
|
141
|
-
tooltip
|
|
290
|
+
// A counted entry is named with its count, as its tooltip shows it.
|
|
291
|
+
aria-label={counted ?? undefined}
|
|
292
|
+
tooltip={
|
|
293
|
+
tooltip && counted ? { ...tooltip, children: counted } : tooltip
|
|
294
|
+
}
|
|
142
295
|
className={SELECTED}
|
|
143
296
|
>
|
|
144
297
|
{Icon ? <Icon /> : null}
|
|
145
298
|
<span>{label}</span>
|
|
146
299
|
</SidebarMenuButton>
|
|
300
|
+
{badge.count > 0 && (
|
|
301
|
+
<EntryBadge
|
|
302
|
+
{...badge}
|
|
303
|
+
dot={state === 'collapsed' && !isMobile && !inPopover}
|
|
304
|
+
/>
|
|
305
|
+
)}
|
|
147
306
|
</SidebarMenuItem>
|
|
148
307
|
);
|
|
149
308
|
|
|
@@ -195,6 +354,50 @@ function NavigationItem({
|
|
|
195
354
|
);
|
|
196
355
|
}
|
|
197
356
|
|
|
357
|
+
/**
|
|
358
|
+
* An entry's count at the row's end, on a round ground of its own; work under way in the primary color behind a
|
|
359
|
+
* pulsing dot. The primitive hides it in the icon mode, where a dot on the icon's corner keeps it noticed and the
|
|
360
|
+
* tooltip carries the count. The words are the entry's, so this is hidden from screen readers.
|
|
361
|
+
*/
|
|
362
|
+
function EntryBadge({
|
|
363
|
+
count,
|
|
364
|
+
live,
|
|
365
|
+
text,
|
|
366
|
+
dot,
|
|
367
|
+
}: {
|
|
368
|
+
readonly count: number;
|
|
369
|
+
readonly live: boolean;
|
|
370
|
+
readonly text: string;
|
|
371
|
+
readonly dot: boolean;
|
|
372
|
+
}): ReactElement {
|
|
373
|
+
const pulse = 'animate-pulse motion-reduce:animate-none';
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
<SidebarMenuBadge
|
|
377
|
+
aria-hidden
|
|
378
|
+
title={live ? text : undefined}
|
|
379
|
+
className={
|
|
380
|
+
live
|
|
381
|
+
? 'gap-1 rounded-full bg-primary/10 px-1.5 text-primary dark:bg-primary/20'
|
|
382
|
+
: 'rounded-full bg-foreground/8 px-1.5 dark:bg-foreground/15'
|
|
383
|
+
}
|
|
384
|
+
>
|
|
385
|
+
{live && (
|
|
386
|
+
<span className={`size-1.5 rounded-full bg-primary ${pulse}`} />
|
|
387
|
+
)}
|
|
388
|
+
{count > 99 ? '99+' : count}
|
|
389
|
+
</SidebarMenuBadge>
|
|
390
|
+
{dot && (
|
|
391
|
+
<span
|
|
392
|
+
aria-hidden
|
|
393
|
+
data-slot='navigation-badge-dot'
|
|
394
|
+
className={`pointer-events-none absolute top-1 right-1 size-2 rounded-full bg-primary ${live ? pulse : ''}`}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
198
401
|
/** An entry inside a sub-menu: a link, or a collapsible sub-menu of its own. */
|
|
199
402
|
function NavigationSubItem({
|
|
200
403
|
item,
|
|
@@ -206,6 +409,14 @@ function NavigationSubItem({
|
|
|
206
409
|
const isSelected = routeKey(item.route) === selectedKey;
|
|
207
410
|
const disclosure = useDisclosure(item, selectedKey);
|
|
208
411
|
const children = item.children;
|
|
412
|
+
if (item.section)
|
|
413
|
+
return (
|
|
414
|
+
<NavigationSection
|
|
415
|
+
item={item}
|
|
416
|
+
selectedKey={selectedKey}
|
|
417
|
+
onNavigate={onNavigate}
|
|
418
|
+
/>
|
|
419
|
+
);
|
|
209
420
|
if (children.length === 0 && !item.route.componentLoader) return null;
|
|
210
421
|
|
|
211
422
|
const link = item.route.componentLoader ? (
|
|
@@ -44,7 +44,7 @@ export function AppSidebarFooter(): ReactElement {
|
|
|
44
44
|
);
|
|
45
45
|
|
|
46
46
|
return (
|
|
47
|
-
<SidebarFooter className='shrink-0 border-t border-sidebar-border
|
|
47
|
+
<SidebarFooter className='shrink-0 border-t border-sidebar-border'>
|
|
48
48
|
{/* The menu button's inset (`p-2`, `gap-2`), so the shield lines up with the menu icons above. */}
|
|
49
49
|
<div className='flex min-w-0 items-start gap-2 p-2 text-xs leading-5'>
|
|
50
50
|
<Tooltip>
|
|
@@ -49,7 +49,7 @@ export function UserMenu(): ReactElement {
|
|
|
49
49
|
aria-label={t('account.openMenu', {
|
|
50
50
|
defaultValue: 'Open account menu',
|
|
51
51
|
})}
|
|
52
|
-
className='flex size-10 cursor-pointer items-center justify-center rounded-full border border-border
|
|
52
|
+
className='flex size-10 cursor-pointer items-center justify-center rounded-full border border-border bg-background/60 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50'
|
|
53
53
|
>
|
|
54
54
|
{identity?.avatar ? (
|
|
55
55
|
<img
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RouteNavigationItem } from '../routing/route-navigation.js';
|
|
2
|
+
|
|
3
|
+
/** Application-owned navigation customization, applied after permission filtering. */
|
|
4
|
+
export function transformNavigation(
|
|
5
|
+
items: RouteNavigationItem[],
|
|
6
|
+
): RouteNavigationItem[] {
|
|
7
|
+
return items;
|
|
8
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Every scrollbar in the application shows only while what it belongs to scrolls, or while the mouse is near the
|
|
2
|
+
// edge it runs along, so it can be found and grabbed (client/styles.css draws a bar under `[data-scrolling]` alone).
|
|
3
|
+
// One listener on the document, in the capture phase since a scroll does not bubble, marks the element that scrolls,
|
|
4
|
+
// or the page's root for the page itself, until it has been still for a moment; a second marks the scroller the mouse
|
|
5
|
+
// is within EDGE_PX of the right or bottom edge of, at most once a frame. Installed once, as the client starts
|
|
6
|
+
// (client/index.tsx).
|
|
7
|
+
const STILL_MS = 800;
|
|
8
|
+
const EDGE_PX = 16;
|
|
9
|
+
const timers = new WeakMap<Element, number>();
|
|
10
|
+
|
|
11
|
+
function reveal(element: HTMLElement): void {
|
|
12
|
+
element.dataset.scrolling = '';
|
|
13
|
+
window.clearTimeout(timers.get(element));
|
|
14
|
+
timers.set(
|
|
15
|
+
element,
|
|
16
|
+
window.setTimeout(() => {
|
|
17
|
+
delete element.dataset.scrolling;
|
|
18
|
+
}, STILL_MS),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scrolls(element: Element, axis: 'x' | 'y'): boolean {
|
|
23
|
+
const overflow =
|
|
24
|
+
getComputedStyle(element)[axis === 'y' ? 'overflowY' : 'overflowX'];
|
|
25
|
+
if (overflow !== 'auto' && overflow !== 'scroll') return false;
|
|
26
|
+
return axis === 'y'
|
|
27
|
+
? element.scrollHeight > element.clientHeight
|
|
28
|
+
: element.scrollWidth > element.clientWidth;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The scroller whose right or bottom edge the point is near: the nearest such ancestor of the target, or the page. */
|
|
32
|
+
function scrollerAtEdge(
|
|
33
|
+
target: Element | null,
|
|
34
|
+
x: number,
|
|
35
|
+
y: number,
|
|
36
|
+
): HTMLElement | undefined {
|
|
37
|
+
for (
|
|
38
|
+
let element = target;
|
|
39
|
+
element && element !== document.body;
|
|
40
|
+
element = element.parentElement
|
|
41
|
+
) {
|
|
42
|
+
if (element === document.documentElement) break;
|
|
43
|
+
const box = element.getBoundingClientRect();
|
|
44
|
+
if (scrolls(element, 'y') && x >= box.right - EDGE_PX && x <= box.right)
|
|
45
|
+
return element as HTMLElement;
|
|
46
|
+
if (scrolls(element, 'x') && y >= box.bottom - EDGE_PX && y <= box.bottom)
|
|
47
|
+
return element as HTMLElement;
|
|
48
|
+
}
|
|
49
|
+
const page = (document.scrollingElement ??
|
|
50
|
+
document.documentElement) as HTMLElement;
|
|
51
|
+
if (page.scrollHeight > page.clientHeight && x >= window.innerWidth - EDGE_PX)
|
|
52
|
+
return page;
|
|
53
|
+
if (page.scrollWidth > page.clientWidth && y >= window.innerHeight - EDGE_PX)
|
|
54
|
+
return page;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function revealScrollbarsWhileScrolling(): void {
|
|
59
|
+
document.addEventListener(
|
|
60
|
+
'scroll',
|
|
61
|
+
(event) => {
|
|
62
|
+
const element =
|
|
63
|
+
event.target instanceof Element
|
|
64
|
+
? event.target
|
|
65
|
+
: (document.scrollingElement ?? document.documentElement);
|
|
66
|
+
if (element instanceof HTMLElement) reveal(element);
|
|
67
|
+
},
|
|
68
|
+
{ capture: true, passive: true },
|
|
69
|
+
);
|
|
70
|
+
let frame = 0;
|
|
71
|
+
document.addEventListener(
|
|
72
|
+
'pointermove',
|
|
73
|
+
(event) => {
|
|
74
|
+
if (event.pointerType !== 'mouse' || frame) return;
|
|
75
|
+
const { clientX, clientY, target } = event;
|
|
76
|
+
frame = window.requestAnimationFrame(() => {
|
|
77
|
+
frame = 0;
|
|
78
|
+
const element = scrollerAtEdge(
|
|
79
|
+
target instanceof Element ? target : null,
|
|
80
|
+
clientX,
|
|
81
|
+
clientY,
|
|
82
|
+
);
|
|
83
|
+
if (element) reveal(element);
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
{ passive: true },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -7,6 +7,10 @@ import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
|
|
|
7
7
|
* upgrade replaces this file, so put the application's own copy in `../en-US.ts` rather than here.
|
|
8
8
|
*/
|
|
9
9
|
const systemEnUS = {
|
|
10
|
+
'auth.passwordResetSuccess':
|
|
11
|
+
'Your password has been reset. Sign in with your new password.',
|
|
12
|
+
'auth.requestNewResetLink': 'Request a new reset link',
|
|
13
|
+
|
|
10
14
|
// The UI Library block of the `/device` page; the keys below may reword it.
|
|
11
15
|
...deviceApprovalEnUS,
|
|
12
16
|
// The UI Library block of the `/inbox` page; the keys below may reword it.
|
|
@@ -4,6 +4,9 @@ import inboxZhCN from '#extensions/nocobase-inbox/locales/zh-CN';
|
|
|
4
4
|
|
|
5
5
|
/** The Chinese copy of what the template ships; see `./en-US.ts`. */
|
|
6
6
|
const systemZhCN: SystemResource = {
|
|
7
|
+
'auth.passwordResetSuccess': '密码已重置,请用新密码登录。',
|
|
8
|
+
'auth.requestNewResetLink': '重新申请重置链接',
|
|
9
|
+
|
|
7
10
|
// The UI Library block of the `/device` page; the keys below may reword it.
|
|
8
11
|
...deviceApprovalZhCN,
|
|
9
12
|
// The UI Library block of the `/inbox` page; the keys below may reword it.
|
|
@@ -15,16 +18,16 @@ const systemZhCN: SystemResource = {
|
|
|
15
18
|
'inboxButton.pendingHint': '{{count}} 项等你处理,处理后减少。',
|
|
16
19
|
'inboxButton.unreadHint': '{{count}} 条未读,阅读后减少。',
|
|
17
20
|
'auth.welcome': '欢迎回来',
|
|
18
|
-
'auth.loginDescription': '
|
|
21
|
+
'auth.loginDescription': '使用用户名或邮箱和密码登录',
|
|
19
22
|
'auth.registerTitle': '创建账户',
|
|
20
|
-
'auth.registerDescription': '
|
|
23
|
+
'auth.registerDescription': '创建账户以开始使用',
|
|
21
24
|
'auth.forgotTitle': '忘记密码',
|
|
22
|
-
'auth.forgotDescription': '
|
|
25
|
+
'auth.forgotDescription': '输入邮箱,如果账户存在,我们将发送重置链接',
|
|
23
26
|
'auth.passwordResetUnavailable': '未开启自助找回,请联系管理员重置密码。',
|
|
24
27
|
'auth.capabilityLoadFailed': '无法检查自助找回是否已开启。',
|
|
25
28
|
'auth.retry': '重试',
|
|
26
29
|
'auth.loading': '加载中',
|
|
27
|
-
'auth.resetDescription': '
|
|
30
|
+
'auth.resetDescription': '为账户设置新密码',
|
|
28
31
|
'auth.resetTitle': '重置密码',
|
|
29
32
|
'auth.identifier': '用户名或邮箱',
|
|
30
33
|
'auth.password': '密码',
|
|
@@ -57,16 +60,16 @@ const systemZhCN: SystemResource = {
|
|
|
57
60
|
'auth.continueWith': '使用 {provider} 继续',
|
|
58
61
|
'auth.about': '关于此应用',
|
|
59
62
|
'auth.platform': 'AI 原生应用平台',
|
|
60
|
-
'auth.marketingTitleFirst': '让 AI
|
|
63
|
+
'auth.marketingTitleFirst': '让 AI 自由构建',
|
|
61
64
|
'auth.marketingTitleSecond': 'NocoBase 保障',
|
|
62
|
-
'auth.marketingTitleThird': '
|
|
65
|
+
'auth.marketingTitleThird': '可靠运行',
|
|
63
66
|
'auth.marketingDescription':
|
|
64
|
-
'让 AI 在灵活的前端框架上构建体验,由 NocoBase
|
|
67
|
+
'让 AI 在灵活的前端框架上构建体验,由 NocoBase 保障底层数据、权限与治理',
|
|
65
68
|
'auth.frontend': 'AI 原生前端',
|
|
66
|
-
'auth.frontendDescription': '
|
|
69
|
+
'auth.frontendDescription': '在灵活的框架上自由构建界面',
|
|
67
70
|
'auth.foundation': 'NocoBase 基础能力',
|
|
68
|
-
'auth.foundationDescription': '
|
|
69
|
-
'auth.marketingFooter': '
|
|
71
|
+
'auth.foundationDescription': '可靠的数据、访问控制与治理',
|
|
72
|
+
'auth.marketingFooter': '自由构建,可靠支撑',
|
|
70
73
|
'status.loading': '加载中',
|
|
71
74
|
'status.loadingPage': '正在加载页面',
|
|
72
75
|
'status.denied': '无权访问',
|
|
@@ -79,8 +82,8 @@ const systemZhCN: SystemResource = {
|
|
|
79
82
|
'status.routeFailedDescription':
|
|
80
83
|
'无法加载 {{packageName}} 的路由 {{label}}。',
|
|
81
84
|
shell: {
|
|
82
|
-
buildFreely: 'AI
|
|
83
|
-
reliability: '<brand>NocoBase</brand>
|
|
85
|
+
buildFreely: 'AI 自由构建',
|
|
86
|
+
reliability: '<brand>NocoBase</brand> 保障可靠',
|
|
84
87
|
},
|
|
85
88
|
home: {
|
|
86
89
|
platform: 'NocoBase · AI 原生应用平台',
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
.auth-brand-panel {
|
|
2
|
+
background: var(--auth-brand-background);
|
|
3
|
+
background-image: radial-gradient(
|
|
4
|
+
ellipse at top right,
|
|
5
|
+
color-mix(in srgb, var(--auth-brand-highlight) 8%, transparent),
|
|
6
|
+
transparent 65%
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Authentication surfaces have their own palette; application pages keep the selected theme. */
|
|
11
|
+
.dark .auth-page {
|
|
12
|
+
--background: #141923;
|
|
13
|
+
--foreground: #eef1f7;
|
|
14
|
+
--card: #1b2230;
|
|
15
|
+
--muted-foreground: #a4adbd;
|
|
16
|
+
--primary: #526bc7;
|
|
17
|
+
--primary-foreground: #ffffff;
|
|
18
|
+
--border: #30394b;
|
|
19
|
+
--input: #3b475c;
|
|
20
|
+
--ring: #8198ed;
|
|
21
|
+
--auth-brand-background: #192130;
|
|
22
|
+
--auth-brand-highlight: #a2b3ec;
|
|
23
|
+
--auth-brand-muted: #aab5c9;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.dark .auth-page > aside {
|
|
27
|
+
border-color: transparent;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.dark .auth-page [data-slot='input'],
|
|
31
|
+
.dark .auth-page [data-slot='input-group'] {
|
|
32
|
+
background-color: var(--card);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.dark .auth-page [data-slot='input-group'] input {
|
|
36
|
+
background-color: transparent;
|
|
37
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { Button } from '#components/ui/button';
|
|
2
|
+
import { AuthFormStatus } from '#extensions/nocobase-auth-forms/form-parts';
|
|
3
|
+
import { usePasswordResetCapability } from '@nocobase/app-plugin-authentication/client';
|
|
1
4
|
import { usePasswordReset } from '@nocobase/app-plugin-authentication/client/actions';
|
|
2
5
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
6
|
import type { ReactElement } from 'react';
|
|
@@ -10,6 +13,7 @@ import { AuthPage } from './shared.js';
|
|
|
10
13
|
export default function ResetPasswordPage(): ReactElement {
|
|
11
14
|
const { t } = useTranslation();
|
|
12
15
|
const reset = usePasswordReset();
|
|
16
|
+
const capability = usePasswordResetCapability();
|
|
13
17
|
const [searchParams] = useSearchParams();
|
|
14
18
|
// The link in the reset email carries the token; without one the form cannot do anything.
|
|
15
19
|
const token = searchParams.get('token') ?? '';
|
|
@@ -19,20 +23,59 @@ export default function ResetPasswordPage(): ReactElement {
|
|
|
19
23
|
description={t('auth.resetDescription')}
|
|
20
24
|
title={t('auth.resetTitle')}
|
|
21
25
|
>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
{capability.isPending ? (
|
|
27
|
+
<div
|
|
28
|
+
className='h-48 animate-pulse rounded-md bg-muted'
|
|
29
|
+
aria-label={t('auth.loading')}
|
|
30
|
+
/>
|
|
31
|
+
) : capability.isError ? (
|
|
32
|
+
<div className='space-y-4'>
|
|
33
|
+
<AuthFormStatus type='error'>
|
|
34
|
+
{t('auth.capabilityLoadFailed')}
|
|
35
|
+
</AuthFormStatus>
|
|
36
|
+
<Button variant='outline' onClick={() => void capability.refetch()}>
|
|
37
|
+
{t('auth.retry')}
|
|
38
|
+
</Button>
|
|
39
|
+
</div>
|
|
40
|
+
) : capability.data?.passwordResetAvailable !== true ? (
|
|
41
|
+
<div className='space-y-4'>
|
|
42
|
+
<AuthFormStatus type='error'>
|
|
43
|
+
{t('auth.passwordResetUnavailable')}
|
|
44
|
+
</AuthFormStatus>
|
|
45
|
+
<Link to='/login'>{t('auth.backToSignIn')}</Link>
|
|
46
|
+
</div>
|
|
47
|
+
) : reset.isSuccess ? (
|
|
48
|
+
<div className='space-y-4'>
|
|
49
|
+
<AuthFormStatus type='success'>
|
|
50
|
+
{t('auth.passwordResetSuccess')}
|
|
51
|
+
</AuthFormStatus>
|
|
52
|
+
<Link to='/login'>{t('auth.backToSignIn')}</Link>
|
|
53
|
+
</div>
|
|
54
|
+
) : (
|
|
55
|
+
<PasswordResetForm
|
|
56
|
+
disabled={!token}
|
|
57
|
+
error={token ? reset.error?.message : t('auth.invalidResetLink')}
|
|
58
|
+
footer={
|
|
59
|
+
<span className='flex flex-wrap justify-center gap-x-4 gap-y-2 [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary'>
|
|
60
|
+
<Link to='/login'>{t('auth.backToSignIn')}</Link>
|
|
61
|
+
{!token || reset.error ? (
|
|
62
|
+
<Link to='/forgot-password'>
|
|
63
|
+
{t('auth.requestNewResetLink')}
|
|
64
|
+
</Link>
|
|
65
|
+
) : null}
|
|
66
|
+
</span>
|
|
67
|
+
}
|
|
68
|
+
labels={{
|
|
69
|
+
confirmPassword: t('auth.confirmNewPassword'),
|
|
70
|
+
password: t('auth.newPassword'),
|
|
71
|
+
passwordMismatch: t('auth.passwordMismatch'),
|
|
72
|
+
submit: t('auth.resetTitle'),
|
|
73
|
+
submitting: t('auth.resetting'),
|
|
74
|
+
}}
|
|
75
|
+
onSubmit={({ password }) => reset.submit({ password, token })}
|
|
76
|
+
submitting={reset.isPending}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
36
79
|
</AuthPage>
|
|
37
80
|
);
|
|
38
81
|
}
|