Files
Netcatty/components/AuthDialog.tsx
T
LAPTOP-O016UC3M\Qi Chen 15575d2de0 Improves auth dialog UI and connection status handling
Refines authentication method tab styles for clearer selection and better accessibility.
Adjusts terminal connection logic to prevent premature status updates and avoid starting timeouts before authentication.
Updates overlay appearance for auth dialog to enhance focus.
Clears terminal before new SSH connection for improved user experience.
2025-12-09 23:29:00 +08:00

277 lines
14 KiB
TypeScript

import React, { useState } from 'react';
import { Lock, Key, User, Eye, EyeOff, ChevronDown } from 'lucide-react';
import { Host, SSHKey } from '../types';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { ScrollArea } from './ui/scroll-area';
import { cn } from '../lib/utils';
interface AuthDialogProps {
host: Host;
keys: SSHKey[];
onSubmit: (auth: {
username: string;
authMethod: 'password' | 'key';
password?: string;
keyId?: string;
saveCredentials: boolean;
}) => void;
onCancel: () => void;
}
const AuthDialog: React.FC<AuthDialogProps> = ({
host,
keys,
onSubmit,
onCancel,
}) => {
const [username, setUsername] = useState(host.username || 'root');
const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
const [password, setPassword] = useState('');
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, setSaveCredentials] = useState(true);
const [isKeySelectOpen, setIsKeySelectOpen] = useState(false);
const selectedKey = keys.find((k) => k.id === selectedKeyId);
const handleSubmit = () => {
onSubmit({
username,
authMethod,
password: authMethod === 'password' ? password : undefined,
keyId: authMethod === 'key' ? selectedKeyId ?? undefined : undefined,
saveCredentials,
});
};
const isValid = username.trim() && (
(authMethod === 'password' && password.trim()) ||
(authMethod === 'key' && selectedKeyId)
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-[420px] max-w-[90vw] bg-background border border-border/60 rounded-2xl shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-5 border-b border-border/50">
<div className="flex items-center gap-3">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-12 w-12" />
<div>
<h2 className="text-base font-semibold">{host.label}</h2>
<p className="text-xs text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
</p>
</div>
</div>
</div>
{/* Progress indicator */}
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<User size={14} />
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center transition-colors",
username.trim() ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
)}>
{authMethod === 'password' ? <Lock size={14} /> : <Key size={14} />}
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
{'>_'}
</div>
</div>
</div>
{/* Auth method tabs */}
<div className="px-6">
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === 'password'
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
onClick={() => setAuthMethod('password')}
>
<Lock size={14} />
Password
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === 'key'
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
onClick={() => setAuthMethod('key')}
>
<Key size={14} />
Public Key
</button>
</div>
</div>
{/* Form */}
<div className="px-6 py-4 space-y-4">
{/* Username field (shown when no username on host) */}
{!host.username && (
<div className="space-y-2">
<Label htmlFor="auth-username">Username</Label>
<Input
id="auth-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="root"
autoFocus
/>
</div>
)}
{/* Password field */}
{authMethod === 'password' && (
<div className="space-y-2">
<Label htmlFor="auth-password">Password</Label>
<div className="relative">
<Input
id="auth-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="pr-10"
autoFocus={!!host.username}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid) {
handleSubmit();
}
}}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
)}
{/* Key selection */}
{authMethod === 'key' && (
<div className="space-y-2">
<Label>Select Key</Label>
{keys.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
No keys available. Add keys in the Keychain section.
</div>
) : (
<div className="space-y-2">
{keys.filter(k => k.category === 'key').slice(0, 5).map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
selectedKeyId === key.id
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50"
)}
onClick={() => setSelectedKeyId(key.id)}
>
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
key.source === 'biometric' ? "bg-purple-500/20 text-purple-500" : "bg-primary/20 text-primary"
)}>
<Key size={14} />
</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">Type {key.type}</div>
</div>
</button>
))}
{keys.filter(k => k.category === 'key').length > 5 && (
<Popover open={isKeySelectOpen} onOpenChange={setIsKeySelectOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
Show all keys
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<ScrollArea className="h-64">
<div className="p-2 space-y-1">
{keys.filter(k => k.category === 'key').map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors",
selectedKeyId === key.id ? "bg-primary/10" : "hover:bg-secondary"
)}
onClick={() => {
setSelectedKeyId(key.id);
setIsKeySelectOpen(false);
}}
>
<Key size={14} className="text-primary" />
<span className="text-sm truncate">{key.label}</span>
<span className="text-xs text-muted-foreground ml-auto">{key.type}</span>
</button>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
<Button variant="secondary" onClick={onCancel}>
Close
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
disabled={!isValid}
onClick={handleSubmit}
>
Continue & Save
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={() => {
setSaveCredentials(false);
handleSubmit();
}}
disabled={!isValid}
>
Continue
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
};
export default AuthDialog;