@knotpad/app 0.1.5 → 0.1.6

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 (340) hide show
  1. package/app/(app)/calendar/page.tsx +57 -0
  2. package/app/(app)/error.tsx +35 -0
  3. package/app/(app)/graph/page.tsx +32 -0
  4. package/app/(app)/guide/page.tsx +21 -0
  5. package/app/(app)/kanban/loading.tsx +24 -0
  6. package/app/(app)/kanban/page.tsx +59 -0
  7. package/app/(app)/layout.tsx +122 -0
  8. package/app/(app)/list/loading.tsx +21 -0
  9. package/app/(app)/list/page.tsx +137 -0
  10. package/app/(app)/loading.tsx +18 -0
  11. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  12. package/app/(app)/notes/layout.tsx +30 -0
  13. package/app/(app)/notes/page.tsx +39 -0
  14. package/app/(app)/page.tsx +5 -0
  15. package/app/(app)/settings/agent-token/page.tsx +59 -0
  16. package/app/(app)/settings/backup/page.tsx +49 -0
  17. package/app/(app)/settings/billing/page.tsx +53 -0
  18. package/app/(app)/settings/calendar/page.tsx +41 -0
  19. package/app/(app)/settings/layout.test.tsx +39 -0
  20. package/app/(app)/settings/layout.tsx +71 -0
  21. package/app/(app)/settings/page.tsx +4 -0
  22. package/app/(app)/settings/security/page.tsx +43 -0
  23. package/app/(app)/settings/team/page.tsx +74 -0
  24. package/app/(app)/settings/workspace/page.tsx +27 -0
  25. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  26. package/app/(auth)/forgot-password/page.tsx +106 -0
  27. package/app/(auth)/guest/page.tsx +56 -0
  28. package/app/(auth)/layout.tsx +13 -0
  29. package/app/(auth)/login/page.tsx +14 -0
  30. package/app/(auth)/register/page.tsx +193 -0
  31. package/app/(auth)/reset-password/page.tsx +138 -0
  32. package/app/api/account/claim/route.tsx +135 -0
  33. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  34. package/app/api/admin/license/route.tsx +42 -0
  35. package/app/api/auth/2fa/route.tsx +148 -0
  36. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  37. package/app/api/auth/change-password/route.tsx +61 -0
  38. package/app/api/auth/check-2fa/route.tsx +19 -0
  39. package/app/api/auth/forgot-password/route.tsx +65 -0
  40. package/app/api/auth/reset-password/route.tsx +52 -0
  41. package/app/api/auth/verify-2fa/route.tsx +88 -0
  42. package/app/api/backup/download/db/route.ts +29 -0
  43. package/app/api/backup/download/notes/route.ts +25 -0
  44. package/app/api/backup/settings/route.ts +92 -0
  45. package/app/api/billing/checkout/route.tsx +81 -0
  46. package/app/api/billing/migrate/route.tsx +163 -0
  47. package/app/api/billing/portal/route.tsx +24 -0
  48. package/app/api/billing/setup-intent/route.tsx +55 -0
  49. package/app/api/billing/status/route.tsx +36 -0
  50. package/app/api/billing/subscribe/route.tsx +85 -0
  51. package/app/api/billing/webhook/route.tsx +199 -0
  52. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  53. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  54. package/app/api/calendar-feeds/events/route.tsx +82 -0
  55. package/app/api/calendar-feeds/route.tsx +52 -0
  56. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  57. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  58. package/app/api/cron/stale-tasks/route.tsx +51 -0
  59. package/app/api/cron/sync/route.tsx +34 -0
  60. package/app/api/devices/[deviceId]/route.tsx +25 -0
  61. package/app/api/devices/route.tsx +41 -0
  62. package/app/api/export/route.tsx +40 -0
  63. package/app/api/feedback/route.tsx +54 -0
  64. package/app/api/folders/[folderId]/route.tsx +51 -0
  65. package/app/api/folders/route.tsx +37 -0
  66. package/app/api/graph/route.tsx +242 -0
  67. package/app/api/guest/route.tsx +58 -0
  68. package/app/api/health/route.tsx +10 -0
  69. package/app/api/holidays/countries/route.tsx +14 -0
  70. package/app/api/holidays/route.tsx +49 -0
  71. package/app/api/holidays/states/route.tsx +21 -0
  72. package/app/api/invites/[token]/route.tsx +131 -0
  73. package/app/api/invites/route.tsx +74 -0
  74. package/app/api/mcp/generate-token/route.tsx +55 -0
  75. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  76. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  77. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  78. package/app/api/notes/[noteId]/route.tsx +360 -0
  79. package/app/api/notes/route.tsx +112 -0
  80. package/app/api/notifications/route.tsx +44 -0
  81. package/app/api/register/route.tsx +67 -0
  82. package/app/api/restore/route.tsx +148 -0
  83. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  84. package/app/api/sync/conflicts/route.tsx +48 -0
  85. package/app/api/sync/status/route.tsx +49 -0
  86. package/app/api/sync/trigger/route.tsx +15 -0
  87. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  88. package/app/api/tasks/[taskId]/route.tsx +259 -0
  89. package/app/api/tasks/bulk/route.tsx +133 -0
  90. package/app/api/tasks/route.tsx +36 -0
  91. package/app/api/workspace/active/route.tsx +39 -0
  92. package/app/api/workspace/create-team/route.tsx +42 -0
  93. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  94. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  95. package/app/api/workspace/route.tsx +24 -0
  96. package/app/download/page.tsx +170 -0
  97. package/app/favicon.ico +0 -0
  98. package/app/generated/prisma/client.d.ts +1 -0
  99. package/app/generated/prisma/client.js +5 -0
  100. package/app/generated/prisma/default.d.ts +1 -0
  101. package/app/generated/prisma/default.js +5 -0
  102. package/app/generated/prisma/edge.d.ts +1 -0
  103. package/app/generated/prisma/edge.js +497 -0
  104. package/app/generated/prisma/index-browser.js +523 -0
  105. package/app/generated/prisma/index.d.ts +46376 -0
  106. package/app/generated/prisma/index.js +497 -0
  107. package/app/generated/prisma/package.json +144 -0
  108. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  111. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  112. package/app/generated/prisma/runtime/client.js +86 -0
  113. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  114. package/app/generated/prisma/runtime/index-browser.js +6 -0
  115. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  116. package/app/generated/prisma/schema.prisma +456 -0
  117. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  118. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  119. package/app/globals.css +54 -0
  120. package/app/invite/[token]/page.tsx +52 -0
  121. package/app/layout.tsx +90 -0
  122. package/app/mcp/route.tsx +430 -0
  123. package/app/opengraph-image.tsx +120 -0
  124. package/app/page.tsx +398 -0
  125. package/app/privacy/page.tsx +69 -0
  126. package/app/robots.tsx +25 -0
  127. package/app/sitemap.tsx +36 -0
  128. package/app/terms/page.tsx +69 -0
  129. package/app/upgrade/page.tsx +75 -0
  130. package/auth.config.ts +33 -0
  131. package/auth.ts +79 -0
  132. package/bin/brief.js +224 -0
  133. package/components/auth/login-form.tsx +302 -0
  134. package/components/auth/password-checklist.tsx +31 -0
  135. package/components/auth/password-input.tsx +36 -0
  136. package/components/auth/switch-account-button.test.tsx +22 -0
  137. package/components/auth/switch-account-button.tsx +19 -0
  138. package/components/auth/two-factor-input.tsx +116 -0
  139. package/components/billing/billing-dashboard.tsx +265 -0
  140. package/components/billing/card-form.tsx +210 -0
  141. package/components/billing/claim-account-form.tsx +99 -0
  142. package/components/branding/app-logo.test.tsx +20 -0
  143. package/components/branding/app-logo.tsx +25 -0
  144. package/components/calendar/calendar-agenda.tsx +150 -0
  145. package/components/calendar/calendar-drag.test.tsx +177 -0
  146. package/components/calendar/calendar-grid.tsx +357 -0
  147. package/components/calendar/calendar-hooks.test.tsx +27 -0
  148. package/components/calendar/calendar-hooks.ts +351 -0
  149. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  150. package/components/calendar/calendar-toolbar.tsx +291 -0
  151. package/components/calendar/calendar-types.ts +148 -0
  152. package/components/calendar/calendar-view.test.tsx +295 -0
  153. package/components/calendar/calendar-view.tsx +307 -0
  154. package/components/calendar/day-detail-popover.tsx +174 -0
  155. package/components/calendar/task-chip.tsx +86 -0
  156. package/components/command/command-palette.test.tsx +33 -0
  157. package/components/command/command-palette.tsx +310 -0
  158. package/components/download-cta.tsx +87 -0
  159. package/components/feedback/feedback-popup.tsx +207 -0
  160. package/components/graph/graph-draw.ts +337 -0
  161. package/components/graph/graph-overlays.tsx +160 -0
  162. package/components/graph/graph-page.test.tsx +131 -0
  163. package/components/graph/graph-page.tsx +263 -0
  164. package/components/graph/graph-types.ts +47 -0
  165. package/components/graph/graph-view.tsx +322 -0
  166. package/components/guide/guide-view.tsx +522 -0
  167. package/components/kanban/kanban-board.test.tsx +128 -0
  168. package/components/kanban/kanban-board.tsx +361 -0
  169. package/components/kanban/kanban-card-menu.tsx +102 -0
  170. package/components/kanban/kanban-card.tsx +227 -0
  171. package/components/kanban/kanban-column.tsx +49 -0
  172. package/components/kanban/kanban-status-context.tsx +28 -0
  173. package/components/landing/calendar-sandbox.test.tsx +15 -0
  174. package/components/landing/calendar-sandbox.tsx +107 -0
  175. package/components/landing/graph-sandbox.test.tsx +27 -0
  176. package/components/landing/graph-sandbox.tsx +80 -0
  177. package/components/landing/kanban-sandbox.test.tsx +24 -0
  178. package/components/landing/kanban-sandbox.tsx +101 -0
  179. package/components/landing/landing-showcase.test.tsx +21 -0
  180. package/components/landing/landing-showcase.tsx +54 -0
  181. package/components/landing/list-sandbox.tsx +86 -0
  182. package/components/landing/mock-workspace.ts +168 -0
  183. package/components/landing/notes-sandbox.test.tsx +14 -0
  184. package/components/landing/notes-sandbox.tsx +88 -0
  185. package/components/layout/app-shell.tsx +83 -0
  186. package/components/layout/backup-scheduler.tsx +122 -0
  187. package/components/layout/bottom-nav.tsx +43 -0
  188. package/components/layout/icon-bar.test.tsx +29 -0
  189. package/components/layout/icon-bar.tsx +118 -0
  190. package/components/layout/mobile-top-bar.tsx +68 -0
  191. package/components/layout/notes-panel-folder.tsx +127 -0
  192. package/components/layout/notes-panel-note-item.tsx +140 -0
  193. package/components/layout/notes-panel-task-tab.tsx +63 -0
  194. package/components/layout/notes-panel-types.ts +44 -0
  195. package/components/layout/notes-panel.tsx +476 -0
  196. package/components/layout/notification-bell.tsx +251 -0
  197. package/components/layout/paywall-screen.tsx +41 -0
  198. package/components/layout/pro-banner.tsx +76 -0
  199. package/components/layout/sw-register.tsx +27 -0
  200. package/components/layout/workspace-switcher.tsx +90 -0
  201. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  202. package/components/notes/note-editor-context-menu.tsx +47 -0
  203. package/components/notes/note-editor-dom.ts +33 -0
  204. package/components/notes/note-editor-dropdowns.tsx +484 -0
  205. package/components/notes/note-editor-hooks.ts +692 -0
  206. package/components/notes/note-editor-keyboard.ts +305 -0
  207. package/components/notes/note-editor-overlay.tsx +90 -0
  208. package/components/notes/note-editor.test.tsx +372 -0
  209. package/components/notes/note-editor.tsx +662 -0
  210. package/components/notes/note-preview-pane.tsx +156 -0
  211. package/components/notes/note-tabs.tsx +120 -0
  212. package/components/notes/note-types.tsx +157 -0
  213. package/components/settings/accept-invite.tsx +108 -0
  214. package/components/settings/agent-token-settings.tsx +369 -0
  215. package/components/settings/backup-restore-settings.test.tsx +25 -0
  216. package/components/settings/backup-restore-settings.tsx +327 -0
  217. package/components/settings/calendar-feeds-settings.tsx +489 -0
  218. package/components/settings/calendar-general-settings.tsx +174 -0
  219. package/components/settings/confirm-danger-action.test.tsx +215 -0
  220. package/components/settings/confirm-danger-action.tsx +65 -0
  221. package/components/settings/security-settings.tsx +252 -0
  222. package/components/settings/settings-guidance.test.tsx +98 -0
  223. package/components/settings/team-settings.tsx +319 -0
  224. package/components/settings/two-factor-auth.tsx +296 -0
  225. package/components/settings/workspace-settings-client.tsx +363 -0
  226. package/components/settings/workspace-settings-form.tsx +73 -0
  227. package/components/sync/conflict-viewer.tsx +247 -0
  228. package/components/sync/sync-indicator.tsx +171 -0
  229. package/components/tasks/snippet-thread.tsx +119 -0
  230. package/components/tasks/status-dot.tsx +47 -0
  231. package/components/tasks/task-badge.tsx +43 -0
  232. package/components/tasks/task-detail.test.tsx +187 -0
  233. package/components/tasks/task-detail.tsx +458 -0
  234. package/components/tasks/task-list-filters.test.tsx +75 -0
  235. package/components/tasks/task-list-filters.tsx +163 -0
  236. package/components/tasks/task-list-types.ts +20 -0
  237. package/components/tasks/task-list.test.tsx +175 -0
  238. package/components/tasks/task-list.tsx +481 -0
  239. package/components/tasks/task-row.tsx +85 -0
  240. package/components/tasks/task-table-row.tsx +259 -0
  241. package/components/ui/skeleton.tsx +3 -0
  242. package/components/ui/toast.test.tsx +42 -0
  243. package/components/ui/toast.tsx +70 -0
  244. package/instrumentation.tsx +23 -0
  245. package/lib/api-error.ts +50 -0
  246. package/lib/backup/backup-runner.test.ts +32 -0
  247. package/lib/backup/backup-runner.ts +19 -0
  248. package/lib/backup/backup-schedule.test.ts +23 -0
  249. package/lib/backup/backup-schedule.ts +55 -0
  250. package/lib/backup/backup-settings.test.ts +30 -0
  251. package/lib/backup/backup-settings.ts +27 -0
  252. package/lib/backup/export-notes-zip.test.ts +26 -0
  253. package/lib/backup/export-notes-zip.ts +82 -0
  254. package/lib/backup/export-workspace-backup.test.ts +17 -0
  255. package/lib/backup/export-workspace-backup.ts +77 -0
  256. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  257. package/lib/backup/restore-workspace-from-export.ts +183 -0
  258. package/lib/backup/types.ts +14 -0
  259. package/lib/brand-icons.ts +1 -0
  260. package/lib/calendar-feed-crypto.ts +38 -0
  261. package/lib/calendar-feed.ts +239 -0
  262. package/lib/client/online-status.ts +47 -0
  263. package/lib/conflict-resolver.test.ts +57 -0
  264. package/lib/conflict-resolver.ts +240 -0
  265. package/lib/db-init.ts +79 -0
  266. package/lib/email.ts +159 -0
  267. package/lib/encryption.test.ts +41 -0
  268. package/lib/encryption.ts +98 -0
  269. package/lib/extract-snippet.test.ts +123 -0
  270. package/lib/extract-snippet.ts +69 -0
  271. package/lib/kanban-status.ts +55 -0
  272. package/lib/license.ts +21 -0
  273. package/lib/limits.ts +31 -0
  274. package/lib/mcp-auth.test.ts +58 -0
  275. package/lib/mcp-auth.ts +65 -0
  276. package/lib/mcp-contract.test.ts +25 -0
  277. package/lib/mcp-contract.ts +210 -0
  278. package/lib/mcp-handler.ts +31 -0
  279. package/lib/mcp-url.test.ts +12 -0
  280. package/lib/mcp-url.ts +7 -0
  281. package/lib/mentions.test.ts +45 -0
  282. package/lib/mentions.ts +73 -0
  283. package/lib/note-crypto.ts +108 -0
  284. package/lib/note-sync.ts +201 -0
  285. package/lib/note-title.ts +93 -0
  286. package/lib/prisma.ts +193 -0
  287. package/lib/pro-flush.ts +292 -0
  288. package/lib/rate-limit.ts +57 -0
  289. package/lib/stripe.ts +38 -0
  290. package/lib/sync-worker.ts +388 -0
  291. package/lib/task-parser.test.ts +91 -0
  292. package/lib/task-parser.ts +81 -0
  293. package/lib/task-utils.ts +52 -0
  294. package/lib/use-is-electron.ts +19 -0
  295. package/lib/use-is-mobile.ts +22 -0
  296. package/lib/validation/calendar-feed.ts +31 -0
  297. package/lib/validation/note.ts +27 -0
  298. package/lib/validation/task.ts +26 -0
  299. package/lib/view-preferences.test.ts +54 -0
  300. package/lib/view-preferences.ts +28 -0
  301. package/lib/workspace.ts +66 -0
  302. package/next.config.ts +21 -0
  303. package/package.json +49 -3
  304. package/postcss.config.mjs +7 -0
  305. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  306. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  307. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  308. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  309. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  310. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  311. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  312. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  313. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  314. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  315. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  316. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  317. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  318. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  319. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  320. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  321. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  322. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  323. package/prisma/migrations/migration_lock.toml +3 -0
  324. package/prisma/schema.prisma +457 -0
  325. package/public/Logo_icon.svg +1 -0
  326. package/public/file.svg +1 -0
  327. package/public/globe.svg +1 -0
  328. package/public/icon-192.png +0 -0
  329. package/public/icon-512.png +0 -0
  330. package/public/icon.svg +4 -0
  331. package/public/icon_dark.svg +1 -0
  332. package/public/knotpad_icon.svg +1 -0
  333. package/public/knotpad_logo_full.svg +1 -0
  334. package/public/manifest.json +14 -0
  335. package/public/next.svg +1 -0
  336. package/public/sw.js +137 -0
  337. package/public/vercel.svg +1 -0
  338. package/public/window.svg +1 -0
  339. package/tsconfig.json +35 -0
  340. package/brief.js +0 -311
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import Link from "next/link";
5
+
6
+ export default function ForgotPasswordPage() {
7
+ const [email, setEmail] = useState("");
8
+ const [loading, setLoading] = useState(false);
9
+ const [sent, setSent] = useState(false);
10
+ const [resetUrl, setResetUrl] = useState(""); // dev/local mode only
11
+ const [error, setError] = useState("");
12
+
13
+ async function handleSubmit(e: React.FormEvent) {
14
+ e.preventDefault();
15
+ setLoading(true);
16
+ setError("");
17
+ try {
18
+ const res = await fetch("/api/auth/forgot-password", {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify({ email }),
22
+ });
23
+ if (res.ok) {
24
+ const data = await res.json();
25
+ setSent(true);
26
+ // In local/dev mode the API returns the URL directly (no email configured).
27
+ if (data.resetUrl) setResetUrl(data.resetUrl);
28
+ } else {
29
+ const d = await res.json();
30
+ setError(d.error ?? "Something went wrong");
31
+ }
32
+ } catch {
33
+ setError("Something went wrong. Please try again.");
34
+ } finally {
35
+ setLoading(false);
36
+ }
37
+ }
38
+
39
+ if (sent) {
40
+ return (
41
+ <div className="w-full max-w-sm space-y-4 text-center">
42
+ <h1 className="text-2xl font-semibold tracking-tight">Check your email</h1>
43
+ <p className="text-sm text-zinc-400">
44
+ If an account exists for <span className="text-zinc-300">{email}</span>, we've sent a
45
+ reset link. It expires in 1 hour.
46
+ </p>
47
+ {resetUrl && (
48
+ <div className="rounded-md border border-amber-800/40 bg-amber-950/20 px-4 py-3 text-left space-y-1">
49
+ <p className="text-xs text-amber-400 font-medium">Dev mode — no email configured</p>
50
+ <p className="text-xs text-zinc-400 break-all">
51
+ <a href={resetUrl} className="underline hover:text-zinc-200">{resetUrl}</a>
52
+ </p>
53
+ </div>
54
+ )}
55
+ <Link href="/login" className="block text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
56
+ Back to sign in
57
+ </Link>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className="w-full max-w-sm space-y-6">
64
+ <div>
65
+ <h1 className="text-2xl font-semibold tracking-tight">Forgot password?</h1>
66
+ <p className="mt-1 text-sm text-zinc-400">
67
+ Enter your email and we'll send you a reset link.
68
+ </p>
69
+ </div>
70
+
71
+ <form onSubmit={handleSubmit} className="space-y-4">
72
+ <div className="space-y-1">
73
+ <label htmlFor="email" className="text-sm font-medium text-zinc-300">
74
+ Email
75
+ </label>
76
+ <input
77
+ id="email"
78
+ type="email"
79
+ required
80
+ value={email}
81
+ onChange={(e) => setEmail(e.target.value)}
82
+ autoComplete="email"
83
+ className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none"
84
+ placeholder="you@example.com"
85
+ />
86
+ </div>
87
+
88
+ {error && <p className="text-sm text-red-400">{error}</p>}
89
+
90
+ <button
91
+ type="submit"
92
+ disabled={loading}
93
+ className="w-full rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
94
+ >
95
+ {loading ? "Sending…" : "Send reset link"}
96
+ </button>
97
+ </form>
98
+
99
+ <p className="text-center text-sm text-zinc-500">
100
+ <Link href="/login" className="text-zinc-300 underline underline-offset-2 hover:text-white">
101
+ Back to sign in
102
+ </Link>
103
+ </p>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { signIn } from "next-auth/react";
5
+ import { useRouter } from "next/navigation";
6
+
7
+ export default function GuestPage() {
8
+ const router = useRouter();
9
+ const [status, setStatus] = useState<"creating" | "error">("creating");
10
+ // Guard against React Strict Mode double-invocation — only create one guest account.
11
+ const started = useRef(false);
12
+
13
+ useEffect(() => {
14
+ if (started.current) return;
15
+ started.current = true;
16
+
17
+ async function createGuest() {
18
+ try {
19
+ const res = await fetch("/api/guest", { method: "POST" });
20
+ if (!res.ok) throw new Error("Failed to create guest");
21
+ const { email } = await res.json();
22
+
23
+ const result = await signIn("credentials", {
24
+ email,
25
+ password: "", // guest: no password
26
+ redirect: false,
27
+ });
28
+
29
+ if (result?.error) throw new Error(result.error);
30
+ router.push("/notes");
31
+ router.refresh();
32
+ } catch {
33
+ setStatus("error");
34
+ }
35
+ }
36
+ createGuest();
37
+ }, [router]);
38
+
39
+ if (status === "error") {
40
+ return (
41
+ <div className="text-center space-y-3">
42
+ <p className="text-zinc-300">Something went wrong starting guest mode.</p>
43
+ <a href="/login" className="text-sm text-zinc-500 underline">
44
+ Back to sign in
45
+ </a>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <div className="flex flex-col items-center gap-3 text-center">
52
+ <div className="h-6 w-6 rounded-full border-2 border-zinc-600 border-t-zinc-300 animate-spin" />
53
+ <p className="text-sm text-zinc-500">Setting up your local workspace…</p>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,13 @@
1
+ import type { Metadata } from "next";
2
+
3
+ export const metadata: Metadata = {
4
+ robots: "noindex, nofollow",
5
+ };
6
+
7
+ export default function AuthLayout({ children }: { children: React.ReactNode }) {
8
+ return (
9
+ <div className="flex min-h-full items-center justify-center">
10
+ {children}
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,14 @@
1
+ import type { Metadata } from "next";
2
+ import { LoginForm } from "@/components/auth/login-form";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "Sign In",
6
+ robots: "noindex, nofollow",
7
+ };
8
+
9
+ // Server component so it can read the runtime IS_CLOUD flag (set per-runtime by
10
+ // the desktop/NPX launcher vs the cloud deploy) and pass it to the client form.
11
+ export default function LoginPage() {
12
+ const isCloud = process.env.IS_CLOUD === "true";
13
+ return <LoginForm isCloud={isCloud} />;
14
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { useState, Suspense } from "react";
4
+ import { signIn } from "next-auth/react";
5
+ import { useRouter, useSearchParams } from "next/navigation";
6
+ import Link from "next/link";
7
+ import { AppLogo } from "@/components/branding/app-logo";
8
+ import { PasswordInput } from "@/components/auth/password-input";
9
+ import { PasswordChecklist } from "@/components/auth/password-checklist";
10
+
11
+ export default function RegisterPage() {
12
+ return (
13
+ <Suspense fallback={<RegisterSkeleton />}>
14
+ <RegisterForm />
15
+ </Suspense>
16
+ );
17
+ }
18
+
19
+ function RegisterSkeleton() {
20
+ return (
21
+ <div className="w-full max-w-sm space-y-6">
22
+ <div className="h-8 w-48 animate-pulse rounded bg-zinc-800" />
23
+ <div className="h-4 w-64 animate-pulse rounded bg-zinc-800" />
24
+ <div className="space-y-4 pt-4">
25
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
26
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
27
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
28
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
29
+ </div>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ function RegisterForm() {
35
+ const router = useRouter();
36
+ const searchParams = useSearchParams();
37
+ const [error, setError] = useState("");
38
+ const [loading, setLoading] = useState(false);
39
+ const [password, setPassword] = useState("");
40
+ const [confirm, setConfirm] = useState("");
41
+
42
+ const rawNext = searchParams.get("next") ?? "";
43
+ const redirectTo =
44
+ rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/notes";
45
+
46
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
47
+ e.preventDefault();
48
+
49
+ if (password !== confirm) {
50
+ setError("Passwords don't match");
51
+ return;
52
+ }
53
+
54
+ setLoading(true);
55
+ setError("");
56
+
57
+ const form = e.currentTarget;
58
+ const name = (form.elements.namedItem("name") as HTMLInputElement).value;
59
+ const email = (form.elements.namedItem("email") as HTMLInputElement).value;
60
+
61
+ try {
62
+ const res = await fetch("/api/register", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ name, email, password }),
66
+ });
67
+
68
+ if (!res.ok) {
69
+ const data = await res.json();
70
+ setError(data.error ?? "Registration failed");
71
+ setLoading(false);
72
+ return;
73
+ }
74
+
75
+ const result = await signIn("credentials", { email, password, redirect: false });
76
+ if (result?.error) {
77
+ setError("Account created but sign-in failed. Please sign in manually.");
78
+ setLoading(false);
79
+ return;
80
+ }
81
+
82
+ router.push(redirectTo);
83
+ router.refresh();
84
+ } catch {
85
+ setError("Something went wrong. Please try again.");
86
+ setLoading(false);
87
+ }
88
+ }
89
+
90
+ return (
91
+ <div className="w-full max-w-sm space-y-6">
92
+ <div className="flex justify-end">
93
+ <Link href="/" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
94
+ back to home
95
+ </Link>
96
+ </div>
97
+ <div>
98
+ <AppLogo className="mb-4 h-8 w-auto" />
99
+ <h1 className="text-2xl font-semibold tracking-tight">Create your account</h1>
100
+ <p className="mt-1 text-sm text-zinc-400">Start writing. Tasks will follow.</p>
101
+ </div>
102
+
103
+ <form onSubmit={handleSubmit} className="space-y-4">
104
+ <div className="space-y-1">
105
+ <label htmlFor="name" className="text-sm font-medium text-zinc-300">
106
+ Name
107
+ </label>
108
+ <input
109
+ id="name"
110
+ name="name"
111
+ type="text"
112
+ required
113
+ autoComplete="name"
114
+ className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none"
115
+ placeholder="Your name"
116
+ />
117
+ </div>
118
+
119
+ <div className="space-y-1">
120
+ <label htmlFor="email" className="text-sm font-medium text-zinc-300">
121
+ Email
122
+ </label>
123
+ <input
124
+ id="email"
125
+ name="email"
126
+ type="email"
127
+ required
128
+ autoComplete="email"
129
+ className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none"
130
+ placeholder="you@example.com"
131
+ />
132
+ </div>
133
+
134
+ <div className="space-y-1">
135
+ <label htmlFor="password" className="text-sm font-medium text-zinc-300">
136
+ Password
137
+ </label>
138
+ <PasswordInput
139
+ id="password"
140
+ name="password"
141
+ required
142
+ minLength={8}
143
+ autoComplete="new-password"
144
+ value={password}
145
+ onChange={(e) => setPassword(e.target.value)}
146
+ placeholder="••••••••"
147
+ />
148
+ {password.length > 0 && (
149
+ <div className="pt-2">
150
+ <PasswordChecklist password={password} />
151
+ </div>
152
+ )}
153
+ </div>
154
+
155
+ <div className="space-y-1">
156
+ <label htmlFor="confirm" className="text-sm font-medium text-zinc-300">
157
+ Confirm password
158
+ </label>
159
+ <PasswordInput
160
+ id="confirm"
161
+ name="confirm"
162
+ required
163
+ minLength={8}
164
+ autoComplete="new-password"
165
+ value={confirm}
166
+ onChange={(e) => setConfirm(e.target.value)}
167
+ placeholder="••••••••"
168
+ />
169
+ {confirm.length > 0 && password !== confirm && (
170
+ <p className="text-xs text-red-400 pt-1">Passwords don't match</p>
171
+ )}
172
+ </div>
173
+
174
+ {error && <p className="text-sm text-red-400">{error}</p>}
175
+
176
+ <button
177
+ type="submit"
178
+ disabled={loading}
179
+ className="w-full rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
180
+ >
181
+ {loading ? "Creating account…" : "Create account"}
182
+ </button>
183
+ </form>
184
+
185
+ <p className="text-center text-sm text-zinc-500">
186
+ Already have an account?{" "}
187
+ <Link href="/login" className="text-zinc-300 underline underline-offset-2 hover:text-white">
188
+ Sign in
189
+ </Link>
190
+ </p>
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { useState, Suspense } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import Link from "next/link";
6
+ import { PasswordInput } from "@/components/auth/password-input";
7
+ import { PasswordChecklist } from "@/components/auth/password-checklist";
8
+
9
+ export default function ResetPasswordPage() {
10
+ return (
11
+ <Suspense fallback={<ResetSkeleton />}>
12
+ <ResetForm />
13
+ </Suspense>
14
+ );
15
+ }
16
+
17
+ function ResetSkeleton() {
18
+ return (
19
+ <div className="w-full max-w-sm space-y-6">
20
+ <div className="h-8 w-48 animate-pulse rounded bg-zinc-800" />
21
+ <div className="h-4 w-64 animate-pulse rounded bg-zinc-800" />
22
+ <div className="space-y-4 pt-4">
23
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
24
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
25
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
26
+ </div>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ function ResetForm() {
32
+ const router = useRouter();
33
+ const searchParams = useSearchParams();
34
+ const token = searchParams.get("token") ?? "";
35
+
36
+ const [password, setPassword] = useState("");
37
+ const [confirm, setConfirm] = useState("");
38
+ const [loading, setLoading] = useState(false);
39
+ const [error, setError] = useState("");
40
+
41
+ if (!token) {
42
+ return (
43
+ <div className="w-full max-w-sm text-center space-y-3">
44
+ <h1 className="text-2xl font-semibold tracking-tight">Invalid link</h1>
45
+ <p className="text-sm text-zinc-400">This reset link is missing a token.</p>
46
+ <Link href="/forgot-password" className="text-sm text-zinc-300 underline">
47
+ Request a new one
48
+ </Link>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ async function handleSubmit(e: React.FormEvent) {
54
+ e.preventDefault();
55
+ if (password !== confirm) {
56
+ setError("Passwords don't match");
57
+ return;
58
+ }
59
+ setLoading(true);
60
+ setError("");
61
+ try {
62
+ const res = await fetch("/api/auth/reset-password", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ token, password }),
66
+ });
67
+ if (res.ok) {
68
+ router.push("/login?reset=1");
69
+ } else {
70
+ const d = await res.json();
71
+ setError(d.error ?? "Reset failed. The link may have expired.");
72
+ }
73
+ } catch {
74
+ setError("Something went wrong. Please try again.");
75
+ } finally {
76
+ setLoading(false);
77
+ }
78
+ }
79
+
80
+ return (
81
+ <div className="w-full max-w-sm space-y-6">
82
+ <div>
83
+ <h1 className="text-2xl font-semibold tracking-tight">Set new password</h1>
84
+ <p className="mt-1 text-sm text-zinc-400">Choose a password with at least 8 characters.</p>
85
+ </div>
86
+
87
+ <form onSubmit={handleSubmit} className="space-y-4">
88
+ <div className="space-y-1">
89
+ <label htmlFor="password" className="text-sm font-medium text-zinc-300">
90
+ New password
91
+ </label>
92
+ <PasswordInput
93
+ id="password"
94
+ required
95
+ minLength={8}
96
+ value={password}
97
+ onChange={(e) => setPassword(e.target.value)}
98
+ autoComplete="new-password"
99
+ placeholder="••••••••"
100
+ />
101
+ {password.length > 0 && (
102
+ <div className="pt-2">
103
+ <PasswordChecklist password={password} />
104
+ </div>
105
+ )}
106
+ </div>
107
+
108
+ <div className="space-y-1">
109
+ <label htmlFor="confirm" className="text-sm font-medium text-zinc-300">
110
+ Confirm password
111
+ </label>
112
+ <PasswordInput
113
+ id="confirm"
114
+ required
115
+ minLength={8}
116
+ value={confirm}
117
+ onChange={(e) => setConfirm(e.target.value)}
118
+ autoComplete="new-password"
119
+ placeholder="••••••••"
120
+ />
121
+ {confirm.length > 0 && password !== confirm && (
122
+ <p className="text-xs text-red-400 pt-1">Passwords don't match</p>
123
+ )}
124
+ </div>
125
+
126
+ {error && <p className="text-sm text-red-400">{error}</p>}
127
+
128
+ <button
129
+ type="submit"
130
+ disabled={loading}
131
+ className="w-full rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
132
+ >
133
+ {loading ? "Saving…" : "Set new password"}
134
+ </button>
135
+ </form>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,135 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import bcrypt from "bcryptjs";
3
+ import { auth } from "@/auth";
4
+ import { prisma, getCloudPrisma } from "@/lib/prisma";
5
+ import { rateLimit, getClientIp } from "@/lib/rate-limit";
6
+
7
+ /**
8
+ * POST /api/account/claim
9
+ * Body: { name, email, password }
10
+ *
11
+ * Converts the current GUEST (local, no-password `*@local.brief`) into a real
12
+ * account WITHOUT orphaning their notes:
13
+ * 1. Attach real name/email/passwordHash to the existing local user (same id,
14
+ * same workspace → their notes stay put).
15
+ * 2. Mirror the identity (User + each Workspace + WorkspaceMember + that
16
+ * workspace's KanbanStatuses) into Neon BY ID, because identity is
17
+ * cloud-scoped (PrismaAdapter(cloud)) and `/api/billing/migrate` is keyed by
18
+ * workspaceId — both need the records to exist in cloud.
19
+ *
20
+ * After this, the client signs in with the new credentials and proceeds to
21
+ * billing (setup-intent → subscribe); the Stripe webhook flips the plan flags
22
+ * and triggers the data migration. This route does NOT touch Stripe or plan
23
+ * flags — it only establishes the real, cloud-resolvable identity.
24
+ */
25
+
26
+ const AUTH_MAX = 10;
27
+ const AUTH_WINDOW_MS = 60 * 60_000;
28
+
29
+ export async function POST(req: NextRequest) {
30
+ const ip = getClientIp(req);
31
+ const rl = rateLimit(`claim:${ip}`, AUTH_MAX, AUTH_WINDOW_MS);
32
+ if (rl.limited) {
33
+ return NextResponse.json(
34
+ { error: "Too many attempts. Try again later." },
35
+ { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }
36
+ );
37
+ }
38
+
39
+ const session = await auth();
40
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
41
+
42
+ const cloud = getCloudPrisma();
43
+ if (!cloud) {
44
+ return NextResponse.json(
45
+ { error: "Cloud is not configured — upgrading requires a cloud connection." },
46
+ { status: 503 }
47
+ );
48
+ }
49
+
50
+ // The signed-in user must be an unclaimed guest.
51
+ const me = await prisma.user.findUnique({ where: { id: session.user.id } });
52
+ if (!me || !me.email?.endsWith("@local.brief") || me.passwordHash) {
53
+ return NextResponse.json(
54
+ { error: "This account can't be upgraded (already a real account)." },
55
+ { status: 400 }
56
+ );
57
+ }
58
+
59
+ const { name, email, password } = await req.json().catch(() => ({}));
60
+ if (!name || !email || !password) {
61
+ return NextResponse.json({ error: "Missing fields" }, { status: 400 });
62
+ }
63
+ if (typeof password !== "string" || password.length < 8) {
64
+ return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
65
+ }
66
+ if (typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
67
+ return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
68
+ }
69
+
70
+ // Email must be free in BOTH databases (auth checks cloud-first, but the local
71
+ // DB also holds users in fully-local mode).
72
+ for (const db of [prisma, cloud]) {
73
+ const taken = await db.user.findUnique({ where: { email } });
74
+ if (taken) return NextResponse.json({ error: "Email already in use" }, { status: 409 });
75
+ }
76
+
77
+ const passwordHash = await bcrypt.hash(password, 12);
78
+
79
+ // 1) Promote the local user in place (keeps id + workspace + notes).
80
+ await prisma.user.update({
81
+ where: { id: me.id },
82
+ data: { name, email, passwordHash },
83
+ });
84
+
85
+ // 2) Mirror identity into Neon by id so cloud login + migrate resolve.
86
+ const memberships = await prisma.workspaceMember.findMany({
87
+ where: { userId: me.id, revokedAt: null },
88
+ include: { workspace: true },
89
+ });
90
+
91
+ await cloud.user.upsert({
92
+ where: { id: me.id },
93
+ create: { id: me.id, name, email, passwordHash, role: me.role, createdAt: me.createdAt },
94
+ update: { name, email, passwordHash },
95
+ });
96
+
97
+ for (const m of memberships) {
98
+ const ws = m.workspace;
99
+ await cloud.workspace.upsert({
100
+ where: { id: ws.id },
101
+ create: {
102
+ id: ws.id,
103
+ name: ws.name,
104
+ slug: ws.slug,
105
+ type: ws.type,
106
+ planType: ws.planType,
107
+ licenseType: ws.licenseType,
108
+ isCloud: ws.isCloud,
109
+ isPro: ws.isPro,
110
+ seatCount: ws.seatCount,
111
+ encryptionSalt: ws.encryptionSalt,
112
+ createdAt: ws.createdAt,
113
+ },
114
+ update: { name: ws.name, slug: ws.slug, encryptionSalt: ws.encryptionSalt },
115
+ });
116
+
117
+ await cloud.workspaceMember.upsert({
118
+ where: { userId_workspaceId: { userId: me.id, workspaceId: ws.id } },
119
+ create: { id: m.id, userId: me.id, workspaceId: ws.id, role: m.role, joinedAt: m.joinedAt },
120
+ update: { role: m.role },
121
+ });
122
+
123
+ // Mirror the workspace's kanban statuses so the board looks identical in cloud.
124
+ const statuses = await prisma.kanbanStatus.findMany({ where: { workspaceId: ws.id } });
125
+ for (const s of statuses) {
126
+ await cloud.kanbanStatus.upsert({
127
+ where: { workspaceId_key: { workspaceId: ws.id, key: s.key } },
128
+ create: { workspaceId: ws.id, key: s.key, label: s.label, color: s.color, order: s.order, isVisible: s.isVisible },
129
+ update: { label: s.label, color: s.color, order: s.order, isVisible: s.isVisible },
130
+ });
131
+ }
132
+ }
133
+
134
+ return NextResponse.json({ ok: true, email });
135
+ }