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,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
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }