Add vault secret manager frontend UI

Three-state page: not initialized → locked → unlocked.
Any user with vault.view can unlock (for store opening).
Admins can lock and change master password.

- Two-panel layout: categories on left, entries on right
- Entry reveal button shows decrypted value for 30s with copy
- Add/edit/delete entries and categories
- KeyRound icon in sidebar navigation
This commit is contained in:
Ryan Moon
2026-03-30 06:17:58 -05:00
parent 7246587955
commit 1f9297f533
4 changed files with 616 additions and 1 deletions

View File

@@ -0,0 +1,485 @@
import { useState, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
vaultStatusOptions, vaultCategoryListOptions, vaultCategoryDetailOptions,
vaultEntryListOptions, vaultKeys, vaultMutations, vaultCategoryMutations, vaultEntryMutations,
} from '@/api/vault'
import { usePagination } from '@/hooks/use-pagination'
import { useAuthStore } from '@/stores/auth.store'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
KeyRound, Lock, Unlock, Plus, MoreVertical, Trash2, Eye, EyeOff, Copy,
FolderKey, ShieldAlert, User2, Globe, StickyNote,
} from 'lucide-react'
import { toast } from 'sonner'
import type { VaultCategory, VaultEntry } from '@/types/vault'
export const Route = createFileRoute('/_authenticated/vault/')({
validateSearch: (search: Record<string, unknown>) => ({
page: Number(search.page) || 1,
limit: Number(search.limit) || 50,
q: (search.q as string) || undefined,
sort: (search.sort as string) || undefined,
order: (search.order as 'asc' | 'desc') || 'asc',
}),
component: VaultPage,
})
function VaultPage() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params } = usePagination()
const { data: status, isLoading: statusLoading } = useQuery(vaultStatusOptions())
if (statusLoading) {
return <div className="flex items-center justify-center h-[calc(100vh-6rem)]"><Skeleton className="h-32 w-80" /></div>
}
if (!status?.initialized) {
return <InitializeView />
}
if (!status?.unlocked) {
return <UnlockView />
}
return <VaultMain />
}
// --- Initialize View ---
function InitializeView() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const initMutation = useMutation({
mutationFn: () => vaultMutations.initialize(password),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.status })
toast.success('Vault initialized')
},
onError: (err) => toast.error(err.message),
})
if (!hasPermission('vault.admin')) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-6rem)] text-muted-foreground">
<ShieldAlert className="h-12 w-12 mb-3 opacity-30" />
<p>Vault has not been set up yet.</p>
<p className="text-xs mt-1">Ask an administrator to initialize the vault.</p>
</div>
)
}
return (
<div className="flex items-center justify-center h-[calc(100vh-6rem)]">
<div className="w-80 space-y-4">
<div className="text-center">
<KeyRound className="h-10 w-10 mx-auto mb-2 text-muted-foreground" />
<h2 className="text-lg font-semibold">Set Up Vault</h2>
<p className="text-sm text-muted-foreground">Create a master password to protect your secrets.</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); if (password === confirm && password.length >= 8) initMutation.mutate() }} className="space-y-3">
<div className="space-y-1">
<Label>Master Password</Label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Min 8 characters" autoFocus />
</div>
<div className="space-y-1">
<Label>Confirm Password</Label>
<Input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
</div>
{password && confirm && password !== confirm && (
<p className="text-xs text-destructive">Passwords do not match</p>
)}
<Button type="submit" className="w-full" disabled={!password || password !== confirm || password.length < 8 || initMutation.isPending}>
{initMutation.isPending ? 'Initializing...' : 'Initialize Vault'}
</Button>
</form>
</div>
</div>
)
}
// --- Unlock View ---
function UnlockView() {
const queryClient = useQueryClient()
const [password, setPassword] = useState('')
const unlockMutation = useMutation({
mutationFn: () => vaultMutations.unlock(password),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.status })
toast.success('Vault unlocked')
},
onError: () => toast.error('Invalid master password'),
})
return (
<div className="flex items-center justify-center h-[calc(100vh-6rem)]">
<div className="w-80 space-y-4">
<div className="text-center">
<Lock className="h-10 w-10 mx-auto mb-2 text-muted-foreground" />
<h2 className="text-lg font-semibold">Vault Locked</h2>
<p className="text-sm text-muted-foreground">Enter the master password to unlock.</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); if (password) unlockMutation.mutate() }} className="space-y-3">
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Master password" autoFocus />
<Button type="submit" className="w-full" disabled={!password || unlockMutation.isPending}>
{unlockMutation.isPending ? 'Unlocking...' : 'Unlock'}
</Button>
</form>
</div>
</div>
)
}
// --- Main Vault UI ---
function VaultMain() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params } = usePagination()
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
const [newCatOpen, setNewCatOpen] = useState(false)
const [newCatName, setNewCatName] = useState('')
const [newEntryOpen, setNewEntryOpen] = useState(false)
const [entryForm, setEntryForm] = useState({ name: '', username: '', url: '', notes: '', secret: '' })
const [editEntryId, setEditEntryId] = useState<string | null>(null)
const { data: catData, isLoading: catsLoading } = useQuery(vaultCategoryListOptions())
const { data: catDetail } = useQuery(vaultCategoryDetailOptions(selectedCategoryId ?? ''))
const { data: entriesData, isLoading: entriesLoading } = useQuery(
vaultEntryListOptions(selectedCategoryId ?? '', { ...params, limit: 50 }),
)
const categories = catData?.data ?? []
const entries = entriesData?.data ?? []
const lockMutation = useMutation({
mutationFn: vaultMutations.lock,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.status })
toast.success('Vault locked')
},
})
const createCatMutation = useMutation({
mutationFn: () => vaultCategoryMutations.create({ name: newCatName.trim() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categories })
setNewCatOpen(false)
setNewCatName('')
toast.success('Category created')
},
onError: (err) => toast.error(err.message),
})
const deleteCatMutation = useMutation({
mutationFn: vaultCategoryMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categories })
setSelectedCategoryId(null)
toast.success('Category deleted')
},
onError: (err) => toast.error(err.message),
})
const createEntryMutation = useMutation({
mutationFn: () => vaultEntryMutations.create(selectedCategoryId!, {
name: entryForm.name.trim(),
username: entryForm.username || undefined,
url: entryForm.url || undefined,
notes: entryForm.notes || undefined,
secret: entryForm.secret || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.entries(selectedCategoryId!) })
setNewEntryOpen(false)
setEntryForm({ name: '', username: '', url: '', notes: '', secret: '' })
toast.success('Entry created')
},
onError: (err) => toast.error(err.message),
})
const updateEntryMutation = useMutation({
mutationFn: () => vaultEntryMutations.update(editEntryId!, {
name: entryForm.name.trim(),
username: entryForm.username || undefined,
url: entryForm.url || undefined,
notes: entryForm.notes || undefined,
secret: entryForm.secret || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.entries(selectedCategoryId!) })
setEditEntryId(null)
setNewEntryOpen(false)
setEntryForm({ name: '', username: '', url: '', notes: '', secret: '' })
toast.success('Entry updated')
},
onError: (err) => toast.error(err.message),
})
const deleteEntryMutation = useMutation({
mutationFn: vaultEntryMutations.delete,
onSuccess: () => {
if (selectedCategoryId) queryClient.invalidateQueries({ queryKey: vaultKeys.entries(selectedCategoryId) })
toast.success('Entry deleted')
},
onError: (err) => toast.error(err.message),
})
function openEditEntry(entry: VaultEntry) {
setEditEntryId(entry.id)
setEntryForm({ name: entry.name, username: entry.username ?? '', url: entry.url ?? '', notes: entry.notes ?? '', secret: '' })
setNewEntryOpen(true)
}
function handleEntrySubmit(e: React.FormEvent) {
e.preventDefault()
if (!entryForm.name.trim()) return
if (editEntryId) {
updateEntryMutation.mutate()
} else {
createEntryMutation.mutate()
}
}
return (
<div className="flex gap-0 h-[calc(100vh-6rem)]">
{/* Left Panel — Categories */}
<div className="w-60 shrink-0 border-r overflow-y-auto p-3">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold">Vault</h2>
<div className="flex gap-1">
{hasPermission('vault.admin') && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => lockMutation.mutate()} title="Lock vault">
<Lock className="h-4 w-4" />
</Button>
)}
<Dialog open={newCatOpen} onOpenChange={setNewCatOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"><Plus className="h-4 w-4" /></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Category</DialogTitle></DialogHeader>
<form onSubmit={(e) => { e.preventDefault(); if (newCatName.trim()) createCatMutation.mutate() }} className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={newCatName} onChange={(e) => setNewCatName(e.target.value)} placeholder="e.g. WiFi Passwords" autoFocus />
</div>
<Button type="submit" disabled={!newCatName.trim() || createCatMutation.isPending}>
{createCatMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{catsLoading ? (
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}</div>
) : categories.length === 0 ? (
<p className="text-xs text-muted-foreground px-2">No categories yet</p>
) : (
<div className="space-y-0.5">
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setSelectedCategoryId(cat.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
selectedCategoryId === cat.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
}`}
>
<FolderKey className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{cat.name}</span>
</button>
))}
</div>
)}
</div>
{/* Right Panel — Entries */}
<div className="flex-1 overflow-y-auto">
{/* Toolbar */}
<div className="flex items-center gap-2 p-3 border-b">
<div className="flex-1 text-sm font-medium">
{catDetail ? catDetail.name : 'Select a category'}
</div>
{selectedCategoryId && (
<>
{catDetail?.accessLevel === 'admin' && (
<Button variant="outline" size="sm" onClick={() => deleteCatMutation.mutate(selectedCategoryId)}>
<Trash2 className="mr-2 h-4 w-4" />Delete Category
</Button>
)}
{hasPermission('vault.edit') && (
<Button variant="outline" size="sm" onClick={() => {
setEditEntryId(null)
setEntryForm({ name: '', username: '', url: '', notes: '', secret: '' })
setNewEntryOpen(true)
}}>
<Plus className="mr-2 h-4 w-4" />Add Entry
</Button>
)}
</>
)}
</div>
{/* Content */}
<div className="p-4">
{!selectedCategoryId ? (
<div className="text-center py-12 text-muted-foreground">
<KeyRound className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Select a category to view secrets</p>
</div>
) : entriesLoading ? (
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-16 w-full" />)}</div>
) : entries.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Plus className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No entries in this category</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<EntryRow key={entry.id} entry={entry} onEdit={() => openEditEntry(entry)} onDelete={() => deleteEntryMutation.mutate(entry.id)} />
))}
</div>
)}
</div>
</div>
{/* Entry Dialog */}
<Dialog open={newEntryOpen} onOpenChange={(open) => { setNewEntryOpen(open); if (!open) setEditEntryId(null) }}>
<DialogContent>
<DialogHeader><DialogTitle>{editEntryId ? 'Edit Entry' : 'New Entry'}</DialogTitle></DialogHeader>
<form onSubmit={handleEntrySubmit} className="space-y-3">
<div className="space-y-1">
<Label>Name *</Label>
<Input value={entryForm.name} onChange={(e) => setEntryForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Store WiFi" autoFocus />
</div>
<div className="space-y-1">
<Label>Username</Label>
<Input value={entryForm.username} onChange={(e) => setEntryForm((f) => ({ ...f, username: e.target.value }))} placeholder="Optional" />
</div>
<div className="space-y-1">
<Label>URL</Label>
<Input value={entryForm.url} onChange={(e) => setEntryForm((f) => ({ ...f, url: e.target.value }))} placeholder="Optional" />
</div>
<div className="space-y-1">
<Label>Notes</Label>
<Input value={entryForm.notes} onChange={(e) => setEntryForm((f) => ({ ...f, notes: e.target.value }))} placeholder="Optional" />
</div>
<div className="space-y-1">
<Label>{editEntryId ? 'New Secret (leave blank to keep current)' : 'Secret'}</Label>
<Input type="password" value={entryForm.secret} onChange={(e) => setEntryForm((f) => ({ ...f, secret: e.target.value }))} placeholder="Password or secret value" />
</div>
<Button type="submit" disabled={!entryForm.name.trim() || createEntryMutation.isPending || updateEntryMutation.isPending}>
{(createEntryMutation.isPending || updateEntryMutation.isPending) ? 'Saving...' : editEntryId ? 'Update' : 'Create'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
)
}
// --- Entry Row with Reveal ---
function EntryRow({ entry, onEdit, onDelete }: { entry: VaultEntry; onEdit: () => void; onDelete: () => void }) {
const hasPermission = useAuthStore((s) => s.hasPermission)
const [revealed, setRevealed] = useState<string | null>(null)
const [revealing, setRevealing] = useState(false)
// Auto-hide after 30 seconds
useEffect(() => {
if (revealed === null) return
const timer = setTimeout(() => setRevealed(null), 30_000)
return () => clearTimeout(timer)
}, [revealed])
async function handleReveal() {
setRevealing(true)
try {
const res = await vaultEntryMutations.reveal(entry.id)
setRevealed(res.value)
} catch {
toast.error('Failed to reveal secret')
} finally {
setRevealing(false)
}
}
function handleCopy() {
if (revealed) {
navigator.clipboard.writeText(revealed)
toast.success('Copied to clipboard')
}
}
return (
<div className="flex items-start gap-3 p-3 rounded-lg border hover:bg-accent/50 transition-colors group">
<KeyRound className="h-5 w-5 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{entry.name}</span>
{entry.hasSecret && <Badge variant="secondary" className="text-[10px]">secret</Badge>}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 mt-1 text-xs text-muted-foreground">
{entry.username && (
<span className="flex items-center gap-1"><User2 className="h-3 w-3" />{entry.username}</span>
)}
{entry.url && (
<span className="flex items-center gap-1"><Globe className="h-3 w-3" />{entry.url}</span>
)}
{entry.notes && (
<span className="flex items-center gap-1"><StickyNote className="h-3 w-3" />{entry.notes}</span>
)}
</div>
{revealed !== null && (
<div className="mt-2 flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{revealed}</code>
<button type="button" onClick={handleCopy} className="text-muted-foreground hover:text-foreground">
<Copy className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRevealed(null)} className="text-muted-foreground hover:text-foreground">
<EyeOff className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{entry.hasSecret && hasPermission('vault.edit') && revealed === null && (
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleReveal} disabled={revealing}>
<Eye className="h-3.5 w-3.5 mr-1" />{revealing ? '...' : 'Reveal'}
</Button>
)}
{hasPermission('vault.edit') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-7 w-7 rounded-md flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity">
<MoreVertical className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}