423 lines
14 KiB
TypeScript
Vendored
423 lines
14 KiB
TypeScript
Vendored
import { useEffect, useState } from 'react'
|
|
import { useForm } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Pencil } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
|
|
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { createUser, updateUser, getUser, getGroups } from '../api'
|
|
import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
|
import {
|
|
userFormSchema,
|
|
type UserFormValues,
|
|
USER_FORM_DEFAULT_VALUES,
|
|
transformFormDataToPayload,
|
|
transformUserToFormDefaults,
|
|
} from '../lib'
|
|
import { type User } from '../types'
|
|
import { UserQuotaDialog } from './user-quota-dialog'
|
|
import { useUsers } from './users-provider'
|
|
|
|
type UsersMutateDrawerProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
currentRow?: User
|
|
}
|
|
|
|
export function UsersMutateDrawer({
|
|
open,
|
|
onOpenChange,
|
|
currentRow,
|
|
}: UsersMutateDrawerProps) {
|
|
const { t } = useTranslation()
|
|
const isUpdate = !!currentRow
|
|
const { triggerRefresh } = useUsers()
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false)
|
|
|
|
// Fetch groups
|
|
const { data: groupsData } = useQuery({
|
|
queryKey: ['groups'],
|
|
queryFn: getGroups,
|
|
staleTime: 5 * 60 * 1000,
|
|
})
|
|
|
|
const groups = groupsData?.data || []
|
|
|
|
const form = useForm<UserFormValues>({
|
|
resolver: zodResolver(userFormSchema),
|
|
defaultValues: USER_FORM_DEFAULT_VALUES,
|
|
})
|
|
|
|
// Load existing data when updating
|
|
useEffect(() => {
|
|
if (open && isUpdate && currentRow) {
|
|
// For update, fetch fresh data
|
|
getUser(currentRow.id).then((result) => {
|
|
if (result.success && result.data) {
|
|
form.reset(transformUserToFormDefaults(result.data))
|
|
}
|
|
})
|
|
} else if (open && !isUpdate) {
|
|
// For create, reset to defaults
|
|
form.reset(USER_FORM_DEFAULT_VALUES)
|
|
}
|
|
}, [open, isUpdate, currentRow, form])
|
|
|
|
const { meta: currencyMeta } = getCurrencyDisplay()
|
|
const currencyLabel = getCurrencyLabel()
|
|
const tokensOnly = currencyMeta.kind === 'tokens'
|
|
|
|
const currentQuotaRaw = form.watch('quota_dollars') || 0
|
|
|
|
const onSubmit = async (data: UserFormValues) => {
|
|
setIsSubmitting(true)
|
|
try {
|
|
const payload = transformFormDataToPayload(data, currentRow?.id)
|
|
const result = isUpdate
|
|
? await updateUser(payload as typeof payload & { id: number })
|
|
: await createUser(payload)
|
|
|
|
if (result.success) {
|
|
toast.success(
|
|
isUpdate
|
|
? t(SUCCESS_MESSAGES.USER_UPDATED)
|
|
: t(SUCCESS_MESSAGES.USER_CREATED)
|
|
)
|
|
onOpenChange(false)
|
|
triggerRefresh()
|
|
} else {
|
|
toast.error(
|
|
result.message ||
|
|
(isUpdate
|
|
? t(ERROR_MESSAGES.UPDATE_FAILED)
|
|
: t(ERROR_MESSAGES.CREATE_FAILED))
|
|
)
|
|
}
|
|
} catch (_error) {
|
|
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const refreshUserData = async () => {
|
|
if (!currentRow) return
|
|
const result = await getUser(currentRow.id)
|
|
if (result.success && result.data) {
|
|
form.reset(transformUserToFormDefaults(result.data))
|
|
}
|
|
triggerRefresh()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Sheet
|
|
open={open}
|
|
onOpenChange={(v) => {
|
|
onOpenChange(v)
|
|
if (!v) {
|
|
form.reset()
|
|
}
|
|
}}
|
|
>
|
|
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
|
|
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
|
<SheetTitle>
|
|
{isUpdate ? t('Update') : t('Create')} {t('User')}
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
{isUpdate
|
|
? t('Update the user by providing necessary info.')
|
|
: t('Add a new user by providing necessary info.')}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<Form {...form}>
|
|
<form
|
|
id='user-form'
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
|
|
>
|
|
{/* Basic Information */}
|
|
<div className='space-y-4'>
|
|
<h3 className='text-sm font-medium'>
|
|
{t('Basic Information')}
|
|
</h3>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='username'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Username')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
{...field}
|
|
placeholder={t('Enter username')}
|
|
disabled={isUpdate}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{!isUpdate && (
|
|
<FormField
|
|
control={form.control}
|
|
name='role'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Role')}</FormLabel>
|
|
<Select
|
|
onValueChange={(value) =>
|
|
field.onChange(parseInt(value))
|
|
}
|
|
value={String(field.value)}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t('Select a role')} />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value='1'>
|
|
{t('Common User')}
|
|
</SelectItem>
|
|
<SelectItem value='10'>{t('Admin')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{t("Set the user's role (cannot be Root)")}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='display_name'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Display Name')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
{...field}
|
|
placeholder={t('Enter display name')}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Leave empty to use username')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='password'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Password')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
{...field}
|
|
type='password'
|
|
placeholder={
|
|
isUpdate
|
|
? t('Leave empty to keep unchanged')
|
|
: t('Enter password (min 8 characters)')
|
|
}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Group & Quota Settings (Update only) */}
|
|
{isUpdate && (
|
|
<div className='space-y-4'>
|
|
<h3 className='text-sm font-medium'>{t('Group & Quota')}</h3>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='group'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Group')}</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t('Select a group')} />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{groups.map((group) => (
|
|
<SelectItem key={group} value={group}>
|
|
{group}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='quota_dollars'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t('Remaining Quota ({{currency}})', {
|
|
currency: currencyLabel,
|
|
})}
|
|
</FormLabel>
|
|
<div className='flex gap-2'>
|
|
<FormControl>
|
|
<Input
|
|
value={
|
|
tokensOnly
|
|
? String(field.value || 0)
|
|
: (field.value || 0).toFixed(6)
|
|
}
|
|
readOnly
|
|
className='flex-1'
|
|
/>
|
|
</FormControl>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
onClick={() => setQuotaDialogOpen(true)}
|
|
>
|
|
<Pencil className='mr-1 h-4 w-4' />
|
|
{t('Adjust Quota')}
|
|
</Button>
|
|
</div>
|
|
<FormDescription>
|
|
{formatQuota(parseQuotaFromDollars(field.value || 0))}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='remark'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Remark')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
placeholder={t(
|
|
'Admin notes (only visible to admins)'
|
|
)}
|
|
rows={3}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Binding Information (Read-only) */}
|
|
{isUpdate && (
|
|
<div className='space-y-4'>
|
|
<h3 className='text-sm font-medium'>
|
|
{t('Binding Information')}
|
|
</h3>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{t(
|
|
'Third-party account bindings (read-only, managed by user in profile settings)'
|
|
)}
|
|
</p>
|
|
|
|
<div className='space-y-3'>
|
|
{BINDING_FIELDS.map(({ key, label }) => (
|
|
<div key={key}>
|
|
<Label className='text-muted-foreground text-xs'>
|
|
{t(label)}
|
|
</Label>
|
|
<Input
|
|
value={
|
|
(currentRow?.[key as keyof User] as string) || '-'
|
|
}
|
|
disabled
|
|
className='mt-1'
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
|
|
<SheetClose asChild>
|
|
<Button variant='outline'>{t('Close')}</Button>
|
|
</SheetClose>
|
|
<Button form='user-form' type='submit' disabled={isSubmitting}>
|
|
{isSubmitting ? t('Saving...') : t('Save changes')}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* Adjust Quota Dialog */}
|
|
{currentRow && (
|
|
<UserQuotaDialog
|
|
open={quotaDialogOpen}
|
|
onOpenChange={setQuotaDialogOpen}
|
|
userId={currentRow.id}
|
|
currentQuota={parseQuotaFromDollars(currentQuotaRaw || 0)}
|
|
onSuccess={refreshUserData}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|