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