@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.58

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 (249) hide show
  1. package/.cursor/mcp.json +8 -0
  2. package/.mcp.json +8 -0
  3. package/.prettierignore +20 -0
  4. package/.vscode/mcp.json +8 -0
  5. package/AGENTS.md +434 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +485 -0
  10. package/cli/plugins.ts +8 -0
  11. package/client/agent-annotations-host.ts +19 -0
  12. package/client/app.ts +38 -0
  13. package/client/components/back-button.tsx +49 -0
  14. package/client/components/breadcrumbs.tsx +167 -0
  15. package/client/components/inbox-badge.ts +49 -0
  16. package/client/components/inbox-button.tsx +132 -0
  17. package/client/components/inbox-header-button.tsx +25 -0
  18. package/client/components/loading.tsx +35 -0
  19. package/client/components/page-container.tsx +24 -0
  20. package/client/components/page-header.tsx +33 -0
  21. package/client/components/route-child-page.tsx +121 -0
  22. package/client/components/route-dialog.tsx +10 -0
  23. package/client/components/route-drawer.tsx +10 -0
  24. package/client/components/route-overlay.tsx +190 -0
  25. package/client/components/ui/alert.tsx +75 -0
  26. package/client/components/ui/badge.tsx +51 -0
  27. package/client/components/ui/breadcrumb.tsx +121 -0
  28. package/client/components/ui/button.tsx +58 -0
  29. package/client/components/ui/collapsible.tsx +19 -0
  30. package/client/components/ui/context-menu.tsx +271 -0
  31. package/client/components/ui/dialog.tsx +160 -0
  32. package/client/components/ui/dropdown-menu.tsx +270 -0
  33. package/client/components/ui/empty.tsx +103 -0
  34. package/client/components/ui/field.tsx +236 -0
  35. package/client/components/ui/input-group.tsx +158 -0
  36. package/client/components/ui/input.tsx +20 -0
  37. package/client/components/ui/kbd.tsx +26 -0
  38. package/client/components/ui/label.tsx +18 -0
  39. package/client/components/ui/popover.tsx +89 -0
  40. package/client/components/ui/select.tsx +200 -0
  41. package/client/components/ui/separator.tsx +22 -0
  42. package/client/components/ui/sheet.tsx +137 -0
  43. package/client/components/ui/sidebar.tsx +723 -0
  44. package/client/components/ui/skeleton.tsx +13 -0
  45. package/client/components/ui/spinner.tsx +20 -0
  46. package/client/components/ui/tabs.tsx +79 -0
  47. package/client/components/ui/textarea.tsx +17 -0
  48. package/client/components/ui/toast.tsx +231 -0
  49. package/client/components/ui/tooltip.tsx +63 -0
  50. package/client/components/use-route-overlay.ts +26 -0
  51. package/client/config/api.ts +13 -0
  52. package/client/config/app.ts +8 -0
  53. package/client/config/auth.ts +18 -0
  54. package/client/config/index.ts +17 -0
  55. package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
  56. package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
  57. package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
  58. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
  59. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
  60. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
  61. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
  62. package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
  63. package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
  64. package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
  65. package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
  66. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
  67. package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
  68. package/client/extensions/nocobase-file-component-ui/README.md +13 -0
  69. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
  70. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
  71. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
  72. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
  73. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
  74. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
  75. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
  76. package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
  77. package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
  78. package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
  79. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
  80. package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
  81. package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
  82. package/client/extensions/nocobase-inbox/frame.ts +31 -0
  83. package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
  84. package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
  85. package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
  86. package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
  87. package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
  88. package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
  89. package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
  90. package/client/extensions/nocobase-inbox/model.ts +428 -0
  91. package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
  92. package/client/extensions/nocobase-inbox/registry.ts +303 -0
  93. package/client/extensions/nocobase-inbox/source.ts +117 -0
  94. package/client/hooks/use-mobile.ts +21 -0
  95. package/client/index.tsx +60 -0
  96. package/client/layouts/app-layout.tsx +105 -0
  97. package/client/layouts/components/app-brand.tsx +49 -0
  98. package/client/layouts/components/app-sidebar.tsx +196 -0
  99. package/client/layouts/components/header-actions.tsx +50 -0
  100. package/client/layouts/components/language-switcher.tsx +95 -0
  101. package/client/layouts/components/layout-header.tsx +17 -0
  102. package/client/layouts/components/navigation-menu.tsx +411 -0
  103. package/client/layouts/components/sidebar-footer.tsx +107 -0
  104. package/client/layouts/components/surface-empty.tsx +35 -0
  105. package/client/layouts/components/user-menu.tsx +127 -0
  106. package/client/layouts/index.ts +2 -0
  107. package/client/layouts/settings-layout.tsx +172 -0
  108. package/client/layouts/use-sidebar-preference.ts +57 -0
  109. package/client/lib/constants.ts +5 -0
  110. package/client/lib/toaster.ts +56 -0
  111. package/client/lib/utils.ts +1 -0
  112. package/client/locales/en-US.ts +178 -0
  113. package/client/locales/index.ts +9 -0
  114. package/client/locales/zh-CN.ts +161 -0
  115. package/client/pages/auth/device.tsx +41 -0
  116. package/client/pages/auth/forgot-password.tsx +39 -0
  117. package/client/pages/auth/login.tsx +66 -0
  118. package/client/pages/auth/register.tsx +46 -0
  119. package/client/pages/auth/reset-password.tsx +38 -0
  120. package/client/pages/auth/shared.tsx +111 -0
  121. package/client/pages/home.tsx +16 -0
  122. package/client/pages/inbox.tsx +12 -0
  123. package/client/plugins.ts +37 -0
  124. package/client/react-providers.ts +27 -0
  125. package/client/route-overrides.ts +9 -0
  126. package/client/routes.ts +75 -0
  127. package/client/routing/app-router.tsx +127 -0
  128. package/client/routing/client-route.tsx +129 -0
  129. package/client/routing/route-context.tsx +85 -0
  130. package/client/routing/route-navigation.ts +153 -0
  131. package/client/routing/route-tree.tsx +42 -0
  132. package/client/routing/standalone-page-layout.tsx +15 -0
  133. package/client/runtime.ts +26 -0
  134. package/client/service-provider.ts +40 -0
  135. package/client/source-extensions.ts +19 -0
  136. package/client/startup.tsx +22 -0
  137. package/client/styles.css +122 -0
  138. package/client/theme/index.ts +4 -0
  139. package/client/theme/theme-context.ts +12 -0
  140. package/client/theme/theme-preferences.ts +84 -0
  141. package/client/theme/theme-presets.ts +7 -0
  142. package/client/theme/theme-provider.tsx +106 -0
  143. package/client/theme/theme-settings.tsx +176 -0
  144. package/client/theme/themes/compact.css +140 -0
  145. package/client/theme/themes/default.css +117 -0
  146. package/client/vite-env.d.ts +5 -0
  147. package/components.json +27 -0
  148. package/config.example.yml +156 -0
  149. package/database/main/migrations/.gitkeep +0 -0
  150. package/database/main/migrations/202610060001_create_device_code.ts +37 -0
  151. package/database/main/seeds/.gitkeep +0 -0
  152. package/database/tsconfig.json +4 -0
  153. package/ecosystem.config.js +35 -0
  154. package/eslint.config.js +24 -0
  155. package/index.html +100 -0
  156. package/package.json +198 -3
  157. package/playwright.config.ts +10 -0
  158. package/public/assets/favicon.ico +0 -0
  159. package/public/assets/logo-dark.png +0 -0
  160. package/public/assets/logo-mark-dark.png +0 -0
  161. package/public/assets/logo-mark.png +0 -0
  162. package/public/assets/logo.png +0 -0
  163. package/server/app.ts +50 -0
  164. package/server/config/api.ts +8 -0
  165. package/server/config/app.ts +40 -0
  166. package/server/config/auth.ts +36 -0
  167. package/server/config/authorization.ts +18 -0
  168. package/server/config/caching.ts +21 -0
  169. package/server/config/database.ts +22 -0
  170. package/server/config/drive.ts +30 -0
  171. package/server/config/i18n.ts +17 -0
  172. package/server/config/index.ts +67 -0
  173. package/server/config/jobs.ts +27 -0
  174. package/server/config/logging.ts +23 -0
  175. package/server/config/notification.ts +11 -0
  176. package/server/config/queue.ts +28 -0
  177. package/server/config/scheduler.ts +23 -0
  178. package/server/config/secrets.ts +10 -0
  179. package/server/config/server.ts +26 -0
  180. package/server/config/session.ts +60 -0
  181. package/server/config/snowflake.ts +21 -0
  182. package/server/config/spa.ts +24 -0
  183. package/server/config/users.ts +11 -0
  184. package/server/config/workflow.ts +24 -0
  185. package/server/config.ts +24 -0
  186. package/server/embedded.ts +23 -0
  187. package/server/locales/en-US.ts +10 -0
  188. package/server/locales/index.ts +10 -0
  189. package/server/locales/zh-CN.ts +5 -0
  190. package/server/plugins.ts +37 -0
  191. package/server/providers/index.ts +5 -0
  192. package/server/routes/index.ts +6 -0
  193. package/server/runtime.ts +24 -0
  194. package/server/standalone.ts +29 -0
  195. package/server/tsconfig.json +4 -0
  196. package/tailwind.config.mjs +59 -0
  197. package/tests/components/app-layout-routes.test.tsx +132 -0
  198. package/tests/components/app-title.test.tsx +57 -0
  199. package/tests/components/auth-i18n.test.tsx +87 -0
  200. package/tests/components/back-button.test.tsx +80 -0
  201. package/tests/components/file-preview.test.tsx +101 -0
  202. package/tests/components/header-hover.test.tsx +87 -0
  203. package/tests/components/inbox.test.tsx +139 -0
  204. package/tests/components/language-switcher.test.tsx +298 -0
  205. package/tests/components/layout-containers.test.tsx +15 -0
  206. package/tests/components/layout-i18n.test.tsx +187 -0
  207. package/tests/components/loading.test.tsx +25 -0
  208. package/tests/components/navigation-menu.test.tsx +355 -0
  209. package/tests/components/page-harness.test.tsx +318 -0
  210. package/tests/components/primitive-labels.test.tsx +80 -0
  211. package/tests/components/sidebar-permissions.test.tsx +110 -0
  212. package/tests/components/sidebar-preference.test.tsx +42 -0
  213. package/tests/fixtures/theme-tokens.html +12 -0
  214. package/tests/fixtures/theme-tokens.tsx +111 -0
  215. package/tests/logic/account-permissions.test.tsx +204 -0
  216. package/tests/logic/app-locale-coverage.test.ts +136 -0
  217. package/tests/logic/app-server.test.ts +1589 -0
  218. package/tests/logic/asset-url-build.test.ts +106 -0
  219. package/tests/logic/client-auth.test.tsx +122 -0
  220. package/tests/logic/client-plugin-registry.test.ts +63 -0
  221. package/tests/logic/client-route-recovery.test.tsx +80 -0
  222. package/tests/logic/client-routes.test.ts +126 -0
  223. package/tests/logic/client-runtime.test.ts +112 -0
  224. package/tests/logic/client-settings.test.tsx +841 -0
  225. package/tests/logic/client-shell.test.tsx +466 -0
  226. package/tests/logic/client-theme.test.tsx +441 -0
  227. package/tests/logic/config.test.ts +197 -0
  228. package/tests/logic/dev-client-watch.test.ts +108 -0
  229. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  230. package/tests/logic/device-code-migration.test.ts +70 -0
  231. package/tests/logic/lifecycle.test.ts +221 -0
  232. package/tests/logic/plugin-commands.test.ts +69 -0
  233. package/tests/logic/plugin-page-authz.test.ts +53 -0
  234. package/tests/logic/route-navigation.test.ts +66 -0
  235. package/tests/logic/route-overlay.test.tsx +353 -0
  236. package/tests/logic/server-imports.test.ts +88 -0
  237. package/tests/logic/tailwind-sources.test.ts +151 -0
  238. package/tests/logic/template-package-name.test.ts +88 -0
  239. package/tests/logic/theme-preferences.test.ts +86 -0
  240. package/tests/logic/theme-tokens.test.ts +248 -0
  241. package/tests/logic/toaster.test.tsx +108 -0
  242. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  243. package/tests/setup/client-config.ts +27 -0
  244. package/tsconfig.json +11 -0
  245. package/tsconfig.node.json +12 -0
  246. package/tsconfig.server.json +19 -0
  247. package/vite.config.ts +92 -0
  248. package/vitest.config.ts +44 -0
  249. package/README.md +0 -3
@@ -0,0 +1,170 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+
3
+ import { Button, buttonVariants } from '#components/ui/button';
4
+ import { FieldSeparator } from '#components/ui/field';
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '#components/ui/tabs';
6
+ import { cn } from 'cn';
7
+
8
+ export interface AuthMethod {
9
+ readonly id: string;
10
+ readonly label: ReactNode;
11
+ /** The method's form, rendered while its tab is selected. */
12
+ readonly content: ReactNode;
13
+ }
14
+
15
+ export interface AuthSsoProvider {
16
+ readonly id: string;
17
+ /** The provider's name. It labels the button, and names it when the group shows icons only. */
18
+ readonly label: string;
19
+ readonly icon?: ReactNode;
20
+ /** Renders a link instead of a button. */
21
+ readonly href?: string;
22
+ readonly onClick?: () => void;
23
+ readonly disabled?: boolean;
24
+ }
25
+
26
+ export interface AuthMethodsLabels {
27
+ /** Names the method tabs for assistive technology. */
28
+ readonly methods: string;
29
+ /** Separates the methods from the SSO providers. */
30
+ readonly separator: string;
31
+ /** The text of a provider's button; `{provider}` is replaced by its label. */
32
+ readonly continueWith: string;
33
+ }
34
+
35
+ const defaultAuthMethodsLabels: AuthMethodsLabels = {
36
+ methods: 'Sign-in methods',
37
+ separator: 'Or continue with',
38
+ continueWith: 'Continue with {provider}',
39
+ };
40
+
41
+ export interface AuthMethodsProps {
42
+ /** The sign-in methods. Tabs appear only when there is more than one. */
43
+ readonly methods?: readonly AuthMethod[];
44
+ /** Single sign-on providers, below the methods. */
45
+ readonly providers?: readonly AuthSsoProvider[];
46
+ /** The method selected at first; defaults to the first one. */
47
+ readonly defaultMethodId?: string;
48
+ readonly labels?: Partial<AuthMethodsLabels>;
49
+ readonly className?: string;
50
+ }
51
+
52
+ /** Every way to sign in on one page: method tabs, the selected method's form, and the SSO providers. */
53
+ export function AuthMethods({
54
+ className,
55
+ defaultMethodId,
56
+ labels: labelOverrides,
57
+ methods = [],
58
+ providers = [],
59
+ }: AuthMethodsProps): ReactElement {
60
+ const labels = { ...defaultAuthMethodsLabels, ...labelOverrides };
61
+ return (
62
+ <div className={cn('grid gap-6', className)}>
63
+ {methods.length > 1 ? (
64
+ <Tabs
65
+ className='gap-6'
66
+ defaultValue={defaultMethodId ?? methods[0]?.id}
67
+ >
68
+ <TabsList aria-label={labels.methods} className='w-full'>
69
+ {methods.map((method) => (
70
+ <TabsTrigger key={method.id} value={method.id}>
71
+ {method.label}
72
+ </TabsTrigger>
73
+ ))}
74
+ </TabsList>
75
+ {methods.map((method) => (
76
+ <TabsContent key={method.id} value={method.id}>
77
+ {method.content}
78
+ </TabsContent>
79
+ ))}
80
+ </Tabs>
81
+ ) : (
82
+ methods[0]?.content
83
+ )}
84
+ {providers.length > 0 ? (
85
+ <>
86
+ {methods.length > 0 ? (
87
+ <FieldSeparator>{labels.separator}</FieldSeparator>
88
+ ) : null}
89
+ <AuthSsoButtons
90
+ continueWith={labels.continueWith}
91
+ providers={providers}
92
+ />
93
+ </>
94
+ ) : null}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export interface AuthSsoButtonsProps {
100
+ readonly providers: readonly AuthSsoProvider[];
101
+ /** The text of a provider's button; `{provider}` is replaced by its label. */
102
+ readonly continueWith?: string;
103
+ }
104
+
105
+ /**
106
+ * One full-width outline button per provider. From three providers on, the group becomes a grid of icon buttons, each
107
+ * named by its provider; a provider without an icon keeps its text.
108
+ */
109
+ export function AuthSsoButtons({
110
+ continueWith = defaultAuthMethodsLabels.continueWith,
111
+ providers,
112
+ }: AuthSsoButtonsProps): ReactElement {
113
+ const compact = providers.length >= 3;
114
+ return (
115
+ <div
116
+ className={cn(
117
+ 'grid gap-3',
118
+ compact && 'grid-cols-[repeat(auto-fit,minmax(3rem,1fr))]',
119
+ )}
120
+ >
121
+ {providers.map((provider) => {
122
+ const text = continueWith.replace('{provider}', provider.label);
123
+ const iconOnly = compact && Boolean(provider.icon);
124
+ const content = (
125
+ <>
126
+ {provider.icon ? (
127
+ <span aria-hidden='true' className='contents'>
128
+ {provider.icon}
129
+ </span>
130
+ ) : null}
131
+ {iconOnly ? null : <span className='truncate'>{text}</span>}
132
+ </>
133
+ );
134
+ const name = iconOnly ? text : undefined;
135
+ // A provider that signs in by navigation is a link, styled as the button it sits beside.
136
+ if (provider.href && !provider.disabled) {
137
+ return (
138
+ <a
139
+ aria-label={name}
140
+ className={cn(
141
+ buttonVariants({ size: 'lg', variant: 'outline' }),
142
+ 'w-full',
143
+ )}
144
+ href={provider.href}
145
+ key={provider.id}
146
+ title={name}
147
+ >
148
+ {content}
149
+ </a>
150
+ );
151
+ }
152
+ return (
153
+ <Button
154
+ aria-label={name}
155
+ className='w-full'
156
+ disabled={provider.disabled}
157
+ key={provider.id}
158
+ onClick={provider.onClick}
159
+ size='lg'
160
+ title={name}
161
+ type='button'
162
+ variant='outline'
163
+ >
164
+ {content}
165
+ </Button>
166
+ );
167
+ })}
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,126 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+
3
+ import { cn } from 'cn';
4
+
5
+ export interface AuthSplitLayoutProps {
6
+ /** The application's mark, shown in a small tile. Pass an icon or an image sized to fill it. */
7
+ readonly logo?: ReactNode;
8
+ /** The application's name, beside the mark. */
9
+ readonly name?: ReactNode;
10
+ /** The page's heading, rendered as its `h1`. */
11
+ readonly title: ReactNode;
12
+ readonly description?: ReactNode;
13
+ /** The form, or `AuthMethods` with several. */
14
+ readonly children: ReactNode;
15
+ /** A short line at the bottom of the form column, such as terms or a copyright. */
16
+ readonly footer?: ReactNode;
17
+ /** What fills the right half from the `xl` breakpoint up, such as a product screenshot or a few highlights. */
18
+ readonly aside?: ReactNode;
19
+ /** The accessible name of the `aside` landmark. */
20
+ readonly asideLabel?: string;
21
+ readonly className?: string;
22
+ }
23
+
24
+ /** The page around an authentication form: the form in a column on the left, the application's `aside` on the right. */
25
+ export function AuthSplitLayout({
26
+ aside,
27
+ asideLabel,
28
+ children,
29
+ className,
30
+ description,
31
+ footer,
32
+ logo,
33
+ name,
34
+ title,
35
+ }: AuthSplitLayoutProps): ReactElement {
36
+ return (
37
+ <div
38
+ className={cn(
39
+ 'grid min-h-svh bg-background text-foreground',
40
+ aside ? 'xl:grid-cols-2' : null,
41
+ className,
42
+ )}
43
+ >
44
+ <div className='flex flex-col gap-6 p-6 md:p-10'>
45
+ <AuthBrand
46
+ className='justify-center md:justify-start'
47
+ logo={logo}
48
+ name={name}
49
+ />
50
+ <main className='flex flex-1 items-center justify-center'>
51
+ <div className='w-full max-w-sm'>
52
+ <header className='mb-8 grid gap-1.5'>
53
+ <h1 className='font-heading text-2xl font-semibold tracking-tight'>
54
+ {title}
55
+ </h1>
56
+ {description ? (
57
+ <p className='text-sm text-balance text-muted-foreground'>
58
+ {description}
59
+ </p>
60
+ ) : null}
61
+ </header>
62
+ {children}
63
+ </div>
64
+ </main>
65
+ {footer ? (
66
+ <div className='text-center text-xs text-muted-foreground md:text-left [&_a]:underline [&_a]:underline-offset-4 [&_a:hover]:text-foreground'>
67
+ {footer}
68
+ </div>
69
+ ) : null}
70
+ </div>
71
+ {aside ? (
72
+ <aside
73
+ aria-label={asideLabel}
74
+ className='relative isolate hidden overflow-hidden border-l bg-muted xl:block'
75
+ >
76
+ <DotPattern className='-z-10 [mask-image:linear-gradient(to_bottom,black,transparent)]' />
77
+ {aside}
78
+ </aside>
79
+ ) : null}
80
+ </div>
81
+ );
82
+ }
83
+
84
+ interface AuthBrandProps {
85
+ readonly logo?: ReactNode;
86
+ readonly name?: ReactNode;
87
+ readonly className?: string;
88
+ }
89
+
90
+ function AuthBrand({
91
+ className,
92
+ logo,
93
+ name,
94
+ }: AuthBrandProps): ReactElement | null {
95
+ if (!logo && !name) return null;
96
+ return (
97
+ <div className={cn('flex items-center gap-2.5', className)}>
98
+ {logo ? (
99
+ <span className='flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-background p-1.5 shadow-xs [&_img]:size-full [&_img]:object-contain [&_svg]:size-5'>
100
+ {logo}
101
+ </span>
102
+ ) : null}
103
+ {name ? (
104
+ <span className='font-heading text-base font-semibold tracking-tight'>
105
+ {name}
106
+ </span>
107
+ ) : null}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function DotPattern({
113
+ className,
114
+ }: {
115
+ readonly className?: string;
116
+ }): ReactElement {
117
+ return (
118
+ <div
119
+ aria-hidden='true'
120
+ className={cn(
121
+ 'pointer-events-none absolute inset-0 bg-[radial-gradient(color-mix(in_oklch,var(--foreground)_14%,transparent)_1px,transparent_1px)] [background-size:16px_16px]',
122
+ className,
123
+ )}
124
+ />
125
+ );
126
+ }
@@ -0,0 +1,277 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { CircleAlert, CircleCheck, CircleX, Clock } from 'lucide-react';
3
+ import {
4
+ useId,
5
+ useState,
6
+ type FormEvent,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ } from 'react';
10
+
11
+ import { Alert, AlertDescription } from '#components/ui/alert';
12
+ import { Button } from '#components/ui/button';
13
+ import { Field, FieldGroup, FieldLabel } from '#components/ui/field';
14
+ import { Input } from '#components/ui/input';
15
+ import { Spinner } from '#components/ui/spinner';
16
+ import { cn } from 'cn';
17
+
18
+ import {
19
+ formatUserCode,
20
+ useDeviceApproval,
21
+ type DeviceApprovalStatus,
22
+ } from './use-device-approval.js';
23
+
24
+ export interface DeviceApprovalProps {
25
+ /** The code from the address (`?user_code=`); without one the person types it. */
26
+ readonly userCode?: string | null;
27
+ /** Called with the code the person typed: put it in the address, so a reload keeps it. */
28
+ readonly onUserCodeChange: (userCode: string) => void;
29
+ readonly className?: string;
30
+ }
31
+
32
+ /**
33
+ * Approves or denies a device sign-in (RFC 8628) for the signed-in person: the code from the address, or one they
34
+ * type, checked against the server and shown so they can compare it with their device, then Approve or Deny. Render it
35
+ * on a page only signed-in people reach, inside the application's authentication layout.
36
+ */
37
+ export function DeviceApproval({
38
+ className,
39
+ onUserCodeChange,
40
+ userCode,
41
+ }: DeviceApprovalProps): ReactElement {
42
+ const { t } = useTranslation();
43
+ const approval = useDeviceApproval(userCode ?? undefined);
44
+ const { status } = approval;
45
+
46
+ if (status === 'idle')
47
+ return <CodeEntry className={className} onSubmit={onUserCodeChange} />;
48
+
49
+ const busy = status === 'approving' || status === 'denying';
50
+ const enterAnother = (
51
+ <Button onClick={() => onUserCodeChange('')} type='button' variant='link'>
52
+ {t('deviceApproval.otherCode', { defaultValue: 'Enter another code' })}
53
+ </Button>
54
+ );
55
+
56
+ return (
57
+ <div className={cn('flex flex-col gap-6', className)}>
58
+ {status === 'checking' ? (
59
+ <p className='flex items-center justify-center gap-2 text-sm text-muted-foreground'>
60
+ <Spinner aria-hidden='true' />
61
+ {t('deviceApproval.checking', {
62
+ defaultValue: 'Checking the code…',
63
+ })}
64
+ </p>
65
+ ) : (
66
+ <CodeDisplay clientId={approval.clientId} userCode={userCode ?? ''} />
67
+ )}
68
+ {status === 'ready' || busy ? (
69
+ <div className='flex flex-col gap-4'>
70
+ <p className='text-center text-sm text-muted-foreground'>
71
+ {t('deviceApproval.confirm', {
72
+ defaultValue:
73
+ 'Check that this code matches the one your device shows.',
74
+ })}
75
+ </p>
76
+ <div className='grid grid-cols-2 gap-2'>
77
+ <Button
78
+ disabled={busy}
79
+ onClick={() => void approval.deny()}
80
+ type='button'
81
+ variant='outline'
82
+ >
83
+ {status === 'denying'
84
+ ? t('deviceApproval.denying', { defaultValue: 'Denying…' })
85
+ : t('deviceApproval.deny', { defaultValue: 'Deny' })}
86
+ </Button>
87
+ <Button
88
+ disabled={busy}
89
+ onClick={() => void approval.approve()}
90
+ type='button'
91
+ >
92
+ {status === 'approving'
93
+ ? t('deviceApproval.approving', {
94
+ defaultValue: 'Approving…',
95
+ })
96
+ : t('deviceApproval.approve', { defaultValue: 'Approve' })}
97
+ </Button>
98
+ </div>
99
+ </div>
100
+ ) : null}
101
+ <Outcome onRetry={approval.retry} status={status} />
102
+ {status === 'invalid' ||
103
+ status === 'expired' ||
104
+ status === 'notYours' ||
105
+ status === 'error' ? (
106
+ <div className='flex justify-center'>{enterAnother}</div>
107
+ ) : null}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function CodeEntry({
113
+ className,
114
+ onSubmit,
115
+ }: {
116
+ readonly className?: string | undefined;
117
+ readonly onSubmit: (userCode: string) => void;
118
+ }): ReactElement {
119
+ const { t } = useTranslation();
120
+ const id = useId();
121
+ const [typed, setTyped] = useState('');
122
+ const submit = (event: FormEvent) => {
123
+ event.preventDefault();
124
+ const code = typed.trim();
125
+ if (code) onSubmit(formatUserCode(code));
126
+ };
127
+ return (
128
+ <form className={cn('flex flex-col gap-6', className)} onSubmit={submit}>
129
+ <FieldGroup>
130
+ <Field>
131
+ <FieldLabel htmlFor={id}>
132
+ {t('deviceApproval.codeLabel', { defaultValue: 'Code' })}
133
+ </FieldLabel>
134
+ <Input
135
+ autoCapitalize='characters'
136
+ autoComplete='one-time-code'
137
+ autoFocus
138
+ className='font-mono tracking-widest uppercase'
139
+ id={id}
140
+ onChange={(event) => setTyped(event.target.value)}
141
+ placeholder='ABCD-EFGH'
142
+ spellCheck={false}
143
+ value={typed}
144
+ />
145
+ </Field>
146
+ </FieldGroup>
147
+ <p className='text-sm text-muted-foreground'>
148
+ {t('deviceApproval.enterCode', {
149
+ defaultValue: 'Enter the code your device shows.',
150
+ })}
151
+ </p>
152
+ <Button disabled={typed.trim() === ''} type='submit'>
153
+ {t('deviceApproval.continue', { defaultValue: 'Continue' })}
154
+ </Button>
155
+ </form>
156
+ );
157
+ }
158
+
159
+ function CodeDisplay({
160
+ clientId,
161
+ userCode,
162
+ }: {
163
+ readonly clientId?: string | undefined;
164
+ readonly userCode: string;
165
+ }): ReactElement {
166
+ const { t } = useTranslation();
167
+ return (
168
+ <div className='flex flex-col items-center gap-1 rounded-lg border bg-muted p-4 text-center'>
169
+ <span className='text-xs text-muted-foreground'>
170
+ {t('deviceApproval.codeLabel', { defaultValue: 'Code' })}
171
+ </span>
172
+ <span
173
+ className='font-mono text-2xl font-semibold tracking-widest'
174
+ data-user-code
175
+ >
176
+ {formatUserCode(userCode)}
177
+ </span>
178
+ {clientId ? (
179
+ <span className='text-sm text-muted-foreground'>
180
+ {t('deviceApproval.client', {
181
+ client: clientId,
182
+ defaultValue: 'Requested by {{client}}',
183
+ })}
184
+ </span>
185
+ ) : null}
186
+ </div>
187
+ );
188
+ }
189
+
190
+ function Outcome({
191
+ onRetry,
192
+ status,
193
+ }: {
194
+ readonly onRetry: () => void;
195
+ readonly status: DeviceApprovalStatus;
196
+ }): ReactElement | null {
197
+ const { t } = useTranslation();
198
+ const message = (
199
+ icon: ReactNode,
200
+ text: string,
201
+ tone: 'default' | 'destructive',
202
+ action?: ReactNode,
203
+ ) => (
204
+ <Alert role={tone === 'destructive' ? 'alert' : 'status'} variant={tone}>
205
+ {icon}
206
+ <AlertDescription>
207
+ {text}
208
+ {action}
209
+ </AlertDescription>
210
+ </Alert>
211
+ );
212
+ switch (status) {
213
+ case 'approved':
214
+ return message(
215
+ <CircleCheck aria-hidden='true' />,
216
+ t('deviceApproval.approved', {
217
+ defaultValue:
218
+ 'Device approved. Return to your terminal to continue; you can close this page.',
219
+ }),
220
+ 'default',
221
+ );
222
+ case 'denied':
223
+ return message(
224
+ <CircleX aria-hidden='true' />,
225
+ t('deviceApproval.denied', {
226
+ defaultValue:
227
+ 'Request denied. The device was not signed in; you can close this page.',
228
+ }),
229
+ 'default',
230
+ );
231
+ case 'expired':
232
+ return message(
233
+ <Clock aria-hidden='true' />,
234
+ t('deviceApproval.expired', {
235
+ defaultValue:
236
+ 'This code has expired. Start the sign-in on your device again.',
237
+ }),
238
+ 'destructive',
239
+ );
240
+ case 'invalid':
241
+ return message(
242
+ <CircleAlert aria-hidden='true' />,
243
+ t('deviceApproval.invalid', {
244
+ defaultValue:
245
+ 'This code is not valid. Check it against your device and try again.',
246
+ }),
247
+ 'destructive',
248
+ );
249
+ case 'notYours':
250
+ return message(
251
+ <CircleAlert aria-hidden='true' />,
252
+ t('deviceApproval.notYours', {
253
+ defaultValue:
254
+ 'This code is already being approved by another account.',
255
+ }),
256
+ 'destructive',
257
+ );
258
+ case 'error':
259
+ return message(
260
+ <CircleAlert aria-hidden='true' />,
261
+ t('deviceApproval.error', {
262
+ defaultValue: 'Something went wrong. Try again.',
263
+ }),
264
+ 'destructive',
265
+ <Button
266
+ className='h-auto p-0'
267
+ onClick={onRetry}
268
+ type='button'
269
+ variant='link'
270
+ >
271
+ {t('deviceApproval.retry', { defaultValue: 'Try again' })}
272
+ </Button>,
273
+ );
274
+ default:
275
+ return null;
276
+ }
277
+ }
@@ -0,0 +1,55 @@
1
+ /** Every key the device approval looks up, so each locale is checked against the same list. */
2
+ export interface DeviceApprovalLocale {
3
+ readonly 'deviceApproval.title': string;
4
+ readonly 'deviceApproval.description': string;
5
+ readonly 'deviceApproval.enterCode': string;
6
+ readonly 'deviceApproval.codeLabel': string;
7
+ readonly 'deviceApproval.continue': string;
8
+ readonly 'deviceApproval.checking': string;
9
+ readonly 'deviceApproval.confirm': string;
10
+ readonly 'deviceApproval.client': string;
11
+ readonly 'deviceApproval.approve': string;
12
+ readonly 'deviceApproval.approving': string;
13
+ readonly 'deviceApproval.deny': string;
14
+ readonly 'deviceApproval.denying': string;
15
+ readonly 'deviceApproval.approved': string;
16
+ readonly 'deviceApproval.denied': string;
17
+ readonly 'deviceApproval.expired': string;
18
+ readonly 'deviceApproval.invalid': string;
19
+ readonly 'deviceApproval.notYours': string;
20
+ readonly 'deviceApproval.error': string;
21
+ readonly 'deviceApproval.retry': string;
22
+ readonly 'deviceApproval.otherCode': string;
23
+ }
24
+
25
+ const enUS: DeviceApprovalLocale = {
26
+ 'deviceApproval.title': 'Authorize a device',
27
+ 'deviceApproval.description':
28
+ 'A command line or another device asked to sign in as you. Approve it only if you started the sign-in yourself.',
29
+ 'deviceApproval.enterCode': 'Enter the code your device shows.',
30
+ 'deviceApproval.codeLabel': 'Code',
31
+ 'deviceApproval.continue': 'Continue',
32
+ 'deviceApproval.checking': 'Checking the code…',
33
+ 'deviceApproval.confirm':
34
+ 'Check that this code matches the one your device shows.',
35
+ 'deviceApproval.client': 'Requested by {{client}}',
36
+ 'deviceApproval.approve': 'Approve',
37
+ 'deviceApproval.approving': 'Approving…',
38
+ 'deviceApproval.deny': 'Deny',
39
+ 'deviceApproval.denying': 'Denying…',
40
+ 'deviceApproval.approved':
41
+ 'Device approved. Return to your terminal to continue; you can close this page.',
42
+ 'deviceApproval.denied':
43
+ 'Request denied. The device was not signed in; you can close this page.',
44
+ 'deviceApproval.expired':
45
+ 'This code has expired. Start the sign-in on your device again.',
46
+ 'deviceApproval.invalid':
47
+ 'This code is not valid. Check it against your device and try again.',
48
+ 'deviceApproval.notYours':
49
+ 'This code is already being approved by another account.',
50
+ 'deviceApproval.error': 'Something went wrong. Try again.',
51
+ 'deviceApproval.retry': 'Try again',
52
+ 'deviceApproval.otherCode': 'Enter another code',
53
+ };
54
+
55
+ export default enUS;
@@ -0,0 +1,27 @@
1
+ import type { DeviceApprovalLocale } from './en-US.js';
2
+
3
+ const zhCN: DeviceApprovalLocale = {
4
+ 'deviceApproval.title': '授权设备',
5
+ 'deviceApproval.description':
6
+ '一个命令行或其他设备请求以你的身份登录。只有在登录是你自己发起的时候才批准。',
7
+ 'deviceApproval.enterCode': '输入设备上显示的代码。',
8
+ 'deviceApproval.codeLabel': '代码',
9
+ 'deviceApproval.continue': '继续',
10
+ 'deviceApproval.checking': '正在检查代码…',
11
+ 'deviceApproval.confirm': '确认此代码与设备上显示的一致。',
12
+ 'deviceApproval.client': '由 {{client}} 发起',
13
+ 'deviceApproval.approve': '批准',
14
+ 'deviceApproval.approving': '正在批准…',
15
+ 'deviceApproval.deny': '拒绝',
16
+ 'deviceApproval.denying': '正在拒绝…',
17
+ 'deviceApproval.approved': '设备已批准。回到终端继续操作,可以关闭此页面。',
18
+ 'deviceApproval.denied': '已拒绝请求,设备没有登录。可以关闭此页面。',
19
+ 'deviceApproval.expired': '此代码已过期。请在设备上重新发起登录。',
20
+ 'deviceApproval.invalid': '此代码无效。请对照设备上的代码重试。',
21
+ 'deviceApproval.notYours': '此代码已由另一个账号处理。',
22
+ 'deviceApproval.error': '出了点问题,请重试。',
23
+ 'deviceApproval.retry': '重试',
24
+ 'deviceApproval.otherCode': '输入其他代码',
25
+ };
26
+
27
+ export default zhCN;