feat(git): add token authentication and credential discovery

Add support for token-based git authentication in identity profiles.
Enable discovery and import of credentials from ~/.git-credentials file.
Introduce remote URL-based filtering for token identities in git view.
This commit is contained in:
btriapitsyn
2026-01-14 02:47:26 +02:00
parent d56eef8aa6
commit fa83c1e645
16 changed files with 704 additions and 44 deletions
@@ -2,7 +2,7 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { useGitIdentitiesStore, type GitIdentityProfile } from '@/stores/useGitIdentitiesStore';
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
import {
RiUser3Line,
RiSaveLine,
@@ -12,7 +12,9 @@ import {
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiInformationLine
RiInformationLine,
RiKeyLine,
RiLock2Line
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -43,40 +45,67 @@ export const GitIdentitiesPage: React.FC = () => {
deleteProfile,
} = useGitIdentitiesStore();
// Parse import: prefix for credential import flow
const importData = React.useMemo(() => {
if (selectedProfileId?.startsWith('import:')) {
const [, host, username] = selectedProfileId.split(':');
return { host, username };
}
return null;
}, [selectedProfileId]);
const selectedProfile = React.useMemo(() =>
selectedProfileId && selectedProfileId !== 'new' ? getProfileById(selectedProfileId) : null,
[selectedProfileId, getProfileById]
selectedProfileId && selectedProfileId !== 'new' && !importData ? getProfileById(selectedProfileId) : null,
[selectedProfileId, getProfileById, importData]
);
const isNewProfile = selectedProfileId === 'new';
const isNewProfile = selectedProfileId === 'new' || importData !== null;
const isGlobalProfile = selectedProfileId === 'global';
const [name, setName] = React.useState('');
const [userName, setUserName] = React.useState('');
const [userEmail, setUserEmail] = React.useState('');
const [authType, setAuthType] = React.useState<GitIdentityAuthType>('ssh');
const [sshKey, setSshKey] = React.useState('');
const [host, setHost] = React.useState('');
const [color, setColor] = React.useState('keyword');
const [icon, setIcon] = React.useState('branch');
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (isNewProfile) {
if (importData) {
// Pre-fill from imported credential
// For repo-specific hosts like "github.com/user/repo", use just "repo" as name
const parts = importData.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
setName(displayName);
setUserName(importData.username);
setUserEmail('');
setAuthType('token');
setSshKey('');
setHost(importData.host);
setColor('string'); // cyan for token-based
setIcon('code');
} else if (isNewProfile) {
setName('');
setUserName('');
setUserEmail('');
setAuthType('ssh');
setSshKey('');
setHost('');
setColor('keyword');
setIcon('branch');
} else if (selectedProfile) {
setName(selectedProfile.name);
setUserName(selectedProfile.userName);
setUserEmail(selectedProfile.userEmail);
setAuthType(selectedProfile.authType || 'ssh');
setSshKey(selectedProfile.sshKey || '');
setHost(selectedProfile.host || '');
setColor(selectedProfile.color || 'keyword');
setIcon(selectedProfile.icon || 'branch');
}
}, [selectedProfile, isNewProfile, selectedProfileId]);
}, [selectedProfile, isNewProfile, selectedProfileId, importData]);
const handleSave = async () => {
if (!userName.trim() || !userEmail.trim()) {
@@ -84,6 +113,11 @@ export const GitIdentitiesPage: React.FC = () => {
return;
}
if (authType === 'token' && !host.trim()) {
toast.error('Host is required for token-based authentication');
return;
}
setIsSaving(true);
try {
@@ -91,7 +125,9 @@ export const GitIdentitiesPage: React.FC = () => {
name: name.trim() || userName.trim(),
userName: userName.trim(),
userEmail: userEmail.trim(),
sshKey: sshKey.trim() || null,
authType,
sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
host: authType === 'token' ? (host.trim() || null) : null,
color,
icon,
};
@@ -161,10 +197,12 @@ export const GitIdentitiesPage: React.FC = () => {
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
{importData ? 'Import Credential' : isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
</h1>
<p className="typography-body text-muted-foreground mt-1">
{isNewProfile
{importData
? `Import token credential for ${importData.host} - please fill in your email address`
: isNewProfile
? 'Create a new Git identity profile for your repositories'
: isGlobalProfile
? 'System-wide Git identity from global configuration (read-only)'
@@ -315,6 +353,54 @@ export const GitIdentitiesPage: React.FC = () => {
</p>
</div>
{/* Auth Type Selector */}
{!isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Authentication Type
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
SSH: Uses SSH key for authentication<br/>
Token: Uses personal access token from ~/.git-credentials
</TooltipContent>
</Tooltip>
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setAuthType('ssh')}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
authType === 'ssh'
? 'border-primary bg-accent'
: 'border-border hover:border-primary/50'
)}
>
<RiLock2Line className="w-4 h-4" />
<span className="typography-ui-label">SSH Key</span>
</button>
<button
type="button"
onClick={() => setAuthType('token')}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
authType === 'token'
? 'border-primary bg-accent'
: 'border-border hover:border-primary/50'
)}
>
<RiKeyLine className="w-4 h-4" />
<span className="typography-ui-label">Token (HTTPS)</span>
</button>
</div>
</div>
)}
{/* SSH Key Path - only for SSH auth type */}
{authType === 'ssh' && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
SSH Key Path
@@ -324,22 +410,51 @@ export const GitIdentitiesPage: React.FC = () => {
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Path to SSH private key used for Git authentication.<br/>
This key will be used for HTTPS and SSH Git operations.<br/>
This key will be used for SSH Git operations.<br/>
Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
</TooltipContent>
</Tooltip>
</label>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="/Users/username/.ssh/id_rsa"
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Path to SSH private key for authentication (optional)
</p>
</div>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="/Users/username/.ssh/id_rsa"
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Path to SSH private key for authentication (optional)
</p>
</div>
)}
{/* Host - only for Token auth type */}
{authType === 'token' && !isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Host {<span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The Git host this credential applies to.<br/>
Token will be read from ~/.git-credentials for this host.<br/>
Examples: github.com, gitlab.com
</TooltipContent>
</Tooltip>
</label>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="github.com"
required
/>
<p className="typography-meta text-muted-foreground">
Git host for token authentication (from ~/.git-credentials)
</p>
</div>
)}
{}
{!isGlobalProfile && (
@@ -17,6 +17,7 @@ import {
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiDownloadLine,
} from '@remixicon/react';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -24,7 +25,7 @@ import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import type { GitIdentityProfile } from '@/stores/useGitIdentitiesStore';
import type { GitIdentityProfile, DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
branch: RiGitBranchLine,
@@ -56,6 +57,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
deleteProfile,
loadProfiles,
loadGlobalIdentity,
loadDiscoveredCredentials,
getUnimportedCredentials,
} = useGitIdentitiesStore();
const { setSidebarOpen } = useUIStore();
@@ -73,10 +76,23 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
const unimportedCredentials = getUnimportedCredentials();
React.useEffect(() => {
loadProfiles();
loadGlobalIdentity();
}, [loadProfiles, loadGlobalIdentity]);
loadDiscoveredCredentials();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials]);
const handleImportCredential = (credential: DiscoveredGitCredential) => {
// Set a special "import" selection that carries the credential data
// The form will read this and pre-fill fields
setSelectedProfile(`import:${credential.host}:${credential.username}`);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const bgClass = isDesktopRuntime
? 'bg-transparent'
@@ -150,7 +166,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
</div>
)}
{profiles.length === 0 && !globalIdentity ? (
{profiles.length === 0 && !globalIdentity && unimportedCredentials.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiGitBranchLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No profiles configured</p>
@@ -175,6 +191,25 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
))}
</>
)}
{/* Discovered Credentials Section */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Discovered Credentials
</div>
<p className="px-2 pb-2 typography-micro text-muted-foreground/60">
Found in ~/.git-credentials
</p>
{unimportedCredentials.map((cred) => (
<DiscoveredCredentialItem
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => handleImportCredential(cred)}
/>
))}
</>
)}
</ScrollableOverlay>
</div>
);
@@ -197,6 +232,7 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
}) => {
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
const iconColor = COLOR_MAP[profile.color || ''];
const authType = profile.authType || 'ssh';
return (
<div
@@ -211,18 +247,21 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<IconComponent
className="w-4 h-4 flex-shrink-0"
style={{ color: iconColor }}
/>
<span className="typography-ui-label font-normal truncate flex-1 text-foreground">
<span className="typography-ui-label font-normal truncate text-foreground">
{profile.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{profile.userEmail}
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
</div>
</button>
@@ -255,3 +294,57 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
</div>
);
};
interface DiscoveredCredentialItemProps {
credential: DiscoveredGitCredential;
onImport: () => void;
}
/**
* Get display name for a credential host.
* For repo-specific hosts like "github.com/user/repo", returns just "repo".
* For host-only like "github.com", returns "github.com".
*/
const getCredentialDisplayName = (host: string): string => {
const parts = host.split('/');
if (parts.length >= 3) {
// repo-specific: github.com/user/repo -> repo
return parts[parts.length - 1];
}
// host-only: github.com
return host;
};
const DiscoveredCredentialItem: React.FC<DiscoveredCredentialItemProps> = ({
credential,
onImport,
}) => {
const displayName = getCredentialDisplayName(credential.host);
const isRepoSpecific = credential.host.includes('/');
return (
<div className="group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 hover:dark:bg-accent/40 hover:bg-primary/6">
<div className="flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{displayName}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{isRepoSpecific ? credential.host : credential.username}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onImport}
className="h-6 px-2 text-xs gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiDownloadLine className="h-3 w-3" />
Import
</Button>
</div>
</div>
);
};