@knotpad/app 0.1.4 → 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,39 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { getActiveWorkspaceId } from "@/lib/workspace";
5
+
6
+ export default async function NotesHome() {
7
+ const session = await auth();
8
+ if (!session) redirect("/login");
9
+
10
+ const workspaceId = await getActiveWorkspaceId(session.user.id);
11
+ const latestNote = workspaceId
12
+ ? await prisma.note.findFirst({
13
+ where: { workspaceId },
14
+ orderBy: { updatedAt: "desc" },
15
+ select: { id: true },
16
+ })
17
+ : null;
18
+ if (latestNote) redirect(`/notes/${latestNote.id}`);
19
+
20
+ return (
21
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-6">
22
+ <div className="max-w-xs space-y-2">
23
+ <h2 className="text-lg font-semibold text-zinc-300">No notes yet</h2>
24
+ <p className="text-sm text-zinc-500">
25
+ Click the{" "}
26
+ <span className="font-mono text-xs bg-zinc-800 px-1.5 py-0.5 rounded text-zinc-400">
27
+ +
28
+ </span>{" "}
29
+ button in the panel to create your first note.
30
+ </p>
31
+ <p className="text-xs text-zinc-600 mt-3">
32
+ Write{" "}
33
+ <code className="bg-zinc-800 px-1 rounded text-zinc-400">- [ ] task @you</code>{" "}
34
+ inside a note to create tasks automatically.
35
+ </p>
36
+ </div>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,5 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ export default function AppHome() {
4
+ redirect("/notes");
5
+ }
@@ -0,0 +1,59 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { AgentTokenSettings } from "@/components/settings/agent-token-settings";
5
+ import { getMcpUrl } from "@/lib/mcp-url";
6
+
7
+ export default async function AgentTokenPage() {
8
+ const session = await auth();
9
+ if (!session) redirect("/login");
10
+
11
+ const memberships = await prisma.workspaceMember.findMany({
12
+ where: { userId: session.user.id, revokedAt: null },
13
+ include: { workspace: true },
14
+ orderBy: { joinedAt: "asc" },
15
+ });
16
+ if (!memberships.length) redirect("/login");
17
+
18
+ const tokens = await prisma.mcpToken.findMany({
19
+ where: {
20
+ userId: session.user.id,
21
+ workspaceId: { in: memberships.map((m) => m.workspaceId) },
22
+ revokedAt: null,
23
+ },
24
+ });
25
+
26
+ const tokenByWorkspace = Object.fromEntries(tokens.map((t) => [t.workspaceId, t]));
27
+
28
+ const workspaces = memberships.map((m) => {
29
+ const t = tokenByWorkspace[m.workspaceId] ?? null;
30
+ return {
31
+ id: m.workspaceId,
32
+ name: m.workspace.name,
33
+ slug: m.workspace.slug,
34
+ existingToken: t
35
+ ? { id: t.id, alias: t.alias, lastUsed: t.lastUsed?.toISOString() ?? null }
36
+ : null,
37
+ };
38
+ });
39
+
40
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
41
+ const mcpUrl = getMcpUrl(appUrl, process.env.NEXT_PUBLIC_MCP_URL);
42
+
43
+ return (
44
+ <div className="max-w-2xl space-y-6">
45
+ <div>
46
+ <h2 className="text-base font-semibold text-zinc-200">Agent Token</h2>
47
+ <p className="text-sm text-zinc-500 mt-1">
48
+ Generate a personal MCP token, then reuse the setup templates below whenever you reconnect an AI agent.
49
+ </p>
50
+ </div>
51
+ <AgentTokenSettings
52
+ workspaces={workspaces}
53
+ mcpUrl={mcpUrl}
54
+ appUrl={appUrl}
55
+ userId={session.user.id}
56
+ />
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,49 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { BackupRestoreSettings } from "@/components/settings/backup-restore-settings";
5
+
6
+ export default async function BackupPage() {
7
+ const session = await auth();
8
+ if (!session) redirect("/login");
9
+
10
+ const member = await prisma.workspaceMember.findFirst({
11
+ where: { userId: session.user.id, revokedAt: null },
12
+ include: {
13
+ workspace: {
14
+ include: {
15
+ backupSettings: true,
16
+ },
17
+ },
18
+ },
19
+ });
20
+ if (!member) redirect("/login");
21
+
22
+ const initialSettings = member.workspace.backupSettings
23
+ ? {
24
+ scheduleEnabled: member.workspace.backupSettings.scheduleEnabled,
25
+ scheduleCadence: member.workspace.backupSettings.scheduleCadence,
26
+ destinationPath: member.workspace.backupSettings.destinationPath,
27
+ includeMarkdownZip: member.workspace.backupSettings.includeMarkdownZip,
28
+ lastBackupAt: member.workspace.backupSettings.lastBackupAt?.toISOString() ?? null,
29
+ lastBackupStatus: member.workspace.backupSettings.lastBackupStatus,
30
+ lastBackupError: member.workspace.backupSettings.lastBackupError,
31
+ }
32
+ : null;
33
+
34
+ return (
35
+ <div className="max-w-2xl space-y-6">
36
+ <div>
37
+ <h2 className="text-base font-semibold text-zinc-200">Backup & Restore</h2>
38
+ <p className="mt-1 text-sm text-zinc-500">
39
+ Configure local backups, download exports, and restore from a backup file.
40
+ </p>
41
+ </div>
42
+ <BackupRestoreSettings
43
+ isCloudWorkspace={member.workspace.isCloud}
44
+ isOwner={member.role !== "MEMBER"}
45
+ initialSettings={initialSettings}
46
+ />
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,53 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { BillingDashboard } from "@/components/billing/billing-dashboard";
5
+
6
+ export default async function BillingPage() {
7
+ const session = await auth();
8
+ if (!session) redirect("/login");
9
+
10
+ const member = await prisma.workspaceMember.findFirst({
11
+ where: { userId: session.user.id, revokedAt: null },
12
+ include: {
13
+ workspace: {
14
+ include: { _count: { select: { members: { where: { revokedAt: null } } } } },
15
+ },
16
+ },
17
+ orderBy: { joinedAt: "asc" },
18
+ });
19
+ if (!member) redirect("/login");
20
+
21
+ const { workspace } = member;
22
+
23
+ // A guest (local, no-password account) must create a real account before they
24
+ // can pay — surfaced as a claim step in the dashboard.
25
+ const me = await prisma.user.findUnique({
26
+ where: { id: session.user.id },
27
+ select: { email: true, passwordHash: true },
28
+ });
29
+ const isGuest = !!me?.email?.endsWith("@local.brief") && !me?.passwordHash;
30
+
31
+ return (
32
+ <div className="max-w-lg space-y-6">
33
+ <div>
34
+ <h2 className="text-base font-semibold text-zinc-200">Billing</h2>
35
+ <p className="text-sm text-zinc-500 mt-1">
36
+ Manage your subscription and seats.
37
+ </p>
38
+ </div>
39
+ <BillingDashboard
40
+ isPro={workspace.isPro}
41
+ isOwner={member.role === "OWNER"}
42
+ memberCount={workspace._count.members}
43
+ seatCount={workspace.seatCount}
44
+ stripeId={workspace.stripeId}
45
+ planType={workspace.planType}
46
+ licenseType={workspace.licenseType}
47
+ workspaceType={workspace.type}
48
+ workspaceId={workspace.id}
49
+ isGuest={isGuest}
50
+ />
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,41 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { CalendarFeedsSettings } from "@/components/settings/calendar-feeds-settings";
5
+ import { CalendarGeneralSettings } from "@/components/settings/calendar-general-settings";
6
+
7
+ export default async function CalendarSettingsPage() {
8
+ const session = await auth();
9
+ if (!session) redirect("/login");
10
+
11
+ const feeds = await prisma.calendarFeed.findMany({
12
+ where: { userId: session.user.id },
13
+ orderBy: { createdAt: "asc" },
14
+ select: { id: true, label: true, color: true, enabled: true, lastFetchedAt: true, lastError: true },
15
+ });
16
+
17
+ return (
18
+ <div className="max-w-lg space-y-8">
19
+ <div>
20
+ <h2 className="text-base font-semibold text-zinc-200">Calendars</h2>
21
+ <p className="text-sm text-zinc-500 mt-1">
22
+ Connect read-only calendar subscriptions (Google, Apple, etc.) to see your busy times
23
+ on the Knotpad calendar. This is import-only — Knotpad never edits these calendars.
24
+ </p>
25
+ </div>
26
+
27
+ <CalendarGeneralSettings />
28
+
29
+ <CalendarFeedsSettings
30
+ feeds={feeds.map((f) => ({
31
+ id: f.id,
32
+ label: f.label,
33
+ color: f.color,
34
+ enabled: f.enabled,
35
+ lastFetchedAt: f.lastFetchedAt?.toISOString() ?? null,
36
+ lastError: f.lastError,
37
+ }))}
38
+ />
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import SettingsLayout from "@/app/(app)/settings/layout";
6
+
7
+ const { mockUsePathname } = vi.hoisted(() => ({
8
+ mockUsePathname: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("next/navigation", () => ({
12
+ usePathname: mockUsePathname,
13
+ }));
14
+
15
+ describe("SettingsLayout", () => {
16
+ beforeEach(() => {
17
+ mockUsePathname.mockReset();
18
+ mockUsePathname.mockReturnValue("/settings/workspace");
19
+ });
20
+
21
+ afterEach(() => {
22
+ cleanup();
23
+ });
24
+
25
+ it("filters visible settings tabs from the search field", async () => {
26
+ const user = userEvent.setup();
27
+
28
+ render(
29
+ <SettingsLayout>
30
+ <div>Body</div>
31
+ </SettingsLayout>
32
+ );
33
+
34
+ await user.type(screen.getByPlaceholderText("Search settings"), "agent");
35
+
36
+ expect(screen.getByText("Agent Token")).toBeInTheDocument();
37
+ expect(screen.queryByText("Workspace")).not.toBeInTheDocument();
38
+ });
39
+ });
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { useState } from "react";
6
+
7
+ const tabs = [
8
+ { href: "/settings/workspace", label: "Workspace" },
9
+ { href: "/settings/team", label: "Team" },
10
+ { href: "/settings/billing", label: "Billing" },
11
+ { href: "/settings/security", label: "Security" },
12
+ { href: "/settings/backup", label: "Backup & Restore" },
13
+ { href: "/settings/calendar", label: "Calendars" },
14
+ { href: "/settings/agent-token", label: "Agent Token" },
15
+ ];
16
+
17
+ const descriptions: Record<string, string> = {
18
+ "/settings/workspace": "Rename the workspace, adjust defaults, and manage shared workspace behavior.",
19
+ "/settings/team": "Invite people, manage access, and keep ownership clear.",
20
+ "/settings/billing": "Review plan status, limits, and billing controls.",
21
+ "/settings/security": "Manage authentication and account security settings.",
22
+ "/settings/backup": "Export, restore, and protect your workspace data.",
23
+ "/settings/calendar": "Control calendars, feeds, and scheduling defaults.",
24
+ "/settings/agent-token": "Create and revoke agent access with clear operational consequences.",
25
+ };
26
+
27
+ export default function SettingsLayout({ children }: { children: React.ReactNode }) {
28
+ const pathname = usePathname();
29
+ const [query, setQuery] = useState("");
30
+ const normalizedQuery = query.trim().toLowerCase();
31
+ const visibleTabs = tabs.filter((tab) => tab.label.toLowerCase().includes(normalizedQuery));
32
+
33
+ return (
34
+ <div className="flex flex-1 flex-col overflow-hidden">
35
+ <div className="border-b border-zinc-800 px-4 py-3 md:px-6">
36
+ <h1 className="text-sm font-semibold text-zinc-300">Settings</h1>
37
+ <p className="mt-1 max-w-2xl text-xs text-zinc-500">
38
+ {descriptions[pathname] ?? "Manage your workspace configuration."}
39
+ </p>
40
+ </div>
41
+ <div className="flex flex-1 flex-col overflow-hidden md:flex-row">
42
+ <nav className="min-w-0 shrink-0 border-b border-zinc-800 py-2 md:w-52 md:border-b-0 md:border-r md:py-3">
43
+ <div className="px-2 pb-2">
44
+ <input
45
+ value={query}
46
+ onChange={(event) => setQuery(event.target.value)}
47
+ placeholder="Search settings"
48
+ className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-300 outline-none placeholder:text-zinc-500"
49
+ />
50
+ </div>
51
+ <div className="flex gap-1 overflow-x-auto px-2 [&::-webkit-scrollbar]:hidden md:flex-col md:space-y-0.5">
52
+ {visibleTabs.map((t) => (
53
+ <Link
54
+ key={t.href}
55
+ href={t.href}
56
+ className={`flex shrink-0 items-center whitespace-nowrap rounded-md px-3 py-2 text-sm transition-colors ${
57
+ pathname === t.href
58
+ ? "bg-zinc-800 text-zinc-200"
59
+ : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
60
+ }`}
61
+ >
62
+ {t.label}
63
+ </Link>
64
+ ))}
65
+ </div>
66
+ </nav>
67
+ <div className="min-w-0 flex-1 overflow-y-auto px-4 py-5 md:px-8 md:py-6">{children}</div>
68
+ </div>
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,4 @@
1
+ import { redirect } from "next/navigation";
2
+ export default function SettingsRoot() {
3
+ redirect("/settings/workspace");
4
+ }
@@ -0,0 +1,43 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { SecuritySettings } from "@/components/settings/security-settings";
5
+
6
+ export default async function SecurityPage() {
7
+ const session = await auth();
8
+ if (!session) redirect("/login");
9
+
10
+ const member = await prisma.workspaceMember.findFirst({
11
+ where: { userId: session.user.id },
12
+ include: { workspace: true, user: { select: { passwordHash: true, twoFactorEnabled: true } } },
13
+ });
14
+ if (!member) redirect("/login");
15
+
16
+ const devices = await prisma.deviceSession.findMany({
17
+ where: { userId: session.user.id, revokedAt: null },
18
+ orderBy: { lastActive: "desc" },
19
+ });
20
+
21
+ return (
22
+ <div className="max-w-lg space-y-6">
23
+ <div>
24
+ <h2 className="text-base font-semibold text-zinc-200">Security</h2>
25
+ <p className="text-sm text-zinc-500 mt-1">
26
+ Manage connected devices, change your password, and restore from cloud.
27
+ </p>
28
+ </div>
29
+ <SecuritySettings
30
+ isPro={member.workspace.isPro}
31
+ isOwner={member.role === "OWNER"}
32
+ devices={devices.map((d) => ({
33
+ id: d.id,
34
+ deviceName: d.deviceName,
35
+ deviceId: d.deviceId,
36
+ lastActive: d.lastActive.toISOString(),
37
+ }))}
38
+ hasPassword={!!member.user.passwordHash}
39
+ twoFactorEnabled={member.user.twoFactorEnabled}
40
+ />
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,74 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { TeamSettings } from "@/components/settings/team-settings";
5
+
6
+ export default async function TeamSettingsPage() {
7
+ const session = await auth();
8
+ if (!session) redirect("/login");
9
+
10
+ const member = await prisma.workspaceMember.findFirst({
11
+ where: { userId: session.user.id, revokedAt: null },
12
+ include: {
13
+ workspace: {
14
+ include: {
15
+ members: { where: { revokedAt: null }, include: { user: true } },
16
+ invites: { where: { acceptedAt: null, expiresAt: { gt: new Date() } } },
17
+ mcpTokens: {
18
+ where: { revokedAt: null },
19
+ include: { user: { select: { id: true, name: true, email: true } } },
20
+ orderBy: { createdAt: "desc" },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ });
26
+ if (!member) redirect("/login");
27
+
28
+ const members = member.workspace.members.map((m) => ({
29
+ id: m.id,
30
+ userId: m.userId,
31
+ name: m.user.name ?? m.user.email ?? "Unknown",
32
+ email: m.user.email ?? "",
33
+ role: m.role,
34
+ joinedAt: m.joinedAt.toISOString(),
35
+ isCurrentUser: m.userId === session.user.id,
36
+ }));
37
+
38
+ const pendingInvites = member.workspace.invites.map((inv) => ({
39
+ id: inv.id,
40
+ email: inv.email,
41
+ role: inv.role,
42
+ token: inv.token,
43
+ expiresAt: inv.expiresAt.toISOString(),
44
+ }));
45
+
46
+ const canManage = member.role === "OWNER" || member.role === "ADMIN";
47
+
48
+ const mcpTokens = member.workspace.mcpTokens.map((t) => ({
49
+ id: t.id,
50
+ alias: t.alias,
51
+ userName: t.user.name ?? t.user.email ?? "Unknown",
52
+ userId: t.userId,
53
+ lastUsed: t.lastUsed?.toISOString() ?? null,
54
+ }));
55
+
56
+ return (
57
+ <div className="max-w-2xl space-y-8">
58
+ <div>
59
+ <h2 className="text-base font-semibold text-zinc-200">Team</h2>
60
+ <p className="text-sm text-zinc-500 mt-1">
61
+ Invite people and manage workspace access.
62
+ </p>
63
+ </div>
64
+ <TeamSettings
65
+ members={members}
66
+ pendingInvites={pendingInvites}
67
+ canManage={canManage}
68
+ currentUserId={session.user.id}
69
+ workspaceId={member.workspaceId}
70
+ mcpTokens={mcpTokens}
71
+ />
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,27 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { WorkspaceSettingsClient } from "@/components/settings/workspace-settings-client";
5
+ import { getKanbanStatuses } from "@/lib/kanban-status";
6
+
7
+ export default async function WorkspaceSettingsPage() {
8
+ const session = await auth();
9
+ if (!session) redirect("/login");
10
+
11
+ const member = await prisma.workspaceMember.findFirst({
12
+ where: { userId: session.user.id, revokedAt: null },
13
+ include: { workspace: true },
14
+ });
15
+ if (!member) redirect("/login");
16
+
17
+ const isOwnerOrAdmin = member.role === "OWNER" || member.role === "ADMIN";
18
+ const kanbanStatuses = await getKanbanStatuses(member.workspace.id);
19
+
20
+ return (
21
+ <WorkspaceSettingsClient
22
+ workspace={member.workspace}
23
+ kanbanStatuses={kanbanStatuses}
24
+ canEdit={isOwnerOrAdmin}
25
+ />
26
+ );
27
+ }
@@ -0,0 +1,79 @@
1
+ import { auth } from "@/auth";
2
+ import { redirect, notFound } from "next/navigation";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { decryptContent } from "@/lib/note-crypto";
5
+ import { getActiveWorkspaceId } from "@/lib/workspace";
6
+ import { extractSnippet } from "@/lib/extract-snippet";
7
+ import { TaskDetail } from "@/components/tasks/task-detail";
8
+
9
+ export default async function TaskDetailPage({
10
+ params,
11
+ searchParams,
12
+ }: {
13
+ params: Promise<{ taskId: string }>;
14
+ searchParams: Promise<{ from?: string }>;
15
+ }) {
16
+ const session = await auth();
17
+ if (!session) redirect("/login");
18
+
19
+ const { taskId } = await params;
20
+ const { from } = await searchParams;
21
+ const backHref = from ?? "/list";
22
+
23
+ const workspaceId = await getActiveWorkspaceId(session.user.id);
24
+ if (!workspaceId) redirect("/login");
25
+
26
+ const task = await prisma.task.findFirst({
27
+ where: { id: taskId, workspaceId },
28
+ include: {
29
+ note: { select: { id: true, title: true, content: true } },
30
+ assignee: { select: { id: true, name: true, email: true, image: true } },
31
+ auditLogs: {
32
+ orderBy: { createdAt: "desc" },
33
+ take: 20,
34
+ include: { user: { select: { id: true, name: true } } },
35
+ },
36
+ references: {
37
+ include: { note: { select: { id: true, title: true, content: true } } },
38
+ },
39
+ },
40
+ });
41
+
42
+ if (!task) notFound();
43
+
44
+ const noteContent = await decryptContent(task.note.content, workspaceId);
45
+ const snippet = extractSnippet(noteContent, task.title);
46
+ const refNotes = await Promise.all(
47
+ task.references
48
+ .filter((r) => r.noteId !== task.noteId)
49
+ .map(async (r) => {
50
+ const refContent = await decryptContent(r.note.content, workspaceId);
51
+ return {
52
+ noteId: r.noteId,
53
+ noteTitle: r.note.title,
54
+ snippet: r.snippet || extractSnippet(refContent, task.title),
55
+ };
56
+ })
57
+ );
58
+ const references = [
59
+ { noteId: task.note.id, noteTitle: task.note.title, snippet },
60
+ ...refNotes,
61
+ ];
62
+
63
+ const taskData = {
64
+ ...task,
65
+ priority: task.priority.toLowerCase(),
66
+ note: { id: task.note.id, title: task.note.title },
67
+ startDate: task.startDate?.toISOString() ?? null,
68
+ dueDate: task.dueDate?.toISOString() ?? null,
69
+ createdAt: task.createdAt.toISOString(),
70
+ updatedAt: task.updatedAt.toISOString(),
71
+ auditLogs: task.auditLogs.map((l) => ({
72
+ ...l,
73
+ createdAt: l.createdAt.toISOString(),
74
+ })),
75
+ references,
76
+ };
77
+
78
+ return <TaskDetail task={taskData} backHref={backHref} />;
79
+ }