handoff-app 2.0.0-9 → 2.0.0

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 (303) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +813 -406
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/cli/create.js +1 -2
  55. package/dist/commands/build/index.d.ts +1 -1
  56. package/dist/commands/build/index.js +1 -1
  57. package/dist/commands/utils.d.ts +3 -3
  58. package/dist/config/defaults.js +7 -4
  59. package/dist/config/from-env.d.ts +4 -0
  60. package/dist/config/from-env.js +38 -1
  61. package/dist/config/helpers.js +2 -2
  62. package/dist/config/index.d.ts +0 -1
  63. package/dist/config/index.js +1 -4
  64. package/dist/config/loader.js +26 -2
  65. package/dist/config/resolve-env.d.ts +2 -1
  66. package/dist/config/resolve-env.js +28 -12
  67. package/dist/config/runtime.js +1 -1
  68. package/dist/index.d.ts +12 -7
  69. package/dist/index.js +18 -22
  70. package/dist/nav/index.d.ts +2 -2
  71. package/dist/nav/registry.d.ts +3 -3
  72. package/dist/nav/registry.js +1 -1
  73. package/dist/nav/workspace.d.ts +3 -3
  74. package/dist/nav/workspace.js +1 -1
  75. package/dist/pipeline/figma.d.ts +13 -2
  76. package/dist/pipeline/figma.js +86 -46
  77. package/dist/pipeline/styles.js +1 -1
  78. package/dist/pipeline/validation.d.ts +1 -1
  79. package/dist/pipeline/validation.js +3 -3
  80. package/dist/providers.d.ts +10 -0
  81. package/dist/providers.js +13 -0
  82. package/dist/registry/asset-storage/define.d.ts +5 -5
  83. package/dist/registry/asset-storage/define.js +4 -4
  84. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  85. package/dist/registry/asset-storage/resolve.js +33 -21
  86. package/dist/registry/asset-storage/types.d.ts +37 -17
  87. package/dist/registry/asset-storage/types.js +5 -5
  88. package/dist/registry/auth/ai-keys.d.ts +32 -0
  89. package/dist/registry/auth/ai-keys.js +69 -0
  90. package/dist/registry/auth/crypto.d.ts +17 -0
  91. package/dist/registry/auth/crypto.js +61 -1
  92. package/dist/registry/auth/index.d.ts +2 -1
  93. package/dist/registry/auth/index.js +11 -2
  94. package/dist/registry/checkout/index.d.ts +2 -1
  95. package/dist/registry/checkout/index.js +57 -127
  96. package/dist/registry/client.js +53 -17
  97. package/dist/registry/db/driver.js +3 -3
  98. package/dist/registry/db/schema.d.ts +261 -3
  99. package/dist/registry/db/schema.js +29 -4
  100. package/dist/registry/email/define.d.ts +10 -0
  101. package/dist/registry/email/define.js +13 -0
  102. package/dist/registry/email/providers/smtp.d.ts +10 -0
  103. package/dist/registry/email/providers/smtp.js +73 -0
  104. package/dist/registry/email/resolve.d.ts +28 -0
  105. package/dist/registry/email/resolve.js +74 -0
  106. package/dist/registry/email/types.d.ts +24 -0
  107. package/dist/registry/email/types.js +6 -0
  108. package/dist/registry/path.d.ts +2 -0
  109. package/dist/registry/path.js +11 -1
  110. package/dist/registry/publish/package.js +8 -0
  111. package/dist/registry/transfer.d.ts +4 -0
  112. package/dist/store/registry.d.ts +3 -3
  113. package/dist/store/registry.js +4 -4
  114. package/dist/transformers/preview/component/builder.d.ts +1 -9
  115. package/dist/transformers/preview/component/builder.js +17 -17
  116. package/dist/transformers/preview/component/css.d.ts +2 -1
  117. package/dist/transformers/preview/component/css.js +8 -5
  118. package/dist/transformers/preview/component/html.d.ts +3 -1
  119. package/dist/transformers/preview/component/html.js +6 -2
  120. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  121. package/dist/transformers/preview/component/javascript.js +10 -4
  122. package/dist/transformers/utils/build.d.ts +5 -0
  123. package/dist/transformers/utils/build.js +21 -4
  124. package/dist/types/config.d.ts +129 -18
  125. package/dist/utils/path.d.ts +1 -5
  126. package/dist/utils/path.js +6 -23
  127. package/docs/api.md +6 -4
  128. package/docs/cli.md +275 -54
  129. package/drizzle/0007_add_ai_keys.sql +11 -0
  130. package/drizzle/0008_add_source_dir.sql +2 -0
  131. package/drizzle/meta/0007_snapshot.json +2013 -0
  132. package/drizzle/meta/0008_snapshot.json +2025 -0
  133. package/drizzle/meta/_journal.json +14 -0
  134. package/package.json +19 -8
  135. package/src/ai/README.md +42 -0
  136. package/src/ai/connections.ts +189 -0
  137. package/src/ai/define.ts +15 -0
  138. package/src/ai/types.ts +30 -0
  139. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  140. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  141. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  142. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  143. package/src/app/components/AiAssistant/dockState.ts +86 -0
  144. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  145. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  146. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  147. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  148. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  149. package/src/app/components/Auth/AuthShell.tsx +27 -33
  150. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  151. package/src/app/components/Auth/installStatus.ts +29 -0
  152. package/src/app/components/Component/Preview.tsx +5 -2
  153. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  154. package/src/app/components/Layout/Header.tsx +21 -15
  155. package/src/app/components/Layout/Main.tsx +2 -2
  156. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  157. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  158. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  159. package/src/app/components/Navigation/SideNav.tsx +1 -15
  160. package/src/app/components/context/NavProvider.tsx +7 -3
  161. package/src/app/components/ui/attachment.tsx +50 -0
  162. package/src/app/components/ui/bubble.tsx +57 -0
  163. package/src/app/components/ui/button.tsx +3 -1
  164. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  165. package/src/app/components/ui/dialog.tsx +7 -2
  166. package/src/app/components/ui/marker.tsx +56 -0
  167. package/src/app/components/ui/sidebar.tsx +3 -1
  168. package/src/app/components/util/index.ts +3 -12
  169. package/src/app/css/index.css +64 -0
  170. package/src/app/lib/ai/auth.ts +33 -0
  171. package/src/app/lib/ai/context.ts +99 -0
  172. package/src/app/lib/ai/model.ts +79 -0
  173. package/src/app/lib/ai/prompt.ts +36 -0
  174. package/src/app/lib/ai/resolve.ts +97 -0
  175. package/src/app/lib/ai/tools.ts +120 -0
  176. package/src/app/lib/api/methods.ts +13 -0
  177. package/src/app/lib/asset-storage.ts +35 -36
  178. package/src/app/lib/auth/api.ts +4 -12
  179. package/src/app/lib/auth/email.ts +96 -23
  180. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  181. package/src/app/lib/docs-api/registry.ts +2 -3
  182. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  183. package/src/app/lib/mcp/components.ts +523 -0
  184. package/src/app/lib/mcp/page-url.ts +24 -0
  185. package/src/app/lib/mcp/pages.ts +84 -0
  186. package/src/app/lib/mcp/result.ts +33 -0
  187. package/src/app/lib/mcp/server.ts +39 -318
  188. package/src/app/lib/mcp/tokens.ts +334 -0
  189. package/src/app/lib/mcp/tool.ts +19 -0
  190. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  191. package/src/app/lib/registry-api/errors.ts +2 -0
  192. package/src/app/lib/registry-api/handler.ts +7 -1
  193. package/src/app/lib/registry-api/transfer.ts +28 -6
  194. package/src/app/lib/server-module.ts +34 -0
  195. package/src/app/lib/utils.ts +20 -0
  196. package/src/app/next.config.mjs +26 -24
  197. package/src/app/pages/[...slug]/index.tsx +2 -1
  198. package/src/app/pages/_app.tsx +6 -2
  199. package/src/app/pages/_document.tsx +6 -0
  200. package/src/app/pages/account/ai.tsx +183 -0
  201. package/src/app/pages/account/index.tsx +68 -68
  202. package/src/app/pages/account/tokens.tsx +37 -7
  203. package/src/app/pages/account/users.tsx +75 -15
  204. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  205. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  206. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  207. package/src/app/pages/api/admin/users/index.ts +2 -2
  208. package/src/app/pages/api/ai/chat.ts +115 -0
  209. package/src/app/pages/api/ai/connections.ts +23 -0
  210. package/src/app/pages/api/auth/request-reset.ts +14 -16
  211. package/src/app/pages/api/docs/nav.json.ts +1 -1
  212. package/src/app/pages/api/install.ts +3 -3
  213. package/src/app/pages/api/mcp.ts +6 -0
  214. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  215. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  216. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  217. package/src/app/pages/cli/device.tsx +10 -5
  218. package/src/app/pages/foundations/colors.tsx +9 -8
  219. package/src/app/pages/foundations/index.tsx +3 -3
  220. package/src/app/pages/index.tsx +2 -2
  221. package/src/app/pages/install.tsx +7 -2
  222. package/src/app/pages/login.tsx +32 -27
  223. package/src/app/pages/reset-password.tsx +21 -20
  224. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  225. package/src/app/pages/system/index.tsx +11 -11
  226. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  227. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  228. package/src/app-builder/README.md +2 -1
  229. package/src/app-builder/build.ts +218 -73
  230. package/src/app-builder/client-config.ts +3 -2
  231. package/src/app-builder/paths.ts +3 -22
  232. package/src/app-builder/static-export.ts +1 -1
  233. package/src/app-builder/vercel-output.ts +5 -4
  234. package/src/cache/README.md +4 -1
  235. package/src/cache/build-cache.ts +128 -43
  236. package/src/cache/build-inputs.ts +28 -0
  237. package/src/cache/index.ts +4 -0
  238. package/src/cli/create.ts +1 -2
  239. package/src/commands/build/index.ts +1 -1
  240. package/src/config/README.md +10 -9
  241. package/src/config/defaults.ts +7 -4
  242. package/src/config/from-env.ts +33 -0
  243. package/src/config/helpers.ts +1 -3
  244. package/src/config/index.ts +0 -3
  245. package/src/config/loader.ts +25 -3
  246. package/src/config/resolve-env.ts +28 -15
  247. package/src/config/runtime.ts +1 -1
  248. package/src/index.ts +33 -29
  249. package/src/nav/index.ts +2 -2
  250. package/src/nav/registry.ts +3 -3
  251. package/src/nav/workspace.ts +3 -3
  252. package/src/pipeline/README.md +1 -1
  253. package/src/pipeline/figma.ts +98 -49
  254. package/src/pipeline/styles.ts +1 -1
  255. package/src/pipeline/validation.ts +3 -3
  256. package/src/providers.ts +18 -0
  257. package/src/registry/asset-storage/define.ts +6 -6
  258. package/src/registry/asset-storage/resolve.ts +38 -37
  259. package/src/registry/asset-storage/types.ts +39 -17
  260. package/src/registry/auth/ai-keys.ts +62 -0
  261. package/src/registry/auth/crypto.ts +63 -1
  262. package/src/registry/auth/index.ts +11 -1
  263. package/src/registry/checkout/index.ts +60 -138
  264. package/src/registry/client.ts +60 -20
  265. package/src/registry/db/driver.ts +2 -2
  266. package/src/registry/db/schema.ts +49 -22
  267. package/src/registry/email/define.ts +15 -0
  268. package/src/registry/email/providers/smtp.ts +39 -0
  269. package/src/registry/email/resolve.ts +94 -0
  270. package/src/registry/email/types.ts +28 -0
  271. package/src/registry/path.ts +11 -0
  272. package/src/registry/publish/package.ts +9 -0
  273. package/src/registry/transfer.ts +4 -0
  274. package/src/store/registry.ts +7 -7
  275. package/src/transformers/preview/component/builder.ts +19 -25
  276. package/src/transformers/preview/component/css.ts +9 -4
  277. package/src/transformers/preview/component/html.ts +6 -2
  278. package/src/transformers/preview/component/javascript.ts +14 -4
  279. package/src/transformers/utils/build.ts +16 -3
  280. package/src/types/config.ts +142 -20
  281. package/src/utils/README.md +1 -1
  282. package/src/utils/path.ts +6 -29
  283. package/templates/default/handoff.config.js.tpl +10 -2
  284. package/templates/default/handoff.config.ts.tpl +10 -2
  285. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  286. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  287. package/templates/default/package.json.tpl +2 -2
  288. package/.editorconfig +0 -19
  289. package/.eslintrc.json +0 -4
  290. package/.prettierignore +0 -0
  291. package/.prettierrc.json +0 -8
  292. package/.vscode/settings.json +0 -17
  293. package/dist/config/validator.d.ts +0 -9
  294. package/dist/config/validator.js +0 -24
  295. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  296. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  297. package/src/app/components/old/Header.tsx +0 -89
  298. package/src/app/lib/mcp/shape.ts +0 -596
  299. package/src/app/pages/assets/fonts.tsx +0 -129
  300. package/src/app/pages/assets/index.tsx +0 -91
  301. package/src/app/pages/assets/logos.tsx +0 -75
  302. package/src/config/validator.ts +0 -22
  303. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -1,9 +1,13 @@
