Add username/password login with keyring-backed credential storage

- Dual-mode auth dialog: username/password (default) + API token fallback
- Proxmox ticket-based auth via POST /access/ticket
- OS keyring integration for secure credential persistence
- Auto-open login dialog on launch when unauthenticated
- Auth state tracking with auto-refresh support
This commit is contained in:
Matt
2026-07-29 18:27:27 +00:00
parent 084b64872e
commit dbe3c8d2a1
8 changed files with 477 additions and 152 deletions
+9 -8
View File
@@ -44,6 +44,13 @@ function AppContent() {
const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen)
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen)
// Auto-open login dialog when no active connection
useEffect(() => {
if (!activeConnectionId && !connectionDialogOpen) {
setConnectionDialogOpen(true)
}
}, [activeConnectionId, connectionDialogOpen])
// WebSocket integration connects when a connection is active
useWebSocket(activeConnectionId)
@@ -72,16 +79,10 @@ function AppContent() {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center space-y-4">
<h2 className="text-2xl font-semibold">Welcome to ProxmoxDesktop</h2>
<h2 className="text-2xl font-semibold">ProxmoxDesktop</h2>
<p className="text-muted-foreground">
Add a Proxmox server to get started
Connect to a Proxmox server to get started
</p>
<button
onClick={() => setConnectionDialogOpen(true)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Add Connection
</button>
</div>
</div>
)
+143 -69
View File
@@ -10,8 +10,10 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useConnectionStore } from '@/stores/connectionStore'
import type { ConnectionConfig } from '@/types/connection'
import { loginWithPassword, loginWithToken, addConnection } from '@/lib/tauri'
import type { ConnectionConfig, AuthMode } from '@/types/connection'
interface ConnectionDialogProps {
open: boolean
@@ -19,48 +21,79 @@ interface ConnectionDialogProps {
}
export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps) {
const addConnection = useConnectionStore((s) => s.addConnection)
const addConnectionToStore = useConnectionStore((s) => s.addConnection)
const setAuthStatus = useConnectionStore((s) => s.setAuthStatus)
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
const [authMode, setAuthMode] = useState<AuthMode>('password')
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [apiToken, setApiToken] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const resetForm = () => {
setName('')
setUrl('')
setUsername('')
setPassword('')
setApiToken('')
setError(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
// Validate URL
if (!url.startsWith('https://')) {
throw new Error('URL must start with https://')
}
// Create connection config
const cleanUrl = url.replace(/\/$/, '')
let result
if (authMode === 'password') {
if (!username || !password) {
throw new Error('Username and password are required')
}
result = await loginWithPassword(cleanUrl, username, password)
} else {
if (!apiToken) {
throw new Error('API token is required')
}
result = await loginWithToken(cleanUrl, apiToken)
}
const connectionId = result.connectionId || crypto.randomUUID()
const config: ConnectionConfig = {
id: crypto.randomUUID(),
name: name || 'New Connection',
id: connectionId,
name: name || (authMode === 'password' ? username : 'API Token Connection'),
primary: {
url: url.replace(/\/$/, ''), // Remove trailing slash
token: apiToken,
url: cleanUrl,
token: authMode === 'token' ? apiToken : undefined,
},
fallbacks: [],
trusted: false,
status: 'disconnected',
status: 'connected',
isCluster: false,
authMode,
username: authMode === 'password' ? username : undefined,
}
// Add connection to store
addConnection(config)
// Reset form and close dialog
setName('')
setUrl('')
setApiToken('')
await addConnection(config)
addConnectionToStore(config)
setActiveConnection(connectionId)
setAuthStatus('authenticated')
resetForm()
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add connection')
setError(err instanceof Error ? err.message : 'Failed to connect')
} finally {
setIsLoading(false)
}
@@ -70,62 +103,103 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Proxmox Connection</DialogTitle>
<DialogTitle>Connect to Proxmox</DialogTitle>
<DialogDescription>
Connect to a Proxmox VE server or cluster
Sign in with your Proxmox credentials or API token
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Connection Name</Label>
<Input
id="name"
placeholder="Home Lab"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="url">Server URL</Label>
<Input
id="url"
placeholder="https://192.168.1.10:8006"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
The URL of your Proxmox server (must use HTTPS)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="token">API Token</Label>
<Input
id="token"
type="password"
placeholder="user@realm!tokenid=secret"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Format: user@realm!tokenid=secret
</p>
</div>
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
<Tabs value={authMode} onValueChange={(v) => { setAuthMode(v as AuthMode); setError(null) }}>
<TabsList className="w-full">
<TabsTrigger value="password" className="flex-1">Username & Password</TabsTrigger>
<TabsTrigger value="token" className="flex-1">API Token</TabsTrigger>
</TabsList>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="url">Server URL</Label>
<Input
id="url"
placeholder="https://192.168.1.10:8006"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
The URL of your Proxmox server (must use HTTPS)
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Connecting...' : 'Add Connection'}
</Button>
</DialogFooter>
</form>
<TabsContent value="password" className="space-y-4 mt-0">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder="root@pam"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Format: user@realm (e.g. root@pam)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</TabsContent>
<TabsContent value="token" className="space-y-4 mt-0">
<div className="space-y-2">
<Label htmlFor="token">API Token</Label>
<Input
id="token"
type="password"
placeholder="user@realm!tokenid=secret"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Format: user@realm!tokenid=secret
</p>
</div>
</TabsContent>
<div className="space-y-2">
<Label htmlFor="name">Connection Name (optional)</Label>
<Input
id="name"
placeholder="Home Lab"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Connecting...' : 'Connect'}
</Button>
</DialogFooter>
</form>
</Tabs>
</DialogContent>
</Dialog>
)
+47 -1
View File
@@ -1,7 +1,7 @@
// Tauri IPC commands interface
// These functions call into the Rust backend via Tauri's invoke mechanism
import type { ConnectionConfig, CertificateInfo } from '@/types/connection'
import type { ConnectionConfig, CertificateInfo, LoginResult } from '@/types/connection'
import type {
ProxmoxNode,
ProxmoxVM,
@@ -60,6 +60,52 @@ export const disconnectFromServer = async (id: string): Promise<void> => {
return invoke('disconnect_from_server', { id })
}
// Authentication
export const loginWithPassword = async (
url: string,
username: string,
password: string,
): Promise<LoginResult> => {
if (!isTauri()) {
return mockResponse({
connectionId: crypto.randomUUID(),
ticket: 'mock-ticket-' + Date.now(),
csrfToken: 'mock-csrf-' + Date.now(),
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('login_with_password', { url, username, password })
}
export const loginWithToken = async (
url: string,
token: string,
): Promise<LoginResult> => {
if (!isTauri()) {
return mockResponse({
connectionId: crypto.randomUUID(),
ticket: token,
csrfToken: '',
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('login_with_token', { url, token })
}
export const logout = async (connectionId: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('logout', { connectionId })
}
export const getStoredCredentials = async (
connectionId: string,
): Promise<string | null> => {
if (!isTauri()) return mockResponse(null)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_stored_credentials', { connectionId })
}
export const getCertificateInfo = async (url: string): Promise<CertificateInfo> => {
if (!isTauri()) {
return mockResponse({
+16 -8
View File
@@ -1,12 +1,15 @@
import { create } from 'zustand'
import type { ConnectionConfig, ConnectionStatus } from '@/types/connection'
export type AuthStatus = 'authenticated' | 'expired' | 'unauthenticated'
interface ConnectionState {
connections: ConnectionConfig[]
activeConnectionId: string | null
isLoading: boolean
error: string | null
authStatus: AuthStatus
// Actions
addConnection: (config: ConnectionConfig) => void
removeConnection: (id: string) => void
@@ -15,6 +18,7 @@ interface ConnectionState {
setConnectionStatus: (id: string, status: ConnectionStatus) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setAuthStatus: (status: AuthStatus) => void
}
export const useConnectionStore = create<ConnectionState>((set) => ({
@@ -22,36 +26,40 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
activeConnectionId: null,
isLoading: false,
error: null,
authStatus: 'unauthenticated',
addConnection: (config) =>
set((state) => ({
connections: [...state.connections, config],
})),
removeConnection: (id) =>
set((state) => ({
connections: state.connections.filter((c) => c.id !== id),
activeConnectionId: state.activeConnectionId === id ? null : state.activeConnectionId,
authStatus: state.activeConnectionId === id ? 'unauthenticated' : state.authStatus,
})),
updateConnection: (id, updates) =>
set((state) => ({
connections: state.connections.map((c) =>
c.id === id ? { ...c, ...updates } : c
),
})),
setActiveConnection: (id) =>
set({ activeConnectionId: id }),
setConnectionStatus: (id, status) =>
set((state) => ({
connections: state.connections.map((c) =>
c.id === id ? { ...c, status } : c
),
})),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
setAuthStatus: (status) => set({ authStatus: status }),
}))
+11 -6
View File
@@ -1,20 +1,19 @@
// Connection configuration types
export type AuthMode = 'password' | 'token'
export interface ConnectionConfig {
id: string
name: string
// Primary endpoint
primary: EndpointConfig
// Fallback endpoints for cluster failover
fallbacks: EndpointConfig[]
// Certificate trust
certFingerprint?: string
trusted: boolean
// Connection state
status: ConnectionStatus
// Cluster info (populated after connection)
clusterName?: string
isCluster: boolean
authMode: AuthMode
username?: string
}
export interface EndpointConfig {
@@ -23,7 +22,7 @@ export interface EndpointConfig {
token?: string // API token (stored in keyring, not in config)
}
export type ConnectionStatus =
export type ConnectionStatus =
| 'disconnected'
| 'connecting'
| 'connected'
@@ -43,3 +42,9 @@ export interface ConnectionCredentials {
connectionId: string
apiToken: string
}
export interface LoginResult {
connectionId: string
ticket: string
csrfToken: string
}