@knotpad/app 0.1.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 (342) hide show
  1. package/README.md +167 -0
  2. package/app/(app)/calendar/page.tsx +57 -0
  3. package/app/(app)/error.tsx +35 -0
  4. package/app/(app)/graph/page.tsx +32 -0
  5. package/app/(app)/guide/page.tsx +21 -0
  6. package/app/(app)/kanban/loading.tsx +24 -0
  7. package/app/(app)/kanban/page.tsx +59 -0
  8. package/app/(app)/layout.tsx +122 -0
  9. package/app/(app)/list/loading.tsx +21 -0
  10. package/app/(app)/list/page.tsx +137 -0
  11. package/app/(app)/loading.tsx +18 -0
  12. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  13. package/app/(app)/notes/layout.tsx +30 -0
  14. package/app/(app)/notes/page.tsx +39 -0
  15. package/app/(app)/page.tsx +5 -0
  16. package/app/(app)/settings/agent-token/page.tsx +59 -0
  17. package/app/(app)/settings/backup/page.tsx +49 -0
  18. package/app/(app)/settings/billing/page.tsx +53 -0
  19. package/app/(app)/settings/calendar/page.tsx +41 -0
  20. package/app/(app)/settings/layout.test.tsx +39 -0
  21. package/app/(app)/settings/layout.tsx +71 -0
  22. package/app/(app)/settings/page.tsx +4 -0
  23. package/app/(app)/settings/security/page.tsx +43 -0
  24. package/app/(app)/settings/team/page.tsx +74 -0
  25. package/app/(app)/settings/workspace/page.tsx +27 -0
  26. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  27. package/app/(auth)/forgot-password/page.tsx +106 -0
  28. package/app/(auth)/guest/page.tsx +56 -0
  29. package/app/(auth)/layout.tsx +13 -0
  30. package/app/(auth)/login/page.tsx +14 -0
  31. package/app/(auth)/register/page.tsx +193 -0
  32. package/app/(auth)/reset-password/page.tsx +138 -0
  33. package/app/api/account/claim/route.tsx +135 -0
  34. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  35. package/app/api/admin/license/route.tsx +42 -0
  36. package/app/api/auth/2fa/route.tsx +148 -0
  37. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  38. package/app/api/auth/change-password/route.tsx +61 -0
  39. package/app/api/auth/check-2fa/route.tsx +19 -0
  40. package/app/api/auth/forgot-password/route.tsx +65 -0
  41. package/app/api/auth/reset-password/route.tsx +52 -0
  42. package/app/api/auth/verify-2fa/route.tsx +88 -0
  43. package/app/api/backup/download/db/route.ts +29 -0
  44. package/app/api/backup/download/notes/route.ts +25 -0
  45. package/app/api/backup/settings/route.ts +92 -0
  46. package/app/api/billing/checkout/route.tsx +81 -0
  47. package/app/api/billing/migrate/route.tsx +163 -0
  48. package/app/api/billing/portal/route.tsx +24 -0
  49. package/app/api/billing/setup-intent/route.tsx +55 -0
  50. package/app/api/billing/status/route.tsx +36 -0
  51. package/app/api/billing/subscribe/route.tsx +85 -0
  52. package/app/api/billing/webhook/route.tsx +199 -0
  53. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  54. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  55. package/app/api/calendar-feeds/events/route.tsx +82 -0
  56. package/app/api/calendar-feeds/route.tsx +52 -0
  57. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  58. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  59. package/app/api/cron/stale-tasks/route.tsx +51 -0
  60. package/app/api/cron/sync/route.tsx +34 -0
  61. package/app/api/devices/[deviceId]/route.tsx +25 -0
  62. package/app/api/devices/route.tsx +41 -0
  63. package/app/api/export/route.tsx +40 -0
  64. package/app/api/feedback/route.tsx +54 -0
  65. package/app/api/folders/[folderId]/route.tsx +51 -0
  66. package/app/api/folders/route.tsx +37 -0
  67. package/app/api/graph/route.tsx +242 -0
  68. package/app/api/guest/route.tsx +58 -0
  69. package/app/api/health/route.tsx +10 -0
  70. package/app/api/holidays/countries/route.tsx +14 -0
  71. package/app/api/holidays/route.tsx +49 -0
  72. package/app/api/holidays/states/route.tsx +21 -0
  73. package/app/api/invites/[token]/route.tsx +131 -0
  74. package/app/api/invites/route.tsx +74 -0
  75. package/app/api/mcp/generate-token/route.tsx +55 -0
  76. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  77. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  78. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  79. package/app/api/notes/[noteId]/route.tsx +360 -0
  80. package/app/api/notes/route.tsx +112 -0
  81. package/app/api/notifications/route.tsx +44 -0
  82. package/app/api/register/route.tsx +67 -0
  83. package/app/api/restore/route.tsx +148 -0
  84. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  85. package/app/api/sync/conflicts/route.tsx +48 -0
  86. package/app/api/sync/status/route.tsx +49 -0
  87. package/app/api/sync/trigger/route.tsx +15 -0
  88. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  89. package/app/api/tasks/[taskId]/route.tsx +259 -0
  90. package/app/api/tasks/bulk/route.tsx +133 -0
  91. package/app/api/tasks/route.tsx +36 -0
  92. package/app/api/workspace/active/route.tsx +39 -0
  93. package/app/api/workspace/create-team/route.tsx +42 -0
  94. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  95. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  96. package/app/api/workspace/route.tsx +24 -0
  97. package/app/download/page.tsx +170 -0
  98. package/app/favicon.ico +0 -0
  99. package/app/generated/prisma/client.d.ts +1 -0
  100. package/app/generated/prisma/client.js +5 -0
  101. package/app/generated/prisma/default.d.ts +1 -0
  102. package/app/generated/prisma/default.js +5 -0
  103. package/app/generated/prisma/edge.d.ts +1 -0
  104. package/app/generated/prisma/edge.js +497 -0
  105. package/app/generated/prisma/index-browser.js +523 -0
  106. package/app/generated/prisma/index.d.ts +46376 -0
  107. package/app/generated/prisma/index.js +497 -0
  108. package/app/generated/prisma/package.json +144 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  111. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  112. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  113. package/app/generated/prisma/runtime/client.js +86 -0
  114. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  115. package/app/generated/prisma/runtime/index-browser.js +6 -0
  116. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  117. package/app/generated/prisma/schema.prisma +456 -0
  118. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  119. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  120. package/app/globals.css +54 -0
  121. package/app/invite/[token]/page.tsx +52 -0
  122. package/app/layout.tsx +90 -0
  123. package/app/mcp/route.tsx +430 -0
  124. package/app/opengraph-image.tsx +120 -0
  125. package/app/page.tsx +398 -0
  126. package/app/privacy/page.tsx +69 -0
  127. package/app/robots.tsx +25 -0
  128. package/app/sitemap.tsx +36 -0
  129. package/app/terms/page.tsx +69 -0
  130. package/app/upgrade/page.tsx +75 -0
  131. package/auth.config.ts +33 -0
  132. package/auth.ts +79 -0
  133. package/bin/brief.js +224 -0
  134. package/components/auth/login-form.tsx +302 -0
  135. package/components/auth/password-checklist.tsx +31 -0
  136. package/components/auth/password-input.tsx +36 -0
  137. package/components/auth/switch-account-button.test.tsx +22 -0
  138. package/components/auth/switch-account-button.tsx +19 -0
  139. package/components/auth/two-factor-input.tsx +116 -0
  140. package/components/billing/billing-dashboard.tsx +265 -0
  141. package/components/billing/card-form.tsx +210 -0
  142. package/components/billing/claim-account-form.tsx +99 -0
  143. package/components/branding/app-logo.test.tsx +20 -0
  144. package/components/branding/app-logo.tsx +25 -0
  145. package/components/calendar/calendar-agenda.tsx +150 -0
  146. package/components/calendar/calendar-drag.test.tsx +177 -0
  147. package/components/calendar/calendar-grid.tsx +357 -0
  148. package/components/calendar/calendar-hooks.test.tsx +27 -0
  149. package/components/calendar/calendar-hooks.ts +351 -0
  150. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  151. package/components/calendar/calendar-toolbar.tsx +291 -0
  152. package/components/calendar/calendar-types.ts +148 -0
  153. package/components/calendar/calendar-view.test.tsx +295 -0
  154. package/components/calendar/calendar-view.tsx +307 -0
  155. package/components/calendar/day-detail-popover.tsx +174 -0
  156. package/components/calendar/task-chip.tsx +86 -0
  157. package/components/command/command-palette.test.tsx +33 -0
  158. package/components/command/command-palette.tsx +310 -0
  159. package/components/download-cta.tsx +87 -0
  160. package/components/feedback/feedback-popup.tsx +207 -0
  161. package/components/graph/graph-draw.ts +337 -0
  162. package/components/graph/graph-overlays.tsx +160 -0
  163. package/components/graph/graph-page.test.tsx +131 -0
  164. package/components/graph/graph-page.tsx +263 -0
  165. package/components/graph/graph-types.ts +47 -0
  166. package/components/graph/graph-view.tsx +322 -0
  167. package/components/guide/guide-view.tsx +522 -0
  168. package/components/kanban/kanban-board.test.tsx +128 -0
  169. package/components/kanban/kanban-board.tsx +361 -0
  170. package/components/kanban/kanban-card-menu.tsx +102 -0
  171. package/components/kanban/kanban-card.tsx +227 -0
  172. package/components/kanban/kanban-column.tsx +49 -0
  173. package/components/kanban/kanban-status-context.tsx +28 -0
  174. package/components/landing/calendar-sandbox.test.tsx +15 -0
  175. package/components/landing/calendar-sandbox.tsx +107 -0
  176. package/components/landing/graph-sandbox.test.tsx +27 -0
  177. package/components/landing/graph-sandbox.tsx +80 -0
  178. package/components/landing/kanban-sandbox.test.tsx +24 -0
  179. package/components/landing/kanban-sandbox.tsx +101 -0
  180. package/components/landing/landing-showcase.test.tsx +21 -0
  181. package/components/landing/landing-showcase.tsx +54 -0
  182. package/components/landing/list-sandbox.tsx +86 -0
  183. package/components/landing/mock-workspace.ts +168 -0
  184. package/components/landing/notes-sandbox.test.tsx +14 -0
  185. package/components/landing/notes-sandbox.tsx +88 -0
  186. package/components/layout/app-shell.tsx +83 -0
  187. package/components/layout/backup-scheduler.tsx +122 -0
  188. package/components/layout/bottom-nav.tsx +43 -0
  189. package/components/layout/icon-bar.test.tsx +29 -0
  190. package/components/layout/icon-bar.tsx +118 -0
  191. package/components/layout/mobile-top-bar.tsx +68 -0
  192. package/components/layout/notes-panel-folder.tsx +127 -0
  193. package/components/layout/notes-panel-note-item.tsx +140 -0
  194. package/components/layout/notes-panel-task-tab.tsx +63 -0
  195. package/components/layout/notes-panel-types.ts +44 -0
  196. package/components/layout/notes-panel.tsx +476 -0
  197. package/components/layout/notification-bell.tsx +251 -0
  198. package/components/layout/paywall-screen.tsx +41 -0
  199. package/components/layout/pro-banner.tsx +76 -0
  200. package/components/layout/sw-register.tsx +27 -0
  201. package/components/layout/workspace-switcher.tsx +90 -0
  202. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  203. package/components/notes/note-editor-context-menu.tsx +47 -0
  204. package/components/notes/note-editor-dom.ts +33 -0
  205. package/components/notes/note-editor-dropdowns.tsx +484 -0
  206. package/components/notes/note-editor-hooks.ts +692 -0
  207. package/components/notes/note-editor-keyboard.ts +305 -0
  208. package/components/notes/note-editor-overlay.tsx +90 -0
  209. package/components/notes/note-editor.test.tsx +372 -0
  210. package/components/notes/note-editor.tsx +662 -0
  211. package/components/notes/note-preview-pane.tsx +156 -0
  212. package/components/notes/note-tabs.tsx +120 -0
  213. package/components/notes/note-types.tsx +157 -0
  214. package/components/settings/accept-invite.tsx +108 -0
  215. package/components/settings/agent-token-settings.tsx +369 -0
  216. package/components/settings/backup-restore-settings.test.tsx +25 -0
  217. package/components/settings/backup-restore-settings.tsx +327 -0
  218. package/components/settings/calendar-feeds-settings.tsx +489 -0
  219. package/components/settings/calendar-general-settings.tsx +174 -0
  220. package/components/settings/confirm-danger-action.test.tsx +215 -0
  221. package/components/settings/confirm-danger-action.tsx +65 -0
  222. package/components/settings/security-settings.tsx +252 -0
  223. package/components/settings/settings-guidance.test.tsx +98 -0
  224. package/components/settings/team-settings.tsx +319 -0
  225. package/components/settings/two-factor-auth.tsx +296 -0
  226. package/components/settings/workspace-settings-client.tsx +363 -0
  227. package/components/settings/workspace-settings-form.tsx +73 -0
  228. package/components/sync/conflict-viewer.tsx +247 -0
  229. package/components/sync/sync-indicator.tsx +171 -0
  230. package/components/tasks/snippet-thread.tsx +119 -0
  231. package/components/tasks/status-dot.tsx +47 -0
  232. package/components/tasks/task-badge.tsx +43 -0
  233. package/components/tasks/task-detail.test.tsx +187 -0
  234. package/components/tasks/task-detail.tsx +458 -0
  235. package/components/tasks/task-list-filters.test.tsx +75 -0
  236. package/components/tasks/task-list-filters.tsx +163 -0
  237. package/components/tasks/task-list-types.ts +20 -0
  238. package/components/tasks/task-list.test.tsx +175 -0
  239. package/components/tasks/task-list.tsx +481 -0
  240. package/components/tasks/task-row.tsx +85 -0
  241. package/components/tasks/task-table-row.tsx +259 -0
  242. package/components/ui/skeleton.tsx +3 -0
  243. package/components/ui/toast.test.tsx +42 -0
  244. package/components/ui/toast.tsx +70 -0
  245. package/electron/main.ts +251 -0
  246. package/electron/preload.ts +56 -0
  247. package/instrumentation.tsx +23 -0
  248. package/lib/api-error.ts +50 -0
  249. package/lib/backup/backup-runner.test.ts +32 -0
  250. package/lib/backup/backup-runner.ts +19 -0
  251. package/lib/backup/backup-schedule.test.ts +23 -0
  252. package/lib/backup/backup-schedule.ts +55 -0
  253. package/lib/backup/backup-settings.test.ts +30 -0
  254. package/lib/backup/backup-settings.ts +27 -0
  255. package/lib/backup/export-notes-zip.test.ts +26 -0
  256. package/lib/backup/export-notes-zip.ts +82 -0
  257. package/lib/backup/export-workspace-backup.test.ts +17 -0
  258. package/lib/backup/export-workspace-backup.ts +77 -0
  259. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  260. package/lib/backup/restore-workspace-from-export.ts +183 -0
  261. package/lib/backup/types.ts +14 -0
  262. package/lib/brand-icons.ts +1 -0
  263. package/lib/calendar-feed-crypto.ts +38 -0
  264. package/lib/calendar-feed.ts +239 -0
  265. package/lib/client/online-status.ts +47 -0
  266. package/lib/conflict-resolver.test.ts +57 -0
  267. package/lib/conflict-resolver.ts +240 -0
  268. package/lib/db-init.ts +79 -0
  269. package/lib/email.ts +159 -0
  270. package/lib/encryption.test.ts +41 -0
  271. package/lib/encryption.ts +98 -0
  272. package/lib/extract-snippet.test.ts +123 -0
  273. package/lib/extract-snippet.ts +69 -0
  274. package/lib/kanban-status.ts +55 -0
  275. package/lib/license.ts +21 -0
  276. package/lib/limits.ts +31 -0
  277. package/lib/mcp-auth.test.ts +58 -0
  278. package/lib/mcp-auth.ts +65 -0
  279. package/lib/mcp-contract.test.ts +25 -0
  280. package/lib/mcp-contract.ts +210 -0
  281. package/lib/mcp-handler.ts +31 -0
  282. package/lib/mcp-url.test.ts +12 -0
  283. package/lib/mcp-url.ts +7 -0
  284. package/lib/mentions.test.ts +45 -0
  285. package/lib/mentions.ts +73 -0
  286. package/lib/note-crypto.ts +108 -0
  287. package/lib/note-sync.ts +201 -0
  288. package/lib/note-title.ts +93 -0
  289. package/lib/prisma.ts +193 -0
  290. package/lib/pro-flush.ts +292 -0
  291. package/lib/rate-limit.ts +57 -0
  292. package/lib/stripe.ts +38 -0
  293. package/lib/sync-worker.ts +388 -0
  294. package/lib/task-parser.test.ts +91 -0
  295. package/lib/task-parser.ts +81 -0
  296. package/lib/task-utils.ts +52 -0
  297. package/lib/use-is-electron.ts +19 -0
  298. package/lib/use-is-mobile.ts +22 -0
  299. package/lib/validation/calendar-feed.ts +31 -0
  300. package/lib/validation/note.ts +27 -0
  301. package/lib/validation/task.ts +26 -0
  302. package/lib/view-preferences.test.ts +54 -0
  303. package/lib/view-preferences.ts +28 -0
  304. package/lib/workspace.ts +66 -0
  305. package/next.config.ts +21 -0
  306. package/package.json +99 -0
  307. package/postcss.config.mjs +7 -0
  308. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  309. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  310. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  311. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  312. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  313. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  314. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  315. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  316. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  317. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  318. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  319. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  320. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  321. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  322. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  323. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  324. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  325. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  326. package/prisma/migrations/migration_lock.toml +3 -0
  327. package/prisma/schema.prisma +457 -0
  328. package/public/Logo_icon.svg +1 -0
  329. package/public/file.svg +1 -0
  330. package/public/globe.svg +1 -0
  331. package/public/icon-192.png +0 -0
  332. package/public/icon-512.png +0 -0
  333. package/public/icon.svg +4 -0
  334. package/public/icon_dark.svg +1 -0
  335. package/public/knotpad_icon.svg +1 -0
  336. package/public/knotpad_logo_full.svg +1 -0
  337. package/public/manifest.json +14 -0
  338. package/public/next.svg +1 -0
  339. package/public/sw.js +137 -0
  340. package/public/vercel.svg +1 -0
  341. package/public/window.svg +1 -0
  342. package/tsconfig.json +35 -0
