kcg-code 0.0.0-stage → 0.2.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 (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,714 @@
1
+ /**
2
+ * Lock screen — menutupi seluruh app saat kunci aktif dan sesi belum dibuka.
3
+ *
4
+ * Tampilan ala lock screen: jam & tanggal, foto profil, nickname, lalu input
5
+ * sesuai jenis kunci:
6
+ * - PIN: titik indikator + keypad. Keyboard fisik didengar di level window
7
+ * (angka baris atas / numpad, Backspace, Enter, Esc) sehingga tidak perlu
8
+ * klik apa pun dulu. Tombol keypad sengaja tidak menyala saat mengetik
9
+ * dari keyboard (tidak membocorkan angka saat layar dibagikan). Tempel
10
+ * (paste) juga didukung. Panjang PIN tidak diketahui klien, jadi dikirim dengan
11
+ * Enter / tombol ✓.
12
+ * - Password: kartu dengan kolom sandi besar, tombol tampilkan, indikator
13
+ * Caps Lock, dan tombol "Unlock" penuh.
14
+ * - Passkey: bila alamat ini punya passkey terdaftar & browser mendukung,
15
+ * tampilan utama berganti jadi satu tombol sidik jari besar. PIN/password
16
+ * tetap tersedia lewat "Use PIN instead". Dialog sistem tidak dibuka
17
+ * otomatis — beberapa browser (Safari) mewajibkan ketukan pengguna.
18
+ * Salah -> getar; terlalu banyak percobaan -> hitung mundur dari server.
19
+ *
20
+ * Aksesibilitas: dialog modal (`role="dialog"`, `aria-modal`), status
21
+ * diumumkan lewat `aria-live`.
22
+ */
23
+ import {
24
+ ArrowRightIcon,
25
+ CornerDownLeftIcon,
26
+ DeleteIcon,
27
+ EyeIcon,
28
+ EyeOffIcon,
29
+ FingerprintIcon,
30
+ KeyboardIcon,
31
+ LockIcon,
32
+ TriangleAlertIcon,
33
+ } from "lucide-react";
34
+ import { useCallback, useEffect, useRef, useState } from "react";
35
+ import { ProfileAvatar } from "@/components/auth/ProfileAvatar";
36
+ import { Spinner } from "@/components/ui/spinner";
37
+ import { refreshAuth } from "@/lib/auth";
38
+ import { applyPinAction, digitsFromPaste, pinKeyAction } from "@/lib/lock-screen";
39
+ import { getPasskeyAvailability, passkeysSupportedHere, unlockWithPasskey } from "@/lib/passkeys";
40
+ import { cn } from "@/lib/utils";
41
+ import type { AuthStatus } from "@/server/services/auth";
42
+
43
+ export function LockScreen({ status }: { status: AuthStatus }) {
44
+ const kind = status.lockKind ?? "password";
45
+ const [secret, setSecret] = useState("");
46
+ const [busy, setBusy] = useState(false);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [shake, setShake] = useState(false);
49
+ const [retryUntil, setRetryUntil] = useState<number>(() =>
50
+ status.retryAfterSec > 0 ? Date.now() + status.retryAfterSec * 1000 : 0,
51
+ );
52
+ const [now, setNow] = useState(() => Date.now());
53
+ /** Alamat ini punya passkey & browser mendukung (diketahui setelah mount). */
54
+ const [passkeyReady, setPasskeyReady] = useState(false);
55
+ /** Mode input: passkey jadi utama bila tersedia; user bisa beralih. */
56
+ const [mode, setMode] = useState<"passkey" | "secret">("secret");
57
+ const [passkeyBusy, setPasskeyBusy] = useState(false);
58
+
59
+ useEffect(() => {
60
+ if (!passkeysSupportedHere()) return;
61
+ let cancelled = false;
62
+ void getPasskeyAvailability()
63
+ .then((a) => {
64
+ if (cancelled || !a.supported || a.count === 0) return;
65
+ setPasskeyReady(true);
66
+ setMode("passkey");
67
+ })
68
+ .catch(() => {
69
+ // Gagal cek -> tetap PIN/password.
70
+ });
71
+ return () => {
72
+ cancelled = true;
73
+ };
74
+ }, []);
75
+
76
+ // Jam (dan hitung mundur rate limit).
77
+ useEffect(() => {
78
+ const t = setInterval(() => setNow(Date.now()), 1000);
79
+ return () => clearInterval(t);
80
+ }, []);
81
+
82
+ const waitSec = Math.max(0, Math.ceil((retryUntil - now) / 1000));
83
+ const blocked = waitSec > 0;
84
+
85
+ const fail = useCallback((message: string) => {
86
+ setError(message);
87
+ setSecret("");
88
+ setShake(true);
89
+ setTimeout(() => setShake(false), 450);
90
+ navigator.vibrate?.(120);
91
+ }, []);
92
+
93
+ const submit = useCallback(
94
+ async (value: string) => {
95
+ if (busy || blocked || value === "") return;
96
+ setBusy(true);
97
+ setError(null);
98
+ try {
99
+ const res = await fetch("/api/auth/login", {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify({ secret: value }),
103
+ });
104
+ if (res.ok) {
105
+ setSecret("");
106
+ await refreshAuth();
107
+ return;
108
+ }
109
+ const body = (await res.json().catch(() => ({}))) as {
110
+ error?: string;
111
+ retryAfterSec?: number;
112
+ };
113
+ if (res.status === 429) {
114
+ const sec = body.retryAfterSec ?? Number(res.headers.get("retry-after") ?? 30);
115
+ setRetryUntil(Date.now() + sec * 1000);
116
+ fail("Too many attempts.");
117
+ } else if (body.error === "AUTH_NOT_CONFIGURED") {
118
+ await refreshAuth();
119
+ } else {
120
+ fail(kind === "pin" ? "Wrong PIN. Try again." : "Wrong password. Try again.");
121
+ }
122
+ } catch {
123
+ setError("Can't reach KCG Code. Check your connection.");
124
+ } finally {
125
+ setBusy(false);
126
+ }
127
+ },
128
+ [busy, blocked, kind, fail],
129
+ );
130
+
131
+ const unlockPasskey = useCallback(async () => {
132
+ if (passkeyBusy || blocked) return;
133
+ setPasskeyBusy(true);
134
+ setError(null);
135
+ const res = await unlockWithPasskey();
136
+ if (res.ok) {
137
+ await refreshAuth();
138
+ setPasskeyBusy(false);
139
+ return;
140
+ }
141
+ setPasskeyBusy(false);
142
+ // Menutup dialog sistem bukan kesalahan — diam saja.
143
+ if (res.cancelled) return;
144
+ if (res.code === "AUTH_RATE_LIMITED") {
145
+ // Detik tunggu dibaca dari status server (sumber yang sama dgn PIN).
146
+ try {
147
+ const st = (await (await fetch("/api/auth/status")).json()) as AuthStatus;
148
+ setRetryUntil(Date.now() + Math.max(1, st.retryAfterSec) * 1000);
149
+ } catch {
150
+ setRetryUntil(Date.now() + 30_000);
151
+ }
152
+ fail("Too many attempts.");
153
+ return;
154
+ }
155
+ fail(res.message);
156
+ }, [passkeyBusy, blocked, fail]);
157
+
158
+ const time = new Date(now).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
159
+ const date = new Date(now).toLocaleDateString(undefined, {
160
+ weekday: "long",
161
+ day: "numeric",
162
+ month: "long",
163
+ });
164
+ const name = status.profile.nickname;
165
+ const message = blocked ? `Too many attempts. Try again in ${waitSec}s.` : error;
166
+
167
+ return (
168
+ <div
169
+ role="dialog"
170
+ aria-modal="true"
171
+ aria-labelledby="lock-title"
172
+ // Animasi masuk/keluar ditangani pembungkus `m.div` di AuthGate.
173
+ className="fixed inset-0 z-[100] flex flex-col items-center overflow-y-auto bg-background"
174
+ >
175
+ {/* Mobile / layar sempit: satu kolom (jam -> profil -> input).
176
+ md+: dua kolom — identitas (jam, tanggal, profil) di kiri, input di
177
+ kanan — sehingga semuanya muat tanpa scroll di layar lebar. */}
178
+ <div
179
+ className={cn(
180
+ "relative flex w-full max-w-sm flex-1 flex-col items-center px-6",
181
+ "pt-[max(2.5rem,env(safe-area-inset-top))] pb-[max(1.5rem,env(safe-area-inset-bottom))]",
182
+ "md:my-auto md:max-w-4xl md:flex-none md:grid md:grid-cols-2 md:items-center md:gap-16 md:px-10 md:py-10",
183
+ )}
184
+ >
185
+ {/* ---- Kiri: identitas ---- */}
186
+ {/* Mobile: `mt-auto` di sini + `mt-auto` di petunjuk bawah membagi
187
+ ruang kosong atas & bawah -> blok utama berada di tengah vertikal.
188
+ Bila isi lebih tinggi dari layar, margin jadi 0 dan halaman scroll. */}
189
+ <div className="mt-auto flex flex-col items-center text-center md:mt-0 md:items-start md:text-left">
190
+ <div className="flex flex-col items-center gap-1 md:items-start">
191
+ <p className="text-5xl font-light tabular-nums tracking-tight md:text-7xl">{time}</p>
192
+ <p className="text-sm text-muted-foreground md:text-base">{date}</p>
193
+ </div>
194
+
195
+ <div className="mt-8 flex flex-col items-center gap-3 md:mt-12 md:flex-row md:gap-4">
196
+ <ProfileAvatar
197
+ nickname={name}
198
+ avatarUrl={status.profile.avatarUrl}
199
+ avatarPreset={status.profile.avatarPreset}
200
+ className="size-20 shadow-lg ring-4 ring-background md:size-16"
201
+ textClassName="text-2xl md:text-xl"
202
+ />
203
+ <div className="flex flex-col items-center gap-0.5 md:items-start">
204
+ <h1 id="lock-title" className="text-xl font-semibold tracking-tight">
205
+ {name ? `Welcome back, ${name}` : "KCG Code is locked"}
206
+ </h1>
207
+ <p className="flex items-center gap-1.5 text-sm text-muted-foreground">
208
+ {mode === "passkey" ? (
209
+ <>
210
+ <FingerprintIcon className="size-3.5" aria-hidden />
211
+ Use your passkey to unlock
212
+ </>
213
+ ) : (
214
+ <>
215
+ <LockIcon className="size-3.5" aria-hidden />
216
+ {kind === "pin" ? "Enter your PIN to unlock" : "Enter your password to unlock"}
217
+ </>
218
+ )}
219
+ </p>
220
+ </div>
221
+ </div>
222
+
223
+ <ForgotHint className="mt-12 hidden md:block" />
224
+ </div>
225
+
226
+ {/* ---- Kanan: input ---- */}
227
+ <div
228
+ className={cn(
229
+ "mt-7 w-full md:mt-0 md:rounded-3xl md:border md:bg-card/50 md:px-8 md:py-9 md:shadow-sm",
230
+ kind === "password" && "md:py-10",
231
+ shake && "animate-[lock-shake_0.45s]",
232
+ )}
233
+ >
234
+ {mode === "passkey" ? (
235
+ <PasskeyEntry
236
+ busy={passkeyBusy}
237
+ disabled={passkeyBusy || blocked}
238
+ message={message}
239
+ secretLabel={kind === "pin" ? "PIN" : "password"}
240
+ onUnlock={() => void unlockPasskey()}
241
+ onUseSecret={() => {
242
+ setError(null);
243
+ setMode("secret");
244
+ }}
245
+ />
246
+ ) : kind === "pin" ? (
247
+ <PinEntry
248
+ value={secret}
249
+ onChange={(v) => {
250
+ setError(null);
251
+ setSecret(v);
252
+ }}
253
+ onSubmit={() => void submit(secret)}
254
+ disabled={busy || blocked}
255
+ busy={busy}
256
+ error={shake}
257
+ message={message}
258
+ />
259
+ ) : (
260
+ <PasswordEntry
261
+ value={secret}
262
+ onChange={(v) => {
263
+ setError(null);
264
+ setSecret(v);
265
+ }}
266
+ onSubmit={() => void submit(secret)}
267
+ disabled={busy || blocked}
268
+ busy={busy}
269
+ message={message}
270
+ invalid={Boolean(error)}
271
+ />
272
+ )}
273
+ {mode === "secret" && passkeyReady && (
274
+ <div className="mt-5 flex justify-center">
275
+ <button
276
+ type="button"
277
+ onClick={() => {
278
+ setError(null);
279
+ setSecret("");
280
+ setMode("passkey");
281
+ }}
282
+ className="inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
283
+ >
284
+ <FingerprintIcon className="size-4" aria-hidden />
285
+ Use passkey instead
286
+ </button>
287
+ </div>
288
+ )}
289
+ </div>
290
+
291
+ {/* Mobile: petunjuk lupa kunci di dasar layar; md+: di kolom kiri. */}
292
+ <ForgotHint className="mt-auto pt-8 text-center md:hidden" />
293
+ </div>
294
+ </div>
295
+ );
296
+ }
297
+
298
+ function ForgotHint({ className }: { className?: string }) {
299
+ return (
300
+ <p className={cn("text-xs text-muted-foreground/80", className)}>
301
+ Forgot it? Run <code className="font-mono">kcgcode reset-lock</code> on the host machine.
302
+ </p>
303
+ );
304
+ }
305
+
306
+ // -------------------------------------------------------------- passkey ----
307
+
308
+ /**
309
+ * Mode passkey: satu tombol bundar besar (target sentuh 96px+) dengan cincin
310
+ * yang berdenyut pelan saat menunggu perangkat. Enter / Space juga memicu.
311
+ */
312
+ export function PasskeyEntry({
313
+ busy,
314
+ disabled,
315
+ message,
316
+ secretLabel,
317
+ onUnlock,
318
+ onUseSecret,
319
+ }: {
320
+ busy: boolean;
321
+ disabled: boolean;
322
+ message: string | null;
323
+ secretLabel: string;
324
+ onUnlock: () => void;
325
+ onUseSecret: () => void;
326
+ }) {
327
+ const buttonRef = useRef<HTMLButtonElement>(null);
328
+ // Fokus ke tombol: Enter langsung memicu passkey dari keyboard.
329
+ useEffect(() => {
330
+ buttonRef.current?.focus({ preventScroll: true });
331
+ }, []);
332
+
333
+ return (
334
+ <div className="flex flex-col items-center gap-5 py-2">
335
+ <button
336
+ ref={buttonRef}
337
+ type="button"
338
+ onClick={onUnlock}
339
+ disabled={disabled}
340
+ aria-busy={busy}
341
+ aria-describedby="lock-passkey-status"
342
+ className="group relative flex size-28 items-center justify-center rounded-full outline-none disabled:cursor-not-allowed [@media(min-height:760px)]:size-32"
343
+ >
344
+ {/* Cincin luar: berdenyut saat menunggu konfirmasi di perangkat. */}
345
+ <span
346
+ aria-hidden
347
+ className={cn(
348
+ "absolute inset-0 rounded-full border-2 border-primary/25 transition-colors",
349
+ busy && "animate-ping border-primary/40 motion-reduce:animate-none",
350
+ )}
351
+ />
352
+ <span
353
+ aria-hidden
354
+ className={cn(
355
+ "flex size-full items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-[transform,opacity] duration-150",
356
+ "group-hover:scale-[1.03] group-active:scale-95 group-focus-visible:ring-[4px] group-focus-visible:ring-ring/50 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background",
357
+ disabled && !busy && "opacity-40",
358
+ )}
359
+ >
360
+ {busy ? (
361
+ <Spinner className="size-9" />
362
+ ) : (
363
+ <FingerprintIcon className="size-12" strokeWidth={1.6} />
364
+ )}
365
+ </span>
366
+ <span className="sr-only">{busy ? "Waiting for your passkey" : "Unlock with passkey"}</span>
367
+ </button>
368
+
369
+ <div className="flex flex-col items-center gap-1 text-center">
370
+ <p className="text-[15px] font-medium">
371
+ {busy ? "Confirm on your device…" : "Unlock with passkey"}
372
+ </p>
373
+ <p
374
+ id="lock-passkey-status"
375
+ aria-live="polite"
376
+ className={cn(
377
+ "min-h-5 max-w-xs text-sm",
378
+ message ? "text-destructive" : "text-muted-foreground",
379
+ )}
380
+ >
381
+ {message ?? (busy ? "Use your fingerprint, face, or screen lock." : "Tap to continue")}
382
+ </p>
383
+ </div>
384
+
385
+ <button
386
+ type="button"
387
+ onClick={onUseSecret}
388
+ disabled={busy}
389
+ className="inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40"
390
+ >
391
+ <KeyboardIcon className="size-4" aria-hidden />
392
+ Use {secretLabel} instead
393
+ </button>
394
+ </div>
395
+ );
396
+ }
397
+
398
+ // ------------------------------------------------------------------ PIN ----
399
+
400
+ function PinEntry({
401
+ value,
402
+ onChange,
403
+ onSubmit,
404
+ disabled,
405
+ busy,
406
+ error,
407
+ message,
408
+ }: {
409
+ value: string;
410
+ onChange: (v: string) => void;
411
+ onSubmit: () => void;
412
+ disabled: boolean;
413
+ busy: boolean;
414
+ error: boolean;
415
+ message: string | null;
416
+ }) {
417
+ // Nilai terbaru untuk handler window (tanpa memasang ulang listener).
418
+ const latest = useRef({ value, disabled, onChange, onSubmit });
419
+ latest.current = { value, disabled, onChange, onSubmit };
420
+
421
+ // Keyboard fisik: didengar di window, jadi langsung bisa mengetik tanpa
422
+ // fokus ke elemen tertentu. Sengaja TIDAK menyalakan tombol keypad yang
423
+ // sesuai: saat layar dibagikan / diintip, itu akan membocorkan angka PIN.
424
+ // Umpan balik cukup lewat titik indikator.
425
+ useEffect(() => {
426
+ const onKey = (e: KeyboardEvent) => {
427
+ const t = e.target as HTMLElement | null;
428
+ // Jangan ganggu kolom input lain (tidak ada di lock screen, defensif).
429
+ if (t && t.tagName === "INPUT" && t.dataset.pinInput === undefined) return;
430
+ const action = pinKeyAction(e);
431
+ if (!action) return;
432
+ // Tombol keypad yang sedang fokus: biarkan Enter/Space mengaktifkan
433
+ // tombol itu sendiri (navigasi Tab tetap berfungsi).
434
+ if (action.type === "submit" && t?.tagName === "BUTTON") return;
435
+ e.preventDefault();
436
+ const cur = latest.current;
437
+ if (cur.disabled) return;
438
+ if (action.type === "submit") {
439
+ cur.onSubmit();
440
+ return;
441
+ }
442
+ cur.onChange(applyPinAction(cur.value, action));
443
+ };
444
+ const onPaste = (e: ClipboardEvent) => {
445
+ const text = e.clipboardData?.getData("text") ?? "";
446
+ if (!/\d/.test(text)) return;
447
+ e.preventDefault();
448
+ const cur = latest.current;
449
+ if (!cur.disabled) cur.onChange(digitsFromPaste(text, cur.value));
450
+ };
451
+ window.addEventListener("keydown", onKey);
452
+ window.addEventListener("paste", onPaste);
453
+ return () => {
454
+ window.removeEventListener("keydown", onKey);
455
+ window.removeEventListener("paste", onPaste);
456
+ };
457
+ }, []);
458
+
459
+ const press = (d: string) => {
460
+ if (disabled) return;
461
+ onChange(applyPinAction(value, { type: "digit", digit: d }));
462
+ };
463
+
464
+ return (
465
+ <div className="flex flex-col items-center gap-4">
466
+ {/* Ringkasan untuk pembaca layar (titik bersifat visual) */}
467
+ <p className="sr-only" aria-live="polite">
468
+ {value.length === 0 ? "No digits entered" : `${value.length} digits entered`}
469
+ </p>
470
+ <PinDots length={value.length} error={error} />
471
+
472
+ <p
473
+ aria-live="polite"
474
+ className={cn(
475
+ "min-h-5 text-center text-sm",
476
+ message ? "text-destructive" : "text-muted-foreground",
477
+ )}
478
+ >
479
+ {message ?? ""}
480
+ </p>
481
+
482
+ <PinPad
483
+ disabled={disabled}
484
+ canSubmit={value.length > 0}
485
+ busy={busy}
486
+ onDigit={press}
487
+ onBackspace={() => onChange(applyPinAction(value, { type: "backspace" }))}
488
+ onSubmit={onSubmit}
489
+ />
490
+
491
+ <p className="hidden items-center gap-1.5 text-xs text-muted-foreground pointer-fine:flex">
492
+ <KeyboardIcon className="size-3.5" aria-hidden />
493
+ Type your PIN, then press
494
+ <kbd className="inline-flex items-center gap-0.5 rounded border bg-muted px-1.5 py-px font-mono text-[11px]">
495
+ <CornerDownLeftIcon className="size-3" aria-hidden />
496
+ Enter
497
+ </kbd>
498
+ </p>
499
+ </div>
500
+ );
501
+ }
502
+
503
+ function PinDots({ length, error }: { length: number; error: boolean }) {
504
+ // Minimal 6 slot (panjang PIN minimum); bertambah bila PIN lebih panjang.
505
+ const slots = Math.max(6, length);
506
+ return (
507
+ <div className="flex h-4 items-center gap-3" aria-hidden>
508
+ {Array.from({ length: slots }, (_, i) => (
509
+ <span
510
+ // biome-ignore lint/suspicious/noArrayIndexKey: slot statis
511
+ key={i}
512
+ className={cn(
513
+ "size-3 rounded-full border-2 transition-all duration-150",
514
+ i < length
515
+ ? error
516
+ ? "scale-110 border-destructive bg-destructive"
517
+ : "scale-110 border-foreground bg-foreground"
518
+ : "border-muted-foreground/40",
519
+ )}
520
+ />
521
+ ))}
522
+ </div>
523
+ );
524
+ }
525
+
526
+ function PinPad({
527
+ disabled,
528
+ canSubmit,
529
+ busy,
530
+ onDigit,
531
+ onBackspace,
532
+ onSubmit,
533
+ }: {
534
+ disabled: boolean;
535
+ canSubmit: boolean;
536
+ busy: boolean;
537
+ onDigit: (d: string) => void;
538
+ onBackspace: () => void;
539
+ onSubmit: () => void;
540
+ }) {
541
+ const key =
542
+ "flex size-16 items-center justify-center rounded-full text-2xl [@media(min-height:760px)]:size-[4.5rem] font-medium tabular-nums transition-[background-color,transform] duration-100 select-none outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40";
543
+ const digitKey = "bg-muted/70 hover:bg-muted active:scale-95 active:bg-accent";
544
+ return (
545
+ <fieldset
546
+ className="grid grid-cols-3 gap-x-6 gap-y-3 border-0 p-0 [@media(min-height:760px)]:gap-y-4"
547
+ aria-label="PIN keypad"
548
+ >
549
+ {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
550
+ <button
551
+ key={d}
552
+ type="button"
553
+ tabIndex={-1}
554
+ disabled={disabled}
555
+ onClick={() => onDigit(d)}
556
+ className={cn(key, digitKey)}
557
+ >
558
+ {d}
559
+ </button>
560
+ ))}
561
+ <button
562
+ type="button"
563
+ tabIndex={-1}
564
+ disabled={disabled}
565
+ onClick={onBackspace}
566
+ aria-label="Delete last digit"
567
+ className={cn(key, "text-muted-foreground hover:text-foreground active:scale-95")}
568
+ >
569
+ <DeleteIcon className="size-6" />
570
+ </button>
571
+ <button
572
+ type="button"
573
+ tabIndex={-1}
574
+ disabled={disabled}
575
+ onClick={() => onDigit("0")}
576
+ className={cn(key, digitKey)}
577
+ >
578
+ 0
579
+ </button>
580
+ <button
581
+ type="button"
582
+ disabled={disabled || !canSubmit}
583
+ onClick={onSubmit}
584
+ aria-label="Unlock"
585
+ className={cn(
586
+ key,
587
+ "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95",
588
+ )}
589
+ >
590
+ {busy ? <Spinner className="size-6" /> : <ArrowRightIcon className="size-6" />}
591
+ </button>
592
+ </fieldset>
593
+ );
594
+ }
595
+
596
+ // ------------------------------------------------------------- password ----
597
+
598
+ function PasswordEntry({
599
+ value,
600
+ onChange,
601
+ onSubmit,
602
+ disabled,
603
+ busy,
604
+ message,
605
+ invalid,
606
+ }: {
607
+ value: string;
608
+ onChange: (v: string) => void;
609
+ onSubmit: () => void;
610
+ disabled: boolean;
611
+ busy: boolean;
612
+ message: string | null;
613
+ invalid: boolean;
614
+ }) {
615
+ const [reveal, setReveal] = useState(false);
616
+ const [capsLock, setCapsLock] = useState(false);
617
+ const inputRef = useRef<HTMLInputElement>(null);
618
+
619
+ // Fokus otomatis (termasuk setelah jeda rate limit selesai / sandi salah).
620
+ useEffect(() => {
621
+ if (!disabled) inputRef.current?.focus();
622
+ }, [disabled]);
623
+
624
+ const trackCaps = (e: React.KeyboardEvent | React.MouseEvent) => {
625
+ setCapsLock(e.getModifierState?.("CapsLock") ?? false);
626
+ };
627
+
628
+ return (
629
+ <form
630
+ onSubmit={(e) => {
631
+ e.preventDefault();
632
+ onSubmit();
633
+ }}
634
+ className="flex flex-col gap-3"
635
+ >
636
+ <div
637
+ className={cn(
638
+ "group relative flex items-center rounded-2xl border bg-card shadow-sm transition-[border-color,box-shadow]",
639
+ "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/40",
640
+ invalid &&
641
+ "border-destructive/60 focus-within:border-destructive focus-within:ring-destructive/20",
642
+ disabled && "opacity-60",
643
+ )}
644
+ >
645
+ <LockIcon
646
+ className="pointer-events-none absolute left-4 size-4 text-muted-foreground transition-colors group-focus-within:text-foreground"
647
+ aria-hidden
648
+ />
649
+ <input
650
+ ref={inputRef}
651
+ type={reveal ? "text" : "password"}
652
+ autoComplete="current-password"
653
+ autoCapitalize="off"
654
+ autoCorrect="off"
655
+ spellCheck={false}
656
+ aria-label="Password"
657
+ aria-invalid={invalid || undefined}
658
+ aria-describedby="lock-password-status"
659
+ placeholder="Password"
660
+ value={value}
661
+ disabled={disabled}
662
+ onChange={(e) => onChange(e.target.value)}
663
+ onKeyDown={trackCaps}
664
+ onKeyUp={trackCaps}
665
+ onMouseDown={trackCaps}
666
+ onBlur={() => setCapsLock(false)}
667
+ className="h-14 w-full min-w-0 rounded-2xl bg-transparent pr-12 pl-11 text-base outline-none placeholder:text-muted-foreground/70 disabled:cursor-not-allowed"
668
+ />
669
+ <button
670
+ type="button"
671
+ onClick={() => {
672
+ setReveal((v) => !v);
673
+ inputRef.current?.focus();
674
+ }}
675
+ aria-label={reveal ? "Hide password" : "Show password"}
676
+ aria-pressed={reveal}
677
+ disabled={disabled}
678
+ className="absolute right-2 flex size-10 items-center justify-center rounded-xl text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
679
+ >
680
+ {reveal ? <EyeOffIcon className="size-[18px]" /> : <EyeIcon className="size-[18px]" />}
681
+ </button>
682
+ </div>
683
+
684
+ <div id="lock-password-status" aria-live="polite" className="min-h-5 px-1 text-sm">
685
+ {message ? (
686
+ <p className="text-destructive">{message}</p>
687
+ ) : capsLock ? (
688
+ <p className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400">
689
+ <TriangleAlertIcon className="size-3.5" aria-hidden />
690
+ Caps Lock is on
691
+ </p>
692
+ ) : null}
693
+ </div>
694
+
695
+ <button
696
+ type="submit"
697
+ disabled={disabled || value === ""}
698
+ className="flex h-12 items-center justify-center gap-2 rounded-2xl bg-primary text-[15px] font-medium text-primary-foreground shadow-sm transition-[opacity,transform] outline-none hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.98] disabled:opacity-40"
699
+ >
700
+ {busy ? (
701
+ <>
702
+ <Spinner className="size-4" />
703
+ Unlocking…
704
+ </>
705
+ ) : (
706
+ <>
707
+ Unlock
708
+ <ArrowRightIcon className="size-4" aria-hidden />
709
+ </>
710
+ )}
711
+ </button>
712
+ </form>
713
+ );
714
+ }