| 1 | import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; |
| 2 | import { useEffect, useId, useRef, useState } from "react"; |
| 3 | |
| 4 | import { Button } from "../ui/button"; |
| 5 | import { |
| 6 | Dialog, |
| 7 | DialogDescription, |
| 8 | DialogFooter, |
| 9 | DialogHeader, |
| 10 | DialogPanel, |
| 11 | DialogPopup, |
| 12 | DialogTitle, |
| 13 | } from "../ui/dialog"; |
| 14 | import { Input } from "../ui/input"; |
| 15 | |
| 16 | function describeSshTarget(request: DesktopSshPasswordPromptRequest): string { |
| 17 | return request.username ? `${request.username}@${request.destination}` : request.destination; |
| 18 | } |
| 19 | |
| 20 | function formatRemainingSeconds(seconds: number): string { |
| 21 | return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; |
| 22 | } |
| 23 | |
| 24 | function getPromptErrorMessage(error: unknown): string { |
| 25 | const message = error instanceof Error ? error.message : "SSH password prompt failed."; |
| 26 | return message.includes("expired") || message.includes("no longer pending") |
| 27 | ? "This SSH password prompt expired. Try connecting again." |
| 28 | : message; |
| 29 | } |
| 30 | |
| 31 | export function SshPasswordPromptDialog() { |
| 32 | const [queue, setQueue] = useState<readonly DesktopSshPasswordPromptRequest[]>([]); |
| 33 | const currentRequest = queue[0] ?? null; |
| 34 | |
| 35 | useEffect(() => { |
| 36 | const bridge = window.desktopBridge; |
| 37 | if (!bridge?.onSshPasswordPrompt) { |
| 38 | return; |
| 39 | } |
| 40 | |
| 41 | return bridge.onSshPasswordPrompt((request) => { |
| 42 | setQueue((currentQueue) => [...currentQueue, request]); |
| 43 | }); |
| 44 | }, []); |
| 45 | |
| 46 | if (!currentRequest) { |
| 47 | return null; |
| 48 | } |
| 49 | |
| 50 | return ( |
| 51 | <ActiveSshPasswordPrompt |
| 52 | key={currentRequest.requestId} |
| 53 | request={currentRequest} |
| 54 | onRemove={(requestId) => { |
| 55 | setQueue((currentQueue) => |
| 56 | currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, |
| 57 | ); |
| 58 | }} |
| 59 | /> |
| 60 | ); |
| 61 | } |
| 62 | |
| 63 | function ActiveSshPasswordPrompt({ |
| 64 | request, |
| 65 | onRemove, |
| 66 | }: { |
| 67 | readonly request: DesktopSshPasswordPromptRequest; |
| 68 | readonly onRemove: (requestId: string) => void; |
| 69 | }) { |
| 70 | const [password, setPassword] = useState(""); |
| 71 | const [isResponding, setIsResponding] = useState(false); |
| 72 | const [now, setNow] = useState(() => Date.now()); |
| 73 | const [responseError, setResponseError] = useState<string | null>(null); |
| 74 | const inputRef = useRef<HTMLInputElement | null>(null); |
| 75 | const isRespondingRef = useRef(false); |
| 76 | const formId = useId(); |
| 77 | |
| 78 | useEffect(() => { |
| 79 | const frame = window.requestAnimationFrame(() => { |
| 80 | inputRef.current?.focus(); |
| 81 | inputRef.current?.select(); |
| 82 | }); |
| 83 | return () => { |
| 84 | window.cancelAnimationFrame(frame); |
| 85 | }; |
| 86 | }, []); |
| 87 | |
| 88 | useEffect(() => { |
| 89 | const interval = window.setInterval(() => { |
| 90 | setNow(Date.now()); |
| 91 | }, 1_000); |
| 92 | return () => { |
| 93 | window.clearInterval(interval); |
| 94 | }; |
| 95 | }, []); |
| 96 | |
| 97 | const expiresAtMs = Date.parse(request.expiresAt); |
| 98 | const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; |
| 99 | const isExpired = remainingMs !== null && remainingMs <= 0; |
| 100 | const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); |
| 101 | const remainingLabel = |
| 102 | remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); |
| 103 | const visibleResponseError = isExpired |
| 104 | ? "This SSH password prompt expired. Try connecting again." |
| 105 | : responseError; |
| 106 | |
| 107 | const respond = async (nextPassword: string | null) => { |
| 108 | if (isRespondingRef.current) { |
| 109 | return; |
| 110 | } |
| 111 | |
| 112 | const requestId = request.requestId; |
| 113 | if (nextPassword !== null && isExpired) { |
| 114 | setResponseError("This SSH password prompt expired. Try connecting again."); |
| 115 | return; |
| 116 | } |
| 117 | |
| 118 | isRespondingRef.current = true; |
| 119 | setIsResponding(true); |
| 120 | setResponseError(null); |
| 121 | try { |
| 122 | await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); |
| 123 | onRemove(requestId); |
| 124 | } catch (error) { |
| 125 | if (nextPassword === null) { |
| 126 | onRemove(requestId); |
| 127 | } else { |
| 128 | setResponseError(getPromptErrorMessage(error)); |
| 129 | } |
| 130 | } finally { |
| 131 | isRespondingRef.current = false; |
| 132 | setIsResponding(false); |
| 133 | } |
| 134 | }; |
| 135 | |
| 136 | const dismissExpiredPrompt = () => { |
| 137 | onRemove(request.requestId); |
| 138 | }; |
| 139 | |
| 140 | const cancelPrompt = () => { |
| 141 | if (isExpired) { |
| 142 | dismissExpiredPrompt(); |
| 143 | return; |
| 144 | } |
| 145 | void respond(null); |
| 146 | }; |
| 147 | |
| 148 | const target = describeSshTarget(request); |
| 149 | |
| 150 | return ( |
| 151 | <Dialog |
| 152 | open |
| 153 | onOpenChange={(open) => { |
| 154 | if (!open) { |
| 155 | cancelPrompt(); |
| 156 | } |
| 157 | }} |
| 158 | > |
| 159 | <DialogPopup className="max-w-md" showCloseButton={false}> |
| 160 | <DialogHeader> |
| 161 | <DialogTitle>SSH Password Required</DialogTitle> |
| 162 | <DialogDescription> |
| 163 | T3 needs your SSH password to connect to <code>{target}</code>. The password is passed |
| 164 | to the local SSH process for this connection attempt and is not saved by The Knife. |
| 165 | </DialogDescription> |
| 166 | </DialogHeader> |
| 167 | <DialogPanel scrollFade={false}> |
| 168 | <form |
| 169 | className="space-y-3" |
| 170 | id={formId} |
| 171 | onSubmit={(event) => { |
| 172 | event.preventDefault(); |
| 173 | void respond(password); |
| 174 | }} |
| 175 | > |
| 176 | <div className="space-y-2"> |
| 177 | <div className="flex items-center justify-between gap-3"> |
| 178 | <p className="text-sm font-medium text-foreground">{request.prompt}</p> |
| 179 | {remainingLabel ? ( |
| 180 | <span |
| 181 | className={ |
| 182 | isExpired |
| 183 | ? "shrink-0 text-xs font-medium text-destructive" |
| 184 | : "shrink-0 text-xs text-muted-foreground" |
| 185 | } |
| 186 | > |
| 187 | {isExpired ? "Expired" : remainingLabel} |
| 188 | </span> |
| 189 | ) : null} |
| 190 | </div> |
| 191 | <Input |
| 192 | ref={inputRef} |
| 193 | autoComplete="current-password" |
| 194 | disabled={isResponding || isExpired} |
| 195 | name="ssh-password" |
| 196 | type="password" |
| 197 | value={password} |
| 198 | onChange={(event) => setPassword(event.target.value)} |
| 199 | /> |
| 200 | </div> |
| 201 | {visibleResponseError ? ( |
| 202 | <p className="text-sm text-destructive">{visibleResponseError}</p> |
| 203 | ) : ( |
| 204 | <p className="text-sm text-muted-foreground"> |
| 205 | Use SSH keys to avoid repeated password prompts on new SSH sessions. |
| 206 | </p> |
| 207 | )} |
| 208 | </form> |
| 209 | </DialogPanel> |
| 210 | <DialogFooter> |
| 211 | <Button disabled={isResponding} type="button" variant="outline" onClick={cancelPrompt}> |
| 212 | {isExpired ? "Dismiss" : "Cancel"} |
| 213 | </Button> |
| 214 | <Button disabled={isResponding || isExpired} form={formId} type="submit"> |
| 215 | Continue |
| 216 | </Button> |
| 217 | </DialogFooter> |
| 218 | </DialogPopup> |
| 219 | </Dialog> |
| 220 | ); |
| 221 | } |