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.
- package/README.md +157 -0
- package/package.json +69 -0
- package/src/VaultApp.tsx +82 -0
- package/src/components/datetime-picker.tsx +262 -0
- package/src/components/dialogs.tsx +467 -0
- package/src/components/searchable-multi-select.tsx +275 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/calendar.tsx +210 -0
- package/src/components/ui/command.tsx +181 -0
- package/src/components/ui/dialog.tsx +140 -0
- package/src/components/ui/input.tsx +27 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/popover.tsx +45 -0
- package/src/components/ui/scroll-area.tsx +64 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/table.tsx +122 -0
- package/src/components/vault/entries-table.tsx +283 -0
- package/src/components/vault/password-cell.tsx +26 -0
- package/src/context/VaultProvider.tsx +49 -0
- package/src/hooks/useVault.ts +177 -0
- package/src/index.tsx +99 -0
- package/src/lib/utils.ts +6 -0
- package/src/pages/AuditPage.tsx +408 -0
- package/src/pages/EntriesPage.tsx +111 -0
- package/src/pages/VaultPage.tsx +184 -0
- package/src/pages/meyi-connect-password.code-workspace +11 -0
- package/src/services/api.ts +139 -0
- package/src/style.css +5 -0
|
@@ -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
|
+
}
|