meyi-vault-client-dev 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ import { useState } from 'react'
2
+ import {
3
+ ScrollText, FolderLock, Shield, UserCheck,
4
+ UserX, Pencil, Trash2, Plus, RefreshCw,
5
+ ChevronLeft, ChevronRight, Search, X,
6
+ Loader2,
7
+ } from 'lucide-react'
8
+ import { useAuditLogs, useMe } from '@/hooks/useVault'
9
+ import type { AuditLog } from '@/services/api'
10
+ import { Button } from '@/components/ui/button'
11
+
12
+ // ── Action definitions ────────────────────────────────────────────────────────
13
+ // Each action gets an icon, human label, and colour treatment
14
+
15
+ type ActionDef = {
16
+ label: string
17
+ icon: React.ComponentType<{ className?: string }>
18
+ iconCls: string
19
+ badgeCls: string
20
+ }
21
+
22
+ const ACTION_MAP: Record<string, ActionDef> = {
23
+ VAULT_CREATED: { label: 'Created vault', icon: FolderLock, iconCls: 'text-blue-600', badgeCls: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300' },
24
+ VAULT_UPDATED: { label: 'Renamed vault', icon: Pencil, iconCls: 'text-blue-500', badgeCls: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300' },
25
+ VAULT_DELETED: { label: 'Deleted vault', icon: Trash2, iconCls: 'text-red-600', badgeCls: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300' },
26
+ ENTRY_CREATED: { label: 'Added credential', icon: Plus, iconCls: 'text-green-600', badgeCls: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300' },
27
+ ENTRY_UPDATED: { label: 'Updated credential', icon: Pencil, iconCls: 'text-amber-600', badgeCls: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300' },
28
+ ENTRY_DELETED: { label: 'Deleted credential', icon: Trash2, iconCls: 'text-red-600', badgeCls: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300' },
29
+ GRANT_CREATED: { label: 'Granted access', icon: UserCheck, iconCls: 'text-emerald-600', badgeCls: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' },
30
+ GRANT_REVOKED: { label: 'Revoked access', icon: UserX, iconCls: 'text-orange-600', badgeCls: 'bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300' },
31
+ ENTRIES_VIEWED: { label: 'Viewed entries', icon: Shield, iconCls: 'text-slate-600', badgeCls: 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-300' },
32
+ }
33
+
34
+ function getActionDef(action: string): ActionDef {
35
+ return ACTION_MAP[action] ?? {
36
+ label: action.replace(/_/g, ' ').toLowerCase().replace(/^\w/, c => c.toUpperCase()),
37
+ icon: RefreshCw,
38
+ iconCls: 'text-slate-500',
39
+ badgeCls: 'bg-slate-100 text-slate-600',
40
+ }
41
+ }
42
+
43
+ // ── Module badge ──────────────────────────────────────────────────────────────
44
+
45
+ const MODULE_COLOURS: Record<string, string> = {
46
+ vault: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
47
+ entry: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
48
+ grant: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
49
+ }
50
+
51
+ function ModuleBadge({ module }: { module: string }) {
52
+ const cls = MODULE_COLOURS[module.toLowerCase()] ?? 'text-muted-foreground'
53
+ return (
54
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium capitalize text-muted-foreground ${cls}`}>
55
+ {module}
56
+ </span>
57
+ )
58
+ }
59
+
60
+ // ── Actor cell ────────────────────────────────────────────────────────────────
61
+
62
+ function ActorCell({ email }: { email: string }) {
63
+ return (
64
+ <div className='flex items-center min-w-0'>
65
+ <div className='min-w-0'>
66
+ <p className='text-sm font-medium text-muted-foreground leading-none truncate'>
67
+ {email || '—'}
68
+ </p>
69
+ </div>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ // ── Time helpers ──────────────────────────────────────────────────────────────
75
+
76
+ function formatDateTime(iso: string): string {
77
+ return new Date(iso).toLocaleString(undefined, {
78
+ year: 'numeric', month: 'short', day: 'numeric',
79
+ hour: '2-digit', minute: '2-digit',
80
+ })
81
+ }
82
+
83
+ function fullTimestamp(iso: string): string {
84
+ return new Date(iso).toLocaleString(undefined, {
85
+ year: 'numeric', month: 'short', day: 'numeric',
86
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
87
+ })
88
+ }
89
+
90
+
91
+ // ── Empty state ───────────────────────────────────────────────────────────────
92
+
93
+ function EmptyState({ hasFilters, onClear }: { hasFilters: boolean; onClear: () => void }) {
94
+ return (
95
+ <tr>
96
+ <td colSpan={5}>
97
+ <div className='flex flex-col items-center justify-center py-4 gap-4 text-center'>
98
+ <div className='rounded-full bg-muted p-4'>
99
+ <ScrollText className='h-8 w-8 text-muted-foreground' />
100
+ </div>
101
+ <div>
102
+ <p className='font-semibold text-foreground'>No audit events found</p>
103
+ <p className='text-sm text-muted-foreground mt-1'>
104
+ {hasFilters
105
+ ? 'Try adjusting your filters or search term.'
106
+ : 'Events will appear here as users interact with vaults.'}
107
+ </p>
108
+ </div>
109
+ {hasFilters && (
110
+ <Button
111
+ variant="outline"
112
+ onClick={onClear}
113
+ className='inline-flex items-center gap-1.5 text-sm text-primary'
114
+ >
115
+ <X className='h-3.5 w-3.5' /> Clear filters
116
+ </Button>
117
+ )}
118
+ </div>
119
+ </td>
120
+ </tr>
121
+ )
122
+ }
123
+
124
+ // ── Main AuditPage ────────────────────────────────────────────────────────────
125
+
126
+ const PAGE_SIZE = 25
127
+
128
+ const ALL_MODULES = ['vault', 'entry', 'grant']
129
+ const ALL_ACTIONS = Object.keys(ACTION_MAP)
130
+
131
+ export function AuditPage() {
132
+ const { data: me } = useMe()
133
+ const [page, setPage] = useState(1)
134
+ const [search, setSearch] = useState('')
135
+ const [moduleFilter, setModule] = useState('')
136
+ const [actionFilter, setAction] = useState('')
137
+
138
+ const isAdmin = me?.role === 'admin'
139
+
140
+ const { data, isLoading, error } = useAuditLogs({
141
+ page,
142
+ limit: PAGE_SIZE,
143
+ module: moduleFilter || undefined,
144
+ action: actionFilter || undefined,
145
+ search: search || undefined,
146
+ })
147
+
148
+ // Access Denied State
149
+ if (me && !isAdmin) {
150
+ return (
151
+ <div className='flex flex-col items-center justify-center min-h-full bg-background p-6 text-center'>
152
+ <div className='rounded-full bg-destructive/10 p-6 mb-4'>
153
+ <Shield className='h-12 w-12 text-destructive' />
154
+ </div>
155
+ <h1 className='text-2xl font-bold tracking-tight text-foreground'>Access Denied</h1>
156
+ <p className='text-muted-foreground mt-2 max-w-sm'>
157
+ Only administrators have permission to view the audit log.
158
+ </p>
159
+ </div>
160
+ )
161
+ }
162
+
163
+ // Extract error message from axios error if possible
164
+ const errorMessage = (error as any)?.response?.data?.details || (error as any)?.message
165
+
166
+ const logs: AuditLog[] = data?.logs ?? []
167
+ const total: number = data?.total ?? 0
168
+ const totalPages = data?.total_pages ?? 1
169
+ const hasFilters = !!(search || moduleFilter || actionFilter)
170
+
171
+ const clearFilters = () => {
172
+ setSearch(''); setModule(''); setAction(''); setPage(1)
173
+ }
174
+
175
+ const changePage = (n: number) => setPage(Math.max(1, Math.min(totalPages, n)))
176
+
177
+ return (
178
+ <div className='flex flex-col min-h-full bg-background'>
179
+
180
+ {/* ── Page header ──────────────────────────────────────────────────── */}
181
+ <div className='flex items-center justify-between px-6 py-4 border-b border-border'>
182
+ <div className='flex flex-col items-start gap-1'>
183
+ <h1 className='text-xl font-semibold tracking-tight text-foreground'>Audit Log</h1>
184
+ <p className='text-muted-foreground text-sm'>Manage user activity and system events</p>
185
+ </div>
186
+ </div>
187
+
188
+ {/* ── Search & Filters ────────────────────────────────────────────── */}
189
+ <div className='flex items-center justify-between gap-3 px-6 py-4 flex-wrap'>
190
+ {/* Search */}
191
+ <div className='relative flex-1 min-w-[300px] max-w-sm'>
192
+ <Search className='absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none' />
193
+ <input
194
+ value={search}
195
+ onChange={e => { setSearch(e.target.value); setPage(1) }}
196
+ placeholder='Search by user or item...'
197
+ className='h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring text-foreground'
198
+ />
199
+ {search && (
200
+ <button onClick={() => setSearch('')} className='absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground'>
201
+ <X className='h-4 w-4' />
202
+ </button>
203
+ )}
204
+ </div>
205
+
206
+ <div className='flex items-center gap-2 flex-wrap'>
207
+ {/* Module */}
208
+ <select
209
+ value={moduleFilter}
210
+ onChange={e => { setModule(e.target.value); setPage(1) }}
211
+ className='h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring min-w-[140px]'
212
+ >
213
+ <option value=''>All modules</option>
214
+ {ALL_MODULES.map(m => (
215
+ <option key={m} value={m}>{m.charAt(0).toUpperCase() + m.slice(1)}</option>
216
+ ))}
217
+ </select>
218
+
219
+ {/* Action */}
220
+ <select
221
+ value={actionFilter}
222
+ onChange={e => { setAction(e.target.value); setPage(1) }}
223
+ className='h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring min-w-[140px]'
224
+ >
225
+ <option value=''>All actions</option>
226
+ {ALL_ACTIONS.map(a => (
227
+ <option key={a} value={a}>{getActionDef(a).label}</option>
228
+ ))}
229
+ </select>
230
+
231
+ {/* Clear */}
232
+ {hasFilters && (
233
+ <Button
234
+ variant="outline"
235
+ onClick={clearFilters}
236
+ className='inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors'
237
+ >
238
+ <X className='h-4 w-4' /> Clear
239
+ </Button>
240
+ )}
241
+ </div>
242
+ </div>
243
+
244
+ {/* ── Table ────────────────────────────────────────────────────────── */}
245
+ <div className='flex-1 px-6 py-6 overflow-x-auto'>
246
+ <div className='rounded-xl border border-border bg-card overflow-hidden min-w-[800px]'>
247
+ <table className='w-full text-xs'>
248
+ <thead>
249
+ <tr className='border-b border-border bg-muted/30'>
250
+ <th className='h-12 px-4 text-left text-xs font-medium text-foreground whitespace-nowrap'>
251
+ Action
252
+ </th>
253
+ <th className='h-12 px-4 text-left text-xs font-medium text-foreground whitespace-nowrap'>
254
+ Module
255
+ </th>
256
+ <th className='h-12 px-4 text-left text-xs font-medium text-foreground whitespace-nowrap'>
257
+ Performed By
258
+ </th>
259
+ <th className='h-12 px-4 text-left text-xs font-medium text-foreground whitespace-nowrap'>
260
+ Item Details
261
+ </th>
262
+ <th className='h-12 px-4 text-right text-xs font-medium text-foreground whitespace-nowrap'>
263
+ Performed On
264
+ </th>
265
+ </tr>
266
+ </thead>
267
+ <tbody className='divide-y divide-border'>
268
+ {isLoading ? (
269
+ <div className='flex items-center justify-center py-4 text-center'>
270
+ <Loader2 className='h-4 w-4 animate-spin' />
271
+ </div>
272
+ ) : errorMessage ? (
273
+ <tr>
274
+ <td colSpan={5}>
275
+ <div className='flex flex-col items-center justify-center py-20 gap-3 text-center'>
276
+ <div className='rounded-full bg-destructive/10 p-4'>
277
+ <X className='h-6 w-6 text-destructive' />
278
+ </div>
279
+ <div>
280
+ <p className='font-semibold text-foreground'>Failed to load audit logs</p>
281
+ <p className='text-sm text-muted-foreground mt-1'>{errorMessage}</p>
282
+ </div>
283
+ <button
284
+ onClick={() => window.location.reload()}
285
+ className='inline-flex items-center gap-1.5 h-9 px-4 rounded-md text-sm border border-input bg-background hover:bg-muted transition-colors'
286
+ >
287
+ <RefreshCw className="h-3.5 w-3.5" /> Retry
288
+ </button>
289
+ </div>
290
+ </td>
291
+ </tr>
292
+ ) : logs.length === 0 ? (
293
+ <EmptyState hasFilters={hasFilters} onClear={clearFilters} />
294
+ ) : (
295
+ logs.map(log => {
296
+ const def = getActionDef(log.action)
297
+ return (
298
+ <tr key={log.id} className='hover:bg-muted/20 transition-colors'>
299
+
300
+ {/* ── Action ───────────────────────────────────────── */}
301
+ <td className='px-4 py-4'>
302
+ <span className='inline-flex items-center gap-2.5'>
303
+ <span className='font-medium text-muted-foreground whitespace-nowrap text-sm'>
304
+ {def.label}
305
+ </span>
306
+ </span>
307
+ </td>
308
+
309
+ {/* ── Module ───────────────────────────────────────── */}
310
+ <td className='px-4 py-4'>
311
+ <ModuleBadge module={log.module} />
312
+ </td>
313
+
314
+ {/* ── Who ──────────────────────────────────────────── */}
315
+ <td className='px-4 py-4'>
316
+ <ActorCell email={log.actor_email || log.actor_name} />
317
+ </td>
318
+
319
+ {/* ── Item ─────────────────────────────────────────── */}
320
+ <td className='px-4 py-4 max-w-[200px]'>
321
+ {log.item_label ? (
322
+ <span className='text-muted-foreground font-medium truncate block text-sm' title={log.item_label}>
323
+ {log.item_label}
324
+ </span>
325
+ ) : (
326
+ <span className='text-muted-foreground font-mono text-sm'>
327
+ {log.item_id?.slice(0, 8)}…
328
+ </span>
329
+ )}
330
+ </td>
331
+
332
+ {/* ── When ─────────────────────────────────────────── */}
333
+ <td className='px-4 py-4 text-right'>
334
+ <span
335
+ className='text-sm text-muted-foreground whitespace-nowrap tabular-nums'
336
+ title={fullTimestamp(log.created_at)}
337
+ >
338
+ {formatDateTime(log.created_at)}
339
+ </span>
340
+ </td>
341
+
342
+ </tr>
343
+ )
344
+ })
345
+ )}
346
+ </tbody>
347
+ </table>
348
+ </div>
349
+
350
+ {/* ── Pagination ─────────────────────────────────────────────────── */}
351
+ {totalPages > 1 && (
352
+ <div className='flex items-center justify-between mt-6'>
353
+ <p className='text-sm text-muted-foreground'>
354
+ Showing{' '}
355
+ <span className='font-medium text-foreground tabular-nums'>
356
+ {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)}
357
+ </span>{' '}
358
+ of{' '}
359
+ <span className='font-medium text-foreground tabular-nums'>
360
+ {total.toLocaleString()}
361
+ </span>
362
+ </p>
363
+ <div className='flex items-center gap-1.5'>
364
+ <button
365
+ onClick={() => changePage(page - 1)}
366
+ disabled={page <= 1}
367
+ className='inline-flex items-center gap-1 h-9 px-3 rounded-md text-sm border border-input bg-background hover:bg-muted disabled:opacity-40 disabled:pointer-events-none transition-colors'
368
+ >
369
+ <ChevronLeft className='h-4 w-4' />
370
+ Previous
371
+ </button>
372
+
373
+ <div className='flex items-center gap-1'>
374
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
375
+ const start = Math.max(1, Math.min(page - 2, totalPages - 4))
376
+ const n = start + i
377
+ if (n > totalPages) return null
378
+ return (
379
+ <button
380
+ key={n}
381
+ onClick={() => changePage(n)}
382
+ className={`h-9 w-9 rounded-md text-sm border transition-colors ${
383
+ n === page
384
+ ? 'border-primary bg-primary text-primary-foreground font-medium'
385
+ : 'border-input bg-background hover:bg-muted text-foreground'
386
+ }`}
387
+ >
388
+ {n}
389
+ </button>
390
+ )
391
+ })}
392
+ </div>
393
+
394
+ <button
395
+ onClick={() => changePage(page + 1)}
396
+ disabled={page >= totalPages}
397
+ className='inline-flex items-center gap-1 h-9 px-3 rounded-md text-sm border border-input bg-background hover:bg-muted disabled:opacity-40 disabled:pointer-events-none transition-colors'
398
+ >
399
+ Next
400
+ <ChevronRight className='h-4 w-4' />
401
+ </button>
402
+ </div>
403
+ </div>
404
+ )}
405
+ </div>
406
+ </div>
407
+ )
408
+ }
@@ -0,0 +1,111 @@
1
+ import { useState } from 'react'
2
+ import { Plus, ArrowLeft, Key } from 'lucide-react'
3
+ import { toast } from 'sonner'
4
+ import { useEntries, useDeleteEntry } from '@/hooks/useVault'
5
+ import { EntryFormDialog, GrantAccessDialog, ConfirmDialog } from '@/components/dialogs'
6
+ import type { VaultEntry } from '@/services/api'
7
+ import { EntriesTable } from '@/components/vault/entries-table'
8
+
9
+ interface EntriesPageProps {
10
+ vaultId: string
11
+ isOwner?: boolean
12
+ onNavigate: (to: 'vaults') => void
13
+ }
14
+
15
+ export function EntriesPage({ vaultId, isOwner, onNavigate }: EntriesPageProps) {
16
+ const { data: entries = [], isLoading } = useEntries(vaultId)
17
+ const deleteEntry = useDeleteEntry(vaultId)
18
+ const [formOpen, setFormOpen] = useState(false)
19
+ const [editEntry, setEditEntry] = useState<VaultEntry | undefined>()
20
+ const [grantTarget, setGrantTarget] = useState<VaultEntry | null>(null)
21
+ const [deleteTarget, setDeleteTarget] = useState<VaultEntry | null>(null)
22
+
23
+ const handleDelete = async () => {
24
+ if (!deleteTarget) return
25
+ await deleteEntry.mutateAsync(deleteTarget.id)
26
+ setDeleteTarget(null)
27
+ toast.success('Entry deleted')
28
+ }
29
+
30
+ return (
31
+ <div className='flex flex-col h-full bg-background'>
32
+ <header className='flex items-center gap-4 p-6 border-b bg-card/50 backdrop-blur-sm sticky top-0 z-10'>
33
+ <button
34
+ onClick={() => onNavigate('vaults')}
35
+ className='rounded-lg p-2 hover:bg-muted transition-colors'
36
+ title='Back to Vaults'
37
+ >
38
+ <ArrowLeft className='h-5 w-5' />
39
+ </button>
40
+ <div className='flex-1 min-w-0'>
41
+ <h1 className='text-2xl font-bold tracking-tight truncate'>
42
+ Vault Credentials
43
+ </h1>
44
+ <p className='text-sm text-muted-foreground'>Securely store and manage your sensitive data.</p>
45
+ </div>
46
+ {isOwner && (
47
+ <button
48
+ onClick={() => { setEditEntry(undefined); setFormOpen(true) }}
49
+ className='inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-sm hover:bg-primary/90 transition-all hover:scale-[1.02] active:scale-[0.98]'
50
+ >
51
+ <Plus className='h-4 w-4' />
52
+ <span>New Entry</span>
53
+ </button>
54
+ )}
55
+ </header>
56
+
57
+ <main className='flex-1 overflow-y-auto p-6'>
58
+ {isLoading ? (
59
+ <div className='space-y-4'>
60
+ <div className='h-10 w-full rounded-lg bg-muted/20 animate-pulse' />
61
+ {[1, 2, 3, 4, 5].map(i => (
62
+ <div key={i} className='h-16 rounded-xl border bg-muted/10 animate-pulse' />
63
+ ))}
64
+ </div>
65
+ ) : entries.length === 0 ? (
66
+ <div className='flex flex-col items-center justify-center p-6 py-12 text-center max-w-sm mx-auto mt-8'>
67
+ <div className='mb-4 rounded-full bg-muted p-4 border border-primary/10'>
68
+ <Key className='h-8 w-8 text-muted-foreground' />
69
+ </div>
70
+ <h2 className='text-lg font-bold tracking-tight mb-1'>No entries found</h2>
71
+ <p className='text-muted-foreground mb-6 text-sm leading-relaxed'>
72
+ Add your first credential to this vault. You can store usernames, passwords, domains, and other sensitive details.
73
+ </p>
74
+ </div>
75
+ ) : (
76
+ <EntriesTable
77
+ entries={entries}
78
+ isOwner={isOwner}
79
+ onEdit={(entry) => { setEditEntry(entry); setFormOpen(true) }}
80
+ onDelete={setDeleteTarget}
81
+ onGrant={setGrantTarget}
82
+ />
83
+ )}
84
+ </main>
85
+
86
+ <EntryFormDialog
87
+ open={formOpen}
88
+ onOpenChange={(o) => { setFormOpen(o); if (!o) setEditEntry(undefined) }}
89
+ vaultId={vaultId}
90
+ editEntry={editEntry}
91
+ />
92
+
93
+ <GrantAccessDialog
94
+ open={!!grantTarget}
95
+ onOpenChange={(o) => { if (!o) setGrantTarget(null) }}
96
+ scope='entry'
97
+ scopeId={grantTarget?.id || ''}
98
+ scopeLabel={grantTarget?.alias || grantTarget?.domain || grantTarget?.username || ''}
99
+ />
100
+
101
+ <ConfirmDialog
102
+ open={!!deleteTarget}
103
+ onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
104
+ title='Delete Entry'
105
+ description={`Are you sure you want to delete "${deleteTarget?.alias || deleteTarget?.domain || deleteTarget?.username || 'this entry'}"? This action cannot be undone.`}
106
+ onConfirm={handleDelete}
107
+ loading={deleteEntry.isPending}
108
+ />
109
+ </div>
110
+ )
111
+ }