@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,215 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { AgentTokenSettings } from "@/components/settings/agent-token-settings";
6
+ import { BackupRestoreSettings } from "@/components/settings/backup-restore-settings";
7
+ import { ConfirmDangerAction } from "@/components/settings/confirm-danger-action";
8
+
9
+ const { refreshMock } = vi.hoisted(() => ({
10
+ refreshMock: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("next/navigation", () => ({
14
+ useRouter: () => ({ refresh: refreshMock }),
15
+ }));
16
+
17
+ describe("ConfirmDangerAction", () => {
18
+ beforeEach(() => {
19
+ vi.stubGlobal("fetch", vi.fn());
20
+ refreshMock.mockReset();
21
+ });
22
+
23
+ afterEach(() => {
24
+ cleanup();
25
+ vi.unstubAllGlobals();
26
+ });
27
+
28
+ it("requires explicit confirmation before calling onConfirm", async () => {
29
+ const user = userEvent.setup();
30
+ const onConfirm = vi.fn();
31
+
32
+ render(
33
+ <ConfirmDangerAction
34
+ open
35
+ title="Delete"
36
+ body="Irreversible."
37
+ onConfirm={onConfirm}
38
+ onClose={vi.fn()}
39
+ />
40
+ );
41
+
42
+ await user.click(screen.getByRole("button", { name: "Confirm" }));
43
+
44
+ expect(onConfirm).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it("confirms before regenerating an existing token", async () => {
48
+ const user = userEvent.setup();
49
+ const fetchMock = vi.mocked(fetch);
50
+ fetchMock.mockResolvedValue({
51
+ ok: true,
52
+ json: async () => ({ token: "mcp_tok_new", id: "tok-2", alias: null }),
53
+ } as Response);
54
+
55
+ render(
56
+ <AgentTokenSettings
57
+ workspaces={[
58
+ {
59
+ id: "ws-1",
60
+ name: "Workspace One",
61
+ slug: "workspace-one",
62
+ existingToken: { id: "tok-1", alias: "Existing token", lastUsed: null },
63
+ },
64
+ ]}
65
+ mcpUrl="https://example.com/mcp"
66
+ appUrl="https://example.com"
67
+ userId="user-1"
68
+ />
69
+ );
70
+
71
+ await user.click(screen.getByRole("button", { name: "Regenerate" }));
72
+
73
+ expect(fetchMock).not.toHaveBeenCalled();
74
+ expect(screen.getByText("Regenerate token")).toBeInTheDocument();
75
+
76
+ await user.click(screen.getByRole("button", { name: "Confirm" }));
77
+
78
+ await waitFor(() =>
79
+ expect(fetchMock).toHaveBeenCalledWith("/api/mcp/generate-token", {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({ workspaceId: "ws-1" }),
83
+ })
84
+ );
85
+ });
86
+
87
+ it("confirms before revoking a token", async () => {
88
+ const user = userEvent.setup();
89
+ const fetchMock = vi.mocked(fetch);
90
+ fetchMock.mockResolvedValue({
91
+ ok: true,
92
+ json: async () => ({}),
93
+ } as Response);
94
+
95
+ render(
96
+ <AgentTokenSettings
97
+ workspaces={[
98
+ {
99
+ id: "ws-1",
100
+ name: "Workspace One",
101
+ slug: "workspace-one",
102
+ existingToken: { id: "tok-1", alias: "Existing token", lastUsed: null },
103
+ },
104
+ ]}
105
+ mcpUrl="https://example.com/mcp"
106
+ appUrl="https://example.com"
107
+ userId="user-1"
108
+ />
109
+ );
110
+
111
+ await user.click(screen.getByRole("button", { name: "Revoke" }));
112
+
113
+ expect(fetchMock).not.toHaveBeenCalled();
114
+ expect(screen.getByText("Revoke token")).toBeInTheDocument();
115
+
116
+ await user.click(screen.getByRole("button", { name: "Confirm" }));
117
+
118
+ await waitFor(() =>
119
+ expect(fetchMock).toHaveBeenCalledWith("/api/mcp/revoke-token/tok-1", {
120
+ method: "DELETE",
121
+ })
122
+ );
123
+ });
124
+
125
+ it("shows reusable setup guidance for an existing token without revealing the secret", () => {
126
+ render(
127
+ <AgentTokenSettings
128
+ workspaces={[
129
+ {
130
+ id: "ws-1",
131
+ name: "Workspace One",
132
+ slug: "workspace-one",
133
+ existingToken: { id: "tok-1", alias: "Laptop", lastUsed: null },
134
+ },
135
+ ]}
136
+ mcpUrl="https://example.com/mcp"
137
+ appUrl="https://example.com"
138
+ userId="user-1"
139
+ />
140
+ );
141
+
142
+ expect(screen.getByText("Quick Setup")).toBeInTheDocument();
143
+ expect(screen.getAllByText(/<your-existing-token>/)).toHaveLength(2);
144
+ expect(screen.queryByText(/won't be shown again/i)).not.toBeInTheDocument();
145
+ });
146
+
147
+ it("copies the reusable MCP config template for an existing token", async () => {
148
+ const user = userEvent.setup();
149
+ const writeText = vi.fn();
150
+ Object.assign(navigator, {
151
+ clipboard: {
152
+ writeText,
153
+ },
154
+ });
155
+
156
+ render(
157
+ <AgentTokenSettings
158
+ workspaces={[
159
+ {
160
+ id: "ws-1",
161
+ name: "Workspace One",
162
+ slug: "workspace-one",
163
+ existingToken: { id: "tok-1", alias: "Laptop", lastUsed: null },
164
+ },
165
+ ]}
166
+ mcpUrl="https://example.com/mcp"
167
+ appUrl="https://example.com"
168
+ userId="user-1"
169
+ />
170
+ );
171
+
172
+ await user.click(screen.getByRole("button", { name: "Copy MCP config" }));
173
+
174
+ expect(writeText).toHaveBeenCalledWith(
175
+ expect.stringContaining('"Authorization": "Bearer <your-existing-token>"')
176
+ );
177
+ });
178
+
179
+ it("waits for confirmation before restoring a selected backup", async () => {
180
+ const user = userEvent.setup();
181
+ const fetchMock = vi.mocked(fetch);
182
+ fetchMock.mockResolvedValue({
183
+ ok: true,
184
+ json: async () => ({ notesRestored: 2, tasksRestored: 1 }),
185
+ } as Response);
186
+
187
+ const { container } = render(
188
+ <BackupRestoreSettings
189
+ isCloudWorkspace
190
+ isOwner
191
+ initialSettings={null}
192
+ />
193
+ );
194
+
195
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
196
+ const file = new File([JSON.stringify({ version: 1 })], "backup.json", {
197
+ type: "application/json",
198
+ });
199
+
200
+ fireEvent.change(fileInput, { target: { files: [file] } });
201
+
202
+ expect(fetchMock).not.toHaveBeenCalled();
203
+ expect(screen.getByText("Restore backup")).toBeInTheDocument();
204
+
205
+ await user.click(screen.getByRole("button", { name: "Confirm" }));
206
+
207
+ await waitFor(() =>
208
+ expect(fetchMock).toHaveBeenCalledWith("/api/restore", {
209
+ method: "POST",
210
+ headers: { "Content-Type": "application/json" },
211
+ body: JSON.stringify({ type: "file-replace", data: { version: 1 } }),
212
+ })
213
+ );
214
+ });
215
+ });
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ type ConfirmDangerActionProps = {
4
+ open: boolean;
5
+ title: string;
6
+ body: string;
7
+ confirmLabel?: string;
8
+ cancelLabel?: string;
9
+ busy?: boolean;
10
+ onConfirm: () => void | Promise<void>;
11
+ onClose: () => void;
12
+ };
13
+
14
+ export function ConfirmDangerAction({
15
+ open,
16
+ title,
17
+ body,
18
+ confirmLabel = "Confirm",
19
+ cancelLabel = "Cancel",
20
+ busy = false,
21
+ onConfirm,
22
+ onClose,
23
+ }: ConfirmDangerActionProps) {
24
+ if (!open) return null;
25
+
26
+ return (
27
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
28
+ <div
29
+ role="dialog"
30
+ aria-modal="true"
31
+ aria-labelledby="confirm-danger-action-title"
32
+ aria-describedby="confirm-danger-action-body"
33
+ className="w-full max-w-md rounded-xl border border-red-900/40 bg-zinc-950 p-4 shadow-2xl"
34
+ >
35
+ <h3 id="confirm-danger-action-title" className="text-sm font-medium text-red-200">
36
+ {title}
37
+ </h3>
38
+ <p
39
+ id="confirm-danger-action-body"
40
+ className="mt-2 whitespace-pre-line text-sm text-zinc-400"
41
+ >
42
+ {body}
43
+ </p>
44
+ <div className="mt-4 flex justify-end gap-2">
45
+ <button
46
+ type="button"
47
+ onClick={onClose}
48
+ disabled={busy}
49
+ className="rounded-md border border-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-900 disabled:opacity-50"
50
+ >
51
+ {cancelLabel}
52
+ </button>
53
+ <button
54
+ type="button"
55
+ onClick={() => void onConfirm()}
56
+ disabled={busy}
57
+ className="rounded-md bg-red-900/70 px-3 py-1.5 text-xs text-red-100 transition-colors hover:bg-red-900 disabled:opacity-50"
58
+ >
59
+ {confirmLabel}
60
+ </button>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ import { useState } from "react"
4
+ import { TwoFactorAuth } from "@/components/settings/two-factor-auth";
5
+ import { useRouter } from "next/navigation";
6
+ import { Laptop, Trash2, CloudDownload, Loader2, KeyRound } from "lucide-react";
7
+ import { PasswordInput } from "@/components/auth/password-input";
8
+ import { PasswordChecklist } from "@/components/auth/password-checklist";
9
+ import { ConfirmDangerAction } from "@/components/settings/confirm-danger-action";
10
+
11
+ type Device = { id: string; deviceName: string; deviceId: string; lastActive: string };
12
+
13
+ type Props = {
14
+ isPro: boolean;
15
+ isOwner: boolean;
16
+ devices: Device[];
17
+ hasPassword: boolean;
18
+ twoFactorEnabled?: boolean;
19
+ };
20
+
21
+ export function SecuritySettings({ isPro, isOwner, devices, hasPassword, twoFactorEnabled }: Props) {
22
+ const router = useRouter();
23
+ const [revoking, setRevoking] = useState<string | null>(null);
24
+ const [restoring, setRestoring] = useState(false);
25
+ const [restoreMsg, setRestoreMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
26
+ const [restoreConfirm, setRestoreConfirm] = useState(false);
27
+
28
+ const [currentPw, setCurrentPw] = useState("");
29
+ const [newPw, setNewPw] = useState("");
30
+ const [confirmPw, setConfirmPw] = useState("");
31
+ const [changingPw, setChangingPw] = useState(false);
32
+ const [pwMsg, setPwMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
33
+
34
+ async function handleChangePassword(e: React.FormEvent) {
35
+ e.preventDefault();
36
+ setPwMsg(null);
37
+
38
+ if (newPw !== confirmPw) {
39
+ setPwMsg({ type: "err", text: "New passwords do not match." });
40
+ return;
41
+ }
42
+ if (newPw.length < 8) {
43
+ setPwMsg({ type: "err", text: "New password must be at least 8 characters." });
44
+ return;
45
+ }
46
+
47
+ setChangingPw(true);
48
+ try {
49
+ const res = await fetch("/api/auth/change-password", {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ currentPassword: currentPw, newPassword: newPw }),
53
+ });
54
+ const data = await res.json();
55
+ if (res.ok) {
56
+ setPwMsg({ type: "ok", text: "Password updated successfully." });
57
+ setCurrentPw("");
58
+ setNewPw("");
59
+ setConfirmPw("");
60
+ } else {
61
+ setPwMsg({ type: "err", text: data.error ?? "Failed to update password." });
62
+ }
63
+ } finally {
64
+ setChangingPw(false);
65
+ }
66
+ }
67
+
68
+ async function handleCloudRestore() {
69
+ setRestoreConfirm(true);
70
+ }
71
+
72
+ async function confirmCloudRestore() {
73
+ setRestoring(true);
74
+ setRestoreMsg(null);
75
+ try {
76
+ const res = await fetch("/api/restore", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({ type: "cloud" }),
80
+ });
81
+ const data = await res.json();
82
+ if (res.ok) {
83
+ setRestoreMsg({ type: "ok", text: `Cloud restore complete. ${data.notesProcessed ?? 0} notes synced.` });
84
+ router.refresh();
85
+ } else {
86
+ setRestoreMsg({ type: "err", text: data.error ?? "Cloud restore failed." });
87
+ }
88
+ } finally {
89
+ setRestoring(false);
90
+ setRestoreConfirm(false);
91
+ }
92
+ }
93
+
94
+ async function revokeDevice(deviceId: string) {
95
+ setRevoking(deviceId);
96
+ await fetch(`/api/devices/${deviceId}`, { method: "DELETE" });
97
+ router.refresh();
98
+ setRevoking(null);
99
+ }
100
+
101
+ return (
102
+ <div className="space-y-8">
103
+ {/* Device sessions */}
104
+ <div className="space-y-3">
105
+ <h3 className="text-sm font-semibold text-zinc-300">Connected devices</h3>
106
+ {devices.length === 0 ? (
107
+ <p className="text-xs text-zinc-600">No active device sessions.</p>
108
+ ) : (
109
+ <div className="space-y-1 rounded-lg border border-zinc-800 overflow-hidden">
110
+ {devices.map((d) => (
111
+ <div
112
+ key={d.id}
113
+ className="flex items-center justify-between px-4 py-3 bg-zinc-900 border-b border-zinc-800 last:border-0"
114
+ >
115
+ <div className="flex items-center gap-2 min-w-0">
116
+ <Laptop size={14} className="shrink-0 text-zinc-500" />
117
+ <div className="min-w-0">
118
+ <p className="text-sm text-zinc-300 truncate">{d.deviceName}</p>
119
+ <p className="text-xs text-zinc-600">
120
+ Last active {new Date(d.lastActive).toLocaleDateString()}
121
+ </p>
122
+ </div>
123
+ </div>
124
+ <button
125
+ onClick={() => revokeDevice(d.id)}
126
+ disabled={revoking === d.id}
127
+ className="shrink-0 text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50"
128
+ title="Revoke device"
129
+ >
130
+ <Trash2 size={13} />
131
+ </button>
132
+ </div>
133
+ ))}
134
+ </div>
135
+ )}
136
+ <p className="text-xs text-zinc-700">
137
+ Revoking a device removes its session. It will need to log in again to reconnect.
138
+ </p>
139
+ </div>
140
+
141
+ {/* Change password */}
142
+ {hasPassword && (
143
+ <div className="space-y-3">
144
+ <h3 className="text-sm font-semibold text-zinc-300">Change password</h3>
145
+ <form onSubmit={handleChangePassword} className="space-y-3">
146
+ <div className="space-y-1.5">
147
+ <label className="text-xs text-zinc-400">Current password</label>
148
+ <PasswordInput
149
+ value={currentPw}
150
+ onChange={(e) => setCurrentPw(e.target.value)}
151
+ placeholder="Enter current password"
152
+ required
153
+ />
154
+ </div>
155
+ <div className="space-y-1.5">
156
+ <label className="text-xs text-zinc-400">New password</label>
157
+ <PasswordInput
158
+ value={newPw}
159
+ onChange={(e) => setNewPw(e.target.value)}
160
+ placeholder="Enter new password"
161
+ required
162
+ />
163
+ <PasswordChecklist password={newPw} />
164
+ </div>
165
+ <div className="space-y-1.5">
166
+ <label className="text-xs text-zinc-400">Confirm new password</label>
167
+ <PasswordInput
168
+ value={confirmPw}
169
+ onChange={(e) => setConfirmPw(e.target.value)}
170
+ placeholder="Re-enter new password"
171
+ required
172
+ />
173
+ </div>
174
+ <button
175
+ type="submit"
176
+ disabled={changingPw || !currentPw || !newPw || !confirmPw}
177
+ className="flex items-center gap-1.5 rounded-md bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 transition-colors"
178
+ >
179
+ {changingPw ? (
180
+ <Loader2 size={13} className="animate-spin" />
181
+ ) : (
182
+ <KeyRound size={13} />
183
+ )}
184
+ Update password
185
+ </button>
186
+ {pwMsg && (
187
+ <p className={`text-xs ${pwMsg.type === "ok" ? "text-emerald-400" : "text-red-400"}`}>
188
+ {pwMsg.text}
189
+ </p>
190
+ )}
191
+ </form>
192
+ </div>
193
+ )}
194
+
195
+ {/* 2FA */}
196
+ {hasPassword && (
197
+ <div className="space-y-3">
198
+ <TwoFactorAuth initialEnabled={twoFactorEnabled ?? false} />
199
+ </div>
200
+ )}
201
+
202
+ {/* Restore */}
203
+ {isOwner && (
204
+ <div className="space-y-3">
205
+ <h3 className="text-sm font-semibold text-zinc-300">Restore data</h3>
206
+ <p className="text-xs text-zinc-500">
207
+ Restore your workspace from the cloud. File restore now lives under Backup & Restore.
208
+ </p>
209
+
210
+ <div className="flex flex-wrap gap-2">
211
+ {isPro && (
212
+ <button
213
+ onClick={handleCloudRestore}
214
+ disabled={restoring}
215
+ className="flex items-center gap-1.5 rounded-md bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 transition-colors"
216
+ >
217
+ {restoring ? (
218
+ <Loader2 size={13} className="animate-spin" />
219
+ ) : (
220
+ <CloudDownload size={13} />
221
+ )}
222
+ Restore from cloud
223
+ </button>
224
+ )}
225
+ </div>
226
+
227
+ {restoreMsg && (
228
+ <p
229
+ className={`text-xs ${restoreMsg.type === "ok" ? "text-emerald-400" : "text-red-400"}`}
230
+ >
231
+ {restoreMsg.text}
232
+ </p>
233
+ )}
234
+ <p className="text-xs text-zinc-700">
235
+ Cloud restore pulls all data from your cloud backup.
236
+ </p>
237
+ </div>
238
+ )}
239
+
240
+ {/* Confirmation dialog */}
241
+ <ConfirmDangerAction
242
+ open={restoreConfirm}
243
+ title="Restore from Cloud"
244
+ body="Pull all data from cloud to this device. Local changes may be overwritten. Continue?"
245
+ confirmLabel="Restore"
246
+ cancelLabel="Cancel"
247
+ onConfirm={confirmCloudRestore}
248
+ onClose={() => setRestoreConfirm(false)}
249
+ />
250
+ </div>
251
+ );
252
+ }
@@ -0,0 +1,98 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import SettingsLayout from "@/app/(app)/settings/layout";
5
+ import { AgentTokenSettings } from "@/components/settings/agent-token-settings";
6
+ import { BackupRestoreSettings } from "@/components/settings/backup-restore-settings";
7
+ import { TeamSettings } from "@/components/settings/team-settings";
8
+
9
+ const { mockUsePathname } = vi.hoisted(() => ({
10
+ mockUsePathname: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("next/navigation", () => ({
14
+ usePathname: mockUsePathname,
15
+ useRouter: () => ({ refresh: vi.fn() }),
16
+ }));
17
+
18
+ describe("settings guidance", () => {
19
+ beforeEach(() => {
20
+ mockUsePathname.mockReset();
21
+ });
22
+
23
+ afterEach(() => {
24
+ cleanup();
25
+ });
26
+
27
+ it("shows route-specific intro copy in the shared settings layout", () => {
28
+ mockUsePathname.mockReturnValue("/settings/agent-token");
29
+
30
+ render(
31
+ <SettingsLayout>
32
+ <div>Content</div>
33
+ </SettingsLayout>
34
+ );
35
+
36
+ expect(
37
+ screen.getByText("Create and revoke agent access with clear operational consequences.")
38
+ ).toBeInTheDocument();
39
+ });
40
+
41
+ it("shows a danger zone in agent token settings", () => {
42
+ render(
43
+ <AgentTokenSettings
44
+ workspaces={[
45
+ {
46
+ id: "ws-1",
47
+ name: "Workspace One",
48
+ slug: "workspace-one",
49
+ existingToken: null,
50
+ },
51
+ ]}
52
+ mcpUrl="https://example.com/mcp"
53
+ appUrl="https://example.com"
54
+ userId="user-1"
55
+ />
56
+ );
57
+
58
+ expect(screen.getByText("Danger zone")).toBeInTheDocument();
59
+ expect(
60
+ screen.getByText("Revoking or regenerating a token disconnects any agent currently using it.")
61
+ ).toBeInTheDocument();
62
+ });
63
+
64
+ it("explains backup export and restore outcomes before destructive actions", () => {
65
+ render(
66
+ <BackupRestoreSettings
67
+ isCloudWorkspace
68
+ isOwner
69
+ initialSettings={null}
70
+ />
71
+ );
72
+
73
+ expect(
74
+ screen.getByText(
75
+ "Export creates a portable backup of the current workspace state. Restore replaces current local data with the uploaded backup."
76
+ )
77
+ ).toBeInTheDocument();
78
+ expect(screen.getByText("Danger zone")).toBeInTheDocument();
79
+ });
80
+
81
+ it("explains pending invites and immediate revocation outcomes", () => {
82
+ render(
83
+ <TeamSettings
84
+ members={[]}
85
+ pendingInvites={[]}
86
+ canManage
87
+ currentUserId="user-1"
88
+ workspaceId="ws-1"
89
+ />
90
+ );
91
+
92
+ expect(
93
+ screen.getByText(
94
+ "Invites stay pending until accepted. Revoking an invite immediately invalidates the link."
95
+ )
96
+ ).toBeInTheDocument();
97
+ });
98
+ });