1
+ import type { ClientConfig } from '@handoff/types/config';
2
+ import type { GetStaticProps } from 'next';
3
+ import { getClientRuntimeConfig } from '../../components/util';
1
4
  import { AccountLayout } from '../../components/Auth/AccountLayout';
2
5
  import { authApiUrl, readApiError } from '../../components/Auth/api';
3
6
  import { Alert, AlertDescription, AlertTitle } from '../../components/ui/alert';
4
7
  import { Badge } from '../../components/ui/badge';
5
8
  import { Button } from '../../components/ui/button';
6
9
  import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
10
+ import { ConfirmDialog } from '../../components/ui/confirm-dialog';
7
11
  import { Input } from '../../components/ui/input';
8
12
  import { Label } from '../../components/ui/label';
9
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
@@ -29,15 +33,17 @@ const formatDate = (value?: string | null) => {
29
33
  return Number.isNaN(date.valueOf()) ? value : date.toLocaleDateString();
30
34
  };
31
35
 
32
- export default function TokensPage() {
36
+ export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
37
+
38
+ export default function TokensPage({ config }: { config: ClientConfig }) {
33
39
  if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
34
- return <AccountLayout title="Access tokens">{null}</AccountLayout>;
40
+ return <AccountLayout config={config} title="Access tokens">{null}</AccountLayout>;
35
41
  }
36
42
 
37
- return <RegistryTokensPage />;
43
+ return <RegistryTokensPage config={config} />;
38
44
  }
