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,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
|
+
}
|