[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
139
components/keychain/GenerateStandardPanel.tsx
Normal file
139
components/keychain/GenerateStandardPanel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Generate Key Panel - Standard SSH Key generation form
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
interface GenerateStandardPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
showPassphrase: boolean;
|
||||
setShowPassphrase: (show: boolean) => void;
|
||||
isGenerating: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export const GenerateStandardPanel: React.FC<GenerateStandardPanelProps> = ({
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.label')}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
|
||||
placeholder={t('keychain.generate.labelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.generate.keyType')}</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={draftKey.type === t ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
"flex-1 h-10",
|
||||
draftKey.type === t && "bg-primary/15 text-primary"
|
||||
)}
|
||||
onClick={() => {
|
||||
// Set default keySize based on type
|
||||
const defaultSize = t === 'ED25519' ? undefined : (t === 'RSA' ? 4096 : 256);
|
||||
setDraftKey({ ...draftKey, type: t, keySize: defaultSize });
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Size selector - only for RSA and ECDSA */}
|
||||
{(draftKey.type === 'RSA' || draftKey.type === 'ECDSA') && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.generate.keySize')}</Label>
|
||||
<div className="flex gap-2">
|
||||
{(draftKey.type === 'RSA'
|
||||
? [4096, 2048, 1024]
|
||||
: [256, 384, 521]
|
||||
).map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
variant={draftKey.keySize === size ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
"flex-1 h-10",
|
||||
draftKey.keySize === size && "bg-primary/15 text-primary"
|
||||
)}
|
||||
onClick={() => setDraftKey({ ...draftKey, keySize: size })}
|
||||
>
|
||||
{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="savePassphrase"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="savePassphrase" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating || !draftKey.label?.trim()}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="h-4 w-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
||||
) : (
|
||||
t('keychain.generate.generateSave')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
components/keychain/IdentityCard.tsx
Normal file
87
components/keychain/IdentityCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Identity Card component for displaying saved identities
|
||||
*/
|
||||
|
||||
import { Pencil,User } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Identity } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { VaultEntityIcon, vaultIdentityIconClass } from '../vault/VaultEntityIcon';
|
||||
|
||||
interface IdentityCardProps {
|
||||
identity: Identity;
|
||||
viewMode: 'grid' | 'list';
|
||||
isSelected: boolean;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const IdentityCard: React.FC<IdentityCardProps> = ({
|
||||
identity,
|
||||
viewMode,
|
||||
isSelected,
|
||||
reorderProps,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasPassword = !!identity.password;
|
||||
const hasKey = !!identity.keyId;
|
||||
const keyKind = identity.authMethod === 'certificate' ? 'certificate' : 'key';
|
||||
|
||||
const summary = hasPassword && hasKey
|
||||
? (keyKind === 'certificate'
|
||||
? t('keychain.identity.summary.passwordAndCertificate')
|
||||
: t('keychain.identity.summary.passwordAndKey'))
|
||||
: hasKey
|
||||
? (keyKind === 'certificate'
|
||||
? t('keychain.identity.summary.certificate')
|
||||
: t('keychain.identity.summary.key'))
|
||||
: hasPassword
|
||||
? t('keychain.identity.summary.password')
|
||||
: t('keychain.identity.summary.none');
|
||||
|
||||
return (
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer min-w-0 w-full max-w-full",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<VaultEntityIcon
|
||||
className={vaultIdentityIconClass}
|
||||
icon={<User size={18} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 basis-0 overflow-hidden">
|
||||
<div className="block max-w-full truncate text-sm font-semibold">{identity.label || 'Add a label...'}</div>
|
||||
<div className="block max-w-full truncate text-[11px] font-mono text-muted-foreground">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
267
components/keychain/IdentityPanel.tsx
Normal file
267
components/keychain/IdentityPanel.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
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-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={clearSelectedKey}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.clear')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</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"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.cancel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</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"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.cancel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
201
components/keychain/ImportKeyPanel.tsx
Normal file
201
components/keychain/ImportKeyPanel.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Import Key Panel - Import existing SSH key
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff, Upload } from 'lucide-react';
|
||||
import React,{ useCallback,useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { detectKeyType } from './utils';
|
||||
|
||||
interface ImportKeyPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
showPassphrase: boolean;
|
||||
setShowPassphrase: (show: boolean) => void;
|
||||
onImport: () => void;
|
||||
}
|
||||
|
||||
export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
onImport,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileImport = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (content) {
|
||||
const detectedType = detectKeyType(content);
|
||||
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
|
||||
|
||||
setDraftKey({
|
||||
...draftKey,
|
||||
privateKey: content,
|
||||
label: draftKey.label || label,
|
||||
type: detectedType,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
}, [draftKey, setDraftKey]);
|
||||
|
||||
const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (content) {
|
||||
const detectedType = detectKeyType(content);
|
||||
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
|
||||
|
||||
setDraftKey({
|
||||
...draftKey,
|
||||
privateKey: content,
|
||||
label: draftKey.label || label,
|
||||
type: detectedType,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, [draftKey, setDraftKey]);
|
||||
|
||||
const handleDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileImport}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.label')}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
|
||||
placeholder={t('keychain.field.labelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.privateKeyRequired')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.publicKey')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
|
||||
placeholder="ssh-ed25519 AAAAC3... user@host"
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{t('terminal.auth.certificate')}
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{t('common.optional')}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, certificate: e.target.value })}
|
||||
placeholder={t('keychain.field.certificatePlaceholder')}
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="savePassphraseImport"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="savePassphraseImport" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60 transition-colors hover:border-primary/50"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Upload size={16} />
|
||||
<span className="text-sm">{t('keychain.import.dropHint')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{t('keychain.import.importFromFile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={onImport}
|
||||
disabled={!draftKey.label?.trim() || !draftKey.privateKey?.trim()}
|
||||
>
|
||||
{t('keychain.import.saveKey')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
117
components/keychain/KeyCard.tsx
Normal file
117
components/keychain/KeyCard.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Key Card component for displaying SSH keys in grid/list view
|
||||
*/
|
||||
|
||||
import { Copy,ExternalLink,Pencil,Trash2 } 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 {
|
||||
VaultEntityIcon,
|
||||
vaultCertificateIconClass,
|
||||
vaultKeyIconClass,
|
||||
} from '../vault/VaultEntityIcon';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '../ui/context-menu';
|
||||
import { getKeyIcon,getKeyTypeDisplay } from './utils';
|
||||
|
||||
interface KeyCardProps {
|
||||
keyItem: SSHKey;
|
||||
viewMode: 'grid' | 'list';
|
||||
isSelected: boolean;
|
||||
isMac: boolean;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onExport: () => void;
|
||||
onCopyPublicKey: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const KeyCard: React.FC<KeyCardProps> = ({
|
||||
keyItem,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isMac,
|
||||
reorderProps,
|
||||
onClick,
|
||||
onEdit,
|
||||
onExport,
|
||||
onCopyPublicKey,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer min-w-0 w-full max-w-full",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<VaultEntityIcon
|
||||
className={keyItem.certificate
|
||||
? vaultCertificateIconClass
|
||||
: vaultKeyIconClass}
|
||||
icon={getKeyIcon(keyItem)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 basis-0 overflow-hidden">
|
||||
<div className="block max-w-full truncate text-sm font-semibold">{keyItem.label}</div>
|
||||
<div className="block max-w-full truncate text-[11px] font-mono text-muted-foreground">
|
||||
Type {getKeyTypeDisplay(keyItem, isMac)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onCopyPublicKey} disabled={!keyItem.publicKey}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t("action.copyPublicKey")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onExport}>
|
||||
<ExternalLink size={14} className="mr-2" />
|
||||
{t("action.keyExport")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Pencil size={14} className="mr-2" />
|
||||
{t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
94
components/keychain/ViewKeyPanel.tsx
Normal file
94
components/keychain/ViewKeyPanel.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* View Key Panel - Display SSH key details
|
||||
*/
|
||||
|
||||
import { Check, Copy, Info } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { copyToClipboard } from './utils';
|
||||
|
||||
interface ViewKeyPanelProps {
|
||||
keyItem: SSHKey;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
|
||||
keyItem,
|
||||
onExport,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyPublicKey = useCallback(async () => {
|
||||
const ok = await copyToClipboard(keyItem.publicKey || '');
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [keyItem.publicKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.field.label')}</Label>
|
||||
<p className="text-sm">{keyItem.label}</p>
|
||||
</div>
|
||||
|
||||
{keyItem.publicKey && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
|
||||
<div className="flex rounded-lg border border-border/80 bg-card overflow-hidden">
|
||||
<div className="flex-1 min-w-0 p-3 font-mono text-xs break-all max-h-32 overflow-y-auto">
|
||||
{keyItem.publicKey}
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col border-l border-border/60 p-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => void handleCopyPublicKey()}
|
||||
aria-label={
|
||||
copied
|
||||
? t('cloudSync.githubFlow.copied')
|
||||
: t('action.copyPublicKey')
|
||||
}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{copied
|
||||
? t('cloudSync.githubFlow.copied')
|
||||
: t('action.copyPublicKey')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">{t('field.type')}</Label>
|
||||
<p className="text-sm">{keyItem.type}</p>
|
||||
</div>
|
||||
|
||||
{/* Key Export section */}
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">{t('keychain.export.title')}</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-11" onClick={onExport}>
|
||||
{t('keychain.export.exportToHost')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
20
components/keychain/index.ts
Normal file
20
components/keychain/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Keychain Components - Index
|
||||
*
|
||||
* Re-exports all keychain-related components and utilities
|
||||
*/
|
||||
|
||||
// Utilities and types
|
||||
export {
|
||||
isMacOS,shouldShowIdentitySection,shouldShowKeySection,shouldShowSearchNoResults,type PanelMode
|
||||
} from './utils';
|
||||
|
||||
// Card components
|
||||
export { IdentityCard } from './IdentityCard';
|
||||
export { KeyCard } from './KeyCard';
|
||||
|
||||
// Panel components
|
||||
export { GenerateStandardPanel } from './GenerateStandardPanel';
|
||||
export { IdentityPanel } from './IdentityPanel';
|
||||
export { ImportKeyPanel } from './ImportKeyPanel';
|
||||
export { ViewKeyPanel } from './ViewKeyPanel';
|
||||
105
components/keychain/utils.ts
Normal file
105
components/keychain/utils.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Keychain utility functions
|
||||
*/
|
||||
|
||||
import { BadgeCheck, Key } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
|
||||
/**
|
||||
* Get icon element for key source
|
||||
*/
|
||||
export const getKeyIcon = (key: SSHKey): React.ReactElement => {
|
||||
if (key.certificate) return React.createElement(BadgeCheck, { size: 16 });
|
||||
return React.createElement(Key, { size: 16 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get display text for key type
|
||||
*/
|
||||
export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
|
||||
void isMac;
|
||||
return key.type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect key type from private key content
|
||||
*/
|
||||
export const detectKeyType = (privateKey: string): KeyType => {
|
||||
const pk = privateKey.toLowerCase();
|
||||
if (pk.includes('rsa')) return 'RSA';
|
||||
if (pk.includes('ecdsa') || pk.includes('ec ')) return 'ECDSA';
|
||||
return 'ED25519';
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Failed to copy to clipboard:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if running on macOS
|
||||
*/
|
||||
export const isMacOS = (): boolean => {
|
||||
return navigator.platform.toLowerCase().includes('mac') ||
|
||||
navigator.userAgent.toLowerCase().includes('mac');
|
||||
};
|
||||
|
||||
// Panel modes type
|
||||
export type PanelMode =
|
||||
| { type: 'closed' }
|
||||
| { type: 'view'; key: SSHKey }
|
||||
| { type: 'edit'; key: SSHKey }
|
||||
| { type: 'generate'; keyType: 'standard' }
|
||||
| { type: 'import' }
|
||||
| { type: 'identity'; identity?: import('../../types').Identity }
|
||||
| { type: 'export'; key: SSHKey };
|
||||
|
||||
interface IdentitySectionVisibilityOptions {
|
||||
identityCount: number;
|
||||
filteredIdentityCount: number;
|
||||
filteredKeyCount: number;
|
||||
search: string;
|
||||
}
|
||||
|
||||
/** Show identities whenever any exist; while searching, keep the section if it matches or nothing matches. */
|
||||
export const shouldShowIdentitySection = ({
|
||||
identityCount,
|
||||
filteredIdentityCount,
|
||||
filteredKeyCount,
|
||||
search,
|
||||
}: IdentitySectionVisibilityOptions): boolean => {
|
||||
if (identityCount === 0) return false;
|
||||
if (!search.trim()) return true;
|
||||
|
||||
return filteredIdentityCount > 0 || filteredKeyCount === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Show keys when any match (or exist while browsing). Hide the empty-key CTA when
|
||||
* identities alone already fill the page - including identity-only vaults.
|
||||
*/
|
||||
export const shouldShowKeySection = ({
|
||||
identityCount,
|
||||
filteredKeyCount,
|
||||
}: Pick<
|
||||
IdentitySectionVisibilityOptions,
|
||||
'identityCount' | 'filteredKeyCount' | 'search'
|
||||
>): boolean => {
|
||||
return filteredKeyCount > 0 || identityCount === 0;
|
||||
};
|
||||
|
||||
export const shouldShowSearchNoResults = (
|
||||
search: string,
|
||||
filteredItemCount: number,
|
||||
totalItemCount: number,
|
||||
): boolean => Boolean(search.trim()) && totalItemCount > 0 && filteredItemCount === 0;
|
||||
Reference in New Issue
Block a user