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,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings -> Security (`/settings/security`): kunci aplikasi (PIN/password),
|
|
3
|
+
* ganti/hapus kunci lewat dialog, kunci otomatis, passkey, dan perangkat
|
|
4
|
+
* yang sedang login (dulu tab "Devices" sendiri).
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
KeyRoundIcon,
|
|
8
|
+
LockIcon,
|
|
9
|
+
LockOpenIcon,
|
|
10
|
+
ShieldAlertIcon,
|
|
11
|
+
ShieldCheckIcon,
|
|
12
|
+
TimerIcon,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
|
15
|
+
import { toast } from "sonner";
|
|
16
|
+
import { DevicesGroup } from "@/components/settings/DevicesGroup";
|
|
17
|
+
import { PasskeysGroup } from "@/components/settings/PasskeysGroup";
|
|
18
|
+
import { ActionRow, PrefsGroup } from "@/components/settings/prefs";
|
|
19
|
+
import { Button } from "@/components/ui/button";
|
|
20
|
+
import {
|
|
21
|
+
Dialog,
|
|
22
|
+
DialogContent,
|
|
23
|
+
DialogDescription,
|
|
24
|
+
DialogFooter,
|
|
25
|
+
DialogHeader,
|
|
26
|
+
DialogTitle,
|
|
27
|
+
} from "@/components/ui/dialog";
|
|
28
|
+
import {
|
|
29
|
+
Select,
|
|
30
|
+
SelectContent,
|
|
31
|
+
SelectItem,
|
|
32
|
+
SelectTrigger,
|
|
33
|
+
SelectValue,
|
|
34
|
+
} from "@/components/ui/select";
|
|
35
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
36
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
37
|
+
import { lockLabel, refreshAuth } from "@/lib/auth";
|
|
38
|
+
import { cn } from "@/lib/utils";
|
|
39
|
+
import type { AuthStatus } from "@/server/services/auth";
|
|
40
|
+
|
|
41
|
+
type LockKind = "pin" | "password";
|
|
42
|
+
|
|
43
|
+
const AUTO_LOCK_OPTIONS = [
|
|
44
|
+
{ value: 0, label: "Never" },
|
|
45
|
+
{ value: 5, label: "5 minutes" },
|
|
46
|
+
{ value: 15, label: "15 minutes" },
|
|
47
|
+
{ value: 30, label: "30 minutes" },
|
|
48
|
+
{ value: 60, label: "1 hour" },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function errorText(e: unknown, fallback: string): string {
|
|
52
|
+
return e instanceof ApiError ? e.message : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function SecurityGroup({ status, onOk }: { status: AuthStatus; onOk: (t: string) => void }) {
|
|
56
|
+
const [dialog, setDialog] = useState<"set" | "remove" | null>(null);
|
|
57
|
+
const [autoLock, setAutoLock] = useState(status.autoLockMinutes);
|
|
58
|
+
const [autoLockError, setAutoLockError] = useState<string | null>(null);
|
|
59
|
+
const autoLockId = useId();
|
|
60
|
+
|
|
61
|
+
const saveAutoLock = async (minutes: number) => {
|
|
62
|
+
const prev = autoLock;
|
|
63
|
+
setAutoLock(minutes);
|
|
64
|
+
setAutoLockError(null);
|
|
65
|
+
try {
|
|
66
|
+
await apiFetch("/api/auth/settings", {
|
|
67
|
+
method: "PATCH",
|
|
68
|
+
body: JSON.stringify({ autoLockMinutes: minutes }),
|
|
69
|
+
});
|
|
70
|
+
toast.success(
|
|
71
|
+
minutes === 0
|
|
72
|
+
? "Auto-lock turned off."
|
|
73
|
+
: `Auto-lock set to ${AUTO_LOCK_OPTIONS.find((o) => o.value === minutes)?.label ?? `${minutes} minutes`}.`,
|
|
74
|
+
);
|
|
75
|
+
void refreshAuth();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
setAutoLock(prev);
|
|
78
|
+
setAutoLockError(errorText(err, "Couldn't save auto-lock."));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const kind = status.lockKind ?? "password";
|
|
83
|
+
const label = lockLabel(kind);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
{/* Kartu status: satu pesan jelas + satu aksi utama. Tata letak
|
|
88
|
+
vertikal & terpusat agar tidak sempit di layar HP. */}
|
|
89
|
+
<section
|
|
90
|
+
aria-labelledby="security-status"
|
|
91
|
+
className={cn(
|
|
92
|
+
"flex flex-col items-center gap-3 rounded-xl border px-5 py-6 text-center",
|
|
93
|
+
status.protected ? "bg-card shadow-xs" : "border-amber-500/30 bg-amber-500/10",
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
<span
|
|
97
|
+
className={cn(
|
|
98
|
+
"flex size-14 items-center justify-center rounded-full",
|
|
99
|
+
status.protected
|
|
100
|
+
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
|
101
|
+
: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{status.protected ? (
|
|
105
|
+
<ShieldCheckIcon className="size-7" aria-hidden />
|
|
106
|
+
) : (
|
|
107
|
+
<ShieldAlertIcon className="size-7" aria-hidden />
|
|
108
|
+
)}
|
|
109
|
+
</span>
|
|
110
|
+
<div className="flex max-w-sm flex-col gap-1">
|
|
111
|
+
<h2 id="security-status" className="text-[17px] font-semibold tracking-tight">
|
|
112
|
+
{status.protected ? "KCG Code is locked" : "KCG Code isn't locked"}
|
|
113
|
+
</h2>
|
|
114
|
+
<p className="text-[13px] leading-snug text-muted-foreground">
|
|
115
|
+
{status.protected
|
|
116
|
+
? `Every phone or computer needs your ${label} before it can open KCG Code.`
|
|
117
|
+
: "Anyone who can open this page can control your agents. Add a PIN or password to keep it safe."}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
{!status.protected && (
|
|
121
|
+
<Button type="button" className="mt-1" onClick={() => setDialog("set")}>
|
|
122
|
+
<LockIcon data-icon="inline-start" />
|
|
123
|
+
Set up lock
|
|
124
|
+
</Button>
|
|
125
|
+
)}
|
|
126
|
+
</section>
|
|
127
|
+
|
|
128
|
+
{status.protected && (
|
|
129
|
+
<>
|
|
130
|
+
<PrefsGroup title="Lock settings">
|
|
131
|
+
<ActionRow
|
|
132
|
+
prefix={<RowGlyph icon={<KeyRoundIcon className="size-[18px]" aria-hidden />} />}
|
|
133
|
+
title={`Change ${label}`}
|
|
134
|
+
subtitle="Other devices will need to unlock again"
|
|
135
|
+
onActivate={() => setDialog("set")}
|
|
136
|
+
/>
|
|
137
|
+
{/* Label + select ditumpuk di HP, sebaris di layar lebar. */}
|
|
138
|
+
<li className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
|
|
139
|
+
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
140
|
+
<RowGlyph icon={<TimerIcon className="size-[18px]" aria-hidden />} />
|
|
141
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
142
|
+
<label htmlFor={autoLockId} className="text-[15px] leading-5">
|
|
143
|
+
Lock automatically
|
|
144
|
+
</label>
|
|
145
|
+
<span
|
|
146
|
+
className={cn(
|
|
147
|
+
"text-[13px] leading-snug",
|
|
148
|
+
autoLockError ? "text-destructive" : "text-muted-foreground",
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{autoLockError ?? "When a device isn't used for a while"}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<Select value={String(autoLock)} onValueChange={(v) => void saveAutoLock(Number(v))}>
|
|
156
|
+
<SelectTrigger
|
|
157
|
+
id={autoLockId}
|
|
158
|
+
className="w-full shrink-0 sm:w-40"
|
|
159
|
+
aria-label="Lock automatically after"
|
|
160
|
+
>
|
|
161
|
+
<SelectValue />
|
|
162
|
+
</SelectTrigger>
|
|
163
|
+
<SelectContent align="end">
|
|
164
|
+
{AUTO_LOCK_OPTIONS.map((o) => (
|
|
165
|
+
<SelectItem key={o.value} value={String(o.value)}>
|
|
166
|
+
{o.value === 0 ? "Never" : `After ${o.label}`}
|
|
167
|
+
</SelectItem>
|
|
168
|
+
))}
|
|
169
|
+
</SelectContent>
|
|
170
|
+
</Select>
|
|
171
|
+
</li>
|
|
172
|
+
</PrefsGroup>
|
|
173
|
+
|
|
174
|
+
{/* Passkey = cara buka kunci tambahan; hanya ada bila kunci aktif. */}
|
|
175
|
+
<PasskeysGroup status={status} />
|
|
176
|
+
|
|
177
|
+
{/* Perangkat yang login (dulu tab "Devices" sendiri). */}
|
|
178
|
+
<DevicesGroup onError={(t) => toast.error(t)} />
|
|
179
|
+
|
|
180
|
+
<PrefsGroup>
|
|
181
|
+
<ActionRow
|
|
182
|
+
prefix={
|
|
183
|
+
<RowGlyph destructive icon={<LockOpenIcon className="size-[18px]" aria-hidden />} />
|
|
184
|
+
}
|
|
185
|
+
title={`Remove ${label}`}
|
|
186
|
+
subtitle={`KCG Code will open without a ${label}`}
|
|
187
|
+
destructive
|
|
188
|
+
onActivate={() => setDialog("remove")}
|
|
189
|
+
/>
|
|
190
|
+
</PrefsGroup>
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
<LockDialog
|
|
195
|
+
open={dialog === "set"}
|
|
196
|
+
status={status}
|
|
197
|
+
onOpenChange={(o) => setDialog(o ? "set" : null)}
|
|
198
|
+
onDone={(k) => {
|
|
199
|
+
setDialog(null);
|
|
200
|
+
onOk(
|
|
201
|
+
status.protected
|
|
202
|
+
? "Lock updated. Other devices were signed out."
|
|
203
|
+
: `${k === "pin" ? "PIN" : "Password"} set. This device stays unlocked.`,
|
|
204
|
+
);
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
<RemoveLockDialog
|
|
208
|
+
open={dialog === "remove"}
|
|
209
|
+
status={status}
|
|
210
|
+
onOpenChange={(o) => setDialog(o ? "remove" : null)}
|
|
211
|
+
onDone={() => {
|
|
212
|
+
setDialog(null);
|
|
213
|
+
onOk("Lock removed. KCG Code is no longer protected.");
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function RowGlyph({ icon, destructive }: { icon: ReactNode; destructive?: boolean }) {
|
|
221
|
+
return (
|
|
222
|
+
<span
|
|
223
|
+
className={cn(
|
|
224
|
+
"flex size-9 shrink-0 items-center justify-center rounded-full",
|
|
225
|
+
destructive ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
{icon}
|
|
229
|
+
</span>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------- dialog: kunci ----
|
|
234
|
+
|
|
235
|
+
function SecretField({
|
|
236
|
+
label,
|
|
237
|
+
kind,
|
|
238
|
+
value,
|
|
239
|
+
onChange,
|
|
240
|
+
autoComplete,
|
|
241
|
+
autoFocus,
|
|
242
|
+
hint,
|
|
243
|
+
}: {
|
|
244
|
+
label: string;
|
|
245
|
+
kind: LockKind;
|
|
246
|
+
value: string;
|
|
247
|
+
onChange: (v: string) => void;
|
|
248
|
+
autoComplete: string;
|
|
249
|
+
autoFocus?: boolean;
|
|
250
|
+
hint?: string;
|
|
251
|
+
}) {
|
|
252
|
+
const id = useId();
|
|
253
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (autoFocus) {
|
|
256
|
+
// Tunggu animasi buka dialog agar fokus tidak dicuri kembali.
|
|
257
|
+
const t = setTimeout(() => ref.current?.focus(), 50);
|
|
258
|
+
return () => clearTimeout(t);
|
|
259
|
+
}
|
|
260
|
+
}, [autoFocus]);
|
|
261
|
+
return (
|
|
262
|
+
<li className="flex flex-col px-4 py-2 focus-within:bg-muted/30">
|
|
263
|
+
<label htmlFor={id} className="text-[12px] leading-4 text-muted-foreground">
|
|
264
|
+
{label}
|
|
265
|
+
</label>
|
|
266
|
+
<input
|
|
267
|
+
ref={ref}
|
|
268
|
+
id={id}
|
|
269
|
+
type="password"
|
|
270
|
+
inputMode={kind === "pin" ? "numeric" : undefined}
|
|
271
|
+
autoComplete={autoComplete}
|
|
272
|
+
maxLength={kind === "pin" ? 12 : 128}
|
|
273
|
+
value={value}
|
|
274
|
+
onChange={(e) =>
|
|
275
|
+
onChange(kind === "pin" ? e.target.value.replace(/\D/g, "") : e.target.value)
|
|
276
|
+
}
|
|
277
|
+
aria-describedby={hint ? `${id}-hint` : undefined}
|
|
278
|
+
className={cn(
|
|
279
|
+
// 16px di mobile: iOS Safari men-zoom halaman saat fokus ke input < 16px.
|
|
280
|
+
"h-7 w-full bg-transparent text-base outline-none sm:text-[15px]",
|
|
281
|
+
kind === "pin" && "tracking-[0.3em]",
|
|
282
|
+
)}
|
|
283
|
+
/>
|
|
284
|
+
{hint && (
|
|
285
|
+
<span id={`${id}-hint`} className="sr-only">
|
|
286
|
+
{hint}
|
|
287
|
+
</span>
|
|
288
|
+
)}
|
|
289
|
+
</li>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function LockDialog({
|
|
294
|
+
open,
|
|
295
|
+
status,
|
|
296
|
+
onOpenChange,
|
|
297
|
+
onDone,
|
|
298
|
+
}: {
|
|
299
|
+
open: boolean;
|
|
300
|
+
status: AuthStatus;
|
|
301
|
+
onOpenChange: (open: boolean) => void;
|
|
302
|
+
onDone: (kind: LockKind) => void;
|
|
303
|
+
}) {
|
|
304
|
+
const [kind, setKind] = useState<LockKind>(status.lockKind ?? "pin");
|
|
305
|
+
const [current, setCurrent] = useState("");
|
|
306
|
+
const [secret, setSecret] = useState("");
|
|
307
|
+
const [confirm, setConfirm] = useState("");
|
|
308
|
+
const [busy, setBusy] = useState(false);
|
|
309
|
+
const [error, setError] = useState<string | null>(null);
|
|
310
|
+
const currentKind = status.lockKind ?? "password";
|
|
311
|
+
|
|
312
|
+
// Reset setiap kali dibuka.
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (!open) return;
|
|
315
|
+
setKind(status.lockKind ?? "pin");
|
|
316
|
+
setCurrent("");
|
|
317
|
+
setSecret("");
|
|
318
|
+
setConfirm("");
|
|
319
|
+
setError(null);
|
|
320
|
+
}, [open, status.lockKind]);
|
|
321
|
+
|
|
322
|
+
const hint =
|
|
323
|
+
kind === "pin"
|
|
324
|
+
? "6–12 digits. Avoid repeated or sequential digits like 111111 or 123456."
|
|
325
|
+
: "At least 8 characters. A short phrase is easy to remember and hard to guess.";
|
|
326
|
+
|
|
327
|
+
const localError = (): string | null => {
|
|
328
|
+
if (status.protected && current === "") return `Enter your current ${lockLabel(currentKind)}.`;
|
|
329
|
+
if (kind === "pin" && (secret.length < 6 || secret.length > 12)) {
|
|
330
|
+
return "PIN must be 6–12 digits.";
|
|
331
|
+
}
|
|
332
|
+
if (kind === "password" && secret.length < 8) return "Password must be at least 8 characters.";
|
|
333
|
+
if (secret !== confirm) return `The ${lockLabel(kind)}s don't match.`;
|
|
334
|
+
return null;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const submit = async (e: React.FormEvent) => {
|
|
338
|
+
e.preventDefault();
|
|
339
|
+
const local = localError();
|
|
340
|
+
if (local) {
|
|
341
|
+
setError(local);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
setBusy(true);
|
|
345
|
+
setError(null);
|
|
346
|
+
try {
|
|
347
|
+
await apiFetch("/api/auth/lock", {
|
|
348
|
+
method: "PUT",
|
|
349
|
+
body: JSON.stringify({ kind, secret, current: status.protected ? current : undefined }),
|
|
350
|
+
});
|
|
351
|
+
await refreshAuth();
|
|
352
|
+
onDone(kind);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
setError(errorText(err, "Couldn't save the lock."));
|
|
355
|
+
} finally {
|
|
356
|
+
setBusy(false);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
|
362
|
+
<DialogContent className="gap-5 sm:max-w-md">
|
|
363
|
+
<DialogHeader>
|
|
364
|
+
<DialogTitle>{status.protected ? "Change lock" : "Set up app lock"}</DialogTitle>
|
|
365
|
+
<DialogDescription>
|
|
366
|
+
{status.protected
|
|
367
|
+
? "Other devices will be signed out and must unlock again."
|
|
368
|
+
: "You'll need it to open KCG Code on any device."}
|
|
369
|
+
</DialogDescription>
|
|
370
|
+
</DialogHeader>
|
|
371
|
+
|
|
372
|
+
<form id="lock-form" onSubmit={submit} className="flex flex-col gap-5">
|
|
373
|
+
{status.protected && (
|
|
374
|
+
<div className="overflow-hidden rounded-xl border bg-card">
|
|
375
|
+
<ul className="flex flex-col divide-y">
|
|
376
|
+
<SecretField
|
|
377
|
+
label={`Current ${lockLabel(currentKind)}`}
|
|
378
|
+
kind={currentKind}
|
|
379
|
+
value={current}
|
|
380
|
+
onChange={(v) => {
|
|
381
|
+
setCurrent(v);
|
|
382
|
+
setError(null);
|
|
383
|
+
}}
|
|
384
|
+
autoComplete="current-password"
|
|
385
|
+
autoFocus
|
|
386
|
+
/>
|
|
387
|
+
</ul>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{/* Pemilih jenis kunci: segmented control ala GNOME */}
|
|
392
|
+
<div className="flex flex-col gap-2">
|
|
393
|
+
<span id="lock-kind-label" className="px-1 text-[13px] font-medium">
|
|
394
|
+
Lock type
|
|
395
|
+
</span>
|
|
396
|
+
<fieldset
|
|
397
|
+
aria-labelledby="lock-kind-label"
|
|
398
|
+
className="grid grid-cols-2 gap-1 rounded-lg border-0 bg-muted p-1"
|
|
399
|
+
>
|
|
400
|
+
{(["pin", "password"] as const).map((k) => (
|
|
401
|
+
<label
|
|
402
|
+
key={k}
|
|
403
|
+
className={cn(
|
|
404
|
+
"cursor-pointer rounded-md px-3 py-1.5 text-center text-sm font-medium transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50",
|
|
405
|
+
kind === k
|
|
406
|
+
? "bg-background text-foreground shadow-sm"
|
|
407
|
+
: "text-muted-foreground hover:text-foreground",
|
|
408
|
+
)}
|
|
409
|
+
>
|
|
410
|
+
<input
|
|
411
|
+
type="radio"
|
|
412
|
+
name="lock-kind"
|
|
413
|
+
value={k}
|
|
414
|
+
checked={kind === k}
|
|
415
|
+
onChange={() => {
|
|
416
|
+
setKind(k);
|
|
417
|
+
setSecret("");
|
|
418
|
+
setConfirm("");
|
|
419
|
+
setError(null);
|
|
420
|
+
}}
|
|
421
|
+
className="sr-only"
|
|
422
|
+
/>
|
|
423
|
+
{k === "pin" ? "PIN" : "Password"}
|
|
424
|
+
</label>
|
|
425
|
+
))}
|
|
426
|
+
</fieldset>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<div className="flex flex-col gap-2">
|
|
430
|
+
<div className="overflow-hidden rounded-xl border bg-card">
|
|
431
|
+
<ul className="flex flex-col divide-y">
|
|
432
|
+
<SecretField
|
|
433
|
+
label={`New ${lockLabel(kind)}`}
|
|
434
|
+
kind={kind}
|
|
435
|
+
value={secret}
|
|
436
|
+
onChange={(v) => {
|
|
437
|
+
setSecret(v);
|
|
438
|
+
setError(null);
|
|
439
|
+
}}
|
|
440
|
+
autoComplete="new-password"
|
|
441
|
+
autoFocus={!status.protected}
|
|
442
|
+
hint={hint}
|
|
443
|
+
/>
|
|
444
|
+
<SecretField
|
|
445
|
+
label={`Confirm ${lockLabel(kind)}`}
|
|
446
|
+
kind={kind}
|
|
447
|
+
value={confirm}
|
|
448
|
+
onChange={(v) => {
|
|
449
|
+
setConfirm(v);
|
|
450
|
+
setError(null);
|
|
451
|
+
}}
|
|
452
|
+
autoComplete="new-password"
|
|
453
|
+
/>
|
|
454
|
+
</ul>
|
|
455
|
+
</div>
|
|
456
|
+
<p className="px-1 text-[12px] text-muted-foreground">{hint}</p>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
{error && (
|
|
460
|
+
<p role="alert" className="px-1 text-sm text-destructive">
|
|
461
|
+
{error}
|
|
462
|
+
</p>
|
|
463
|
+
)}
|
|
464
|
+
</form>
|
|
465
|
+
|
|
466
|
+
<DialogFooter>
|
|
467
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={busy}>
|
|
468
|
+
Cancel
|
|
469
|
+
</Button>
|
|
470
|
+
<Button type="submit" form="lock-form" disabled={busy || secret === "" || confirm === ""}>
|
|
471
|
+
{busy && <Spinner data-icon="inline-start" />}
|
|
472
|
+
{status.protected ? "Change" : "Turn on"}
|
|
473
|
+
</Button>
|
|
474
|
+
</DialogFooter>
|
|
475
|
+
</DialogContent>
|
|
476
|
+
</Dialog>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function RemoveLockDialog({
|
|
481
|
+
open,
|
|
482
|
+
status,
|
|
483
|
+
onOpenChange,
|
|
484
|
+
onDone,
|
|
485
|
+
}: {
|
|
486
|
+
open: boolean;
|
|
487
|
+
status: AuthStatus;
|
|
488
|
+
onOpenChange: (open: boolean) => void;
|
|
489
|
+
onDone: () => void;
|
|
490
|
+
}) {
|
|
491
|
+
const [current, setCurrent] = useState("");
|
|
492
|
+
const [busy, setBusy] = useState(false);
|
|
493
|
+
const [error, setError] = useState<string | null>(null);
|
|
494
|
+
const kind = status.lockKind ?? "password";
|
|
495
|
+
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (!open) return;
|
|
498
|
+
setCurrent("");
|
|
499
|
+
setError(null);
|
|
500
|
+
}, [open]);
|
|
501
|
+
|
|
502
|
+
const submit = async (e: React.FormEvent) => {
|
|
503
|
+
e.preventDefault();
|
|
504
|
+
setBusy(true);
|
|
505
|
+
setError(null);
|
|
506
|
+
try {
|
|
507
|
+
await apiFetch("/api/auth/lock", { method: "DELETE", body: JSON.stringify({ current }) });
|
|
508
|
+
await refreshAuth();
|
|
509
|
+
onDone();
|
|
510
|
+
} catch (err) {
|
|
511
|
+
setError(errorText(err, "Couldn't remove the lock."));
|
|
512
|
+
} finally {
|
|
513
|
+
setBusy(false);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
|
519
|
+
<DialogContent className="gap-5 sm:max-w-md">
|
|
520
|
+
<DialogHeader>
|
|
521
|
+
<DialogTitle>Remove app lock?</DialogTitle>
|
|
522
|
+
<DialogDescription>
|
|
523
|
+
Without a lock, anyone who can reach this address can control your agents. All devices
|
|
524
|
+
will be signed out and every passkey will be removed.
|
|
525
|
+
</DialogDescription>
|
|
526
|
+
</DialogHeader>
|
|
527
|
+
<form id="remove-lock-form" onSubmit={submit} className="flex flex-col gap-3">
|
|
528
|
+
<div className="overflow-hidden rounded-xl border bg-card">
|
|
529
|
+
<ul className="flex flex-col divide-y">
|
|
530
|
+
<SecretField
|
|
531
|
+
label={`Current ${lockLabel(kind)}`}
|
|
532
|
+
kind={kind}
|
|
533
|
+
value={current}
|
|
534
|
+
onChange={(v) => {
|
|
535
|
+
setCurrent(v);
|
|
536
|
+
setError(null);
|
|
537
|
+
}}
|
|
538
|
+
autoComplete="current-password"
|
|
539
|
+
autoFocus
|
|
540
|
+
/>
|
|
541
|
+
</ul>
|
|
542
|
+
</div>
|
|
543
|
+
{error && (
|
|
544
|
+
<p role="alert" className="px-1 text-sm text-destructive">
|
|
545
|
+
{error}
|
|
546
|
+
</p>
|
|
547
|
+
)}
|
|
548
|
+
</form>
|
|
549
|
+
<DialogFooter>
|
|
550
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={busy}>
|
|
551
|
+
Cancel
|
|
552
|
+
</Button>
|
|
553
|
+
<Button
|
|
554
|
+
type="submit"
|
|
555
|
+
form="remove-lock-form"
|
|
556
|
+
variant="destructive"
|
|
557
|
+
disabled={busy || current === ""}
|
|
558
|
+
>
|
|
559
|
+
{busy && <Spinner data-icon="inline-start" />}
|
|
560
|
+
Remove
|
|
561
|
+
</Button>
|
|
562
|
+
</DialogFooter>
|
|
563
|
+
</DialogContent>
|
|
564
|
+
</Dialog>
|
|
565
|
+
);
|
|
566
|
+
}
|