@@ -0,0 +1,302 @@
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 { TwoFactorInput } from "@/components/auth/two-factor-input";
10
+ import { Shield, Loader2 } from "lucide-react";
11
+
12
+ export function LoginSkeleton() {
13
+ return (
14
+ <div className="w-full max-w-sm space-y-6">
15
+ <div className="h-8 w-48 animate-pulse rounded bg-zinc-800" />
16
+ <div className="h-4 w-64 animate-pulse rounded bg-zinc-800" />
17
+ <div className="space-y-4 pt-4">
18
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
19
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
20
+ <div className="h-10 animate-pulse rounded bg-zinc-800" />
21
+ </div>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ // `isCloud` reflects the runtime: desktop/NPX (false) offers a local, no-account
27
+ // "Start writing" path; the browser/cloud build (true) has no local DB, so it
28
+ // only offers Log in / Sign up.
29
+ export function LoginForm({ isCloud }: { isCloud: boolean }) {
30
+ return (
31
+ <Suspense fallback={<LoginSkeleton />}>
32
+ <LoginFormInner isCloud={isCloud} />
33
+ </Suspense>
34
+ );
35
+ }
36
+
37
+ function LoginFormInner({ isCloud }: { isCloud: boolean }) {
38
+ const router = useRouter();
39
+ const searchParams = useSearchParams();
40
+ const [error, setError] = useState("");
41
+ const [loading, setLoading] = useState(false);
42
+ const [needs2FA, setNeeds2FA] = useState(false);
43
+ const [twoFactorCode, setTwoFactorCode] = useState("");
44
+ const [credentials, setCredentials] = useState({ email: "", password: "" });
45
+
46
+ // Validate that ?next= is a same-origin relative path to prevent open redirect.
47
+ const rawNext = searchParams.get("next") ?? "";
48
+ const redirectTo =
49
+ rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/notes";
50
+ const justReset = searchParams.get("reset") === "1";
51
+
52
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
53
+ e.preventDefault();
54
+ setLoading(true);
55
+ setError("");
56
+
57
+ const form = e.currentTarget;
58
+ const email = (form.elements.namedItem("email") as HTMLInputElement).value;
59
+ const password = (form.elements.namedItem("password") as HTMLInputElement).value;
60
+
61
+ setCredentials({ email, password });
62
+
63
+ try {
64
+ // Check if 2FA is required first
65
+ const checkRes = await fetch("/api/auth/check-2fa", {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ email }),
69
+ });
70
+
71
+ const checkData = await checkRes.json();
72
+
73
+ if (checkData.requires2FA) {
74
+ setNeeds2FA(true);
75
+ setLoading(false);
76
+ return;
77
+ }
78
+
79
+ // No 2FA required, proceed with normal sign in
80
+ const result = await signIn("credentials", {
81
+ email,
82
+ password,
83
+ redirect: false,
84
+ });
85
+
86
+ if (result?.error) {
87
+ setError("Invalid email or password");
88
+ setLoading(false);
89
+ } else {
90
+ router.push(redirectTo);
91
+ router.refresh();
92
+ }
93
+ } catch {
94
+ setError("Something went wrong. Please try again.");
95
+ setLoading(false);
96
+ }
97
+ }
98
+
99
+ async function handle2FASubmit(e: React.FormEvent) {
100
+ e.preventDefault();
101
+ setLoading(true);
102
+ setError("");
103
+
104
+ try {
105
+ // Verify 2FA code
106
+ const res = await fetch("/api/auth/verify-2fa", {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify({
110
+ email: credentials.email,
111
+ password: credentials.password,
112
+ code: twoFactorCode,
113
+ }),
114
+ });
115
+
116
+ const data = await res.json();
117
+
118
+ if (!res.ok) {
119
+ setError(data.error || "Invalid 2FA code");
120
+ setLoading(false);
121
+ return;
122
+ }
123
+
124
+ // 2FA verified, now sign in
125
+ const result = await signIn("credentials", {
126
+ email: credentials.email,
127
+ password: credentials.password,
128
+ redirect: false,
129
+ });
130
+
131
+ if (result?.error) {
132
+ setError("Authentication failed");
133
+ setLoading(false);
134
+ } else {
135
+ router.push(redirectTo);
136
+ router.refresh();
137
+ }
138
+ } catch {
139
+ setError("Something went wrong. Please try again.");
140
+ setLoading(false);
141
+ }
142
+ }
143
+
144
+ return (
145
+ <div className="w-full max-w-sm space-y-6">
146
+ <div className="flex justify-end">
147
+ <Link href="/" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
148
+ back to home
149
+ </Link>
150
+ </div>
151
+ <div>
152
+ <AppLogo className="mb-4 h-8 w-auto" />
153
+ <h1 className="text-2xl font-semibold tracking-tight">Welcome to Knotpad</h1>
154
+ <p className="mt-1 text-sm text-zinc-400">
155
+ {isCloud
156
+ ? "Sign in, or create an account to get started."
157
+ : "Start writing instantly — or sign in to your account."}
158
+ </p>
159
+ </div>
160
+
161
+ {/* Desktop/local: the primary, no-account path. */}
162
+ {!isCloud && (
163
+ <div className="space-y-2">
164
+ <Link
165
+ href="/guest"
166
+ className="block w-full rounded-md bg-zinc-100 px-4 py-2.5 text-center text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors"
167
+ >
168
+ Start writing
169
+ </Link>
170
+ <p className="text-center text-xs text-zinc-600">
171
+ No account needed. Your notes stay on this device.
172
+ </p>
173
+ </div>
174
+ )}
175
+
176
+ {!isCloud && (
177
+ <div className="relative">
178
+ <div className="absolute inset-0 flex items-center">
179
+ <div className="w-full border-t border-zinc-800" />
180
+ </div>
181
+ <div className="relative flex justify-center text-xs">
182
+ <span className="bg-zinc-950 px-3 text-zinc-600">or sign in</span>
183
+ </div>
184
+ </div>
185
+ )}
186
+
187
+ {justReset && (
188
+ <div className="rounded-md border border-emerald-800/40 bg-emerald-950/30 px-4 py-3">
189
+ <p className="text-sm text-emerald-300">Password updated. Sign in with your new password.</p>
190
+ </div>
191
+ )}
192
+
193
+ {needs2FA ? (
194
+ <form onSubmit={handle2FASubmit} className="space-y-4">
195
+ <div className="flex items-center gap-2 text-zinc-300">
196
+ <Shield size={18} />
197
+ <h2 className="text-sm font-medium">Two-Factor Authentication</h2>
198
+ </div>
199
+ <p className="text-xs text-zinc-400">
200
+ Enter the 6-digit code from your authenticator app.
201
+ </p>
202
+
203
+ <div className="space-y-1">
204
+ <label className="text-sm font-medium text-zinc-300">
205
+ 2FA Code
206
+ </label>
207
+ <TwoFactorInput
208
+ value={twoFactorCode}
209
+ onChange={setTwoFactorCode}
210
+ length={6}
211
+ disabled={loading}
212
+ autoFocus
213
+ />
214
+ </div>
215
+
216
+ {error && <p className="text-sm text-red-400">{error}</p>}
217
+
218
+ <div className="flex gap-2">
219
+ <button
220
+ type="button"
221
+ onClick={() => {
222
+ setNeeds2FA(false);
223
+ setTwoFactorCode("");
224
+ setError("");
225
+ }}
226
+ className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800"
227
+ >
228
+ Back
229
+ </button>
230
+ <button
231
+ type="submit"
232
+ disabled={loading || twoFactorCode.length !== 6}
233
+ className="flex-1 rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
234
+ >
235
+ {loading ? (
236
+ <span className="flex items-center justify-center gap-1">
237
+ <Loader2 size={14} className="animate-spin" />
238
+ Verifying…
239
+ </span>
240
+ ) : (
241
+ "Verify"
242
+ )}
243
+ </button>
244
+ </div>
245
+ </form>
246
+ ) : (
247
+ <form onSubmit={handleSubmit} className="space-y-4">
248
+ <div className="space-y-1">
249
+ <label htmlFor="email" className="text-sm font-medium text-zinc-300">
250
+ Email
251
+ </label>
252
+ <input
253
+ id="email"
254
+ name="email"
255
+ type="email"
256
+ required
257
+ autoComplete="email"
258
+ 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"
259
+ placeholder="you@example.com"
260
+ />
261
+ </div>
262
+
263
+ <div className="space-y-1">
264
+ <label htmlFor="password" className="text-sm font-medium text-zinc-300">
265
+ Password
266
+ </label>
267
+ <PasswordInput
268
+ id="password"
269
+ name="password"
270
+ required
271
+ autoComplete="current-password"
272
+ placeholder="••••••••"
273
+ />
274
+ </div>
275
+
276
+ {error && <p className="text-sm text-red-400">{error}</p>}
277
+
278
+ <div className="flex justify-end">
279
+ <Link href="/forgot-password" className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors">
280
+ Forgot password?
281
+ </Link>
282
+ </div>
283
+
284
+ <button
285
+ type="submit"
286
+ disabled={loading}
287
+ className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
288
+ >
289
+ {loading ? "Signing in…" : "Sign in"}
290
+ </button>
291
+ </form>
292
+ )}
293
+
294
+ <p className="text-center text-sm text-zinc-500">
295
+ {isCloud ? "Want cloud sync and Pro? " : "Need cloud sync across devices? "}
296
+ <Link href="/register" className="text-zinc-300 underline underline-offset-2 hover:text-white">
297
+ Sign up
298
+ </Link>
299
+ </p>
300
+ </div>
301
+ );
302
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { Check, X } from "lucide-react";
4
+
5
+ const RULES: { label: string; test: (pw: string) => boolean }[] = [
6
+ { label: "At least 8 characters", test: (pw) => pw.length >= 8 },
7
+ { label: "One uppercase letter", test: (pw) => /[A-Z]/.test(pw) },
8
+ { label: "One lowercase letter", test: (pw) => /[a-z]/.test(pw) },
9
+ { label: "One number", test: (pw) => /[0-9]/.test(pw) },
10
+ { label: "One symbol (e.g. !?#$%)", test: (pw) => /[^A-Za-z0-9]/.test(pw) },
11
+ ];
12
+
13
+ export function PasswordChecklist({ password }: { password: string }) {
14
+ return (
15
+ <ul className="space-y-1">
16
+ {RULES.map((rule) => {
17
+ const met = rule.test(password);
18
+ return (
19
+ <li key={rule.label} className="flex items-center gap-1.5 text-xs">
20
+ {met ? (
21
+ <Check className="h-3.5 w-3.5 text-emerald-500" />
22
+ ) : (
23
+ <X className="h-3.5 w-3.5 text-zinc-700" />
24
+ )}
25
+ <span className={met ? "text-zinc-400" : "text-zinc-600"}>{rule.label}</span>
26
+ </li>
27
+ );
28
+ })}
29
+ </ul>
30
+ );
31
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useState, forwardRef } from "react";
4
+ import { Eye, EyeOff } from "lucide-react";
5
+
6
+ type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, "type">;
7
+
8
+ export const PasswordInput = forwardRef<HTMLInputElement, Props>(function PasswordInput(
9
+ { className, ...props },
10
+ ref
11
+ ) {
12
+ const [visible, setVisible] = useState(false);
13
+
14
+ return (
15
+ <div className="relative">
16
+ <input
17
+ {...props}
18
+ ref={ref}
19
+ type={visible ? "text" : "password"}
20
+ className={
21
+ className ??
22
+ "w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 pr-10 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none ring-focus"
23
+ }
24
+ />
25
+ <button
26
+ type="button"
27
+ onClick={() => setVisible((v) => !v)}
28
+ tabIndex={-1}
29
+ aria-label={visible ? "Hide password" : "Show password"}
30
+ className="absolute inset-y-0 right-0 flex items-center px-3 text-zinc-500 hover:text-zinc-300 transition-colors"
31
+ >
32
+ {visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
33
+ </button>
34
+ </div>
35
+ );
36
+ });
@@ -0,0 +1,22 @@
1
+ // @vitest-environment jsdom
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { SwitchAccountButton } from "@/components/auth/switch-account-button";
6
+
7
+ const signOut = vi.fn();
8
+
9
+ vi.mock("next-auth/react", () => ({
10
+ signOut: (...args: unknown[]) => signOut(...args),
11
+ }));
12
+
13
+ describe("SwitchAccountButton", () => {
14
+ it("signs the user out to the login page when clicked", async () => {
15
+ const user = userEvent.setup();
16
+ render(<SwitchAccountButton />);
17
+
18
+ await user.click(screen.getByRole("button", { name: "Switch account" }));
19
+
20
+ expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/login" });
21
+ });
22
+ });
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { signOut } from "next-auth/react";
4
+
5
+ type SwitchAccountButtonProps = {
6
+ className?: string;
7
+ };
8
+
9
+ export function SwitchAccountButton({ className }: SwitchAccountButtonProps) {
10
+ return (
11
+ <button
12
+ type="button"
13
+ onClick={() => signOut({ callbackUrl: "/login" })}
14
+ className={className}
15
+ >
16
+ Switch account
17
+ </button>
18
+ );
19
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+
5
+ interface TwoFactorInputProps {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ length?: number;
9
+ disabled?: boolean;
10
+ autoFocus?: boolean;
11
+ }
12
+
13
+ export function TwoFactorInput({
14
+ value,
15
+ onChange,
16
+ length = 6,
17
+ disabled = false,
18
+ autoFocus = false,
19
+ }: TwoFactorInputProps) {
20
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
21
+ const [focusedIndex, setFocusedIndex] = useState<number>(autoFocus ? 0 : -1);
22
+
23
+ useEffect(() => {
24
+ if (autoFocus) {
25
+ inputRefs.current[0]?.focus();
26
+ }
27
+ }, [autoFocus]);
28
+
29
+ const handleChange = (index: number, e: React.ChangeEvent<HTMLInputElement>) => {
30
+ const newValue = e.target.value;
31
+
32
+ // Only allow digits
33
+ if (!/^\d*$/.test(newValue)) return;
34
+
35
+ const currentValue = value.split("");
36
+ currentValue[index] = newValue.slice(-1); // Take only last character
37
+ const newValueStr = currentValue.join("");
38
+
39
+ onChange(newValueStr);
40
+
41
+ // Auto-focus next input
42
+ if (newValue && index < length - 1) {
43
+ inputRefs.current[index + 1]?.focus();
44
+ }
45
+ };
46
+
47
+ const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
48
+ // Handle backspace
49
+ if (e.key === "Backspace") {
50
+ if (!value[index] && index > 0) {
51
+ // If current is empty, go back and delete previous
52
+ const currentValue = value.split("");
53
+ currentValue[index - 1] = "";
54
+ onChange(currentValue.join(""));
55
+ inputRefs.current[index - 1]?.focus();
56
+ } else {
57
+ // Delete current
58
+ const currentValue = value.split("");
59
+ currentValue[index] = "";
60
+ onChange(currentValue.join(""));
61
+ }
62
+ }
63
+ // Handle left arrow
64
+ else if (e.key === "ArrowLeft" && index > 0) {
65
+ inputRefs.current[index - 1]?.focus();
66
+ }
67
+ // Handle right arrow
68
+ else if (e.key === "ArrowRight" && index < length - 1) {
69
+ inputRefs.current[index + 1]?.focus();
70
+ }
71
+ };
72
+
73
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
74
+ e.preventDefault();
75
+ const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
76
+ onChange(pastedData);
77
+
78
+ // Focus the next empty input or the last one
79
+ const nextIndex = Math.min(pastedData.length, length - 1);
80
+ inputRefs.current[nextIndex]?.focus();
81
+ };
82
+
83
+ const handleFocus = (index: number) => {
84
+ setFocusedIndex(index);
85
+ };
86
+
87
+ return (
88
+ <div className="flex gap-2">
89
+ {Array.from({ length }).map((_, index) => (
90
+ <input
91
+ key={index}
92
+ ref={(el) => {
93
+ inputRefs.current[index] = el;
94
+ }}
95
+ type="text"
96
+ inputMode="numeric"
97
+ pattern="[0-9]*"
98
+ maxLength={1}
99
+ value={value[index] || ""}
100
+ onChange={(e) => handleChange(index, e)}
101
+ onKeyDown={(e) => handleKeyDown(index, e)}
102
+ onPaste={handlePaste}
103
+ onFocus={() => handleFocus(index)}
104
+ disabled={disabled}
105
+ className={`w-12 h-14 rounded-lg border-2 text-center text-2xl font-semibold text-zinc-100 focus:outline-none transition-all
106
+ ${focusedIndex === index
107
+ ? "border-zinc-400 bg-zinc-800"
108
+ : "border-zinc-700 bg-zinc-900"
109
+ }
110
+ ${disabled ? "opacity-50 cursor-not-allowed" : ""}
111
+ `}
112
+ />
113
+ ))}
114
+ </div>
115
+ );
116
+ }