diff --git a/packages/admin/src/api/vault.ts b/packages/admin/src/api/vault.ts new file mode 100644 index 0000000..d415f3f --- /dev/null +++ b/packages/admin/src/api/vault.ts @@ -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('/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(`/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>(`/v1/vault/categories/${categoryId}/entries`, params), + enabled: !!categoryId, + }) +} + +export function vaultEntryDetailOptions(id: string) { + return queryOptions({ + queryKey: vaultKeys.entryDetail(id), + queryFn: () => api.get(`/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) => api.post('/v1/vault/categories', data), + update: (id: string, data: Record) => api.patch(`/v1/vault/categories/${id}`, data), + delete: (id: string) => api.del(`/v1/vault/categories/${id}`), + addPermission: (categoryId: string, data: Record) => + api.post(`/v1/vault/categories/${categoryId}/permissions`, data), + removePermission: (permId: string) => api.del(`/v1/vault/category-permissions/${permId}`), +} + +export const vaultEntryMutations = { + create: (categoryId: string, data: Record) => + api.post(`/v1/vault/categories/${categoryId}/entries`, data), + update: (id: string, data: Record) => api.patch(`/v1/vault/entries/${id}`, data), + delete: (id: string) => api.del(`/v1/vault/entries/${id}`), + reveal: (id: string) => api.post<{ value: string | null }>(`/v1/vault/entries/${id}/reveal`, {}), +} diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index 4b9a957..063bed8 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -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() { )} } label="Files" /> + } label="Vault" /> {canViewUsers && (
Admin diff --git a/packages/admin/src/routes/_authenticated/vault/index.tsx b/packages/admin/src/routes/_authenticated/vault/index.tsx new file mode 100644 index 0000000..7267638 --- /dev/null +++ b/packages/admin/src/routes/_authenticated/vault/index.tsx @@ -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) => ({ + 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
+ } + + if (!status?.initialized) { + return + } + + if (!status?.unlocked) { + return + } + + return +} + +// --- 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 ( +
+ +

Vault has not been set up yet.

+

Ask an administrator to initialize the vault.

+
+ ) + } + + return ( +
+
+
+ +

Set Up Vault

+

Create a master password to protect your secrets.

+
+
{ e.preventDefault(); if (password === confirm && password.length >= 8) initMutation.mutate() }} className="space-y-3"> +
+ + setPassword(e.target.value)} placeholder="Min 8 characters" autoFocus /> +
+
+ + setConfirm(e.target.value)} /> +
+ {password && confirm && password !== confirm && ( +

Passwords do not match

+ )} + +
+
+
+ ) +} + +// --- 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 ( +
+
+
+ +

Vault Locked

+

Enter the master password to unlock.

+
+
{ e.preventDefault(); if (password) unlockMutation.mutate() }} className="space-y-3"> + setPassword(e.target.value)} placeholder="Master password" autoFocus /> + +
+
+
+ ) +} + +// --- Main Vault UI --- +function VaultMain() { + const queryClient = useQueryClient() + const hasPermission = useAuthStore((s) => s.hasPermission) + const { params } = usePagination() + const [selectedCategoryId, setSelectedCategoryId] = useState(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(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 ( +
+ {/* Left Panel — Categories */} +
+
+

Vault

+
+ {hasPermission('vault.admin') && ( + + )} + + + + + + New Category +
{ e.preventDefault(); if (newCatName.trim()) createCatMutation.mutate() }} className="space-y-4"> +
+ + setNewCatName(e.target.value)} placeholder="e.g. WiFi Passwords" autoFocus /> +
+ +
+
+
+
+
+ + {catsLoading ? ( +
{Array.from({ length: 4 }).map((_, i) => )}
+ ) : categories.length === 0 ? ( +

No categories yet

+ ) : ( +
+ {categories.map((cat) => ( + + ))} +
+ )} +
+ + {/* Right Panel — Entries */} +
+ {/* Toolbar */} +
+
+ {catDetail ? catDetail.name : 'Select a category'} +
+ {selectedCategoryId && ( + <> + {catDetail?.accessLevel === 'admin' && ( + + )} + {hasPermission('vault.edit') && ( + + )} + + )} +
+ + {/* Content */} +
+ {!selectedCategoryId ? ( +
+ +

Select a category to view secrets

+
+ ) : entriesLoading ? ( +
{Array.from({ length: 4 }).map((_, i) => )}
+ ) : entries.length === 0 ? ( +
+ +

No entries in this category

+
+ ) : ( +
+ {entries.map((entry) => ( + openEditEntry(entry)} onDelete={() => deleteEntryMutation.mutate(entry.id)} /> + ))} +
+ )} +
+
+ + {/* Entry Dialog */} + { setNewEntryOpen(open); if (!open) setEditEntryId(null) }}> + + {editEntryId ? 'Edit Entry' : 'New Entry'} +
+
+ + setEntryForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Store WiFi" autoFocus /> +
+
+ + setEntryForm((f) => ({ ...f, username: e.target.value }))} placeholder="Optional" /> +
+
+ + setEntryForm((f) => ({ ...f, url: e.target.value }))} placeholder="Optional" /> +
+
+ + setEntryForm((f) => ({ ...f, notes: e.target.value }))} placeholder="Optional" /> +
+
+ + setEntryForm((f) => ({ ...f, secret: e.target.value }))} placeholder="Password or secret value" /> +
+ +
+
+
+
+ ) +} + +// --- 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(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 ( +
+ +
+
+ {entry.name} + {entry.hasSecret && secret} +
+
+ {entry.username && ( + {entry.username} + )} + {entry.url && ( + {entry.url} + )} + {entry.notes && ( + {entry.notes} + )} +
+ {revealed !== null && ( +
+ {revealed} + + +
+ )} +
+
+ {entry.hasSecret && hasPermission('vault.edit') && revealed === null && ( + + )} + {hasPermission('vault.edit') && ( + + + + + + Edit + + Delete + + + + )} +
+
+ ) +} diff --git a/packages/admin/src/types/vault.ts b/packages/admin/src/types/vault.ts new file mode 100644 index 0000000..5a1abff --- /dev/null +++ b/packages/admin/src/types/vault.ts @@ -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 +}