kcg-code 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/README.md +228 -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 +83 -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__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Settings -> Security -> Passkeys.
3
+ *
4
+ * Passkey = cara buka kunci TAMBAHAN (sidik jari, wajah, kunci layar
5
+ * perangkat, atau security key). PIN/password tetap jadi cadangan.
6
+ *
7
+ * - Tambah passkey: konfirmasi PIN/password saat ini dulu, lalu dialog
8
+ * sistem browser. Nama bawaan dari perangkat (bisa diganti).
9
+ * - Tiap passkey terikat ke alamat tempat didaftarkan. Passkey alamat lain
10
+ * tetap tampil (agar bisa dihapus) dengan label alamatnya.
11
+ * - Alamat yang tidak mendukung passkey (http selain localhost / IP) ->
12
+ * tombol tambah dinonaktifkan dengan penjelasan.
13
+ */
14
+ import {
15
+ CheckIcon,
16
+ CloudIcon,
17
+ FingerprintIcon,
18
+ PencilIcon,
19
+ PlusIcon,
20
+ Trash2Icon,
21
+ XIcon,
22
+ } from "lucide-react";
23
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
24
+ import { toast } from "sonner";
25
+ import { PrefsGroup } from "@/components/settings/prefs";
26
+ import { Button } from "@/components/ui/button";
27
+ import {
28
+ Dialog,
29
+ DialogContent,
30
+ DialogDescription,
31
+ DialogFooter,
32
+ DialogHeader,
33
+ DialogTitle,
34
+ } from "@/components/ui/dialog";
35
+ import { Spinner } from "@/components/ui/spinner";
36
+ import { ApiError } from "@/lib/api";
37
+ import { lockLabel } from "@/lib/auth";
38
+ import {
39
+ addPasskey,
40
+ listPasskeys,
41
+ type PasskeyInfo,
42
+ passkeysSupportedHere,
43
+ removePasskey,
44
+ renamePasskey,
45
+ } from "@/lib/passkeys";
46
+ import { formatRelativeTime } from "@/lib/project-overview";
47
+ import { cn } from "@/lib/utils";
48
+ import type { AuthStatus } from "@/server/services/auth";
49
+
50
+ type LoadState =
51
+ | { phase: "loading" }
52
+ | { phase: "error"; message: string }
53
+ | { phase: "ready"; passkeys: PasskeyInfo[] };
54
+
55
+ export function PasskeysGroup({ status }: { status: AuthStatus }) {
56
+ const [state, setState] = useState<LoadState>({ phase: "loading" });
57
+ const [addOpen, setAddOpen] = useState(false);
58
+ const [removing, setRemoving] = useState<PasskeyInfo | null>(null);
59
+ // `null` saat SSR/test; diisi setelah mount (butuh `window`).
60
+ const [supported, setSupported] = useState<boolean | null>(null);
61
+
62
+ const load = useCallback(async () => {
63
+ try {
64
+ setState({ phase: "ready", passkeys: await listPasskeys() });
65
+ } catch (e) {
66
+ setState({
67
+ phase: "error",
68
+ message: e instanceof ApiError ? e.message : "Couldn't load passkeys.",
69
+ });
70
+ }
71
+ }, []);
72
+
73
+ useEffect(() => {
74
+ setSupported(passkeysSupportedHere());
75
+ void load();
76
+ }, [load]);
77
+
78
+ return (
79
+ <>
80
+ <PasskeysView
81
+ state={state}
82
+ supported={supported}
83
+ onAdd={() => setAddOpen(true)}
84
+ onRename={async (p, name) => {
85
+ const updated = await renamePasskey(p.id, name);
86
+ setState((s) =>
87
+ s.phase === "ready"
88
+ ? {
89
+ ...s,
90
+ passkeys: s.passkeys.map((x) =>
91
+ x.id === p.id ? { ...x, name: updated.name } : x,
92
+ ),
93
+ }
94
+ : s,
95
+ );
96
+ }}
97
+ onRemove={setRemoving}
98
+ />
99
+ <AddPasskeyDialog
100
+ open={addOpen}
101
+ status={status}
102
+ onOpenChange={setAddOpen}
103
+ onAdded={(p) => {
104
+ setAddOpen(false);
105
+ toast.success(`${p.name} added. You can now unlock with it.`);
106
+ void load();
107
+ }}
108
+ />
109
+ <RemovePasskeyDialog
110
+ passkey={removing}
111
+ onOpenChange={(o) => !o && setRemoving(null)}
112
+ onRemoved={(p) => {
113
+ setRemoving(null);
114
+ toast.success(`${p.name} removed.`);
115
+ setState((s) =>
116
+ s.phase === "ready" ? { ...s, passkeys: s.passkeys.filter((x) => x.id !== p.id) } : s,
117
+ );
118
+ }}
119
+ />
120
+ </>
121
+ );
122
+ }
123
+
124
+ /** Tampilan murni grup passkey (diuji SSR). */
125
+ export function PasskeysView({
126
+ state,
127
+ supported,
128
+ onAdd,
129
+ onRename,
130
+ onRemove,
131
+ }: {
132
+ state: LoadState;
133
+ /** `null` = belum diketahui (sebelum mount). */
134
+ supported: boolean | null;
135
+ onAdd: () => void;
136
+ onRename: (p: PasskeyInfo, name: string) => Promise<void>;
137
+ onRemove: (p: PasskeyInfo) => void;
138
+ }) {
139
+ const unsupported = supported === false;
140
+ return (
141
+ <PrefsGroup
142
+ title="Passkeys"
143
+ description="Unlock with your fingerprint, face, or device screen lock. Your PIN or password still works as a backup."
144
+ suffix={
145
+ <Button
146
+ type="button"
147
+ size="sm"
148
+ variant="ghost"
149
+ onClick={onAdd}
150
+ disabled={unsupported || state.phase === "loading"}
151
+ >
152
+ <PlusIcon data-icon="inline-start" />
153
+ Add
154
+ </Button>
155
+ }
156
+ >
157
+ {state.phase === "loading" ? (
158
+ <li className="flex min-h-14 items-center justify-center px-4" role="status">
159
+ <Spinner className="size-4 text-muted-foreground" />
160
+ <span className="sr-only">Loading passkeys…</span>
161
+ </li>
162
+ ) : state.phase === "error" ? (
163
+ <li role="alert" className="px-4 py-4 text-[13px] text-destructive">
164
+ {state.message}
165
+ </li>
166
+ ) : state.passkeys.length === 0 ? (
167
+ <EmptyRow unsupported={unsupported} onAdd={onAdd} />
168
+ ) : (
169
+ state.passkeys.map((p) => (
170
+ <PasskeyRow key={p.id} passkey={p} onRename={onRename} onRemove={onRemove} />
171
+ ))
172
+ )}
173
+ {unsupported && state.phase === "ready" && state.passkeys.length > 0 && (
174
+ <li className="px-4 py-3 text-[13px] text-muted-foreground">
175
+ This address can't use passkeys. Open KCG Code over https or on localhost to add one.
176
+ </li>
177
+ )}
178
+ </PrefsGroup>
179
+ );
180
+ }
181
+
182
+ function EmptyRow({ unsupported, onAdd }: { unsupported: boolean; onAdd: () => void }) {
183
+ return (
184
+ <li className="flex flex-col items-center gap-3 px-6 py-7 text-center">
185
+ <span className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
186
+ <FingerprintIcon className="size-6" aria-hidden />
187
+ </span>
188
+ <div className="flex max-w-xs flex-col gap-1">
189
+ <p className="text-[15px] font-medium">No passkeys yet</p>
190
+ <p className="text-[13px] leading-snug text-muted-foreground">
191
+ {unsupported
192
+ ? "Passkeys need a secure address. Open KCG Code over https or on localhost to add one."
193
+ : "Skip typing your PIN. Add a passkey from this device to unlock in one tap."}
194
+ </p>
195
+ </div>
196
+ {!unsupported && (
197
+ <Button type="button" size="sm" variant="secondary" onClick={onAdd}>
198
+ <FingerprintIcon data-icon="inline-start" />
199
+ Add a passkey
200
+ </Button>
201
+ )}
202
+ </li>
203
+ );
204
+ }
205
+
206
+ function PasskeyRow({
207
+ passkey: p,
208
+ onRename,
209
+ onRemove,
210
+ }: {
211
+ passkey: PasskeyInfo;
212
+ onRename: (p: PasskeyInfo, name: string) => Promise<void>;
213
+ onRemove: (p: PasskeyInfo) => void;
214
+ }) {
215
+ const [editing, setEditing] = useState(false);
216
+ const [draft, setDraft] = useState(p.name);
217
+ const [saving, setSaving] = useState(false);
218
+ const [error, setError] = useState<string | null>(null);
219
+ const inputId = useId();
220
+ const inputRef = useRef<HTMLInputElement>(null);
221
+
222
+ useEffect(() => {
223
+ if (editing) inputRef.current?.select();
224
+ }, [editing]);
225
+
226
+ const save = async () => {
227
+ const name = draft.trim();
228
+ if (name === p.name || name === "") {
229
+ setEditing(false);
230
+ setDraft(p.name);
231
+ return;
232
+ }
233
+ setSaving(true);
234
+ setError(null);
235
+ try {
236
+ await onRename(p, name);
237
+ setEditing(false);
238
+ } catch (e) {
239
+ setError(e instanceof ApiError ? e.message : "Couldn't rename the passkey.");
240
+ } finally {
241
+ setSaving(false);
242
+ }
243
+ };
244
+
245
+ const meta = [
246
+ p.lastUsedAt ? `Used ${formatRelativeTime(p.lastUsedAt)}` : "Never used",
247
+ `added ${formatRelativeTime(p.createdAt)}`,
248
+ ].join(" · ");
249
+
250
+ return (
251
+ <li className="flex min-h-16 items-center gap-3 px-4 py-2.5">
252
+ <span
253
+ className={cn(
254
+ "flex size-9 shrink-0 items-center justify-center rounded-full",
255
+ p.usableHere ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
256
+ )}
257
+ >
258
+ <FingerprintIcon className="size-[18px]" aria-hidden />
259
+ </span>
260
+
261
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
262
+ {editing ? (
263
+ <form
264
+ className="flex items-center gap-1.5"
265
+ onSubmit={(e) => {
266
+ e.preventDefault();
267
+ void save();
268
+ }}
269
+ >
270
+ <label htmlFor={inputId} className="sr-only">
271
+ Passkey name
272
+ </label>
273
+ <input
274
+ ref={inputRef}
275
+ id={inputId}
276
+ value={draft}
277
+ maxLength={60}
278
+ disabled={saving}
279
+ onChange={(e) => {
280
+ setDraft(e.target.value);
281
+ setError(null);
282
+ }}
283
+ onKeyDown={(e) => {
284
+ if (e.key === "Escape") {
285
+ e.preventDefault();
286
+ setEditing(false);
287
+ setDraft(p.name);
288
+ setError(null);
289
+ }
290
+ }}
291
+ aria-invalid={error ? true : undefined}
292
+ // 16px di HP: iOS tidak men-zoom saat fokus.
293
+ className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-base outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 sm:text-[15px]"
294
+ />
295
+ <Button
296
+ type="submit"
297
+ size="icon"
298
+ className="size-8"
299
+ disabled={saving}
300
+ aria-label="Save name"
301
+ >
302
+ {saving ? <Spinner className="size-4" /> : <CheckIcon className="size-4" />}
303
+ </Button>
304
+ <Button
305
+ type="button"
306
+ size="icon"
307
+ variant="ghost"
308
+ className="size-8"
309
+ disabled={saving}
310
+ aria-label="Cancel rename"
311
+ onClick={() => {
312
+ setEditing(false);
313
+ setDraft(p.name);
314
+ setError(null);
315
+ }}
316
+ >
317
+ <XIcon className="size-4" />
318
+ </Button>
319
+ </form>
320
+ ) : (
321
+ <span className="flex min-w-0 items-center gap-2">
322
+ <span className="truncate text-[15px] leading-5">{p.name}</span>
323
+ {p.synced && (
324
+ <span
325
+ className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-1.5 py-px text-[11px] text-muted-foreground"
326
+ title="Synced across your devices (e.g. iCloud Keychain or Google Password Manager)"
327
+ >
328
+ <CloudIcon className="size-3" aria-hidden />
329
+ Synced
330
+ </span>
331
+ )}
332
+ </span>
333
+ )}
334
+ {error ? (
335
+ <span role="alert" className="text-[12px] text-destructive">
336
+ {error}
337
+ </span>
338
+ ) : (
339
+ <span className="truncate text-[13px] leading-snug text-muted-foreground">
340
+ {p.usableHere ? meta : `For ${p.rpId}`}
341
+ </span>
342
+ )}
343
+ </div>
344
+
345
+ {!editing && (
346
+ <div className="flex shrink-0 items-center">
347
+ <Button
348
+ type="button"
349
+ size="icon"
350
+ variant="ghost"
351
+ className="size-9 text-muted-foreground hover:text-foreground"
352
+ aria-label={`Rename ${p.name}`}
353
+ onClick={() => {
354
+ setDraft(p.name);
355
+ setEditing(true);
356
+ }}
357
+ >
358
+ <PencilIcon className="size-4" />
359
+ </Button>
360
+ <Button
361
+ type="button"
362
+ size="icon"
363
+ variant="ghost"
364
+ className="size-9 text-muted-foreground hover:text-destructive"
365
+ aria-label={`Remove ${p.name}`}
366
+ onClick={() => onRemove(p)}
367
+ >
368
+ <Trash2Icon className="size-4" />
369
+ </Button>
370
+ </div>
371
+ )}
372
+ </li>
373
+ );
374
+ }
375
+
376
+ // ------------------------------------------------------------ dialogs ----
377
+
378
+ function AddPasskeyDialog({
379
+ open,
380
+ status,
381
+ onOpenChange,
382
+ onAdded,
383
+ }: {
384
+ open: boolean;
385
+ status: AuthStatus;
386
+ onOpenChange: (open: boolean) => void;
387
+ onAdded: (p: PasskeyInfo) => void;
388
+ }) {
389
+ const kind = status.lockKind ?? "password";
390
+ const label = lockLabel(kind);
391
+ const [current, setCurrent] = useState("");
392
+ const [busy, setBusy] = useState(false);
393
+ const [error, setError] = useState<string | null>(null);
394
+ const inputId = useId();
395
+ const inputRef = useRef<HTMLInputElement>(null);
396
+
397
+ useEffect(() => {
398
+ if (!open) return;
399
+ setCurrent("");
400
+ setError(null);
401
+ const t = setTimeout(() => inputRef.current?.focus(), 50);
402
+ return () => clearTimeout(t);
403
+ }, [open]);
404
+
405
+ const submit = async (e: React.FormEvent) => {
406
+ e.preventDefault();
407
+ if (current === "") return;
408
+ setBusy(true);
409
+ setError(null);
410
+ const res = await addPasskey(current);
411
+ setBusy(false);
412
+ if (res.ok) {
413
+ onAdded(res.data);
414
+ return;
415
+ }
416
+ // Batal di dialog sistem: kembali ke dialog ini tanpa pesan merah.
417
+ if (res.cancelled) return;
418
+ setError(res.message);
419
+ if (res.code === "CURRENT_SECRET_INVALID") {
420
+ setCurrent("");
421
+ inputRef.current?.focus();
422
+ }
423
+ };
424
+
425
+ return (
426
+ <Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
427
+ <DialogContent className="gap-5 sm:max-w-md">
428
+ <DialogHeader className="items-center text-center sm:items-center sm:text-center">
429
+ <span className="mb-1 flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
430
+ <FingerprintIcon className="size-7" aria-hidden />
431
+ </span>
432
+ <DialogTitle>Add a passkey</DialogTitle>
433
+ <DialogDescription>
434
+ Confirm your {label} first. Then your device will ask for your fingerprint, face, or
435
+ screen lock.
436
+ </DialogDescription>
437
+ </DialogHeader>
438
+
439
+ <form id="add-passkey-form" onSubmit={submit} className="flex flex-col gap-3">
440
+ <div className="overflow-hidden rounded-xl border bg-card">
441
+ <div className="flex flex-col px-4 py-2 focus-within:bg-muted/30">
442
+ <label htmlFor={inputId} className="text-[12px] leading-4 text-muted-foreground">
443
+ Current {label}
444
+ </label>
445
+ <input
446
+ ref={inputRef}
447
+ id={inputId}
448
+ type="password"
449
+ inputMode={kind === "pin" ? "numeric" : undefined}
450
+ autoComplete="current-password"
451
+ maxLength={kind === "pin" ? 12 : 128}
452
+ value={current}
453
+ disabled={busy}
454
+ onChange={(e) => {
455
+ setCurrent(kind === "pin" ? e.target.value.replace(/\D/g, "") : e.target.value);
456
+ setError(null);
457
+ }}
458
+ className={cn(
459
+ "h-7 w-full bg-transparent text-base outline-none sm:text-[15px]",
460
+ kind === "pin" && "tracking-[0.3em]",
461
+ )}
462
+ />
463
+ </div>
464
+ </div>
465
+ {error && (
466
+ <p role="alert" className="px-1 text-sm text-destructive">
467
+ {error}
468
+ </p>
469
+ )}
470
+ <p className="px-1 text-[12px] leading-snug text-muted-foreground">
471
+ The passkey works on this address only. Your fingerprint or face never leaves your
472
+ device.
473
+ </p>
474
+ </form>
475
+
476
+ <DialogFooter>
477
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={busy}>
478
+ Cancel
479
+ </Button>
480
+ <Button type="submit" form="add-passkey-form" disabled={busy || current === ""}>
481
+ {busy ? (
482
+ <Spinner data-icon="inline-start" />
483
+ ) : (
484
+ <FingerprintIcon data-icon="inline-start" />
485
+ )}
486
+ {busy ? "Waiting for your device…" : "Continue"}
487
+ </Button>
488
+ </DialogFooter>
489
+ </DialogContent>
490
+ </Dialog>
491
+ );
492
+ }
493
+
494
+ function RemovePasskeyDialog({
495
+ passkey,
496
+ onOpenChange,
497
+ onRemoved,
498
+ }: {
499
+ passkey: PasskeyInfo | null;
500
+ onOpenChange: (open: boolean) => void;
501
+ onRemoved: (p: PasskeyInfo) => void;
502
+ }) {
503
+ const [busy, setBusy] = useState(false);
504
+ const [error, setError] = useState<string | null>(null);
505
+
506
+ useEffect(() => {
507
+ if (passkey) setError(null);
508
+ }, [passkey]);
509
+
510
+ const remove = async () => {
511
+ if (!passkey) return;
512
+ setBusy(true);
513
+ setError(null);
514
+ try {
515
+ await removePasskey(passkey.id);
516
+ onRemoved(passkey);
517
+ } catch (e) {
518
+ setError(e instanceof ApiError ? e.message : "Couldn't remove the passkey.");
519
+ } finally {
520
+ setBusy(false);
521
+ }
522
+ };
523
+
524
+ return (
525
+ <Dialog open={passkey !== null} onOpenChange={(o) => !busy && onOpenChange(o)}>
526
+ <DialogContent className="gap-5 sm:max-w-md">
527
+ <DialogHeader>
528
+ <DialogTitle>Remove {passkey?.name}?</DialogTitle>
529
+ <DialogDescription>
530
+ You won't be able to unlock with it anymore. It may still appear in your device's
531
+ password manager; you can delete it there too.
532
+ </DialogDescription>
533
+ </DialogHeader>
534
+ {error && (
535
+ <p role="alert" className="text-sm text-destructive">
536
+ {error}
537
+ </p>
538
+ )}
539
+ <DialogFooter>
540
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={busy}>
541
+ Cancel
542
+ </Button>
543
+ <Button type="button" variant="destructive" onClick={() => void remove()} disabled={busy}>
544
+ {busy && <Spinner data-icon="inline-start" />}
545
+ Remove
546
+ </Button>
547
+ </DialogFooter>
548
+ </DialogContent>
549
+ </Dialog>
550
+ );
551
+ }