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.
- package/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- 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
|
+
}
|