Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
312 lines
16 KiB
TypeScript
312 lines
16 KiB
TypeScript
/**
|
|
* Terminal Authentication Dialog
|
|
* Displays auth form with password/key selection for SSH connection
|
|
*/
|
|
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
|
|
import React from 'react';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import { cn } from '../../lib/utils';
|
|
import { SSHKey } from '../../types';
|
|
import { Button } from '../ui/button';
|
|
import { Input } from '../ui/input';
|
|
import { Label } from '../ui/label';
|
|
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
|
|
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
|
|
|
|
export interface TerminalAuthDialogProps {
|
|
authMethod: TerminalAuthMethod;
|
|
setAuthMethod: (method: TerminalAuthMethod) => void;
|
|
authUsername: string;
|
|
setAuthUsername: (username: string) => void;
|
|
authPassword: string;
|
|
setAuthPassword: (password: string) => void;
|
|
authKeyId: string | null;
|
|
setAuthKeyId: (keyId: string | null) => void;
|
|
authPassphrase: string;
|
|
setAuthPassphrase: (passphrase: string) => void;
|
|
showAuthPassphrase: boolean;
|
|
setShowAuthPassphrase: (show: boolean) => void;
|
|
showAuthPassword: boolean;
|
|
setShowAuthPassword: (show: boolean) => void;
|
|
authRetryMessage: string | null;
|
|
keys: SSHKey[];
|
|
onSubmit: () => void;
|
|
onSubmitWithoutSave?: () => void;
|
|
onCancel: () => void;
|
|
isValid: boolean;
|
|
}
|
|
|
|
export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
|
authMethod,
|
|
setAuthMethod,
|
|
authUsername,
|
|
setAuthUsername,
|
|
authPassword,
|
|
setAuthPassword,
|
|
authKeyId,
|
|
setAuthKeyId,
|
|
authPassphrase,
|
|
setAuthPassphrase,
|
|
showAuthPassphrase,
|
|
setShowAuthPassphrase,
|
|
showAuthPassword,
|
|
setShowAuthPassword,
|
|
authRetryMessage,
|
|
keys,
|
|
onSubmit,
|
|
onSubmitWithoutSave,
|
|
onCancel,
|
|
isValid,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const handleContinue = onSubmitWithoutSave ?? onSubmit;
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && isValid) {
|
|
handleContinue();
|
|
}
|
|
};
|
|
|
|
// Show all keys (both regular keys and certificates) in the single key picker.
|
|
const selectableKeys = React.useMemo(
|
|
() => keys.filter((k) => k.category === 'key' || Boolean(k.certificate?.trim())),
|
|
[keys],
|
|
);
|
|
|
|
const [keyDropdownOpen, setKeyDropdownOpen] = React.useState(false);
|
|
const [submitOptionsOpen, setSubmitOptionsOpen] = React.useState(false);
|
|
|
|
const selectedKey = authKeyId ? keys.find((k) => k.id === authKeyId) : null;
|
|
|
|
return (
|
|
<>
|
|
{/* Auth method tabs */}
|
|
<div className="flex gap-1 p-1 bg-secondary/65 rounded-xl border border-border/50">
|
|
<button
|
|
className={cn(
|
|
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
|
|
authMethod === 'password'
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
|
|
)}
|
|
onClick={() => setAuthMethod('password')}
|
|
>
|
|
<Lock size={13} />
|
|
{t("terminal.auth.password")}
|
|
</button>
|
|
<button
|
|
className={cn(
|
|
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
|
|
authMethod === 'key' || authMethod === 'certificate'
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
|
|
)}
|
|
onClick={() => setAuthMethod('key')}
|
|
>
|
|
<Key size={13} />
|
|
{t("terminal.auth.sshKey")}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Auth retry error message */}
|
|
{authRetryMessage && (
|
|
<div className="flex items-center gap-2.5 rounded-xl border border-destructive/20 bg-destructive/7 px-3 py-2.5 text-xs text-foreground/90">
|
|
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-destructive/12 text-destructive">
|
|
<Unplug size={11} />
|
|
</div>
|
|
<div className="min-w-0 leading-4 text-destructive/95">
|
|
{authRetryMessage}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
|
|
<Input
|
|
id="auth-username"
|
|
value={authUsername}
|
|
onChange={(e) => setAuthUsername(e.target.value)}
|
|
placeholder={t("terminal.auth.username.placeholder")}
|
|
/>
|
|
</div>
|
|
|
|
{authMethod === 'password' ? (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="auth-password">{t("terminal.auth.passwordLabel")}</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="auth-password"
|
|
type={showAuthPassword ? 'text' : 'password'}
|
|
value={authPassword}
|
|
onChange={(e) => setAuthPassword(e.target.value)}
|
|
placeholder={t("terminal.auth.password.placeholder")}
|
|
className={cn("pr-10", authRetryMessage && "border-destructive/50")}
|
|
autoFocus={!!authRetryMessage}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setShowAuthPassword(!showAuthPassword)}
|
|
>
|
|
{showAuthPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label>{t("terminal.auth.selectKey")}</Label>
|
|
{selectableKeys.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
|
|
{t("terminal.auth.noKeysHint")}
|
|
</div>
|
|
) : (
|
|
<Popover open={keyDropdownOpen} onOpenChange={setKeyDropdownOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
|
|
selectedKey
|
|
? "border-primary bg-primary/5"
|
|
: "border-border/50 hover:bg-secondary/50"
|
|
)}
|
|
>
|
|
{selectedKey ? (
|
|
<>
|
|
<div className={cn(
|
|
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
|
selectedKey.certificate?.trim()
|
|
? "bg-emerald-500/20 text-emerald-500"
|
|
: "bg-primary/20 text-primary"
|
|
)}>
|
|
{selectedKey.certificate?.trim()
|
|
? <BadgeCheck size={14} />
|
|
: <Key size={14} />}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{selectedKey.label}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{selectedKey.certificate?.trim() ? t("terminal.auth.certificate") : selectedKey.type}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">{t("hostForm.auth.selectKey")}</span>
|
|
)}
|
|
<ChevronDown size={16} className="text-muted-foreground shrink-0 ml-auto" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-1" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
|
|
<div className="max-h-60 overflow-y-auto">
|
|
{selectableKeys.map((key) => (
|
|
<button
|
|
key={key.id}
|
|
className={cn(
|
|
"w-full flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-left",
|
|
authKeyId === key.id
|
|
? "bg-primary/10 text-primary"
|
|
: "hover:bg-secondary/80"
|
|
)}
|
|
onClick={() => {
|
|
setAuthKeyId(key.id);
|
|
setAuthMethod(key.certificate?.trim() ? 'certificate' : 'key');
|
|
setAuthPassphrase(key.passphrase || '');
|
|
setKeyDropdownOpen(false);
|
|
}}
|
|
>
|
|
<div className={cn(
|
|
"h-7 w-7 rounded-md flex items-center justify-center shrink-0",
|
|
key.certificate?.trim()
|
|
? "bg-emerald-500/20 text-emerald-500"
|
|
: "bg-primary/20 text-primary"
|
|
)}>
|
|
{key.certificate?.trim()
|
|
? <BadgeCheck size={12} />
|
|
: <Key size={12} />}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{key.label}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{key.certificate?.trim() ? t("terminal.auth.certificate") : key.type}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="auth-passphrase">{t("terminal.auth.passphrase")}</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="auth-passphrase"
|
|
type={showAuthPassphrase ? 'text' : 'password'}
|
|
value={authPassphrase}
|
|
onChange={(e) => setAuthPassphrase(e.target.value)}
|
|
placeholder={t("terminal.auth.passphrase.placeholder")}
|
|
className="pr-10"
|
|
disabled={!selectedKey}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
onClick={() => setShowAuthPassphrase(!showAuthPassphrase)}
|
|
disabled={!selectedKey}
|
|
>
|
|
{showAuthPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-2">
|
|
<Button variant="secondary" onClick={onCancel}>
|
|
{t("common.close")}
|
|
</Button>
|
|
<Dropdown open={submitOptionsOpen} onOpenChange={setSubmitOptionsOpen}>
|
|
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
|
<Button
|
|
disabled={!isValid}
|
|
onClick={handleContinue}
|
|
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
|
>
|
|
{t("common.continue")}
|
|
</Button>
|
|
<DropdownTrigger asChild>
|
|
<Button
|
|
disabled={!isValid}
|
|
aria-label={t("terminal.auth.continueSave")}
|
|
aria-haspopup="menu"
|
|
aria-expanded={submitOptionsOpen}
|
|
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
|
>
|
|
<ChevronDown size={14} />
|
|
</Button>
|
|
</DropdownTrigger>
|
|
</div>
|
|
<DropdownContent className="w-44 p-1 z-50" align="end">
|
|
<button
|
|
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
|
onClick={onSubmit}
|
|
disabled={!isValid}
|
|
>
|
|
{t("terminal.auth.continueSave")}
|
|
</button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default TerminalAuthDialog;
|