mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
13f8e8cd59
Introduces reusable SSH identities for authentication, allowing hosts to reference centralized identity records containing usernames, passwords, and key/certificate associations. Updates connection, SFTP, terminal, and sync logic to resolve credentials via identities, improving credential management and reuse. Enhances UI for identity selection, editing, and deletion. Updates localization for related identity and credential strings. Ensures identities are included in sync and vault persistence. Streamlines SSH connection attempts by preferring key-based auth with fallback to password, both for host and identity-based authentication. Facilitates safer, more flexible credential management across the application.
255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
/**
|
|
* Identity Panel - Create/Edit identity
|
|
*/
|
|
|
|
import { Eye,EyeOff,Key,Plus,Shield,User,X } from 'lucide-react';
|
|
import React,{ useMemo,useState } from 'react';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import { Identity,SSHKey } from '../../types';
|
|
import { Button } from '../ui/button';
|
|
import { Combobox } from '../ui/combobox';
|
|
import { Input } from '../ui/input';
|
|
import { Label } from '../ui/label';
|
|
import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover';
|
|
|
|
interface IdentityPanelProps {
|
|
draftIdentity: Partial<Identity>;
|
|
setDraftIdentity: (identity: Partial<Identity>) => void;
|
|
keys: SSHKey[];
|
|
showPassphrase: boolean;
|
|
setShowPassphrase: (show: boolean) => void;
|
|
isNew: boolean;
|
|
onSave: () => void;
|
|
}
|
|
|
|
export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
|
draftIdentity,
|
|
setDraftIdentity,
|
|
keys,
|
|
showPassphrase,
|
|
setShowPassphrase,
|
|
isNew,
|
|
onSave,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
|
|
type CredentialType = 'key' | 'certificate' | null;
|
|
const [credentialPopoverOpen, setCredentialPopoverOpen] = useState(false);
|
|
const [selectedCredentialType, setSelectedCredentialType] = useState<CredentialType>(null);
|
|
|
|
const selectedKey = useMemo(() => {
|
|
if (!draftIdentity.keyId) return undefined;
|
|
return keys.find(k => k.id === draftIdentity.keyId);
|
|
}, [draftIdentity.keyId, keys]);
|
|
|
|
const keysByCategory = useMemo(() => {
|
|
return {
|
|
key: keys.filter(k => k.category === 'key' && !k.certificate),
|
|
certificate: keys.filter(k => k.category === 'certificate' || !!k.certificate),
|
|
};
|
|
}, [keys]);
|
|
|
|
const clearSelectedKey = () => {
|
|
setDraftIdentity({
|
|
...draftIdentity,
|
|
keyId: undefined,
|
|
authMethod: 'password',
|
|
});
|
|
setSelectedCredentialType(null);
|
|
setCredentialPopoverOpen(false);
|
|
};
|
|
|
|
const setSelectedKeyId = (keyId: string, kind: Exclude<CredentialType, null>) => {
|
|
setDraftIdentity({
|
|
...draftIdentity,
|
|
keyId: keyId || undefined,
|
|
authMethod: kind === 'certificate' ? 'certificate' : 'key',
|
|
});
|
|
setSelectedCredentialType(null);
|
|
setCredentialPopoverOpen(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="h-10 w-10 rounded-lg bg-green-500/15 text-green-500 flex items-center justify-center">
|
|
<User size={20} />
|
|
</div>
|
|
<Input
|
|
value={draftIdentity.label || ''}
|
|
onChange={e => setDraftIdentity({ ...draftIdentity, label: e.target.value })}
|
|
placeholder={t('keychain.field.label')}
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t('keychain.identity.usernameRequired')}</Label>
|
|
<div className="relative">
|
|
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={draftIdentity.username || ''}
|
|
onChange={e => setDraftIdentity({ ...draftIdentity, username: e.target.value })}
|
|
placeholder={t('terminal.auth.username')}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t('terminal.auth.passwordLabel')}</Label>
|
|
<div className="relative">
|
|
<Input
|
|
type={showPassphrase ? 'text' : 'password'}
|
|
value={draftIdentity.password || ''}
|
|
onChange={e => setDraftIdentity({ ...draftIdentity, password: e.target.value })}
|
|
placeholder={t('terminal.auth.password.placeholder')}
|
|
className="pr-10"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
|
onClick={() => setShowPassphrase(!showPassphrase)}
|
|
>
|
|
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Selected credential display */}
|
|
{draftIdentity.keyId && (
|
|
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
|
{draftIdentity.authMethod === 'certificate' ? (
|
|
<Shield size={14} className="text-primary" />
|
|
) : (
|
|
<Key size={14} className="text-primary" />
|
|
)}
|
|
<span className="text-sm flex-1 truncate">
|
|
{selectedKey?.label || t('hostDetails.credential.missing')}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={clearSelectedKey}
|
|
title={t('common.clear')}
|
|
>
|
|
<X size={12} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Credential type selection with inline popover */}
|
|
{!draftIdentity.keyId && !selectedCredentialType && (
|
|
<Popover open={credentialPopoverOpen} onOpenChange={setCredentialPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
|
>
|
|
<Plus size={12} />
|
|
<span>{t('hostDetails.credential.keyCertificate')}</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[200px] p-1" align="start" sideOffset={4}>
|
|
<div className="space-y-0.5">
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
|
onClick={() => {
|
|
setSelectedCredentialType('key');
|
|
setCredentialPopoverOpen(false);
|
|
}}
|
|
>
|
|
<Key size={16} className="text-muted-foreground" />
|
|
<span className="text-sm font-medium">{t('hostDetails.credential.key')}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
|
onClick={() => {
|
|
setSelectedCredentialType('certificate');
|
|
setCredentialPopoverOpen(false);
|
|
}}
|
|
>
|
|
<Shield size={16} className="text-muted-foreground" />
|
|
<span className="text-sm font-medium">
|
|
{t('hostDetails.credential.certificate')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
|
|
{/* Key selection combobox - appears after selecting "Key" type */}
|
|
{selectedCredentialType === 'key' && !draftIdentity.keyId && (
|
|
<div className="flex items-center gap-1">
|
|
<Combobox
|
|
options={keysByCategory.key.map(k => ({
|
|
value: k.id,
|
|
label: k.label,
|
|
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ''}`,
|
|
icon: <Key size={14} className="text-muted-foreground" />,
|
|
}))}
|
|
value={draftIdentity.keyId}
|
|
onValueChange={(val) => setSelectedKeyId(val, 'key')}
|
|
placeholder={t('hostDetails.keys.search')}
|
|
emptyText={t('hostDetails.keys.empty')}
|
|
icon={<Key size={14} className="text-muted-foreground" />}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
onClick={() => setSelectedCredentialType(null)}
|
|
title={t('common.cancel')}
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Certificate selection combobox */}
|
|
{selectedCredentialType === 'certificate' && !draftIdentity.keyId && (
|
|
<div className="flex items-center gap-1">
|
|
<Combobox
|
|
options={keysByCategory.certificate.map(k => ({
|
|
value: k.id,
|
|
label: k.label,
|
|
icon: <Shield size={14} className="text-muted-foreground" />,
|
|
}))}
|
|
value={draftIdentity.keyId}
|
|
onValueChange={(val) => setSelectedKeyId(val, 'certificate')}
|
|
placeholder={t('hostDetails.certs.search')}
|
|
emptyText={t('hostDetails.certs.empty')}
|
|
icon={<Shield size={14} className="text-muted-foreground" />}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
onClick={() => setSelectedCredentialType(null)}
|
|
title={t('common.cancel')}
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
className="w-full h-11"
|
|
onClick={onSave}
|
|
disabled={!draftIdentity.label?.trim() || !draftIdentity.username?.trim()}
|
|
>
|
|
{isNew ? t('keychain.identity.save') : t('keychain.identity.update')}
|
|
</Button>
|
|
</>
|
|
);
|
|
};
|