@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.
Files changed (41) hide show
  1. package/.agents/rules/build-and-deploy.md +45 -0
  2. package/.agents/rules/components.md +34 -0
  3. package/.agents/rules/shell.md +31 -0
  4. package/.prettierignore +3 -1
  5. package/AGENTS.md +42 -366
  6. package/CLAUDE.md +0 -8
  7. package/client/components/page-header.tsx +1 -1
  8. package/client/components/status-badge.tsx +7 -1
  9. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  10. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  11. package/client/index.tsx +4 -0
  12. package/client/layouts/components/app-sidebar.tsx +5 -5
  13. package/client/layouts/components/layout-header.tsx +1 -1
  14. package/client/layouts/components/navigation-menu.tsx +223 -12
  15. package/client/layouts/components/sidebar-footer.tsx +1 -1
  16. package/client/layouts/components/user-menu.tsx +1 -1
  17. package/client/lib/navigation-transform.ts +8 -0
  18. package/client/lib/scrollbar.ts +88 -0
  19. package/client/locales/system/en-US.ts +4 -0
  20. package/client/locales/system/zh-CN.ts +15 -12
  21. package/client/pages/auth/auth-page.css +37 -0
  22. package/client/pages/auth/reset-password.tsx +57 -14
  23. package/client/pages/auth/shared.tsx +24 -24
  24. package/client/pages/home/index.tsx +2 -2
  25. package/client/routing/route-navigation.ts +10 -1
  26. package/client/styles.css +253 -17
  27. package/client/theme/theme-settings.tsx +12 -7
  28. package/client/theme/themes/compact.css +9 -14
  29. package/client/theme/themes/default.css +7 -12
  30. package/package.json +10 -9
  31. package/server/app.ts +2 -0
  32. package/server/config/notification.ts +4 -7
  33. package/tests/components/auth-i18n.test.tsx +57 -5
  34. package/tests/components/layout-i18n.test.tsx +2 -2
  35. package/tests/components/navigation-menu.test.tsx +133 -4
  36. package/tests/components/sidebar-permissions.test.tsx +5 -15
  37. package/tests/components/status-badge.test.tsx +22 -0
  38. package/tests/logic/agents-md.test.ts +108 -0
  39. package/tests/logic/scrollbar.test.ts +88 -0
  40. package/tests/logic/theme-tokens.test.ts +1 -0
  41. 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 { useRef, useState, type ReactElement } from 'react';
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 permission-filtered route tree as the sidebar's menu, drawn with shadcn's Sidebar primitives. Groups and parents
42
- * with a page are collapsible sub-menus. In the desktop icon mode the labels hide, every entry carries its label as a
43
- * tooltip, and an entry with children opens them in a popover.
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
- <nav aria-label={label}>
56
- <SidebarGroup>
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
- {items.map((item) => (
60
- <NavigationItem
61
- key={routeKey(item.route)}
62
- item={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
- </nav>
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={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/70'>
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/70 bg-background/60 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50'
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
- <PasswordResetForm
23
- disabled={!token}
24
- error={token ? reset.error?.message : t('auth.invalidResetLink')}
25
- footer={<Link to='/login'>{t('auth.backToSignIn')}</Link>}
26
- labels={{
27
- confirmPassword: t('auth.confirmNewPassword'),
28
- password: t('auth.newPassword'),
29
- passwordMismatch: t('auth.passwordMismatch'),
30
- submit: t('auth.resetTitle'),
31
- submitting: t('auth.resetting'),
32
- }}
33
- onSubmit={({ password }) => reset.submit({ password, token })}
34
- submitting={reset.isPending}
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
  }