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,467 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { useForm } from 'react-hook-form'
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { z } from 'zod'
5
+ import { Eye, EyeOff, Loader2, Shield} from 'lucide-react'
6
+ import { useQuery } from '@tanstack/react-query'
7
+ import { useVaultContext } from '@/context/VaultProvider'
8
+ import { useCreateVault, useUpdateVault, useCreateEntry, useUpdateEntry, useCreateGrant, useMe } from '@/hooks/useVault'
9
+ import type { Vault, VaultEntry } from '@/services/api'
10
+ import { SearchableMultiSelect } from './searchable-multi-select'
11
+ import { DateTimePicker } from './datetime-picker'
12
+
13
+ // ── Shared primitives (inline so no shadcn dep required) ─────────────────────
14
+
15
+ function cn(...classes: (string | undefined | false)[]) {
16
+ return classes.filter(Boolean).join(' ')
17
+ }
18
+
19
+ function Overlay({ onClick }: { onClick: () => void }) {
20
+ return (
21
+ <div
22
+ className='fixed inset-0 z-50 bg-black/25 backdrop-blur-sm'
23
+ onClick={onClick}
24
+ />
25
+ )
26
+ }
27
+
28
+ function Modal({ children, className }: { children: React.ReactNode, className?: string }) {
29
+ return (
30
+ <div className={cn('fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl border bg-background p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200', className)}>
31
+ {children}
32
+ </div>
33
+ )
34
+ }
35
+
36
+ function ModalHeader({ title, description }: { title: string; description?: string }) {
37
+ return (
38
+ <div className='mb-5'>
39
+ <h2 className='text-lg font-semibold'>{title}</h2>
40
+ {description && <p className='text-muted-foreground mt-1 text-sm'>{description}</p>}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ function Field({ label, error, children, icon: Icon }: { label: string; error?: string; children: React.ReactNode, icon?: any }) {
46
+ return (
47
+ <div className='space-y-1.5'>
48
+ <div className='flex items-center gap-2'>
49
+ {Icon && <Icon className='h-3.5 w-3.5 text-muted-foreground' />}
50
+ <label className='text-sm font-medium'>{label}</label>
51
+ </div>
52
+ {children}
53
+ {error && <p className='text-destructive text-xs'>{error}</p>}
54
+ </div>
55
+ )
56
+ }
57
+
58
+ function Inp(props: React.InputHTMLAttributes<HTMLInputElement>) {
59
+ return (
60
+ <input
61
+ {...props}
62
+ className={cn(
63
+ 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
64
+ props.className
65
+ )}
66
+ />
67
+ )
68
+ }
69
+
70
+ function Btn({
71
+ children, variant = 'default', className, ...props
72
+ }: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'default' | 'outline' | 'ghost' }) {
73
+ const base = 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2'
74
+ const variants = {
75
+ default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
76
+ outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
77
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
78
+ }
79
+ return (
80
+ <button {...props} className={cn(base, variants[variant], className)}>
81
+ {children}
82
+ </button>
83
+ )
84
+ }
85
+
86
+ function ModalFooter({ children, className }: { children: React.ReactNode, className?: string }) {
87
+ return <div className={cn('mt-6 flex justify-end gap-2', className)}>{children}</div>
88
+ }
89
+
90
+ // ── VaultCreateDialog ─────────────────────────────────────────────────────────
91
+
92
+ const vaultSchema = z.object({ name: z.string().min(1, 'Name required').max(200) })
93
+ type VaultForm = z.infer<typeof vaultSchema>
94
+
95
+ export function VaultCreateDialog({ open, onOpenChange, editVault }: {
96
+ open: boolean
97
+ onOpenChange: (o: boolean) => void
98
+ editVault?: Vault
99
+ }) {
100
+ const createVault = useCreateVault()
101
+ const updateVault = useUpdateVault()
102
+ const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<VaultForm>({
103
+ resolver: zodResolver(vaultSchema),
104
+ defaultValues: editVault ? { name: editVault.name } : { name: '' }
105
+ })
106
+
107
+ useEffect(() => {
108
+ if (editVault) reset({ name: editVault.name })
109
+ else reset({ name: '' })
110
+ }, [editVault, reset])
111
+
112
+ if (!open) return null
113
+
114
+ const onSubmit = async (values: VaultForm) => {
115
+ if (editVault) {
116
+ await updateVault.mutateAsync({ id: editVault.id, name: values.name })
117
+ } else {
118
+ await createVault.mutateAsync(values.name)
119
+ }
120
+ reset()
121
+ onOpenChange(false)
122
+ }
123
+
124
+ return (
125
+ <>
126
+ <Overlay onClick={() => onOpenChange(false)} />
127
+ <Modal>
128
+ <ModalHeader
129
+ title={editVault ? 'Edit Vault' : 'Create Vault'}
130
+ description={editVault ? 'Update the name of your vault.' : 'A vault holds your encrypted credentials.'}
131
+ />
132
+ <form onSubmit={handleSubmit(onSubmit)} className='space-y-4'>
133
+ <Field label='Vault Name' error={errors.name?.message}>
134
+ <Inp placeholder='e.g. Production' {...register('name')} />
135
+ </Field>
136
+ <ModalFooter>
137
+ <Btn variant='outline' type='button' onClick={() => { reset(); onOpenChange(false) }}>Cancel</Btn>
138
+ <Btn type='submit' disabled={isSubmitting}>
139
+ {isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
140
+ {editVault ? 'Save Changes' : 'Create'}
141
+ </Btn>
142
+ </ModalFooter>
143
+ </form>
144
+ </Modal>
145
+ </>
146
+ )
147
+ }
148
+
149
+ // ── EntryFormDialog ────────────────────────────────────────────────────────────
150
+
151
+ const entrySchema = z.object({
152
+ domain: z.string().max(253).optional().or(z.literal('')),
153
+ username: z.string().optional().or(z.literal('')),
154
+ password: z.string().optional().or(z.literal('')),
155
+ alias: z.string().max(200).optional().or(z.literal('')),
156
+ notes: z.string().optional().or(z.literal('')),
157
+ }).refine(data => {
158
+ return data.domain?.trim() || data.username?.trim() || data.password?.trim()
159
+ }, {
160
+ message: "At least one field (Domain, Username, or Password) must have a value",
161
+ path: ["domain"] // We point to domain as a generic target for the error
162
+ })
163
+
164
+ type EntryForm = z.infer<typeof entrySchema>
165
+
166
+ export function EntryFormDialog({ open, onOpenChange, vaultId, editEntry }: {
167
+ open: boolean
168
+ onOpenChange: (o: boolean) => void
169
+ vaultId: string
170
+ editEntry?: VaultEntry
171
+ }) {
172
+ const [showPw, setShowPw] = useState(false)
173
+ const createEntry = useCreateEntry(vaultId)
174
+ const updateEntry = useUpdateEntry(vaultId)
175
+
176
+ const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<EntryForm>({
177
+ resolver: zodResolver(entrySchema),
178
+ defaultValues: editEntry
179
+ ? { alias: editEntry.alias, domain: editEntry.domain, username: editEntry.username, password: editEntry.password || '', notes: editEntry.notes }
180
+ : { alias: '', domain: '', username: '', password: '', notes: '' },
181
+ })
182
+
183
+ useEffect(() => {
184
+ if (editEntry) {
185
+ reset({
186
+ alias: editEntry.alias || '',
187
+ domain: editEntry.domain || '',
188
+ username: editEntry.username || '',
189
+ password: editEntry.password || '',
190
+ notes: editEntry.notes || ''
191
+ })
192
+ } else {
193
+ reset({ alias: '', domain: '', username: '', password: '', notes: '' })
194
+ }
195
+ }, [editEntry, reset])
196
+
197
+ if (!open) return null
198
+
199
+ const onSubmit = async (values: EntryForm) => {
200
+ if (editEntry) {
201
+ await updateEntry.mutateAsync({ entryId: editEntry.id, data: values })
202
+ } else {
203
+ await createEntry.mutateAsync(values)
204
+ }
205
+ reset()
206
+ onOpenChange(false)
207
+ }
208
+
209
+ return (
210
+ <>
211
+ <Overlay onClick={() => onOpenChange(false)} />
212
+ <Modal>
213
+ <ModalHeader
214
+ title={editEntry ? 'Edit Credential' : 'New Credential'}
215
+ description='Credentials are encrypted with AES-256-GCM before storage.'
216
+ />
217
+ <form onSubmit={handleSubmit(onSubmit)} className='space-y-4'>
218
+ <Field label='Domain / URL' error={errors.domain?.message}>
219
+ <Inp placeholder='e.g. amazon.com' {...register('domain')} />
220
+ </Field>
221
+
222
+ <Field label='Username / Email' error={errors.username?.message}>
223
+ <Inp placeholder='admin@example.com' {...register('username')} />
224
+ </Field>
225
+
226
+ <Field label='Password' error={errors.password?.message}>
227
+ <div className='relative'>
228
+ <Inp
229
+ type={showPw ? 'text' : 'password'}
230
+ placeholder='••••••••••'
231
+ className='pr-12'
232
+ {...register('password')}
233
+ />
234
+
235
+ <button
236
+ type='button'
237
+ onClick={() => setShowPw(!showPw)}
238
+ className='absolute inset-y-0 right-1 flex items-center pr-4 text-muted-foreground hover:text-foreground'
239
+ >
240
+ {showPw ? (
241
+ <EyeOff className='h-4 w-4' />
242
+ ) : (
243
+ <Eye className='h-4 w-4' />
244
+ )}
245
+ </button>
246
+ </div>
247
+ </Field>
248
+
249
+ <Field label='Alias / Label' error={errors.alias?.message}>
250
+ <Inp placeholder='e.g. Work Admin' {...register('alias')} />
251
+ </Field>
252
+
253
+ <Field label='Notes (optional)'>
254
+ <textarea
255
+ rows={2}
256
+ placeholder='Any additional notes...'
257
+ className='flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none'
258
+ {...register('notes')}
259
+ />
260
+ </Field>
261
+
262
+ <ModalFooter>
263
+ <Btn variant='outline' type='button' onClick={() => { reset(); onOpenChange(false) }}>Cancel</Btn>
264
+ <Btn type='submit' disabled={isSubmitting}>
265
+ {isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
266
+ {editEntry ? 'Save Changes' : 'Create'}
267
+ </Btn>
268
+ </ModalFooter>
269
+ </form>
270
+ </Modal>
271
+ </>
272
+ )
273
+ }
274
+
275
+ // ── GrantAccessDialog ─────────────────────────────────────────────────────────
276
+
277
+ const EXPIRY_OPTIONS = [
278
+ { label: 'Custom', value: 'custom' },
279
+ { label: 'No expiry', value: null },
280
+ { label: '1 hour', value: '1h' },
281
+ { label: '8 hours', value: '8h' },
282
+ { label: '24 hours', value: '24h' },
283
+ { label: '7 days', value: '7d' },
284
+ { label: '30 days', value: '30d' }
285
+ ] as const
286
+
287
+ export function GrantAccessDialog({ open, onOpenChange, scope, scopeId, scopeLabel }: {
288
+ open: boolean
289
+ onOpenChange: (o: boolean) => void
290
+ scope: 'vault' | 'entry'
291
+ scopeId: string
292
+ scopeLabel?: string
293
+ }) {
294
+ const { getUsers } = useVaultContext()
295
+ const { data: me } = useMe()
296
+ const createGrant = useCreateGrant()
297
+ const [selectedIds, setSelectedIds] = useState<string[]>([])
298
+ const [expiresIn, setExpiresIn] = useState<string | null>('custom')
299
+ const [customDate, setCustomDate] = useState<Date | undefined>(undefined)
300
+
301
+ const { data: users = [] } = useQuery({
302
+ queryKey: ['vault-grant-users'],
303
+ queryFn: getUsers,
304
+ enabled: open,
305
+ })
306
+
307
+ const otherUsers = users.filter(u => u.id !== me?.id)
308
+
309
+ const fetchUsers = async (search: string, page: number) => {
310
+ const filtered = otherUsers.filter(
311
+ (u) =>
312
+ u.full_name?.toLowerCase().includes(search.toLowerCase()) ||
313
+ u.email?.toLowerCase().includes(search.toLowerCase())
314
+ )
315
+ const pageSize = 20
316
+ const start = (page - 1) * pageSize
317
+ const end = start + pageSize
318
+ return {
319
+ data: filtered.slice(start, end),
320
+ hasMore: end < filtered.length
321
+ }
322
+ }
323
+
324
+ if (!open) return null
325
+
326
+ const handleGrant = async () => {
327
+ if (selectedIds.length === 0) return
328
+ const expiry = expiresIn === 'custom' && customDate ? customDate.toISOString() : expiresIn
329
+
330
+ await Promise.all(selectedIds.map(id =>
331
+ createGrant.mutateAsync({
332
+ scope,
333
+ scope_id: scopeId,
334
+ grantee_id: id,
335
+ expires_in: expiry as any
336
+ })
337
+ ))
338
+
339
+ onOpenChange(false)
340
+ setSelectedIds([])
341
+ setExpiresIn('24h')
342
+ setCustomDate(undefined)
343
+ }
344
+
345
+ return (
346
+ <>
347
+ <Overlay onClick={() => onOpenChange(false)} />
348
+ <Modal className='max-w-md'>
349
+ <ModalHeader
350
+ title='Grant Access'
351
+ description={`Provide access to ${scope}${scopeLabel ? ` "${scopeLabel}"` : ''}. Specify the time period if needed.`}
352
+ />
353
+
354
+ <div className='space-y-6 py-2'>
355
+ <div className='space-y-3'>
356
+ <label className='text-sm font-medium text-foreground flex items-center gap-2'>
357
+ Assigned Users
358
+ </label>
359
+ <SearchableMultiSelect
360
+ value={selectedIds}
361
+ onChange={setSelectedIds}
362
+ fetchData={fetchUsers}
363
+ fieldMapping={{ value: 'id', label: 'full_name' }}
364
+ placeholder='Search and select users...'
365
+ />
366
+ </div>
367
+
368
+ <div className='space-y-4'>
369
+ <label className='text-sm font-medium text-foreground flex items-center gap-2'>
370
+ Expiration Period
371
+ </label>
372
+
373
+ {expiresIn === 'custom' && (
374
+ <div className='pt-2 animate-in slide-in-from-top-2 duration-200'>
375
+ <DateTimePicker
376
+ value={customDate}
377
+ onChange={setCustomDate}
378
+ minDate={new Date()}
379
+ />
380
+ <p className='text-[10px] text-muted-foreground mt-2 px-1 flex items-center gap-1.5'>
381
+ <Shield className='h-3 w-3' /> Selected access will expire at the specified time.
382
+ </p>
383
+ </div>
384
+ )}
385
+ <div className='grid grid-cols-3 gap-2'>
386
+ {EXPIRY_OPTIONS.map((opt) => (
387
+ <button
388
+ key={String(opt.value)}
389
+ onClick={() => setExpiresIn(opt.value as any)}
390
+ className={cn(
391
+ 'px-2 py-2 rounded text-xs font-medium border transition-all active:scale-95',
392
+ expiresIn === opt.value
393
+ ? 'bg-primary text-primary-foreground border-primary shadow-lg shadow-primary/20'
394
+ : 'bg-card border-muted-foreground/10 hover:border-primary/50 hover:bg-card/5'
395
+ )}
396
+ >
397
+ {opt.label}
398
+ </button>
399
+ ))}
400
+ </div>
401
+ </div>
402
+ </div>
403
+
404
+ <ModalFooter className='mt-8 pt-6 border-t'>
405
+ <Btn variant='outline' className='rounded h-10 px-4' onClick={() => onOpenChange(false)}>Cancel</Btn>
406
+ <Btn
407
+ onClick={handleGrant}
408
+ disabled={selectedIds.length === 0 || createGrant.isPending || (expiresIn === 'custom' && !customDate)}
409
+ className='rounded h-10 px-4 shadow-xl shadow-primary/20 bg-primary font-bold'
410
+ >
411
+ {createGrant.isPending && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
412
+ Grant Access
413
+ </Btn>
414
+ </ModalFooter>
415
+ </Modal>
416
+ </>
417
+ )
418
+ }
419
+
420
+ export function ConfirmDialog({
421
+ open,
422
+ onOpenChange,
423
+ title,
424
+ description,
425
+ confirmText = 'Confirm',
426
+ cancelText = 'Cancel',
427
+ onConfirm,
428
+ variant = 'destructive',
429
+ loading = false,
430
+ }: {
431
+ open: boolean
432
+ onOpenChange: (o: boolean) => void
433
+ title: string
434
+ description: string
435
+ confirmText?: string
436
+ cancelText?: string
437
+ onConfirm: () => void
438
+ variant?: 'default' | 'destructive'
439
+ loading?: boolean
440
+ }) {
441
+ if (!open) return null
442
+
443
+ return (
444
+ <>
445
+ <Overlay onClick={() => onOpenChange(false)} />
446
+ <Modal className='max-w-sm'>
447
+ <ModalHeader title={title} description={description} />
448
+ <ModalFooter>
449
+ <Btn variant='outline' onClick={() => onOpenChange(false)} disabled={loading}>
450
+ {cancelText}
451
+ </Btn>
452
+ <Btn
453
+ variant={variant === 'destructive' ? 'default' : 'default'}
454
+ className={variant === 'destructive' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
455
+ onClick={() => {
456
+ onConfirm()
457
+ }}
458
+ disabled={loading}
459
+ >
460
+ {loading && <Loader2 className='h-4 w-4 animate-spin' />}
461
+ {confirmText}
462
+ </Btn>
463
+ </ModalFooter>
464
+ </Modal>
465
+ </>
466
+ )
467
+ }