Files
NetMesh/components/terminal/TerminalAuthDialog.tsx
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

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;