39
45
 
40
- function RegistryTokensPage() {
46
+ function RegistryTokensPage({ config }: { config: ClientConfig }) {
41
47
  const { data: session } = useSession();
42
48
  const role = (session?.user as { role?: string } | undefined)?.role;
43
49
  const [tokens, setTokens] = useState<AccessToken[]>([]);
@@ -46,6 +52,7 @@ function RegistryTokensPage() {
46
52
  const [copied, setCopied] = useState(false);
47
53
  const [pending, setPending] = useState(false);
48
54
  const [error, setError] = useState<string | null>(null);
55
+ const [tokenToRevoke, setTokenToRevoke] = useState<AccessToken | null>(null);
49
56
 
50
57
  const load = useCallback(async () => {
51
58
  const response = await fetch(authApiUrl('/api/account/tokens'), { credentials: 'include', cache: 'no-store' });
@@ -89,7 +96,6 @@ function RegistryTokensPage() {
89
96
  };
90
97
 
91
98
  const revoke = async (token: AccessToken) => {
92
- if (!window.confirm(`Revoke “${token.name}”? Applications using it will immediately lose access.`)) return;
93
99
  setPending(true);
94
100
  setError(null);
95
101
  try {
@@ -115,7 +121,11 @@ function RegistryTokensPage() {
115
121
  };
116
122
 
117
123
  return (
118
- <AccountLayout title="Access tokens">
124
+ <AccountLayout
125
+ config={config}
126
+ title="Access tokens"
127
+ description="Create and revoke credentials for the CLI, CI jobs, and integrations."
128
+ >
119
129
  <div className="space-y-6">
120
130
  <Card>
121
131
  <CardHeader>
@@ -209,7 +219,12 @@ function RegistryTokensPage() {
209
219
  <TableCell>{formatDate(token.lastUsedAt)}</TableCell>
210
220
  <TableCell>{formatDate(token.expiresAt)}</TableCell>
211
221
  <TableCell className="text-right">
212
- <Button size="sm" variant="ghost" disabled={pending || Boolean(token.revokedAt)} onClick={() => void revoke(token)}>
222
+ <Button
223
+ size="sm"
224
+ variant="ghost"
225
+ disabled={pending || Boolean(token.revokedAt)}
226
+ onClick={() => setTokenToRevoke(token)}
227
+ >
213
228
  <Trash2 /> Revoke
214
229
  </Button>
215
230
  </TableCell>
@@ -227,6 +242,21 @@ function RegistryTokensPage() {
227
242
  </CardContent>
228
243
  </Card>
229
244
  </div>
245
+
246
+ {tokenToRevoke ? (
247
+ <ConfirmDialog
248
+ open
249
+ onOpenChange={(open) => {
250
+ if (!open) setTokenToRevoke(null);
251
+ }}
252
+ title={`Revoke “${tokenToRevoke.name}”?`}
253
+ description="Applications that use this token lose access immediately."
254
+ confirmLabel="Revoke"
255
+ pendingLabel="Revoking…"
256
+ variant="destructive"
257
+ onConfirm={() => revoke(tokenToRevoke)}
258
+ />
259
+ ) : null}
230
260
  </AccountLayout>
231
261
  );
232
262
  }
@@ -1,14 +1,18 @@
1
+ import type { ClientConfig } from '@handoff/types/config';
2
+ import type { GetStaticProps } from 'next';
3
+ import { getClientRuntimeConfig } from '../../components/util';
1
4
  import { AccountLayout } from '../../components/Auth/AccountLayout';
2
5
  import { authApiUrl, readApiError } from '../../components/Auth/api';
3
6
  import { Alert, AlertDescription, AlertTitle } from '../../components/ui/alert';
4
7
  import { Badge } from '../../components/ui/badge';
5
8
  import { Button } from '../../components/ui/button';
6
9
  import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
10
+ import { ConfirmDialog, type ConfirmDialogProps } from '../../components/ui/confirm-dialog';
7
11
  import { Input } from '../../components/ui/input';
8
12
  import { Label } from '../../components/ui/label';
9
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
10
14
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
11
- import { Check, Copy, MailPlus, RefreshCw } from 'lucide-react';
15
+ import { Check, Copy, KeyRound, MailPlus, RefreshCw } from 'lucide-react';
12
16
  import { useSession } from 'next-auth/react';
13
17
  import { useRouter } from 'next/router';
14
18
  import { FormEvent, useCallback, useEffect, useState } from 'react';
@@ -29,15 +33,17 @@ interface MutationResult {
29
33
  message?: string;
30
34
  }
31
35
 
32
- export default function UsersPage() {
36
+ export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
37
+
38
+ export default function UsersPage({ config }: { config: ClientConfig }) {
33
39
  if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
34
- return <AccountLayout title="Users">{null}</AccountLayout>;
40
+ return <AccountLayout config={config} title="Users">{null}</AccountLayout>;
35
41
  }
36
42
 
37
- return <RegistryUsersPage />;
43
+ return <RegistryUsersPage config={config} />;
38
44
  }
39
45
 
40
- function RegistryUsersPage() {
46
+ function RegistryUsersPage({ config }: { config: ClientConfig }) {
41
47
  const router = useRouter();
42
48
  const { data: session, status: sessionStatus } = useSession();
43
49
  const currentUser = session?.user as (typeof session.user & { id?: string; role?: string }) | undefined;
@@ -48,6 +54,8 @@ function RegistryUsersPage() {
48
54
  const [success, setSuccess] = useState<string | null>(null);
49
55
  const [manualLink, setManualLink] = useState<string | null>(null);
50
56
  const [copied, setCopied] = useState(false);
57
+ const [emailConfigured, setEmailConfigured] = useState<boolean | null>(null);
58
+ const [confirmation, setConfirmation] = useState<Omit<ConfirmDialogProps, 'open' | 'onOpenChange'> | null>(null);
51
59
 
52
60
  useEffect(() => {
53
61
  if (sessionStatus === 'authenticated' && currentUser?.role !== 'admin') void router.replace('/account');
@@ -56,8 +64,9 @@ function RegistryUsersPage() {
56
64
  const load = useCallback(async () => {
57
65
  const response = await fetch(authApiUrl('/api/admin/users'), { credentials: 'include', cache: 'no-store' });
58
66
  if (!response.ok) throw new Error(await readApiError(response, 'Could not load users.'));
59
- const body = (await response.json()) as RegistryUser[] | { users: RegistryUser[] };
67
+ const body = (await response.json()) as RegistryUser[] | { users: RegistryUser[]; emailConfigured?: boolean };
60
68
  const rows = Array.isArray(body) ? body : body.users;
69
+ if (!Array.isArray(body)) setEmailConfigured(body.emailConfigured ?? null);
61
70
  setUsers(
62
71
  rows.map((user) => ({
63
72
  ...user,
@@ -112,7 +121,12 @@ function RegistryUsersPage() {
112
121
  }
113
122
  };
114
123
 
115
- const mutate = async (user: RegistryUser, action: 'resend' | 'role' | 'status', body: Record<string, string>, successMessage: string) => {
124
+ const mutate = async (
125
+ user: RegistryUser,
126
+ action: 'resend' | 'reset' | 'role' | 'status',
127
+ body: Record<string, string>,
128
+ successMessage: string
129
+ ) => {
116
130
  setPending(true);
117
131
  setError(null);
118
132
  setSuccess(null);
@@ -146,13 +160,17 @@ function RegistryUsersPage() {
146
160
  };
147
161
 
148
162
  return (
149
- <AccountLayout title="Users">
163
+ <AccountLayout config={config} title="Users" description="Invite people and manage their access to this registry.">
150
164
  <div className="space-y-6">
151
165
  <Card>
152
166
  <CardHeader>
153
167
  <CardTitle>Invite a user</CardTitle>
154
168
  <CardDescription>
155
- Email delivery is used when configured. Otherwise, the activation link appears once for manual delivery.
169
+ {emailConfigured === false
170
+ ? 'Email delivery is off. Activation and reset links appear once here for you to deliver.'
171
+ : emailConfigured
172
+ ? 'Invitations and reset links are sent by email.'
173
+ : null}
156
174
  </CardDescription>
157
175
  </CardHeader>
158
176
  <form onSubmit={invite}>
@@ -170,12 +188,12 @@ function RegistryUsersPage() {
170
188
  {manualLink ? (
171
189
  <Alert variant="warning">
172
190
  <MailPlus />
173
- <AlertTitle>Deliver this activation link securely</AlertTitle>
191
+ <AlertTitle>Deliver this link securely</AlertTitle>
174
192
  <AlertDescription>
175
193
  <p className="mb-3">It is shown only in this response.</p>
176
194
  <div className="flex gap-2">
177
195
  <Input value={manualLink} readOnly className="text-xs" />
178
- <Button type="button" size="icon" variant="outline" onClick={() => void copyLink()} aria-label="Copy activation link">
196
+ <Button type="button" size="icon" variant="outline" onClick={() => void copyLink()} aria-label="Copy link">
179
197
  {copied ? <Check /> : <Copy />}
180
198
  </Button>
181
199
  </div>
@@ -272,6 +290,24 @@ function RegistryUsersPage() {
272
290
  <RefreshCw /> Resend
273
291
  </Button>
274
292
  ) : null}
293
+ {user.status === 'active' ? (
294
+ <Button
295
+ size="sm"
296
+ variant="ghost"
297
+ disabled={pending}
298
+ onClick={() =>
299
+ setConfirmation({
300
+ title: `Create a password reset link for ${user.email}?`,
301
+ description: 'They can use the link to set a new password.',
302
+ confirmLabel: 'Create link',
303
+ pendingLabel: 'Creating…',
304
+ onConfirm: () => mutate(user, 'reset', {}, `Password reset link created for ${user.email}.`),
305
+ })
306
+ }
307
+ >
308
+ <KeyRound /> Reset password
309
+ </Button>
310
+ ) : null}
275
311
  {user.status !== 'invited' ? (
276
312
  <Button
277
313
  size="sm"
@@ -279,11 +315,25 @@ function RegistryUsersPage() {
279
315
  disabled={pending || (user.id === currentUser?.id && user.status === 'active')}
280
316
  onClick={() => {
281
317
  const nextStatus = user.status === 'active' ? 'deactivated' : 'active';
282
- const message =
318
+ const onConfirm = () => mutate(user, 'status', { status: nextStatus }, `Updated ${user.email}.`);
319
+ setConfirmation(
283
320
  nextStatus === 'active'
284
- ? `Reactivate ${user.email}?`
285
- : `Deactivate ${user.email} and revoke their credentials?`;
286
- if (window.confirm(message)) void mutate(user, 'status', { status: nextStatus }, `Updated ${user.email}.`);
321
+ ? {
322
+ title: `Reactivate ${user.email}?`,
323
+ description: 'They can sign in again. Access tokens revoked at deactivation stay revoked.',
324
+ confirmLabel: 'Reactivate',
325
+ pendingLabel: 'Reactivating…',
326
+ onConfirm,
327
+ }
328
+ : {
329
+ title: `Deactivate ${user.email}?`,
330
+ description: 'They can no longer sign in, and all their access tokens are revoked.',
331
+ confirmLabel: 'Deactivate',
332
+ pendingLabel: 'Deactivating…',
333
+ variant: 'destructive',
334
+ onConfirm,
335
+ }
336
+ );
287
337
  }}
288
338
  >
289
339
  {user.status === 'active' ? 'Deactivate' : 'Reactivate'}
@@ -305,6 +355,16 @@ function RegistryUsersPage() {
305
355
  </CardContent>
306
356
  </Card>
307
357
  </div>
358
+
359
+ {confirmation ? (
360
+ <ConfirmDialog
361
+ {...confirmation}
362
+ open
363
+ onOpenChange={(open) => {
364
+ if (!open) setConfirmation(null);
365
+ }}
366
+ />
367
+ ) : null}
308
368
  </AccountLayout>
309
369
  );
310
370
  }
@@ -0,0 +1,60 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+ import { MissingEncryptionSecretError, deleteRegistryAiKey, saveRegistryAiKey } from '@handoff/registry/auth';
3
+ import { aiCredentialKind } from '@handoff/ai/connections';
4
+ import { allowApiMethods, prepareRegistryApi } from '@/lib/auth/api';
5
+ import { findAiConnection } from '@/lib/ai/resolve';
6
+ import { getServerRuntimeConfig } from '@/lib/docs-api/runtime-config';
7
+
8
+ /**
9
+ * `/api/account/ai/keys/[connectionId]`: save or remove this reader's key for one declared
10
+ * connection. A connection id the config does not declare with `credential: 'user'` is rejected, so
11
+ * a reader can never introduce an endpoint of their own.
12
+ */
13
+
14
+ /** Longest key worth accepting. Provider keys are short; anything longer is a mistake or an attack. */
15
+ const MAX_KEY_LENGTH = 512;
16
+
17
+ export default async function accountAiKeyHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
18
+ const method = allowApiMethods(req, res, ['PUT', 'DELETE']);
19
+ if (!method) return;
20
+ if (!getServerRuntimeConfig().ai.enabled) {
21
+ res.status(404).end();
22
+ return;
23
+ }
24
+ const context = await prepareRegistryApi(req, res, { auth: 'user', mutation: true });
25
+ if (!context?.user) return;
26
+
27
+ const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : '';
28
+ const connection = findAiConnection(connectionId);
29
+ if (!connection || aiCredentialKind(connection) !== 'user') {
30
+ res.status(404).json({ error: 'That AI connection does not take a personal API key.' });
31
+ return;
32
+ }
33
+
34
+ if (method === 'DELETE') {
35
+ const removed = await deleteRegistryAiKey(context.db, { userId: context.user.id, connectionId });
36
+ if (!removed) {
37
+ res.status(404).json({ error: 'No API key is saved for that connection.' });
38
+ return;
39
+ }
40
+ res.status(200).json({ ok: true });
41
+ return;
42
+ }
43
+
44
+ const key = typeof (req.body as { key?: unknown })?.key === 'string' ? (req.body as { key: string }).key.trim() : '';
45
+ if (!key || key.length > MAX_KEY_LENGTH) {
46
+ res.status(400).json({ error: 'Enter the API key for this provider.' });
47
+ return;
48
+ }
49
+
50
+ try {
51
+ await saveRegistryAiKey(context.db, { userId: context.user.id, connectionId, key });
52
+ } catch (error) {
53
+ if (error instanceof MissingEncryptionSecretError) {
54
+ res.status(503).json({ error: error.message, code: 'encryption_secret_missing' });
55
+ return;
56
+ }
57
+ throw error;
58
+ }
59
+ res.status(200).json({ ok: true });
60
+ }
@@ -0,0 +1,20 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+ import { allowApiMethods, prepareRegistryApi } from '@/lib/auth/api';
3
+ import { describeAiUserConnections } from '@/lib/ai/resolve';
4
+ import { getServerRuntimeConfig } from '@/lib/docs-api/runtime-config';
5
+
6
+ /**
7
+ * `/api/account/ai/keys`: the connections this reader may add a key for, each with whether they
8
+ * already have. The key value is write-only across this boundary and is never returned.
9
+ */
10
+ export default async function accountAiKeysHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
11
+ if (!allowApiMethods(req, res, ['GET'])) return;
12
+ if (!getServerRuntimeConfig().ai.enabled) {
13
+ res.status(404).end();
14
+ return;
15
+ }
16
+ const context = await prepareRegistryApi(req, res, { auth: 'user' });
17
+ if (!context?.user) return;
18
+
19
+ res.status(200).json({ connections: await describeAiUserConnections(context.user.id) });
20
+ }
@@ -1,5 +1,13 @@
1
1
  import type { NextApiRequest, NextApiResponse } from 'next';
2
- import { resendUserInvitation, setRegistryUserStatus, updateRegistryUserRole, type RegistryUserRole } from '@handoff/registry/auth';
2
+ import {
3
+ createPasswordReset,
4
+ getRegistryUserById,
5
+ resendUserInvitation,
6
+ setRegistryUserStatus,
7
+ updateRegistryUserRole,
8
+ type RegistryUser,
9
+ type RegistryUserRole,
10
+ } from '@handoff/registry/auth';
3
11
  import { allowApiMethods, prepareRegistryApi, registryPageUrl } from '../../../../../lib/auth/api';
4
12
  import { registryEmailIsConfigured, sendRegistryAuthEmail } from '../../../../../lib/auth/email';
5
13
 
@@ -10,6 +18,32 @@ const mutationError = (reason: string): { status: number; error: string } => {
10
18
  return { status: 400, error: 'The requested user change is not valid.' };
11
19
  };
12
20
 
21
+ /** Email the link when email is configured; otherwise return it once for the administrator to deliver. */
22
+ const deliverLink = async (
23
+ res: NextApiResponse,
24
+ user: RegistryUser,
25
+ link: { url: string; subject: string; heading: string; message: string; actionLabel: string },
26
+ messages: { sent: string; failed: string; manual: string }
27
+ ) => {
28
+ if (!registryEmailIsConfigured()) {
29
+ res.status(200).json({ user, message: messages.manual, activationUrl: link.url });
30
+ return;
31
+ }
32
+ const delivered = await sendRegistryAuthEmail({
33
+ to: user.email,
34
+ subject: link.subject,
35
+ heading: link.heading,
36
+ message: link.message,
37
+ actionLabel: link.actionLabel,
38
+ actionUrl: link.url,
39
+ });
40
+ if (!delivered) {
41
+ res.status(502).json({ error: messages.failed });
42
+ return;
43
+ }
44
+ res.status(200).json({ user, message: messages.sent });
45
+ };
46
+
13
47
  export default async function userActionHandler(req: NextApiRequest, res: NextApiResponse) {
14
48
  const method = allowApiMethods(req, res, ['POST']);
15
49
  if (!method) return;
@@ -65,23 +99,58 @@ export default async function userActionHandler(req: NextApiRequest, res: NextAp
65
99
  res.status(500).json({ error: 'AUTH_URL is not configured.' });
66
100
  return;
67
101
  }
68
- if (registryEmailIsConfigured()) {
69
- const delivered = await sendRegistryAuthEmail({
70
- to: result.user.email,
102
+ await deliverLink(
103
+ res,
104
+ result.user,
105
+ {
106
+ url: activationUrl,
71
107
  subject: 'Your Handoff Registry invitation',
72
108
  heading: 'You are invited',
73
109
  message: 'Set a password to activate your Handoff Registry account.',
74
110
  actionLabel: 'Accept invitation',
75
- actionUrl: activationUrl,
76
- });
77
- if (!delivered) {
78
- res.status(502).json({ error: 'The invitation was renewed, but email delivery failed.' });
79
- return;
111
+ },
112
+ {
113
+ sent: 'Invitation resent.',
114
+ failed: 'The invitation was renewed, but email delivery failed.',
115
+ manual: 'Invitation renewed for manual delivery.',
80
116
  }
81
- res.status(200).json({ user: result.user, message: 'Invitation resent.' });
117
+ );
118
+ return;
119
+ }
120
+
121
+ if (action === 'reset') {
122
+ const user = await getRegistryUserById(context.db, userId);
123
+ if (!user) {
124
+ res.status(404).json({ error: 'User not found.' });
125
+ return;
126
+ }
127
+ const result = await createPasswordReset(context.db, user.email);
128
+ if (!result.token || !result.user) {
129
+ res.status(409).json({ error: 'Only active users can reset their password.' });
82
130
  return;
83
131
  }
84
- res.status(200).json({ user: result.user, message: 'Invitation renewed for manual delivery.', activationUrl });
132
+ const resetUrl = registryPageUrl('/reset-password', undefined, { token: result.token });
133
+ if (!resetUrl) {
134
+ res.status(500).json({ error: 'AUTH_URL is not configured.' });
135
+ return;
136
+ }
137
+ await deliverLink(
138
+ res,
139
+ result.user,
140
+ {
141
+ url: resetUrl,
142
+ subject: 'Reset your Handoff Registry password',
143
+ heading: 'Reset your password',
144
+ message:
145
+ 'An administrator started a password reset for your account. Use this single-use link to choose a new password. It expires in one hour.',
146
+ actionLabel: 'Reset password',
147
+ },
148
+ {
149
+ sent: 'Password reset link sent.',
150
+ failed: 'The reset link was created, but email delivery failed.',
151
+ manual: 'Password reset link created for manual delivery. It expires in one hour.',
152
+ }
153
+ );
85
154
  return;
86
155
  }
87
156
 
@@ -10,7 +10,7 @@ export default async function usersHandler(req: NextApiRequest, res: NextApiResp
10
10
  if (!context?.user) return;
11
11
 
12
12
  if (method === 'GET') {
13
- res.status(200).json({ users: await listRegistryUsers(context.db) });
13
+ res.status(200).json({ users: await listRegistryUsers(context.db), emailConfigured: registryEmailIsConfigured() });
14
14
  return;
15
15
  }
16
16
 
@@ -43,7 +43,7 @@ export default async function usersHandler(req: NextApiRequest, res: NextApiResp
43
43
  actionUrl: activationUrl,
44
44
  });
45
45
  if (!delivered) {
46
- res.status(502).json({ error: 'The invitation was created, but email delivery failed. Fix Resend configuration and resend it.' });
46
+ res.status(502).json({ error: 'The invitation was created, but email delivery failed. Fix the email configuration and resend it.' });
47
47
  return;
48
48
  }
49
49
  res.status(201).json({ user: result.user, message: 'Invitation sent.' });
@@ -0,0 +1,115 @@
1
+ import {
2
+ convertToModelMessages,
3
+ createUIMessageStream,
4
+ pipeUIMessageStreamToResponse,
5
+ stepCountIs,
6
+ streamText,
7
+ toUIMessageStream,
8
+ type UIMessage,
9
+ } from 'ai';
10
+ import type { NextApiRequest, NextApiResponse } from 'next';
11
+ import { allowApiMethods } from '@/lib/api/methods';
12
+ import { authorizeAiRequest } from '@/lib/ai/auth';
13
+ import { withPageContext } from '@/lib/ai/context';
14
+ import { AiConnectionError, createAiLanguageModel } from '@/lib/ai/model';
15
+ import { DOCS_ASSISTANT_PROMPT } from '@/lib/ai/prompt';
16
+ import { describeAiConnections, findAiConnection, selectAiModel } from '@/lib/ai/resolve';
17
+ import { createAiToolSession, type AiSource } from '@/lib/ai/tools';
18
+ import { resolveDocsBackend } from '@/lib/docs-api';
19
+
20
+ /**
21
+ * `/api/ai/chat`: the docs assistant's agent loop, streamed to the browser.
22
+ *
23
+ * The agent reads through the MCP tools in process (see `lib/ai/tools`), so it answers from the same
24
+ * records `/api/mcp/` returns and a tool added there needs no work here.
25
+ */
26
+
27
+ /**
28
+ * How many model turns one question may take. A constant rather than config: a loop with no stop
29
+ * condition can call tools without end, so this is a liveness control, not a spend control. Spend
30
+ * belongs to the gateway, which enforces budgets and rate limits per key.
31
+ */
32
+ const MAX_AGENT_STEPS = 12;
33
+
34
+ const readMessages = (body: unknown): UIMessage[] => {
35
+ const messages = (body as { messages?: unknown })?.messages;
36
+ return Array.isArray(messages) ? (messages as UIMessage[]) : [];
37
+ };
38
+
39
+ export default async function aiChatHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
40
+ if (!allowApiMethods(req, res, ['POST'])) return;
41
+ const reader = await authorizeAiRequest(req, res, true);
42
+ if (!reader) return;
43
+
44
+ const messages = readMessages(req.body);
45
+ if (messages.length === 0) {
46
+ res.status(400).json({ error: 'Ask a question to start.' });
47
+ return;
48
+ }
49
+
50
+ const requested = typeof (req.body as { model?: unknown })?.model === 'string' ? (req.body as { model: string }).model : undefined;
51
+ const selection = selectAiModel(await describeAiConnections(reader.userId), requested);
52
+ if (!selection) {
53
+ res.status(409).json({ error: 'No AI provider is available for this account.', code: 'no_usable_connection' });
54
+ return;
55
+ }
56
+
57
+ const connection = findAiConnection(selection.connection.id);
58
+ if (!connection) {
59
+ res.status(409).json({ error: 'No AI provider is available for this account.', code: 'no_usable_connection' });
60
+ return;
61
+ }
62
+
63
+ let model;
64
+ try {
65
+ model = await createAiLanguageModel(connection, selection.model, reader.userId);
66
+ } catch (error) {
67
+ if (error instanceof AiConnectionError) {
68
+ res.status(409).json({ error: error.message, code: 'connection_unavailable' });
69
+ return;
70
+ }
71
+ console.error('AI model could not be built.', error);
72
+ res.status(500).json({ error: 'The AI provider could not be reached.' });
73
+ return;
74
+ }
75
+
76
+ const stream = createUIMessageStream({
77
+ execute: async ({ writer }) => {
78
+ // Sources are written as the tools return them, so a link appears as soon as the agent has
79
+ // read the page it came from. Deduplicated because one question often reads a page twice.
80
+ const seen = new Set<string>();
81
+ const writeSources = (sources: AiSource[]) => {
82
+ for (const source of sources) {
83
+ if (seen.has(source.url)) continue;
84
+ seen.add(source.url);
85
+ writer.write({ type: 'source-url', sourceId: source.url, url: source.url, title: source.title });
86
+ }
87
+ };
88
+
89
+ const session = await createAiToolSession(writeSources);
90
+ // Torn down with the response, the way `/api/mcp/` does it: the merged stream outlives this
91
+ // function, and a reader who closes the modal mid-answer must not leave the session open.
92
+ res.on('close', () => void session.close());
93
+
94
+ // A question never fails for want of context.
95
+ const labeled = await resolveDocsBackend()
96
+ .then((backend) => withPageContext(messages, backend))
97
+ .catch(() => messages);
98
+
99
+ const result = streamText({
100
+ model,
101
+ system: DOCS_ASSISTANT_PROMPT,
102
+ messages: await convertToModelMessages(labeled),
103
+ tools: session.tools,
104
+ stopWhen: stepCountIs(MAX_AGENT_STEPS),
105
+ });
106
+ writer.merge(toUIMessageStream({ stream: result.fullStream, tools: session.tools }));
107
+ },
108
+ onError: (error) => {
109
+ console.error('AI chat request failed.', error);
110
+ return 'The AI provider could not complete this answer.';
111
+ },
112
+ });
113
+
114
+ await pipeUIMessageStreamToResponse({ response: res, stream });
115
+ }
@@ -0,0 +1,23 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+ import { authorizeAiRequest } from '@/lib/ai/auth';
3
+ import { describeAiConnections } from '@/lib/ai/resolve';
4
+ import { getServerRuntimeConfig } from '@/lib/docs-api/runtime-config';
5
+ import { allowApiMethods } from '@/lib/api/methods';
6
+
7
+ /**
8
+ * `/api/ai/connections`: the models this reader can actually run right now, for the assistant's
9
+ * model picker. Never carries a key, only whether one is in place.
10
+ */
11
+ export default async function aiConnectionsHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
12
+ if (!allowApiMethods(req, res, ['GET'])) return;
13
+ const reader = await authorizeAiRequest(req, res);
14
+ if (!reader) return;
15
+
16
+ const connections = await describeAiConnections(reader.userId);
17
+ res.status(200).json({
18
+ connections,
19
+ defaultModel: getServerRuntimeConfig().ai.defaultModel ?? null,
20
+ /** Whether this deployment has a page where a reader can add their own key. */
21
+ canAddKeys: getServerRuntimeConfig().mode === 'registry' && connections.some((connection) => connection.credential === 'user'),
22
+ });
23
+ }