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,283 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
type ColumnDef,
|
|
4
|
+
flexRender,
|
|
5
|
+
getCoreRowModel,
|
|
6
|
+
getSortedRowModel,
|
|
7
|
+
getFilteredRowModel,
|
|
8
|
+
useReactTable,
|
|
9
|
+
type SortingState,
|
|
10
|
+
} from '@tanstack/react-table'
|
|
11
|
+
import {
|
|
12
|
+
Copy,
|
|
13
|
+
Shield,
|
|
14
|
+
Pencil,
|
|
15
|
+
Trash2,
|
|
16
|
+
Search,
|
|
17
|
+
ArrowUpDown,
|
|
18
|
+
ArrowUp,
|
|
19
|
+
ArrowDown,
|
|
20
|
+
MoreHorizontal,
|
|
21
|
+
Globe
|
|
22
|
+
} from 'lucide-react'
|
|
23
|
+
import { toast } from 'sonner'
|
|
24
|
+
import type { VaultEntry } from '@/services/api'
|
|
25
|
+
import { PasswordCell } from './password-cell'
|
|
26
|
+
import {
|
|
27
|
+
Table,
|
|
28
|
+
TableBody,
|
|
29
|
+
TableCell,
|
|
30
|
+
TableHead,
|
|
31
|
+
TableHeader,
|
|
32
|
+
TableRow,
|
|
33
|
+
} from '@/components/ui/table'
|
|
34
|
+
import { Input } from '@/components/ui/input'
|
|
35
|
+
import { Button } from '@/components/ui/button'
|
|
36
|
+
|
|
37
|
+
interface EntriesTableProps {
|
|
38
|
+
entries: VaultEntry[]
|
|
39
|
+
isOwner?: boolean
|
|
40
|
+
onEdit: (entry: VaultEntry) => void
|
|
41
|
+
onDelete: (entry: VaultEntry) => void
|
|
42
|
+
onGrant: (entry: VaultEntry) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ActionMenu({
|
|
46
|
+
entry,
|
|
47
|
+
onEdit,
|
|
48
|
+
onDelete,
|
|
49
|
+
onGrant
|
|
50
|
+
}: {
|
|
51
|
+
entry: VaultEntry;
|
|
52
|
+
onEdit: (e: VaultEntry) => void;
|
|
53
|
+
onDelete: (e: VaultEntry) => void;
|
|
54
|
+
onGrant: (e: VaultEntry) => void;
|
|
55
|
+
}) {
|
|
56
|
+
const [open, setOpen] = useState(false)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className='relative flex justify-end'>
|
|
60
|
+
<Button
|
|
61
|
+
variant="ghost"
|
|
62
|
+
size="icon"
|
|
63
|
+
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
|
64
|
+
className='h-8 w-8'
|
|
65
|
+
>
|
|
66
|
+
<MoreHorizontal className='h-4 w-4' />
|
|
67
|
+
</Button>
|
|
68
|
+
|
|
69
|
+
{open && (
|
|
70
|
+
<>
|
|
71
|
+
<div className='fixed inset-0 z-10' onClick={(e) => { e.stopPropagation(); setOpen(false) }} />
|
|
72
|
+
<div className='absolute right-0 top-9 z-20 w-40 rounded-lg border bg-popover shadow-lg py-1.5 overflow-hidden animate-in fade-in zoom-in-95 duration-100'>
|
|
73
|
+
<button
|
|
74
|
+
onClick={(e) => { e.stopPropagation(); setOpen(false); onGrant(entry) }}
|
|
75
|
+
className='w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-left transition-colors'
|
|
76
|
+
>
|
|
77
|
+
<Shield className='h-4 w-4 text-muted-foreground' />
|
|
78
|
+
<span>Manage</span>
|
|
79
|
+
</button>
|
|
80
|
+
<button
|
|
81
|
+
onClick={(e) => { e.stopPropagation(); setOpen(false); onEdit(entry) }}
|
|
82
|
+
className='w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-left transition-colors'
|
|
83
|
+
>
|
|
84
|
+
<Pencil className='h-4 w-4 text-muted-foreground' />
|
|
85
|
+
<span>Edit</span>
|
|
86
|
+
</button>
|
|
87
|
+
<div className='h-px bg-border my-1' />
|
|
88
|
+
<button
|
|
89
|
+
onClick={(e) => { e.stopPropagation(); setOpen(false); onDelete(entry) }}
|
|
90
|
+
className='w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-left text-destructive transition-colors'
|
|
91
|
+
>
|
|
92
|
+
<Trash2 className='h-4 w-4' />
|
|
93
|
+
<span>Delete</span>
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function EntriesTable({ entries, isOwner, onEdit, onDelete, onGrant }: EntriesTableProps) {
|
|
103
|
+
const [sorting, setSorting] = useState<SortingState>([])
|
|
104
|
+
const [globalFilter, setGlobalFilter] = useState('')
|
|
105
|
+
const [domainFilter, setDomainFilter] = useState('')
|
|
106
|
+
|
|
107
|
+
const filteredEntries = useMemo(() => {
|
|
108
|
+
if (!domainFilter) return entries
|
|
109
|
+
return entries.filter(e => (e.domain || '').toLowerCase().includes(domainFilter.toLowerCase()))
|
|
110
|
+
}, [entries, domainFilter])
|
|
111
|
+
|
|
112
|
+
const columns = useMemo<ColumnDef<VaultEntry>[]>(
|
|
113
|
+
() => {
|
|
114
|
+
const cols: ColumnDef<VaultEntry>[] = [
|
|
115
|
+
{
|
|
116
|
+
accessorKey: 'domain',
|
|
117
|
+
header: () => <div className="font-semibold text-foreground px-4">Domain</div>,
|
|
118
|
+
cell: ({ row }) => (
|
|
119
|
+
<div className='flex items-center gap-2 text-xs text-muted-foreground px-4'>
|
|
120
|
+
<span className='truncate max-w-[180px]'>{row.original.domain || '—'}</span>
|
|
121
|
+
</div>
|
|
122
|
+
),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
accessorKey: 'username',
|
|
126
|
+
header: () => <div className="font-semibold text-foreground">Username</div>,
|
|
127
|
+
cell: ({ row }) => (
|
|
128
|
+
<div className='flex items-center gap-1.5 font-mono text-xs text-muted-foreground'>
|
|
129
|
+
<span className='truncate max-w-[150px]'>{row.original.username || '—'}</span>
|
|
130
|
+
{row.original.username && (
|
|
131
|
+
<button
|
|
132
|
+
onClick={(e) => {
|
|
133
|
+
e.stopPropagation();
|
|
134
|
+
navigator.clipboard.writeText(row.original.username);
|
|
135
|
+
toast.success('Username copied')
|
|
136
|
+
}}
|
|
137
|
+
className='p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors'
|
|
138
|
+
title='Copy username'
|
|
139
|
+
>
|
|
140
|
+
<Copy className='h-3.5 w-3.5' />
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
accessorKey: 'password',
|
|
148
|
+
header: () => <div className="font-semibold text-foreground">Password</div>,
|
|
149
|
+
cell: ({ row }) => <PasswordCell password={row.original.password || ''} />,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
accessorKey: 'alias',
|
|
153
|
+
header: ({ column }) => {
|
|
154
|
+
return (
|
|
155
|
+
<Button
|
|
156
|
+
variant="ghost"
|
|
157
|
+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
158
|
+
className="-ml-4 h-8 font-semibold text-foreground"
|
|
159
|
+
>
|
|
160
|
+
Alias
|
|
161
|
+
{column.getIsSorted() === "asc" ? (
|
|
162
|
+
<ArrowUp className="ml-2 h-4 w-4" />
|
|
163
|
+
) : column.getIsSorted() === "desc" ? (
|
|
164
|
+
<ArrowDown className="ml-2 h-4 w-4" />
|
|
165
|
+
) : (
|
|
166
|
+
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
167
|
+
)}
|
|
168
|
+
</Button>
|
|
169
|
+
)
|
|
170
|
+
},
|
|
171
|
+
cell: ({ row }) => (
|
|
172
|
+
<div className='flex items-center gap-2 font-medium '>
|
|
173
|
+
<span className='truncate max-w-[150px]'>{row.original.alias || '—'}</span>
|
|
174
|
+
</div>
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
if (isOwner) {
|
|
180
|
+
cols.push({
|
|
181
|
+
id: 'actions',
|
|
182
|
+
header: () => <div className="text-right font-semibold text-foreground pr-4">Actions</div>,
|
|
183
|
+
cell: ({ row }) => (
|
|
184
|
+
<ActionMenu
|
|
185
|
+
entry={row.original}
|
|
186
|
+
onEdit={onEdit}
|
|
187
|
+
onDelete={onDelete}
|
|
188
|
+
onGrant={onGrant}
|
|
189
|
+
/>
|
|
190
|
+
),
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return cols
|
|
195
|
+
},
|
|
196
|
+
[onEdit, onDelete, onGrant, isOwner]
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const table = useReactTable({
|
|
200
|
+
data: filteredEntries,
|
|
201
|
+
columns,
|
|
202
|
+
state: {
|
|
203
|
+
sorting,
|
|
204
|
+
globalFilter,
|
|
205
|
+
},
|
|
206
|
+
onSortingChange: setSorting,
|
|
207
|
+
onGlobalFilterChange: setGlobalFilter,
|
|
208
|
+
getCoreRowModel: getCoreRowModel(),
|
|
209
|
+
getSortedRowModel: getSortedRowModel(),
|
|
210
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className='space-y-4'>
|
|
215
|
+
<div className='flex items-center gap-4'>
|
|
216
|
+
<div className='relative flex-1 max-w-sm'>
|
|
217
|
+
<Search className='absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground' />
|
|
218
|
+
<Input
|
|
219
|
+
placeholder='Search entries...'
|
|
220
|
+
value={globalFilter ?? ''}
|
|
221
|
+
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
222
|
+
className='pl-9'
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
<div className='relative flex-1 max-w-[200px]'>
|
|
226
|
+
<Globe className='absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground' />
|
|
227
|
+
<Input
|
|
228
|
+
placeholder='Filter domain...'
|
|
229
|
+
value={domainFilter}
|
|
230
|
+
onChange={(e) => setDomainFilter(e.target.value)}
|
|
231
|
+
className='pl-9'
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className='rounded-xl border bg-card'>
|
|
237
|
+
<Table>
|
|
238
|
+
<TableHeader>
|
|
239
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
240
|
+
<TableRow key={headerGroup.id}>
|
|
241
|
+
{headerGroup.headers.map((header) => (
|
|
242
|
+
<TableHead key={header.id}>
|
|
243
|
+
{header.isPlaceholder
|
|
244
|
+
? null
|
|
245
|
+
: flexRender(
|
|
246
|
+
header.column.columnDef.header,
|
|
247
|
+
header.getContext()
|
|
248
|
+
)}
|
|
249
|
+
</TableHead>
|
|
250
|
+
))}
|
|
251
|
+
</TableRow>
|
|
252
|
+
))}
|
|
253
|
+
</TableHeader>
|
|
254
|
+
<TableBody>
|
|
255
|
+
{table.getRowModel().rows?.length ? (
|
|
256
|
+
table.getRowModel().rows.map((row) => (
|
|
257
|
+
<TableRow key={row.id}>
|
|
258
|
+
{row.getVisibleCells().map((cell) => (
|
|
259
|
+
<TableCell key={cell.id}>
|
|
260
|
+
{flexRender(
|
|
261
|
+
cell.column.columnDef.cell,
|
|
262
|
+
cell.getContext()
|
|
263
|
+
)}
|
|
264
|
+
</TableCell>
|
|
265
|
+
))}
|
|
266
|
+
</TableRow>
|
|
267
|
+
))
|
|
268
|
+
) : (
|
|
269
|
+
<TableRow>
|
|
270
|
+
<TableCell
|
|
271
|
+
colSpan={columns.length}
|
|
272
|
+
className="h-24 text-center text-muted-foreground"
|
|
273
|
+
>
|
|
274
|
+
No entries found.
|
|
275
|
+
</TableCell>
|
|
276
|
+
</TableRow>
|
|
277
|
+
)}
|
|
278
|
+
</TableBody>
|
|
279
|
+
</Table>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Copy, Check } from 'lucide-react'
|
|
3
|
+
import { toast } from 'sonner'
|
|
4
|
+
|
|
5
|
+
export function PasswordCell({ password }: { password: string }) {
|
|
6
|
+
const [copied, setCopied] = useState(false)
|
|
7
|
+
|
|
8
|
+
const copy = (e: React.MouseEvent) => {
|
|
9
|
+
e.stopPropagation()
|
|
10
|
+
navigator.clipboard.writeText(password)
|
|
11
|
+
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
12
|
+
toast.success('Password copied')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<span className='flex items-center gap-1'>
|
|
17
|
+
{password ?
|
|
18
|
+
<>
|
|
19
|
+
<span className='font-mono text-sm'>••••••••</span>
|
|
20
|
+
<button onClick={copy} className='ml-1 text-muted-foreground hover:text-foreground'>
|
|
21
|
+
{copied ? <Check className='h-3.5 w-3.5 text-green-500' /> : <Copy className='h-3.5 w-3.5' />}
|
|
22
|
+
</button>
|
|
23
|
+
</> : '—'}
|
|
24
|
+
</span>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
import type { AxiosInstance } from 'axios'
|
|
3
|
+
import { setApiInstance } from '@/services/api'
|
|
4
|
+
|
|
5
|
+
interface VaultContextValue {
|
|
6
|
+
/** Fetch all users for the grant picker — provided by host */
|
|
7
|
+
getUsers: () => Promise<Array<{ id: string; full_name: string; email: string; role: string }>>
|
|
8
|
+
/** Mount path prefix used by the host's router */
|
|
9
|
+
basePath: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const VaultContext = createContext<VaultContextValue | null>(null)
|
|
13
|
+
|
|
14
|
+
export interface VaultProviderProps {
|
|
15
|
+
/** Host's configured axios instance (shares auth headers + interceptors) */
|
|
16
|
+
api: AxiosInstance
|
|
17
|
+
/** Function to fetch all users — delegates to host's existing user service */
|
|
18
|
+
getUsers: VaultContextValue['getUsers']
|
|
19
|
+
/** Base path the host mounts vault routes at. Default: '/vault' */
|
|
20
|
+
basePath?: string
|
|
21
|
+
children: ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* VaultProvider must wrap the vault UI.
|
|
26
|
+
* The host passes its own axios instance so vault-client shares
|
|
27
|
+
* auth, base URL, and token refresh — no duplicate auth code.
|
|
28
|
+
*/
|
|
29
|
+
export function VaultProvider({
|
|
30
|
+
api,
|
|
31
|
+
getUsers,
|
|
32
|
+
basePath = '/vault',
|
|
33
|
+
children,
|
|
34
|
+
}: VaultProviderProps) {
|
|
35
|
+
// Register the host's axios instance with our API module
|
|
36
|
+
setApiInstance(api)
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<VaultContext.Provider value={{ getUsers, basePath }}>
|
|
40
|
+
{children}
|
|
41
|
+
</VaultContext.Provider>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useVaultContext() {
|
|
46
|
+
const ctx = useContext(VaultContext)
|
|
47
|
+
if (!ctx) throw new Error('useVaultContext must be used inside <VaultProvider>')
|
|
48
|
+
return ctx
|
|
49
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { toast } from 'sonner'
|
|
3
|
+
import { vaultApi, type ExpiresIn } from '@/services/api'
|
|
4
|
+
|
|
5
|
+
// ── Query keys ────────────────────────────────────────────────────────────────
|
|
6
|
+
export const vaultKeys = {
|
|
7
|
+
vaults: () => ['vault', 'vaults'] as const,
|
|
8
|
+
entries: (vaultId: string) => ['vault', 'entries', vaultId] as const,
|
|
9
|
+
grants: () => ['vault', 'grants'] as const,
|
|
10
|
+
stats: () => ['vault', 'stats'] as const,
|
|
11
|
+
me: () => ['vault', 'me'] as const,
|
|
12
|
+
audit: (params: any) => ['vault', 'audit', params] as const,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Auth / Me ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function useMe() {
|
|
18
|
+
return useQuery({
|
|
19
|
+
queryKey: vaultKeys.me(),
|
|
20
|
+
queryFn: vaultApi.getMe,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Vaults ────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export function useVaults() {
|
|
27
|
+
return useQuery({
|
|
28
|
+
queryKey: vaultKeys.vaults(),
|
|
29
|
+
queryFn: vaultApi.listVaults,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useCreateVault() {
|
|
34
|
+
const qc = useQueryClient()
|
|
35
|
+
return useMutation({
|
|
36
|
+
mutationFn: (name: string) => vaultApi.createVault(name),
|
|
37
|
+
onSuccess: () => {
|
|
38
|
+
qc.invalidateQueries({ queryKey: vaultKeys.vaults() })
|
|
39
|
+
toast.success('Vault created')
|
|
40
|
+
},
|
|
41
|
+
onError: () => toast.error('Failed to create vault'),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useDeleteVault() {
|
|
46
|
+
const qc = useQueryClient()
|
|
47
|
+
return useMutation({
|
|
48
|
+
mutationFn: (id: string) => vaultApi.deleteVault(id),
|
|
49
|
+
onSuccess: () => {
|
|
50
|
+
qc.invalidateQueries({ queryKey: vaultKeys.vaults() })
|
|
51
|
+
toast.success('Vault deleted')
|
|
52
|
+
},
|
|
53
|
+
onError: () => toast.error('Failed to delete vault'),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useUpdateVault() {
|
|
58
|
+
const qc = useQueryClient()
|
|
59
|
+
return useMutation({
|
|
60
|
+
mutationFn: ({ id, name }: { id: string; name: string }) => vaultApi.updateVault(id, name),
|
|
61
|
+
onSuccess: () => {
|
|
62
|
+
qc.invalidateQueries({ queryKey: vaultKeys.vaults() })
|
|
63
|
+
toast.success('Vault updated')
|
|
64
|
+
},
|
|
65
|
+
onError: () => toast.error('Failed to update vault'),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Entries ───────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function useEntries(vaultId: string) {
|
|
72
|
+
return useQuery({
|
|
73
|
+
queryKey: vaultKeys.entries(vaultId),
|
|
74
|
+
queryFn: () => vaultApi.listEntries(vaultId),
|
|
75
|
+
enabled: !!vaultId,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useCreateEntry(vaultId: string) {
|
|
80
|
+
const qc = useQueryClient()
|
|
81
|
+
return useMutation({
|
|
82
|
+
mutationFn: (data: { alias?: string; username?: string; password?: string; domain?: string; notes?: string }) =>
|
|
83
|
+
vaultApi.createEntry(vaultId, data),
|
|
84
|
+
onSuccess: () => {
|
|
85
|
+
qc.invalidateQueries({ queryKey: vaultKeys.entries(vaultId) })
|
|
86
|
+
toast.success('Credential saved')
|
|
87
|
+
},
|
|
88
|
+
onError: (err: any) => {
|
|
89
|
+
const msg = err.response?.data?.error || 'Failed to save credential'
|
|
90
|
+
toast.error(msg)
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useUpdateEntry(vaultId: string) {
|
|
96
|
+
const qc = useQueryClient()
|
|
97
|
+
return useMutation({
|
|
98
|
+
mutationFn: ({ entryId, data }: { entryId: string; data: any }) =>
|
|
99
|
+
vaultApi.updateEntry(vaultId, entryId, data),
|
|
100
|
+
onSuccess: () => {
|
|
101
|
+
qc.invalidateQueries({ queryKey: vaultKeys.entries(vaultId) })
|
|
102
|
+
toast.success('Credential updated')
|
|
103
|
+
},
|
|
104
|
+
onError: (err: any) => {
|
|
105
|
+
const msg = err.response?.data?.error || 'Failed to update credential'
|
|
106
|
+
toast.error(msg)
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useDeleteEntry(vaultId: string) {
|
|
112
|
+
const qc = useQueryClient()
|
|
113
|
+
return useMutation({
|
|
114
|
+
mutationFn: (entryId: string) => vaultApi.deleteEntry(vaultId, entryId),
|
|
115
|
+
onSuccess: () => {
|
|
116
|
+
qc.invalidateQueries({ queryKey: vaultKeys.entries(vaultId) })
|
|
117
|
+
toast.success('Credential deleted')
|
|
118
|
+
},
|
|
119
|
+
onError: () => toast.error('Failed to delete credential'),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Grants ────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export function useGrants() {
|
|
126
|
+
return useQuery({
|
|
127
|
+
queryKey: vaultKeys.grants(),
|
|
128
|
+
queryFn: vaultApi.listGrants,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function useCreateGrant() {
|
|
133
|
+
const qc = useQueryClient()
|
|
134
|
+
return useMutation({
|
|
135
|
+
mutationFn: (data: {
|
|
136
|
+
scope: 'vault' | 'entry'
|
|
137
|
+
scope_id: string
|
|
138
|
+
grantee_id: string
|
|
139
|
+
expires_in: ExpiresIn
|
|
140
|
+
}) => vaultApi.createGrant(data),
|
|
141
|
+
onSuccess: (_, _vars) => {
|
|
142
|
+
qc.invalidateQueries({ queryKey: vaultKeys.grants() })
|
|
143
|
+
toast.success('Access granted')
|
|
144
|
+
},
|
|
145
|
+
onError: () => toast.error('Failed to grant access'),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function useRevokeGrant() {
|
|
150
|
+
const qc = useQueryClient()
|
|
151
|
+
return useMutation({
|
|
152
|
+
mutationFn: (id: string) => vaultApi.revokeGrant(id),
|
|
153
|
+
onSuccess: () => {
|
|
154
|
+
qc.invalidateQueries({ queryKey: vaultKeys.grants() })
|
|
155
|
+
toast.success('Access revoked')
|
|
156
|
+
},
|
|
157
|
+
onError: () => toast.error('Failed to revoke access'),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Stats ─────────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export function useVaultStats() {
|
|
164
|
+
return useQuery({
|
|
165
|
+
queryKey: vaultKeys.stats(),
|
|
166
|
+
queryFn: vaultApi.stats,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Audit ─────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export function useAuditLogs(params: { page?: number; limit?: number; search?: string; module?: string, action?: string }) {
|
|
173
|
+
return useQuery({
|
|
174
|
+
queryKey: vaultKeys.audit(params),
|
|
175
|
+
queryFn: () => vaultApi.listAuditLogs(params),
|
|
176
|
+
})
|
|
177
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vault-client — main package entry point
|
|
3
|
+
* ─────────────────────────────────────────
|
|
4
|
+
* All public exports. Import what you need:
|
|
5
|
+
*
|
|
6
|
+
* Core integration (MeyiConnect pattern):
|
|
7
|
+
* import { VaultProvider, VaultApp, vaultNavItems } from 'vault-client'
|
|
8
|
+
* import 'vault-client/style.css'
|
|
9
|
+
*
|
|
10
|
+
* Individual pages (if you manage routing yourself):
|
|
11
|
+
* import { VaultPage, EntriesPage } from 'vault-client'
|
|
12
|
+
*
|
|
13
|
+
* Hooks only (bring your own UI):
|
|
14
|
+
* import { useVaults, useEntries, useCreateGrant } from 'vault-client'
|
|
15
|
+
*
|
|
16
|
+
* Types:
|
|
17
|
+
* import type { Vault, VaultEntry, Grant } from 'vault-client'
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ── Styles (emitted as dist/style.css) ────────────────────────────────────────
|
|
21
|
+
import './style.css'
|
|
22
|
+
|
|
23
|
+
// ── Provider — must wrap all vault UI ─────────────────────────────────────────
|
|
24
|
+
export { VaultProvider } from './context/VaultProvider'
|
|
25
|
+
export type { VaultProviderProps } from './context/VaultProvider'
|
|
26
|
+
|
|
27
|
+
// ── Complete self-contained app ───────────────────────────────────────────────
|
|
28
|
+
export { VaultApp } from './VaultApp'
|
|
29
|
+
|
|
30
|
+
// ── Individual pages (host manages routing) ───────────────────────────────────
|
|
31
|
+
export { VaultPage } from './pages/VaultPage'
|
|
32
|
+
export { EntriesPage } from './pages/EntriesPage'
|
|
33
|
+
|
|
34
|
+
// ── Dialogs (compose your own UI) ─────────────────────────────────────────────
|
|
35
|
+
export {
|
|
36
|
+
VaultCreateDialog,
|
|
37
|
+
EntryFormDialog,
|
|
38
|
+
GrantAccessDialog,
|
|
39
|
+
ConfirmDialog,
|
|
40
|
+
} from './components/dialogs'
|
|
41
|
+
|
|
42
|
+
// ── React Query hooks (full control) ─────────────────────────────────────────
|
|
43
|
+
export {
|
|
44
|
+
useVaults,
|
|
45
|
+
useCreateVault,
|
|
46
|
+
useUpdateVault,
|
|
47
|
+
useDeleteVault,
|
|
48
|
+
useEntries,
|
|
49
|
+
useCreateEntry,
|
|
50
|
+
useUpdateEntry,
|
|
51
|
+
useDeleteEntry,
|
|
52
|
+
useGrants,
|
|
53
|
+
useCreateGrant,
|
|
54
|
+
useRevokeGrant,
|
|
55
|
+
useVaultStats,
|
|
56
|
+
vaultKeys,
|
|
57
|
+
} from './hooks/useVault'
|
|
58
|
+
|
|
59
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
60
|
+
export type {
|
|
61
|
+
Vault,
|
|
62
|
+
VaultEntry,
|
|
63
|
+
Grant,
|
|
64
|
+
ExpiresIn,
|
|
65
|
+
} from './services/api'
|
|
66
|
+
|
|
67
|
+
// ── Sidebar nav items for MeyiConnect sidebar-data.ts ────────────────────────
|
|
68
|
+
// Usage: import { vaultNavItems } from 'vault-client'
|
|
69
|
+
// then spread into navGroups in sidebar-data.ts
|
|
70
|
+
export const vaultNavItems = [
|
|
71
|
+
{
|
|
72
|
+
title: 'Vaults',
|
|
73
|
+
url: '/vault',
|
|
74
|
+
icon: 'FolderLock',
|
|
75
|
+
roles: ['admin', 'user'],
|
|
76
|
+
plugin: 'vault',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: 'Audit',
|
|
80
|
+
url: '/vault/audit',
|
|
81
|
+
icon: 'ScrollText',
|
|
82
|
+
roles: ['admin'],
|
|
83
|
+
plugin: 'vault',
|
|
84
|
+
},
|
|
85
|
+
] as const
|
|
86
|
+
|
|
87
|
+
// ── Plugin metadata (used by MeyiConnect plugin registry) ────────────────────
|
|
88
|
+
export const vaultPluginMeta = {
|
|
89
|
+
name: 'vault',
|
|
90
|
+
displayName: 'Password Vault',
|
|
91
|
+
description: 'Self-hosted AES-256-GCM encrypted team password manager',
|
|
92
|
+
version: '1.0.0',
|
|
93
|
+
icon: 'FolderLock',
|
|
94
|
+
author: 'Meyi Technologies',
|
|
95
|
+
requires: {
|
|
96
|
+
server: 'vault-server',
|
|
97
|
+
minServerVersion: '1.0.0',
|
|
98
|
+
},
|
|
99
|
+
}
|