failproofai 1.0.0 → 1.0.1-beta.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 (238) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +0 -1
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +5 -5
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +0 -6
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +4 -3
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +4 -3
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  24. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -2
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js +3 -3
  28. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +4 -3
  32. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  36. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -3
  38. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  42. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/index.html +1 -1
  45. package/.next/standalone/.next/server/app/index.rsc +15 -15
  46. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  47. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  48. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js +4 -3
  51. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  54. package/.next/standalone/.next/server/app/policies/page.js +6 -5
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page.js +5 -4
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  67. package/.next/standalone/.next/server/app/projects/page.js +5 -4
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  71. package/.next/standalone/.next/server/app/settings/page.js +6 -4
  72. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  73. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  74. package/.next/standalone/.next/server/app-paths-manifest.json +0 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0nne229._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ny9q5d._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1hstmft._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p8poxd._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0jx6cp6._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0f-x2b0._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1f290jp._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0hwvl7e._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1g2ji1t._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1ctjdzl._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03eykvd._.js +5 -0
  95. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07175vb._.js → [root-of-the-server]__07oh8b5._.js} +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cxe_2_._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g2rg69._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m98kzy._.js +4 -0
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0sjzdjg._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0v5_91z._.js +4 -0
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-a3135._.js +4 -0
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1hvwiw8._.js → [root-of-the-server]__11z_299._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apc90t._.js → [root-of-the-server]__15c_z97._.js} +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1wmno1q._.js → [root-of-the-server]__17d_ffl._.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +4 -0
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1e5g_ur._.js +4 -0
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hmqefd._.js +4 -0
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +6 -0
  113. package/.next/standalone/.next/server/chunks/ssr/_04j57fb._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/_05enntk._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +3 -0
  116. package/.next/standalone/.next/server/chunks/ssr/_0hom1s1._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/ssr/_1nq-35m._.js +23 -0
  118. package/.next/standalone/.next/server/chunks/ssr/_1q7j05x._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  121. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  123. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +3 -0
  124. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  127. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +3 -0
  128. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  129. package/.next/standalone/.next/server/pages/404.html +1 -1
  130. package/.next/standalone/.next/server/pages/500.html +1 -1
  131. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  132. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  133. package/.next/standalone/.next/static/chunks/00q6cfzuzpc85.js +1 -0
  134. package/.next/standalone/.next/static/chunks/{0zm3_8jpqitxy.js → 054eg7y_cjyf9.js} +22 -22
  135. package/.next/standalone/.next/static/chunks/{2ouq44jv_0x04.js → 0mjufa_0xxz75.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +1 -0
  137. package/.next/standalone/.next/static/chunks/{0zi8zjav5a4rq.js → 1-oz4co31nvc5.js} +1 -1
  138. package/.next/standalone/.next/static/chunks/13--2yvqek_s1.js +1 -0
  139. package/.next/standalone/.next/static/chunks/136ldjmzxhush.js +1 -0
  140. package/.next/standalone/.next/static/chunks/18fqy3doo2ial.js +6 -0
  141. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  142. package/.next/standalone/.next/static/chunks/{31k3yxpy57_fy.js → 1o5sxp_mdaoci.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/29-iqnp9v_axt.css +1 -0
  144. package/.next/standalone/.next/static/chunks/{2676v-lyzgo91.js → 2es4z1ei90vsv.js} +1 -1
  145. package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +1 -0
  146. package/.next/standalone/.next/static/chunks/3peix2vxzj7eh.js +1 -0
  147. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +1 -0
  148. package/.next/standalone/.next/static/chunks/{0km-jn9o5xyqs.css → 3wq9x0c-7ld5d.css} +1 -1
  149. package/.next/standalone/app/actions/get-scheduled-audit.ts +65 -6
  150. package/.next/standalone/app/actions/update-scheduled-audit.ts +90 -17
  151. package/.next/standalone/app/api/auth/status/route.ts +7 -17
  152. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +2 -6
  153. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +101 -281
  154. package/.next/standalone/app/audit/_components/empty-state.tsx +2 -2
  155. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  156. package/.next/standalone/app/audit/audit-styles.css +150 -26
  157. package/.next/standalone/app/globals.css +39 -1
  158. package/.next/standalone/app/settings/page.tsx +38 -21
  159. package/.next/standalone/app/settings/settings-client.tsx +583 -392
  160. package/.next/standalone/app/settings/settings.css +354 -0
  161. package/.next/standalone/components/navbar.tsx +33 -4
  162. package/.next/standalone/lib/auth/api-server-client.ts +51 -29
  163. package/.next/standalone/lib/auth/auth-store.ts +44 -59
  164. package/.next/standalone/package.json +12 -10
  165. package/.next/standalone/server.js +1 -1
  166. package/bin/failproofai.mjs +6 -0
  167. package/bin/failproofaid-shim.mjs +0 -0
  168. package/dist/cli.mjs +1889 -426
  169. package/dist/worker.mjs +27 -5
  170. package/lib/auth/api-server-client.ts +51 -29
  171. package/lib/auth/auth-store.ts +44 -59
  172. package/package.json +12 -10
  173. package/scripts/docs-audit.ts +493 -0
  174. package/scripts/translate-docs/cli.ts +16 -2
  175. package/scripts/translate-docs/mdx-translator.ts +6 -1
  176. package/scripts/translate-docs/readme-translator.ts +4 -2
  177. package/src/audit/cli-login.ts +330 -0
  178. package/src/audit/cli.ts +124 -6
  179. package/src/audit/dashboard-cache.ts +44 -8
  180. package/src/audit/harm-report.ts +260 -0
  181. package/src/audit/machine-store.ts +120 -0
  182. package/src/audit/redact-example.ts +312 -0
  183. package/src/audit/report-harm.ts +167 -0
  184. package/src/audit/schedule-cli.ts +352 -0
  185. package/src/hooks/builtin-policies.ts +26 -0
  186. package/src/hooks/daemon-service.ts +76 -0
  187. package/src/hooks/fp-config.ts +96 -5
  188. package/src/hooks/fp-home.ts +118 -8
  189. package/src/hooks/fp-reset.ts +79 -9
  190. package/src/hooks/migrations.ts +241 -4
  191. package/src/hooks/tui.ts +61 -3
  192. package/.next/standalone/.next/server/app/api/auth/reminder/route/app-paths-manifest.json +0 -3
  193. package/.next/standalone/.next/server/app/api/auth/reminder/route/build-manifest.json +0 -12
  194. package/.next/standalone/.next/server/app/api/auth/reminder/route/server-reference-manifest.json +0 -4
  195. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +0 -9
  196. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.map +0 -5
  197. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +0 -1
  198. package/.next/standalone/.next/server/app/api/auth/reminder/route_client-reference-manifest.js +0 -3
  199. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-xggru._.js +0 -3
  200. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00ds05_._.js +0 -3
  201. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +0 -3
  202. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1dxagv_._.js +0 -3
  203. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fgqj1_._.js +0 -3
  204. package/.next/standalone/.next/server/chunks/_084toou._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/_0g4t8c9._.js +0 -3
  206. package/.next/standalone/.next/server/chunks/_0km521a._.js +0 -3
  207. package/.next/standalone/.next/server/chunks/_0t8kefc._.js +0 -3
  208. package/.next/standalone/.next/server/chunks/_16dwkfn._.js +0 -3
  209. package/.next/standalone/.next/server/chunks/_1phm9ui._.js +0 -3
  210. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_auth_reminder_route_actions_1kjgxf8.js +0 -3
  211. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dk8r_l._.js +0 -3
  212. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kviq2m._.js +0 -3
  213. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ogyhu0._.js +0 -3
  214. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-rfu32._.js +0 -5
  215. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +0 -5
  216. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1t62w37._.js +0 -3
  217. package/.next/standalone/.next/server/chunks/ssr/_032wo7w._.js +0 -3
  218. package/.next/standalone/.next/server/chunks/ssr/_08840em._.js +0 -3
  219. package/.next/standalone/.next/server/chunks/ssr/_0gl2ujp._.js +0 -3
  220. package/.next/standalone/.next/server/chunks/ssr/_0j0ggkc._.js +0 -3
  221. package/.next/standalone/.next/server/chunks/ssr/_0kbv5-q._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/ssr/_0r1jgvp._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/ssr/_0t5xq1b._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/_0w_be0g._.js +0 -3
  225. package/.next/standalone/.next/server/chunks/ssr/_15gba46._.js +0 -5
  226. package/.next/standalone/.next/server/chunks/ssr/_1qyhb9r._.js +0 -23
  227. package/.next/standalone/.next/server/chunks/ssr/_1vel2ey._.js +0 -3
  228. package/.next/standalone/.next/static/chunks/0wd503tv801gy.js +0 -1
  229. package/.next/standalone/.next/static/chunks/1hfocb0sdl85c.js +0 -1
  230. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +0 -1
  231. package/.next/standalone/.next/static/chunks/2kopjq_or743g.js +0 -6
  232. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +0 -2
  233. package/.next/standalone/.next/static/chunks/2ynl0-6aqng_b.js +0 -1
  234. package/.next/standalone/.next/static/chunks/3xvtgz6arztp_.js +0 -1
  235. package/.next/standalone/app/api/auth/reminder/route.ts +0 -213
  236. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_buildManifest.js +0 -0
  237. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_clientMiddlewareManifest.js +0 -0
  238. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_ssgManifest.js +0 -0
@@ -1,31 +1,59 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * /settings client two sections (scheduled audit, email reports) plus the
5
- * degraded states that are most of the real screens: daemon not installed /
6
- * stopped / unsupported, no scan ever run, a scan running now, a last run that
7
- * failed, signed out, and not cloud-enrolled. Each is shown explicitly, because
8
- * a missing control reads as a bug.
4
+ * /settings — what failproof does on its own, while you are not looking.
9
5
  *
10
- * Visual conventions are the site chrome's, matched to /policies: the brutalist
11
- * `.report`/`.section`/`.panel`/`.btn` classes from globals.css, the same
12
- * emerald switch /policies uses (PolicyToggle), inline `var(--…)` colours, and
13
- * the shared `toast()`. No new design language, colour, or component library.
6
+ * ## The design
14
7
  *
15
- * All writes go through server actions that call `updateConfig` never a raw
16
- * file write so the CLI and dashboard cannot diverge. The parity mapping is
17
- * documented on each action module.
8
+ * The subject is not a preferences form, it is a **console for a service running
9
+ * on your box**, so the page is built from what that service actually has: a
10
+ * state, a timer, a last result, and an identity it reports under. Two rows,
11
+ * drawn as one instrument:
12
+ *
13
+ * - a **stat row** — four cells, each a single fact, no two sharing a unit.
14
+ * It answers "what is happening right now" with no history and no chart.
15
+ * - a **panel row** — the controls on the left, what the scan actually does on
16
+ * the right, as three labelled lines rather than the paragraph they used to
17
+ * be. Same words, skimmable.
18
+ *
19
+ * The hairlines between cells are one `gap: 1px` over a line-coloured
20
+ * background, not per-cell borders, so every rule is exactly one pixel and they
21
+ * cannot double up where cells meet.
22
+ *
23
+ * **The signature is still the schedule tape**, now under the panels. A scan on
24
+ * a timer has one fact no number can express — where you are between the last
25
+ * scan and the next — so it is drawn, and it draws nothing when there aren't two
26
+ * real ends to sit between.
27
+ *
28
+ * Everything is existing tokens: the charcoal stack, pink for the control that
29
+ * acts and for anything wrong, mint for the thing that is alive. No third hue,
30
+ * no new webfont, one hard pixel shadow on the console. That is the budget.
31
+ *
32
+ * ## One switch, not two
33
+ *
34
+ * Scheduling and mailing are the same decision — the reason to put a scan on a
35
+ * timer is to be told what it found. So there is one toggle, it requires a
36
+ * sign-in, and "signed out with the timer on" is a real state the page names
37
+ * rather than a contradiction it prevents.
18
38
  */
19
39
 
20
- import { useCallback, useEffect, useRef, useState } from "react";
21
- import { getScheduledAuditAction, type ScheduledAuditView } from "@/app/actions/get-scheduled-audit";
22
- import { setAutoAuditAction, setAuditIntervalAction } from "@/app/actions/update-scheduled-audit";
40
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
41
+ import {
42
+ getScheduledAuditAction,
43
+ type ScheduledAuditView,
44
+ } from "@/app/actions/get-scheduled-audit";
45
+ import {
46
+ setAutoAuditAction,
47
+ setAuditIntervalAction,
48
+ } from "@/app/actions/update-scheduled-audit";
23
49
  import { triggerRun, RerunError } from "@/app/audit/_components/rerun-button";
50
+ import { AuthDialog, type AuthedUser } from "@/app/audit/_components/auth-dialog";
24
51
  import { toast } from "@/app/components/toast";
25
- import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
26
52
  import { formatRelativeTime } from "@/lib/format-duration";
53
+ import "./settings.css";
27
54
 
28
- // ── formatting helpers ───────────────────────────────────────────────────────
55
+ const MIN_INTERVAL_DAYS = 1;
56
+ const MAX_INTERVAL_DAYS = 90;
29
57
 
30
58
  function fmtAbsolute(ms: number): string {
31
59
  return new Date(ms).toLocaleString(undefined, {
@@ -36,18 +64,98 @@ function fmtAbsolute(ms: number): string {
36
64
  });
37
65
  }
38
66
 
39
- /** "in 6d" / "in 3h" / "in 12m" / "now". formatRelativeTime only speaks past. */
40
- function fmtFuture(ms: number): string {
41
- const diff = ms - Date.now();
67
+ /** "6d 4h" / "3h" / "12m" / "now". `formatRelativeTime` only speaks past. */
68
+ function fmtUntil(ms: number, now: number): string {
69
+ const diff = ms - now;
42
70
  if (diff <= 0) return "now";
43
- if (diff < 3_600_000) return `in ${Math.max(1, Math.floor(diff / 60_000))}m`;
44
- if (diff < 86_400_000) return `in ${Math.floor(diff / 3_600_000)}h`;
45
- return `in ${Math.floor(diff / 86_400_000)}d`;
71
+ const d = Math.floor(diff / 86_400_000);
72
+ const h = Math.floor((diff % 86_400_000) / 3_600_000);
73
+ if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
74
+ if (h > 0) return `${h}h`;
75
+ return `${Math.max(1, Math.floor(diff / 60_000))}m`;
76
+ }
77
+
78
+ /** "11d" / "4h" / "9m" — how long the service has been up. */
79
+ function fmtSpan(ms: number): string {
80
+ const d = Math.floor(ms / 86_400_000);
81
+ if (d > 0) return `${d}d`;
82
+ const h = Math.floor(ms / 3_600_000);
83
+ if (h > 0) return `${h}h`;
84
+ return `${Math.max(1, Math.floor(ms / 60_000))}m`;
85
+ }
86
+
87
+ function num(n: number): string {
88
+ return n.toLocaleString("en-US");
89
+ }
90
+
91
+ /**
92
+ * One cell of the stat row: a label, one value, one sub-line.
93
+ *
94
+ * `tone` colours the VALUE only — mint for alive, pink for anything that needs a
95
+ * person. The label and sub-line stay in the ink ramp, so a row of four cells
96
+ * reads as one instrument rather than four competing signals.
97
+ */
98
+ function StatCell({
99
+ label,
100
+ value,
101
+ sub,
102
+ tone,
103
+ }: {
104
+ label: string;
105
+ value: ReactNode;
106
+ sub?: ReactNode;
107
+ tone?: "ok" | "warn";
108
+ }) {
109
+ return (
110
+ <div className="set-stat">
111
+ <div className="set-stat-label">{label}</div>
112
+ <div className={`set-stat-value${tone ? ` ${tone}` : ""}`}>{value}</div>
113
+ <div className="set-stat-sub">{sub ?? " "}</div>
114
+ </div>
115
+ );
46
116
  }
47
117
 
48
- // ── shared primitives (match /policies) ──────────────────────────────────────
118
+ /**
119
+ * The schedule tape — where this machine is between two scans.
120
+ *
121
+ * Drawn rather than stated because the fact is a POSITION, and a position is
122
+ * the one thing a number cannot show at a glance. The filled span is elapsed,
123
+ * the marker is now, the ends are the two scans.
124
+ *
125
+ * Renders nothing without both ends: a machine that has never run a scheduled
126
+ * scan has no interval to be inside, and an empty rail claiming otherwise would
127
+ * be decoration.
128
+ */
129
+ function ScheduleTape({
130
+ lastRunAtMs,
131
+ nextDueAtMs,
132
+ now,
133
+ }: {
134
+ lastRunAtMs: number | null;
135
+ nextDueAtMs: number | null;
136
+ /** Stamped by the parent on load and on every focus refresh. Passed in
137
+ * rather than read here so this component stays pure during render — and so
138
+ * the marker moves when the page is refocused, which is the only moment
139
+ * anyone is looking at it. */
140
+ now: number;
141
+ }) {
142
+ if (lastRunAtMs == null || nextDueAtMs == null || nextDueAtMs <= lastRunAtMs) return null;
143
+ const pct = Math.min(100, Math.max(0, ((now - lastRunAtMs) / (nextDueAtMs - lastRunAtMs)) * 100));
144
+
145
+ return (
146
+ <div className="tape" aria-hidden="true">
147
+ <div className="tape-rail">
148
+ <div className="tape-fill" style={{ width: `${pct}%` }} />
149
+ <div className="tape-now" style={{ left: `${pct}%` }} />
150
+ </div>
151
+ <div className="tape-ends">
152
+ <span>last scan · {fmtAbsolute(lastRunAtMs)}</span>
153
+ <span className="tape-next">next · {fmtUntil(nextDueAtMs, now)}</span>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
49
158
 
50
- /** The exact switch /policies uses — copied shape, not a new control. */
51
159
  function Toggle({
52
160
  enabled,
53
161
  onChange,
@@ -62,426 +170,509 @@ function Toggle({
62
170
  return (
63
171
  <button
64
172
  type="button"
65
- onClick={onChange}
66
- disabled={disabled}
67
173
  role="switch"
68
174
  aria-checked={enabled}
69
175
  aria-label={label}
70
- className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:opacity-40 ${
71
- enabled ? "bg-emerald-500" : "bg-muted-foreground/30"
72
- }`}
176
+ disabled={disabled}
177
+ onClick={onChange}
178
+ className="sw"
179
+ data-on={enabled ? "true" : "false"}
73
180
  >
74
- <span
75
- className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
76
- enabled ? "translate-x-3.5" : "translate-x-0.5"
77
- }`}
78
- />
181
+ <span className="sw-knob" />
79
182
  </button>
80
183
  );
81
184
  }
82
185
 
83
- type PillTone = "ok" | "warn" | "bad" | "muted";
84
- const PILL_TONE: Record<PillTone, { fg: string; bg: string; bd: string }> = {
85
- ok: { fg: "var(--accent-green)", bg: "rgba(102,209,181,0.10)", bd: "rgba(102,209,181,0.30)" },
86
- warn: { fg: "var(--amber)", bg: "rgba(232,196,106,0.10)", bd: "rgba(232,196,106,0.30)" },
87
- bad: { fg: "var(--accent-pink)", bg: "rgba(228,88,124,0.10)", bd: "rgba(228,88,124,0.30)" },
88
- muted: { fg: "var(--ink-2)", bg: "transparent", bd: "var(--line-2)" },
89
- };
90
-
91
- function Pill({ tone, children }: { tone: PillTone; children: React.ReactNode }) {
92
- const t = PILL_TONE[tone];
93
- return (
94
- <span
95
- style={{
96
- display: "inline-flex",
97
- alignItems: "center",
98
- gap: 6,
99
- padding: "3px 9px",
100
- fontFamily: "var(--font-mono)",
101
- fontSize: 11,
102
- letterSpacing: "0.04em",
103
- color: t.fg,
104
- background: t.bg,
105
- border: `1px solid ${t.bd}`,
106
- textTransform: "uppercase",
107
- }}
108
- >
109
- {children}
110
- </span>
111
- );
112
- }
113
-
114
- const SECTION_TITLE: React.CSSProperties = {
115
- fontFamily: "var(--font-mono)",
116
- fontSize: 16,
117
- fontWeight: 600,
118
- letterSpacing: "-0.01em",
119
- color: "var(--ink)",
120
- margin: "0 0 4px",
121
- };
122
- const BODY: React.CSSProperties = {
123
- fontFamily: "var(--font-mono)",
124
- fontSize: 13,
125
- color: "var(--ink-2)",
126
- lineHeight: 1.65,
127
- margin: 0,
128
- };
129
- const MUTED: React.CSSProperties = { ...BODY, color: "var(--dim)", fontSize: 12 };
130
- const CODE: React.CSSProperties = { color: "var(--ink)", fontVariantLigatures: "none" };
131
-
132
- /** A monospace inline command the user can copy by eye. */
133
- function Cmd({ children }: { children: React.ReactNode }) {
134
- return (
135
- <code
136
- style={{
137
- fontFamily: "var(--font-mono)",
138
- fontSize: 12,
139
- color: "var(--accent-green)",
140
- background: "var(--bg-2)",
141
- border: "1px solid var(--line-2)",
142
- padding: "1px 6px",
143
- whiteSpace: "nowrap",
144
- }}
145
- >
146
- {children}
147
- </code>
148
- );
149
- }
150
-
151
- // ── scheduled audit section ──────────────────────────────────────────────────
152
-
153
- function ScheduledAuditSection({
154
- view,
155
- onReload,
156
- }: {
157
- view: ScheduledAuditView;
158
- onReload: () => Promise<void>;
159
- }) {
160
- const [auto, setAuto] = useState(view.auto);
161
- const [interval, setIntervalDays] = useState(view.intervalDays);
162
- const [savingAuto, setSavingAuto] = useState(false);
163
- const [savingInterval, setSavingInterval] = useState(false);
186
+ /**
187
+ * What the scan does, as three labelled lines. This is the old footer paragraph
188
+ * restructured same claims, scannable instead of a wall.
189
+ *
190
+ * "sends" ENUMERATES rather than saying "only counts and redacted examples".
191
+ * That was very nearly true, and very nearly true is the worse kind: the report
192
+ * carries the machine's name too — its hostname, which routinely carries its
193
+ * owner's. A list a person can check beats a stronger claim they cannot, and
194
+ * this panel is the one place they would come to check. The digest email states
195
+ * the same three, in the same order.
196
+ */
197
+ const HOW_IT_WORKS: ReadonlyArray<{ label: string; body: string }> = [
198
+ {
199
+ label: "reads",
200
+ body: "every session transcript on disk — your prompts, the files your agents read and wrote, and command output.",
201
+ },
202
+ { label: "runs", body: "entirely on this machine. the transcripts never leave it." },
203
+ {
204
+ label: "sends",
205
+ body: "counts, redacted examples, and this machine's name — and only when a scan finds something harmful.",
206
+ },
207
+ ];
208
+
209
+ export default function SettingsClient({ initial }: { initial: ScheduledAuditView | null }) {
210
+ // Seeded from the server render, so the first paint already tells the truth
211
+ // about whether scheduled audits are on. See the note in `page.tsx`.
212
+ const [view, setView] = useState<ScheduledAuditView | null>(initial);
213
+ const [auto, setAuto] = useState(initial?.auto ?? false);
214
+ const [intervalDays, setIntervalDays] = useState(initial?.intervalDays ?? 7);
215
+ const [busy, setBusy] = useState(false);
164
216
  const [running, setRunning] = useState(false);
165
- const [runningNow, setRunningNow] = useState(false);
217
+ const [authOpen, setAuthOpen] = useState(false);
218
+ const [loadError, setLoadError] = useState(initial === null);
219
+ /**
220
+ * When the view was last read. Drives the tape's marker and the uptime and
221
+ * countdown readouts; see ScheduleTape.
222
+ *
223
+ * Zero until the client has run, deliberately: `Date.now()` on the server and
224
+ * `Date.now()` in the browser are different clocks, and seeding this from the
225
+ * server render would put the marker at a position the client then corrects —
226
+ * a hydration mismatch on the one element whose whole point is a position.
227
+ * The tape appears on the first client pass instead.
228
+ */
229
+ const [nowMs, setNowMs] = useState(0);
230
+ const mounted = useRef(true);
231
+ /**
232
+ * Whether anything is on screen to protect, readable from a stable callback.
233
+ *
234
+ * `reload` has an empty dep list on purpose (see below), so the `view` it
235
+ * closes over is frozen at the FIRST render forever. Testing that state
236
+ * directly therefore answered a question about page load, not about now: a
237
+ * page seeded with `initial === null` — the case `page.tsx` builds for when
238
+ * the server read fails — kept reading `!view` as true even after the client
239
+ * had successfully loaded, so the next transient failure (the focus listener
240
+ * below fires on every `visibilitychange`, including a tab hide) replaced a
241
+ * working console with "could not read this machine's settings". A ref is
242
+ * read at call time, which is when the question is being asked.
243
+ */
244
+ const hasView = useRef(initial !== null);
166
245
 
167
- // Keep local state honest if a background reload brought new server truth
168
- // (e.g. someone toggled via CLI, or the interval clamp changed the value).
169
- useEffect(() => setAuto(view.auto), [view.auto]);
170
- useEffect(() => setIntervalDays(view.intervalDays), [view.intervalDays]);
246
+ const reload = useCallback(async () => {
247
+ try {
248
+ const next = await getScheduledAuditAction();
249
+ if (!mounted.current) return;
250
+ // Every stat comes from this ONE call, so a finished scan updates the
251
+ // last-scan time, the finding count and the countdown in a single paint.
252
+ // Fetching them separately is how a page ends up showing a fresh
253
+ // timestamp beside a stale count.
254
+ setView(next);
255
+ hasView.current = true;
256
+ setAuto(next.auto);
257
+ setIntervalDays(next.intervalDays);
258
+ setNowMs(Date.now());
259
+ setLoadError(false);
260
+ } catch {
261
+ if (mounted.current && !hasView.current) setLoadError(true);
262
+ // An existing view is LEFT ALONE on a failed refresh: it describes real
263
+ // machine state, and blanking it would report something less true than
264
+ // what is already on screen.
265
+ }
266
+ // Empty on purpose, and now honestly so: reading `view` here would rebuild
267
+ // this callback on every load and re-fire the focus listener below, which is
268
+ // why the "is anything on screen" question goes through the ref above
269
+ // instead. Nothing reactive is left to declare, so the rule no longer needs
270
+ // suppressing — and the suppression had been hiding the stale read.
271
+ }, []);
171
272
 
172
- // Reflect a scan already in flight (started here or from /audit) so the button
173
- // and status line don't claim the machine is idle when it isn't.
174
273
  useEffect(() => {
175
- let cancelled = false;
176
- (async () => {
177
- try {
178
- const res = await fetchWithTimeout("/api/audit/status", { cache: "no-store" });
179
- if (res.ok && !cancelled) {
180
- const s = (await res.json()) as { running?: boolean };
181
- setRunning(Boolean(s.running));
182
- }
183
- } catch {
184
- /* status is best-effort; a missing poll just means we assume idle */
185
- }
186
- })();
274
+ mounted.current = true;
275
+ // Still refreshes on mount even though the server seeded us: it stamps
276
+ // `nowMs` for the tape, and it picks up anything that changed between the
277
+ // server render and the browser getting here.
278
+ void reload();
187
279
  return () => {
188
- cancelled = true;
280
+ mounted.current = false;
189
281
  };
190
- }, []);
282
+ }, [reload]);
283
+
284
+ /**
285
+ * Re-read when the tab regains focus.
286
+ *
287
+ * The CLI writes the same `config.json` through the same `updateConfig`, so
288
+ * the two can never disagree on disk — but a page left open while somebody
289
+ * ran `failproofai audit --schedule 7` in a terminal would keep showing the
290
+ * old toggle. Refreshing on focus picks that up the moment you look at it,
291
+ * without a timer firing on a page that is usually idle.
292
+ */
293
+ useEffect(() => {
294
+ const onFocus = () => void reload();
295
+ window.addEventListener("focus", onFocus);
296
+ document.addEventListener("visibilitychange", onFocus);
297
+ return () => {
298
+ window.removeEventListener("focus", onFocus);
299
+ document.removeEventListener("visibilitychange", onFocus);
300
+ };
301
+ }, [reload]);
191
302
 
192
- const daemonInactive = view.daemon !== "running";
193
- const daemonUnsupported = view.daemon === "unsupported-platform";
303
+ const signedIn = view?.signedInAs ?? null;
304
+ const daemonRunning = view?.daemon === "running";
305
+ const daemonUnsupported = view?.daemon === "unsupported-platform";
306
+ const sched = view?.schedule ?? null;
307
+ const lastScan = view?.lastScan ?? null;
308
+ const lastExitBad =
309
+ sched?.lastExitCode != null && sched.lastExitCode !== 0 && sched.lastExitCode !== 75;
194
310
 
195
- const onToggleAuto = useCallback(async () => {
196
- const next = !auto;
197
- setAuto(next); // optimistic
198
- setSavingAuto(true);
311
+ const enable = useCallback(async () => {
312
+ setBusy(true);
199
313
  try {
200
- const res = await setAutoAuditAction(next);
314
+ const res = await setAutoAuditAction(true);
315
+ if (!res.ok) {
316
+ setAuto(false);
317
+ if (res.reason === "unreachable") {
318
+ // The session is fine and the network is not. Offering a code prompt
319
+ // here would name a failure the user does not have and hand them a
320
+ // flow that cannot succeed either — and abandoning it mid-way is how
321
+ // a working session gets replaced with none at all.
322
+ toast("could not reach the server. check your connection and try again.");
323
+ return;
324
+ }
325
+ // The server rejected the session this page had been showing an address
326
+ // for — expired, or minted against a different api-server. The local
327
+ // file is the only thing that said "signed in", and `whoAmI` has since
328
+ // cleared it, so re-read before opening the dialog: otherwise the page
329
+ // asks for an email while still displaying one.
330
+ await reload();
331
+ setAuthOpen(true);
332
+ toast("that sign-in expired. one more code and it's on.");
333
+ return;
334
+ }
201
335
  setAuto(res.auto);
202
- toast(res.auto ? "Scheduled scanning on." : "Scheduled scanning off.");
203
- await onReload();
336
+ toast("scheduled audits on.");
337
+ await reload();
204
338
  } catch {
205
- setAuto(!next); // revert
206
- toast("Could not save that.");
339
+ setAuto(false);
340
+ toast("could not turn that on.");
207
341
  } finally {
208
- setSavingAuto(false);
342
+ setBusy(false);
209
343
  }
210
- }, [auto, onReload]);
344
+ }, [reload]);
345
+
346
+ const onToggle = useCallback(async () => {
347
+ if (auto) {
348
+ setBusy(true);
349
+ try {
350
+ const res = await setAutoAuditAction(false);
351
+ // Turning it OFF is never refused, so `ok` is always true here — the
352
+ // narrowing is the type system's, not a case that can happen.
353
+ if (res.ok) setAuto(res.auto);
354
+ toast("scheduled audits off.");
355
+ await reload();
356
+ } catch {
357
+ toast("could not turn that off.");
358
+ } finally {
359
+ setBusy(false);
360
+ }
361
+ return;
362
+ }
363
+ // Turning it on needs somewhere to send the digest.
364
+ if (!signedIn) {
365
+ setAuthOpen(true);
366
+ return;
367
+ }
368
+ await enable();
369
+ }, [auto, enable, reload, signedIn]);
211
370
 
212
371
  const commitInterval = useCallback(
213
372
  async (raw: number) => {
214
- setSavingInterval(true);
373
+ setBusy(true);
215
374
  try {
216
- // The config owns the 1..90 clamp; we reflect whatever it stored.
217
375
  const res = await setAuditIntervalAction(raw);
376
+ // The action's return IS the authoritative value — it re-reads through
377
+ // `readConfig`, so it already carries the 1..90 clamp. No reload after:
378
+ // it would re-fetch the same number, and the schedule it would also
379
+ // re-read has not changed yet either, since the daemon recomputes the
380
+ // next due time on its own tick rather than when the interval is saved.
218
381
  setIntervalDays(res.intervalDays);
219
- toast(`Scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`);
382
+ // `view` is this page's mirror of what is ON DISK, and the write just
383
+ // changed disk — so it has to be told, even though nothing is re-read.
384
+ // The blur handler below skips the write when the typed value already
385
+ // equals `view.intervalDays`, and with the mirror left stale that guard
386
+ // compared against a number two edits old: type 14, blur, then type the
387
+ // original 7 back and the second blur was silently dropped. The input
388
+ // read 7, the config still said 14, and nothing said so until the next
389
+ // focus refresh flipped the field back.
390
+ setView((v) => (v ? { ...v, intervalDays: res.intervalDays } : v));
391
+ toast(`scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`);
220
392
  } catch {
221
- setIntervalDays(view.intervalDays);
222
- toast("Could not save that.");
393
+ setIntervalDays(view?.intervalDays ?? 7);
394
+ toast("could not save that.");
223
395
  } finally {
224
- setSavingInterval(false);
396
+ setBusy(false);
225
397
  }
226
398
  },
227
- [view.intervalDays],
399
+ [view?.intervalDays],
228
400
  );
229
401
 
402
+ const onSignOut = useCallback(async () => {
403
+ setBusy(true);
404
+ try {
405
+ await fetch("/api/auth/logout", { method: "POST" });
406
+ toast("signed out. scans continue; digests pause.");
407
+ await reload();
408
+ } catch {
409
+ toast("could not sign out.");
410
+ } finally {
411
+ setBusy(false);
412
+ }
413
+ }, [reload]);
414
+
230
415
  const onRunNow = useCallback(async () => {
231
- if (runningNow || running) return;
232
- setRunningNow(true);
416
+ if (running) return;
233
417
  setRunning(true);
234
418
  try {
235
419
  await triggerRun({ cli: [], since: "all", noCache: false });
236
- toast("Audit complete.");
237
- await onReload();
420
+ toast("scan complete.");
421
+ await reload();
238
422
  } catch (err) {
239
- const msg =
423
+ toast(
240
424
  err instanceof RerunError && err.kind === "timeout"
241
- ? "The scan is taking a while — it will finish in the background."
242
- : "The scan could not be completed.";
243
- toast(msg);
425
+ ? "the scan is taking a while — it will finish in the background."
426
+ : "the scan could not be completed.",
427
+ );
244
428
  } finally {
245
- setRunningNow(false);
246
429
  setRunning(false);
247
430
  }
248
- }, [runningNow, running, onReload]);
249
-
250
- const sched = view.schedule;
251
- const lastExitBad =
252
- sched?.lastExitCode != null && sched.lastExitCode !== 0 && sched.lastExitCode !== 75;
253
-
254
- return (
255
- <div className="panel" style={{ padding: 20, marginBottom: 20 }}>
256
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
257
- <div>
258
- <h3 style={SECTION_TITLE}>Scheduled audit</h3>
259
- <p style={MUTED}>Scan this machine on a timer, in the background.</p>
260
- </div>
261
- <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
262
- {view.daemon === "running" && <Pill tone="ok">daemon running</Pill>}
263
- {view.daemon === "stopped" && <Pill tone="warn">daemon stopped</Pill>}
264
- {view.daemon === "not-installed" && <Pill tone="warn">daemon not installed</Pill>}
265
- {view.daemon === "unsupported-platform" && <Pill tone="muted">daemon unavailable</Pill>}
266
- </div>
267
- </div>
268
-
269
- {/* Enable toggle + the plain statement about what the scan reads. */}
270
- <div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginTop: 18 }}>
271
- <div style={{ marginTop: 2 }}>
272
- <Toggle
273
- enabled={auto}
274
- onChange={onToggleAuto}
275
- disabled={savingAuto || daemonUnsupported}
276
- label={auto ? "Turn off scheduled scanning" : "Turn on scheduled scanning"}
277
- />
278
- </div>
279
- <div style={{ minWidth: 0 }}>
280
- <p style={{ ...BODY, color: "var(--ink)" }}>
281
- {auto ? "Scanning this machine on a schedule." : "Scan this machine on a schedule."}
282
- </p>
283
- <p style={{ ...BODY, marginTop: 6 }}>
284
- The scan reads the <span style={CODE}>contents</span> of every session transcript on
285
- disk across all installed agent CLIs — your prompts, the files they read and wrote,
286
- and command output. It runs entirely on this machine. Nothing is sent anywhere unless
287
- you also turn on emailed reports below.
288
- </p>
289
- </div>
290
- </div>
291
-
292
- {/* Interval. The number bounds mirror the config's own 1..90 clamp as a UX
293
- hint; the config remains the authority and we reflect what it stored. */}
294
- <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 16, flexWrap: "wrap" }}>
295
- <label htmlFor="scan-interval" style={{ ...BODY }}>
296
- Scan every
297
- </label>
298
- <input
299
- id="scan-interval"
300
- type="number"
301
- min={1}
302
- max={90}
303
- step={1}
304
- value={interval}
305
- disabled={savingInterval || daemonUnsupported}
306
- onChange={(e) => setIntervalDays(Number(e.target.value))}
307
- onBlur={(e) => {
308
- const v = Number(e.target.value);
309
- // A cleared/garbage field must not persist NaN — snap back to the
310
- // stored value and let the config keep owning the real bounds.
311
- if (!Number.isFinite(v)) {
312
- setIntervalDays(view.intervalDays);
313
- return;
314
- }
315
- if (v !== view.intervalDays) void commitInterval(v);
316
- }}
317
- style={{
318
- width: 64,
319
- padding: "6px 8px",
320
- background: "var(--bg)",
321
- border: "1px solid var(--line-2)",
322
- color: "var(--ink)",
323
- fontFamily: "var(--font-mono)",
324
- fontSize: 13,
325
- textAlign: "center",
326
- }}
327
- />
328
- <span style={BODY}>day{interval === 1 ? "" : "s"}.</span>
329
- <span style={MUTED}>1–90; the config keeps it in range.</span>
330
- </div>
331
-
332
- {/* Last run / next due — read from the daemon-written schedule file. */}
333
- <div
334
- style={{
335
- marginTop: 18,
336
- paddingTop: 16,
337
- borderTop: "1px dashed var(--line)",
338
- display: "flex",
339
- flexDirection: "column",
340
- gap: 6,
341
- }}
342
- >
343
- {running && (
344
- <p style={{ ...BODY, color: "var(--accent-green)" }}>A scan is running now…</p>
345
- )}
431
+ }, [reload, running]);
346
432
 
347
- {/* Last run */}
348
- {sched?.lastRunAtMs != null ? (
349
- <p style={BODY}>
350
- Last scheduled scan:{" "}
351
- <span style={CODE}>{fmtAbsolute(sched.lastRunAtMs)}</span>{" "}
352
- <span style={MUTED}>({formatRelativeTime(sched.lastRunAtMs)})</span>
353
- </p>
354
- ) : view.lastResultAt ? (
355
- <p style={BODY}>
356
- Last audit result:{" "}
357
- <span style={CODE}>{fmtAbsolute(new Date(view.lastResultAt).getTime())}</span>{" "}
358
- <span style={MUTED}>(no scheduled scan has run yet)</span>
359
- </p>
360
- ) : (
361
- <p style={BODY}>No scan has run yet.</p>
362
- )}
363
-
364
- {/* Next due */}
365
- {auto ? (
366
- sched?.nextDueAtMs != null ? (
367
- <p style={BODY}>
368
- Next scan due:{" "}
369
- <span style={CODE}>{fmtAbsolute(sched.nextDueAtMs)}</span>{" "}
370
- <span style={MUTED}>({fmtFuture(sched.nextDueAtMs)})</span>
371
- </p>
372
- ) : (
373
- <p style={BODY}>
374
- Next scan:{" "}
375
- <span style={MUTED}>the daemon will schedule it shortly.</span>
376
- </p>
377
- )
378
- ) : (
379
- <p style={MUTED}>Scheduled scanning is off — no scan is scheduled.</p>
380
- )}
381
-
382
- {lastExitBad && (
383
- <p style={{ ...BODY, color: "var(--amber)" }}>
384
- The last scheduled scan exited with code {sched?.lastExitCode}. It will retry on the
385
- next tick.
386
- </p>
387
- )}
388
- {sched?.schemaAhead && (
389
- <p style={MUTED}>
390
- A newer daemon wrote this schedule; some fields may not be shown.
391
- </p>
392
- )}
393
- </div>
394
-
395
- {/* Degraded daemon guidance — say plainly why "on" may still not run. */}
396
- {auto && daemonInactive && (
397
- <p style={{ ...BODY, color: "var(--amber)", marginTop: 14 }}>
398
- {daemonUnsupported ? (
399
- <>The background daemon isn&apos;t available on this platform, so scheduled scans
400
- can&apos;t run here. You can still run one now, and use the audit page.</>
401
- ) : view.daemon === "not-installed" ? (
402
- <>Scheduled scanning is on, but the background service isn&apos;t installed, so nothing
403
- will run on the timer yet. Install it with <Cmd>failproofai config</Cmd>.</>
404
- ) : (
405
- <>Scheduled scanning is on, but the background service is stopped, so nothing will run
406
- until it starts. Reinstall or repair it with <Cmd>failproofai config</Cmd>.</>
407
- )}
408
- </p>
409
- )}
410
-
411
- {/* Run now — reuses the existing /api/audit/run route via triggerRun. */}
412
- <div style={{ marginTop: 18 }}>
413
- <button
414
- type="button"
415
- className="btn"
416
- onClick={onRunNow}
417
- disabled={runningNow || running}
418
- >
419
- {runningNow || running ? "[ scanning… ]" : "[ run a scan now ]"}
420
- </button>
421
- </div>
422
- </div>
433
+ const onAuthed = useCallback(
434
+ async (_user: AuthedUser) => {
435
+ setAuthOpen(false);
436
+ await reload();
437
+ await enable();
438
+ },
439
+ [enable, reload],
423
440
  );
424
- }
425
-
426
- // ── page ─────────────────────────────────────────────────────────────────────
427
441
 
428
- export default function SettingsClient() {
429
- const [scheduled, setScheduled] = useState<ScheduledAuditView | null>(null);
430
- const [loading, setLoading] = useState(true);
431
- const [error, setError] = useState(false);
432
- const mounted = useRef(true);
433
-
434
- const reload = useCallback(async () => {
435
- const s = await getScheduledAuditAction();
436
- if (!mounted.current) return;
437
- setScheduled(s);
438
- }, []);
439
-
440
- useEffect(() => {
441
- mounted.current = true;
442
- (async () => {
443
- try {
444
- await reload();
445
- } catch {
446
- if (mounted.current) setError(true);
447
- } finally {
448
- if (mounted.current) setLoading(false);
442
+ // ── the four stats ────────────────────────────────────────────────────────
443
+ // Each is one fact from one source. Where a value is unknown it says so; "—"
444
+ // and 0 are deliberately different claims, because a machine that scanned and
445
+ // found nothing is not the same as a file we could not read.
446
+
447
+ const daemonCell = (() => {
448
+ if (!view) return { value: "…", sub: "reading" } as const;
449
+ if (daemonRunning) {
450
+ const up =
451
+ view.daemonStartedAtMs != null && nowMs > 0
452
+ ? `up ${fmtSpan(Math.max(0, nowMs - view.daemonStartedAtMs))}`
453
+ : "";
454
+ return { value: "running", sub: up, tone: "ok" as const };
455
+ }
456
+ if (daemonUnsupported) return { value: "unavailable", sub: "not on this platform" } as const;
457
+ // The sub-line carries the REMEDY, not a restatement of the value. Both
458
+ // states have the same one, and naming the command is the actionable half.
459
+ if (view.daemon === "not-installed")
460
+ return { value: "not installed", sub: "run failproofai config", tone: "warn" as const };
461
+ if (view.daemon === "unknown") return { value: "unknown", sub: "could not read it" } as const;
462
+ return { value: "stopped", sub: "run failproofai config", tone: "warn" as const };
463
+ })();
464
+
465
+ const nextCell = (() => {
466
+ if (!auto) return { value: "off", sub: "nothing scheduled" } as const;
467
+ if (sched?.nextDueAtMs == null) return { value: "pending", sub: "after the next scan" } as const;
468
+ return {
469
+ value: nowMs > 0 ? fmtUntil(sched.nextDueAtMs, nowMs) : "—",
470
+ sub: fmtAbsolute(sched.nextDueAtMs),
471
+ } as const;
472
+ })();
473
+
474
+ const lastCell = lastScan
475
+ ? {
476
+ value: formatRelativeTime(Date.parse(lastScan.finishedAt)),
477
+ sub:
478
+ lastScan.sessionsScanned != null
479
+ ? `${num(lastScan.sessionsScanned)} sessions`
480
+ : "scanned",
449
481
  }
450
- })();
451
- return () => {
452
- mounted.current = false;
482
+ : ({ value: "none yet", sub: "run one below" } as const);
483
+
484
+ const findingsCell = (() => {
485
+ if (!lastScan) return { value: "—", sub: "no scan yet" } as const;
486
+ if (lastScan.findings == null) return { value: "—", sub: "unreadable" } as const;
487
+ return {
488
+ value: num(lastScan.findings),
489
+ sub: "this scan",
490
+ ...(lastScan.findings > 0 ? { tone: "warn" as const } : {}),
453
491
  };
454
- }, [reload]);
492
+ })();
455
493
 
456
494
  return (
457
495
  <main className="report">
458
- <section className="section" data-screen-label="settings">
459
- <h2
460
- style={{
461
- fontFamily: "var(--font-mono)",
462
- fontSize: 24,
463
- fontWeight: 600,
464
- letterSpacing: "-0.01em",
465
- color: "var(--ink)",
466
- margin: "0 0 8px",
467
- }}
468
- >
469
- Settings
470
- </h2>
471
- <p style={{ ...BODY, maxWidth: 720, margin: "0 0 24px" }}>
472
- Machine-level controls for scheduled scanning and emailed reports.
473
- </p>
474
-
475
- {loading ? (
476
- <p style={MUTED}>Loading…</p>
477
- ) : error || !scheduled ? (
478
- <p style={{ ...BODY, color: "var(--accent-pink)" }}>
479
- Could not load settings. Refresh to try again.
480
- </p>
496
+ <section className="section set-sec">
497
+ <div className="set-mast">
498
+ <h1 className="set-title">settings</h1>
499
+ <p className="set-lede">keeping watch, so you don&apos;t have to.</p>
500
+ </div>
501
+
502
+ {loadError ? (
503
+ <div className="set-console">
504
+ <div className="set-cell">
505
+ <p className="set-warn">
506
+ could not read this machine&apos;s settings. check that the
507
+ dashboard can reach <code>~/.failproofai/config.json</code>.
508
+ </p>
509
+ </div>
510
+ </div>
481
511
  ) : (
482
- <ScheduledAuditSection view={scheduled} onReload={reload} />
512
+ <div className="set-console">
513
+ <div className="set-stats">
514
+ <StatCell label="daemon" {...daemonCell} />
515
+ <StatCell label="next scan" {...nextCell} />
516
+ <StatCell label="last scan" {...lastCell} />
517
+ <StatCell label="findings" {...findingsCell} />
518
+ </div>
519
+
520
+ <div className="set-cols">
521
+ <div className="set-cell set-config">
522
+ <h2 className="set-cell-title">scheduled audit</h2>
523
+
524
+ <div className="set-row set-row-main">
525
+ <Toggle
526
+ enabled={auto}
527
+ disabled={busy || view === null}
528
+ onChange={() => void onToggle()}
529
+ label={auto ? "turn off scheduled audits" : "turn on scheduled audits"}
530
+ />
531
+ <div className="set-row-copy">
532
+ <span className="set-strong">
533
+ {auto ? "scanning on a schedule." : "scan on a schedule."}
534
+ </span>
535
+ <span className="set-dim">
536
+ {auto
537
+ ? "you'll get an email only when a scan finds something."
538
+ : "off. nothing runs and nothing is sent."}
539
+ </span>
540
+ </div>
541
+ </div>
542
+
543
+ <div className="set-row set-row-interval">
544
+ <span className="set-dim">scan every</span>
545
+ <input
546
+ type="number"
547
+ className="set-num"
548
+ min={MIN_INTERVAL_DAYS}
549
+ max={MAX_INTERVAL_DAYS}
550
+ value={intervalDays}
551
+ disabled={busy || view === null}
552
+ aria-label="days between scheduled scans"
553
+ onChange={(e) => setIntervalDays(Number(e.target.value))}
554
+ onBlur={(e) => {
555
+ const v = Number(e.target.value);
556
+ if (!Number.isFinite(v)) {
557
+ setIntervalDays(view?.intervalDays ?? 7);
558
+ return;
559
+ }
560
+ if (v !== view?.intervalDays) void commitInterval(v);
561
+ }}
562
+ />
563
+ <span className="set-dim">days</span>
564
+ <span className="set-hint">
565
+ {MIN_INTERVAL_DAYS}&ndash;{MAX_INTERVAL_DAYS}
566
+ </span>
567
+ </div>
568
+
569
+ <div className="set-rule" />
570
+
571
+ <div className="set-row set-row-identity">
572
+ {signedIn ? (
573
+ <>
574
+ <span className="set-dim">reports go to</span>
575
+ <span className="set-email">{signedIn.email}</span>
576
+ <button
577
+ type="button"
578
+ className="set-link"
579
+ disabled={busy}
580
+ onClick={() => void onSignOut()}
581
+ >
582
+ sign out
583
+ </button>
584
+ </>
585
+ ) : auto ? (
586
+ // A control, not just a sentence. This state told the user
587
+ // to sign in and gave them nothing to sign in WITH: the
588
+ // dialog opened only from the off→on toggle and from
589
+ // `enable()`'s rejection path, so somebody whose session
590
+ // died while the timer stayed on had to guess that toggling
591
+ // off and back on was the way through. The CLI recovers
592
+ // from this in one command; the dashboard could not recover
593
+ // from it at all.
594
+ <>
595
+ <span className="set-warn">
596
+ signed out — scans continue, digests are paused.
597
+ </span>
598
+ <button
599
+ type="button"
600
+ className="set-link"
601
+ disabled={busy}
602
+ onClick={() => setAuthOpen(true)}
603
+ >
604
+ sign in to resume
605
+ </button>
606
+ </>
607
+ ) : (
608
+ <span className="set-dim">
609
+ turning this on asks for an email, so there is somewhere to
610
+ send the report.
611
+ </span>
612
+ )}
613
+ </div>
614
+
615
+ {auto && view && !daemonRunning && (
616
+ <p className="set-warn">
617
+ {daemonUnsupported
618
+ ? "the background service isn't available on this platform, so scheduled scans can't run here. you can still run one now."
619
+ : view.daemon === "not-installed"
620
+ ? "the background service isn't installed, so nothing will run on the timer. install it with `failproofai config`."
621
+ : "the background service is stopped, so nothing will run until it starts. repair it with `failproofai config`."}
622
+ </p>
623
+ )}
624
+
625
+ {lastExitBad && (
626
+ <p className="set-warn">
627
+ the last scheduled scan exited {sched?.lastExitCode}. the next
628
+ one will still run.
629
+ </p>
630
+ )}
631
+
632
+ <div className="set-actions">
633
+ <button
634
+ type="button"
635
+ className="btn btn-press"
636
+ disabled={running}
637
+ onClick={() => void onRunNow()}
638
+ >
639
+ {running ? "[ scanning… ]" : "[ run a scan now ]"}
640
+ </button>
641
+ </div>
642
+ </div>
643
+
644
+ <div className="set-cell set-how">
645
+ <h2 className="set-cell-title">how it works</h2>
646
+ <dl className="set-how-list">
647
+ {HOW_IT_WORKS.map((row) => (
648
+ <div className="set-how-row" key={row.label}>
649
+ <dt className="set-how-label">{row.label}</dt>
650
+ <dd className="set-how-body">{row.body}</dd>
651
+ </div>
652
+ ))}
653
+ </dl>
654
+ </div>
655
+ </div>
656
+ </div>
657
+ )}
658
+
659
+ {auto && nowMs > 0 && (
660
+ <ScheduleTape
661
+ lastRunAtMs={sched?.lastRunAtMs ?? null}
662
+ nextDueAtMs={sched?.nextDueAtMs ?? null}
663
+ now={nowMs}
664
+ />
483
665
  )}
484
666
  </section>
667
+
668
+ <AuthDialog
669
+ open={authOpen}
670
+ source="settings_scheduled_audit"
671
+ headline="where should the report go?"
672
+ subhead="we'll send a one-time code to confirm."
673
+ onClose={() => setAuthOpen(false)}
674
+ onAuthed={(u) => void onAuthed(u)}
675
+ />
485
676
  </main>
486
677
  );
487
678
  }