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:
91
packages/admin/src/api/vault.ts
Normal file
91
packages/admin/src/api/vault.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { VaultStatus, VaultCategory, VaultCategoryPermission, VaultEntry } from '@/types/vault'
|
||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||
|
||||
// --- Keys ---
|
||||
|
||||
export const vaultKeys = {
|
||||
status: ['vault-status'] as const,
|
||||
categories: ['vault-categories'] as const,
|
||||
categoryDetail: (id: string) => ['vault-categories', id] as const,
|
||||
categoryPermissions: (id: string) => ['vault-categories', id, 'permissions'] as const,
|
||||
entries: (categoryId: string) => ['vault-entries', categoryId] as const,
|
||||
entryList: (categoryId: string, params: PaginationInput) => ['vault-entries', categoryId, params] as const,
|
||||
entryDetail: (id: string) => ['vault-entry', id] as const,
|
||||
}
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
export function vaultStatusOptions() {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.status,
|
||||
queryFn: () => api.get<VaultStatus>('/v1/vault/status'),
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultCategoryListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.categories,
|
||||
queryFn: () => api.get<{ data: VaultCategory[] }>('/v1/vault/categories'),
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultCategoryDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.categoryDetail(id),
|
||||
queryFn: () => api.get<VaultCategory>(`/v1/vault/categories/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultCategoryPermissionsOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.categoryPermissions(id),
|
||||
queryFn: () => api.get<{ data: VaultCategoryPermission[] }>(`/v1/vault/categories/${id}/permissions`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultEntryListOptions(categoryId: string, params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.entryList(categoryId, params),
|
||||
queryFn: () => api.get<PaginatedResponse<VaultEntry>>(`/v1/vault/categories/${categoryId}/entries`, params),
|
||||
enabled: !!categoryId,
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultEntryDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.entryDetail(id),
|
||||
queryFn: () => api.get<VaultEntry>(`/v1/vault/entries/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
export const vaultMutations = {
|
||||
initialize: (masterPassword: string) => api.post('/v1/vault/initialize', { masterPassword }),
|
||||
unlock: (masterPassword: string) => api.post('/v1/vault/unlock', { masterPassword }),
|
||||
lock: () => api.post('/v1/vault/lock', {}),
|
||||
changeMasterPassword: (currentPassword: string, newPassword: string) =>
|
||||
api.post('/v1/vault/change-master-password', { currentPassword, newPassword }),
|
||||
}
|
||||
|
||||
export const vaultCategoryMutations = {
|
||||
create: (data: Record<string, unknown>) => api.post<VaultCategory>('/v1/vault/categories', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<VaultCategory>(`/v1/vault/categories/${id}`, data),
|
||||
delete: (id: string) => api.del<VaultCategory>(`/v1/vault/categories/${id}`),
|
||||
addPermission: (categoryId: string, data: Record<string, unknown>) =>
|
||||
api.post<VaultCategoryPermission>(`/v1/vault/categories/${categoryId}/permissions`, data),
|
||||
removePermission: (permId: string) => api.del<VaultCategoryPermission>(`/v1/vault/category-permissions/${permId}`),
|
||||
}
|
||||
|
||||
export const vaultEntryMutations = {
|
||||
create: (categoryId: string, data: Record<string, unknown>) =>
|
||||
api.post<VaultEntry>(`/v1/vault/categories/${categoryId}/entries`, data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<VaultEntry>(`/v1/vault/entries/${id}`, data),
|
||||
delete: (id: string) => api.del<VaultEntry>(`/v1/vault/entries/${id}`),
|
||||
reveal: (id: string) => api.post<{ value: string | null }>(`/v1/vault/entries/${id}/reveal`, {}),
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { myPermissionsOptions } from '@/api/rbac'
|
||||
import { Avatar } from '@/components/shared/avatar-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, Settings } from 'lucide-react'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: () => {
|
||||
@@ -134,6 +134,7 @@ function AuthenticatedLayout() {
|
||||
</>
|
||||
)}
|
||||
<NavLink to="/files" icon={<FolderOpen className="h-4 w-4" />} label="Files" />
|
||||
<NavLink to="/vault" icon={<KeyRound className="h-4 w-4" />} label="Vault" />
|
||||
{canViewUsers && (
|
||||
<div className="mt-4 mb-1 px-3">
|
||||
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide">Admin</span>
|
||||
|
||||
485
packages/admin/src/routes/_authenticated/vault/index.tsx
Normal file
485
packages/admin/src/routes/_authenticated/vault/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
packages/admin/src/types/vault.ts
Normal file
38
packages/admin/src/types/vault.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface VaultStatus {
|
||||
initialized: boolean
|
||||
unlocked: boolean
|
||||
}
|
||||
|
||||
export interface VaultCategory {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
createdBy: string | null
|
||||
isPublic: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
accessLevel?: 'view' | 'edit' | 'admin' | null
|
||||
}
|
||||
|
||||
export interface VaultCategoryPermission {
|
||||
id: string
|
||||
categoryId: string
|
||||
roleId: string | null
|
||||
userId: string | null
|
||||
accessLevel: 'traverse' | 'view' | 'edit' | 'admin'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface VaultEntry {
|
||||
id: string
|
||||
categoryId: string
|
||||
name: string
|
||||
username: string | null
|
||||
url: string | null
|
||||
notes: string | null
|
||||
hasSecret: boolean
|
||||
createdBy: string | null
|
||||
updatedBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
Reference in New Issue
Block a user