Files
chaos-api/web/default/src/features/users/components/users-mutate-drawer.tsx
T
t0ng7u 583da45296 refactor(ui): Improve usage log filter responsiveness and mobile UX
Refactor the usage log filter toolbar into a shared reusable component for common, drawing, and task logs. Optimize desktop filters with a responsive grid, move secondary filters into a mobile drawer, standardize filter typography, remove redundant filter icons, and add the missing i18n translations for the new drawer description.
2026-05-25 05:35:44 +08:00

476 lines
16 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
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,
SelectGroup,
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 {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
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) => {
if (!isUpdate) {
const passwordLength = data.password?.length || 0
if (passwordLength < 8 || passwordLength > 20) {
form.setError('password', {
type: 'manual',
message: t('Password must be between 8 and 20 characters'),
})
return
}
}
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={sideDrawerContentClassName('sm:max-w-[600px]')}
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<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={sideDrawerFormClassName()}
>
{/* Basic Information */}
<SideDrawerSection>
<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
items={[
{ value: '1', label: t('Common User') },
{ value: '10', label: t('Admin') },
]}
onValueChange={(value) =>
value !== null && field.onChange(parseInt(value))
}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a role')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='1'>
{t('Common User')}
</SelectItem>
<SelectItem value='10'>{t('Admin')}</SelectItem>
</SelectGroup>
</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>
)}
/>
</SideDrawerSection>
{/* Group & Quota Settings (Update only) */}
{isUpdate && (
<SideDrawerSection>
<h3 className='text-sm font-medium'>{t('Group & Quota')}</h3>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<Select
items={[
...groups.map((group) => ({
value: group,
label: group,
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a group')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{groups.map((group) => (
<SelectItem key={group} value={group}>
{group}
</SelectItem>
))}
</SelectGroup>
</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>
)}
/>
</SideDrawerSection>
)}
{/* Binding Information (Read-only) */}
{isUpdate && (
<SideDrawerSection>
<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='flex flex-col gap-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>
</SideDrawerSection>
)}
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</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}
/>
)}
</>
)
}