| 'use client';
|
|
|
| import { useState, useEffect, useCallback } from 'react';
|
| import { z } from 'zod';
|
| import { useForm } from 'react-hook-form';
|
| import { zodResolver } from '@hookform/resolvers/zod';
|
| import { graphqlRequest } from '@/gql/graphql';
|
| import { ROLES_QUERY } from '@/gql/roles';
|
| import { useTranslation } from 'react-i18next';
|
| import { toast } from 'sonner';
|
| import { useAuthStore } from '@/stores/authStore';
|
| import { filterGrantableRoles, canEditUserPermissions } from '@/lib/permission-utils';
|
| import { passwordConfirmationSchema } from '@/lib/validation';
|
| import { Button } from '@/components/ui/button';
|
| import { Checkbox } from '@/components/ui/checkbox';
|
| import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
| import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
| import { Input } from '@/components/ui/input';
|
| import { ScopesSelect } from '@/components/scopes-select';
|
| import { User, CreateUserInput, UpdateUserInput } from '../data/schema';
|
| import { useCreateUser, useUpdateUser } from '../data/users';
|
|
|
|
|
| const createFormSchema = (t: (key: string) => string) =>
|
| z
|
| .object({
|
| firstName: z.string().min(1, t('users.validation.firstNameRequired')),
|
| lastName: z.string().min(1, t('users.validation.lastNameRequired')),
|
| email: z.email().min(1, t('users.validation.emailRequired')),
|
| password: z.string().optional(),
|
| confirmPassword: z.string().optional(),
|
| isOwner: z.boolean().optional(),
|
| roleIDs: z.array(z.string()).optional(),
|
| scopes: z.array(z.string()).optional(),
|
| })
|
| .superRefine((data, ctx) => {
|
|
|
| if (data.password || data.confirmPassword) {
|
|
|
| const passwordValidation = passwordConfirmationSchema(t).safeParse({
|
| password: data.password || '',
|
| confirmPassword: data.confirmPassword || '',
|
| });
|
|
|
| if (!passwordValidation.success) {
|
| passwordValidation.error.issues.forEach((issue) => {
|
| ctx.addIssue({
|
| code: z.ZodIssueCode.custom,
|
| message: issue.message,
|
| path: issue.path,
|
| });
|
| });
|
| }
|
| }
|
| });
|
|
|
| interface Role {
|
| id: string;
|
| name: string;
|
| description?: string;
|
| scopes?: string[];
|
| }
|
|
|
| interface Props {
|
| currentRow?: User;
|
| open: boolean;
|
| onOpenChange: (open: boolean) => void;
|
| }
|
|
|
| export function UsersActionDialog({ currentRow, open, onOpenChange }: Props) {
|
| const { t } = useTranslation();
|
| const currentUser = useAuthStore((state) => state.auth.user);
|
| const isEdit = !!currentRow;
|
| const [roles, setRoles] = useState<Role[]>([]);
|
| const [loading, setLoading] = useState(false);
|
| const [canEdit, setCanEdit] = useState(true);
|
| const [dialogContent, setDialogContent] = useState<HTMLDivElement | null>(null);
|
|
|
| const createUser = useCreateUser();
|
| const updateUser = useUpdateUser();
|
|
|
|
|
| const formSchema = createFormSchema(t);
|
| type UserForm = z.infer<typeof formSchema>;
|
|
|
|
|
| const form = useForm<UserForm>({
|
| resolver: zodResolver(formSchema),
|
| defaultValues: isEdit
|
| ? {
|
| firstName: currentRow.firstName,
|
| lastName: currentRow.lastName,
|
| email: currentRow.email,
|
| password: '',
|
| confirmPassword: '',
|
| isOwner: currentRow.isOwner,
|
| roleIDs: currentRow.roles?.edges?.map((edge) => edge.node.id) || [],
|
| scopes: currentRow.scopes || [],
|
| }
|
| : {
|
| firstName: '',
|
| lastName: '',
|
| email: '',
|
| password: '',
|
| confirmPassword: '',
|
| isOwner: false,
|
| roleIDs: [],
|
| scopes: [],
|
| },
|
| });
|
|
|
| const loadRolesAndScopes = useCallback(async () => {
|
| setLoading(true);
|
| try {
|
| const rolesData = await graphqlRequest(ROLES_QUERY, { first: 100, where: { level: 'system' } });
|
|
|
| const rolesResponse = rolesData as {
|
| roles: {
|
| edges: Array<{
|
| node: {
|
| id: string;
|
| name: string;
|
| description?: string;
|
| scopes?: string[];
|
| };
|
| }>;
|
| };
|
| };
|
|
|
| const allRoles = rolesResponse.roles.edges.map((edge) => edge.node);
|
|
|
| const grantableRoles = filterGrantableRoles(currentUser, allRoles);
|
| setRoles(grantableRoles);
|
|
|
| if (isEdit && currentRow) {
|
| const targetScopes = currentRow.scopes || [];
|
| const canEditTarget = canEditUserPermissions(currentUser, targetScopes, currentRow.isOwner || false);
|
| setCanEdit(canEditTarget);
|
| }
|
| } catch (error) {
|
| toast.error(t('common.errors.userLoadFailed'));
|
| } finally {
|
| setLoading(false);
|
| }
|
| }, [t, setRoles, currentUser, isEdit, currentRow]);
|
|
|
|
|
| useEffect(() => {
|
| if (open) {
|
| loadRolesAndScopes();
|
| }
|
| }, [open, loadRolesAndScopes, currentUser, isEdit, currentRow]);
|
|
|
| const onSubmit = async (values: UserForm) => {
|
| try {
|
| if (isEdit && currentRow) {
|
|
|
| const currentRoleIDs = currentRow.roles?.edges?.map((edge) => edge.node.id) || [];
|
| const newRoleIDs = values.roleIDs || [];
|
|
|
| const addRoleIDs = newRoleIDs.filter((id) => !currentRoleIDs.includes(id));
|
| const removeRoleIDs = currentRoleIDs.filter((id) => !newRoleIDs.includes(id));
|
|
|
| const updateInput: UpdateUserInput = {
|
| firstName: values.firstName,
|
| lastName: values.lastName,
|
| email: values.email,
|
| isOwner: values.isOwner,
|
| scopes: values.scopes,
|
| };
|
|
|
|
|
| if (addRoleIDs.length > 0) {
|
| updateInput.addRoleIDs = addRoleIDs;
|
| }
|
| if (removeRoleIDs.length > 0) {
|
| updateInput.removeRoleIDs = removeRoleIDs;
|
| }
|
|
|
| await updateUser.mutateAsync({
|
| id: currentRow.id,
|
| input: updateInput,
|
| });
|
| } else {
|
|
|
| const createInput: CreateUserInput = {
|
| firstName: values.firstName,
|
| lastName: values.lastName,
|
| email: values.email,
|
| password: values.password || '',
|
|
|
| isOwner: values.isOwner,
|
| scopes: values.scopes,
|
| roleIDs: values.roleIDs,
|
| };
|
|
|
| await createUser.mutateAsync(createInput);
|
| }
|
|
|
| form.reset();
|
| onOpenChange(false);
|
| } catch (error) {
|
| toast.error(t('common.errors.userSaveFailed'));
|
| }
|
| };
|
|
|
| const handleRoleToggle = (roleId: string) => {
|
| const currentRoles = form.getValues('roleIDs') || [];
|
| const newRoles = currentRoles.includes(roleId) ? currentRoles.filter((id: string) => id !== roleId) : [...currentRoles, roleId];
|
| form.setValue('roleIDs', newRoles);
|
| };
|
|
|
| return (
|
| <Dialog
|
| open={open}
|
| onOpenChange={(state) => {
|
| if (!state) {
|
| form.reset();
|
| }
|
| onOpenChange(state);
|
| }}
|
| >
|
| <DialogContent className='sm:max-w-2xl' ref={setDialogContent}>
|
| <DialogHeader className='text-left'>
|
| <DialogTitle>{isEdit ? t('users.dialogs.edit.title') : t('users.dialogs.add.title')}</DialogTitle>
|
| <DialogDescription>{isEdit ? t('users.dialogs.edit.description') : t('users.dialogs.add.description')}</DialogDescription>
|
| </DialogHeader>
|
|
|
| <div className='max-h-[60vh] overflow-y-auto px-1'>
|
| <Form {...form}>
|
| <form id='user-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
| <div className='grid grid-cols-2 gap-4'>
|
| <FormField
|
| control={form.control}
|
| name='firstName'
|
| render={({ field, fieldState }) => (
|
| <FormItem>
|
| <FormLabel>{t('users.form.firstName')}</FormLabel>
|
| <FormControl>
|
| <Input placeholder='John' aria-invalid={!!fieldState.error} {...field} />
|
| </FormControl>
|
| <div className='min-h-[1.25rem]'>
|
| <FormMessage />
|
| </div>
|
| </FormItem>
|
| )}
|
| />
|
| <FormField
|
| control={form.control}
|
| name='lastName'
|
| render={({ field, fieldState }) => (
|
| <FormItem>
|
| <FormLabel>{t('users.form.lastName')}</FormLabel>
|
| <FormControl>
|
| <Input placeholder='Doe' aria-invalid={!!fieldState.error} {...field} />
|
| </FormControl>
|
| <div className='min-h-[1.25rem]'>
|
| <FormMessage />
|
| </div>
|
| </FormItem>
|
| )}
|
| />
|
| </div>
|
|
|
| <FormField
|
| control={form.control}
|
| name='email'
|
| render={({ field, fieldState }) => (
|
| <FormItem>
|
| <FormLabel>{t('users.form.email')}</FormLabel>
|
| <FormControl>
|
| <Input placeholder='john.doe@example.com' aria-invalid={!!fieldState.error} {...field} />
|
| </FormControl>
|
| <div className='min-h-[1.25rem]'>
|
| <FormMessage />
|
| </div>
|
| </FormItem>
|
| )}
|
| />
|
|
|
| {/* Password fields - only show when creating new user */}
|
| {!isEdit && (
|
| <div className='grid grid-cols-2 gap-4'>
|
| <FormField
|
| control={form.control}
|
| name='password'
|
| render={({ field, fieldState }) => (
|
| <FormItem>
|
| <FormLabel>{t('users.form.password')}</FormLabel>
|
| <FormControl>
|
| <Input type='password' aria-invalid={!!fieldState.error} {...field} />
|
| </FormControl>
|
| <div className='min-h-[1.25rem]'>
|
| <FormMessage />
|
| </div>
|
| </FormItem>
|
| )}
|
| />
|
| <FormField
|
| control={form.control}
|
| name='confirmPassword'
|
| render={({ field, fieldState }) => (
|
| <FormItem>
|
| <FormLabel>{t('users.form.confirmPassword')}</FormLabel>
|
| <FormControl>
|
| <Input type='password' aria-invalid={!!fieldState.error} {...field} />
|
| </FormControl>
|
| <div className='min-h-[1.25rem]'>
|
| <FormMessage />
|
| </div>
|
| </FormItem>
|
| )}
|
| />
|
| </div>
|
| )}
|
|
|
| <FormField
|
| control={form.control}
|
| name='isOwner'
|
| render={({ field }) => (
|
| <FormItem className='flex flex-row items-start space-y-0 space-x-3'>
|
| <FormControl>
|
| <Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
| </FormControl>
|
| <div className='space-y-1 leading-none'>
|
| <FormLabel>{t('users.form.isOwner')}</FormLabel>
|
| <p className='text-muted-foreground text-sm'>{t('users.form.ownerDescription')}</p>
|
| </div>
|
| </FormItem>
|
| )}
|
| />
|
|
|
| {/* Roles Section */}
|
| <div className='space-y-3 pt-2'>
|
| <FormLabel>{t('users.form.roles')}</FormLabel>
|
| {loading ? (
|
| <div>{t('users.form.loadingRoles')}</div>
|
| ) : (
|
| <div className='grid grid-cols-2 gap-2'>
|
| {roles.map((role) => (
|
| <div key={role.id} className='flex items-center space-x-2'>
|
| <Checkbox
|
| id={`role-${role.id}`}
|
| checked={(form.watch('roleIDs') || []).includes(role.id)}
|
| onCheckedChange={() => handleRoleToggle(role.id)}
|
| />
|
| <label
|
| htmlFor={`role-${role.id}`}
|
| className='text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
| >
|
| {role.name}
|
| </label>
|
| </div>
|
| ))}
|
| </div>
|
| )}
|
| </div>
|
|
|
| {/* Scopes Section */}
|
| <FormField
|
| control={form.control}
|
| name='scopes'
|
| render={({ field }) => (
|
| <FormItem className='pt-2'>
|
| <FormLabel>{t('users.form.scopes')}</FormLabel>
|
| <FormControl>
|
| <ScopesSelect level='system' value={field.value || []} onChange={field.onChange} portalContainer={dialogContent} />
|
| </FormControl>
|
| <div className='min-h-[1.25rem]'>
|
| <FormMessage />
|
| </div>
|
| </FormItem>
|
| )}
|
| />
|
| </form>
|
| </Form>
|
| </div>
|
|
|
| <DialogFooter>
|
| {isEdit && !canEdit && <p className='text-destructive mr-auto text-sm'>{t('users.errors.insufficientPermissions')}</p>}
|
| <Button
|
| type='button'
|
| variant='outline'
|
| onClick={() => onOpenChange(false)}
|
| >
|
| {t('common.buttons.cancel')}
|
| </Button>
|
| <Button type='submit' form='user-form' disabled={createUser.isPending || updateUser.isPending || (isEdit && !canEdit)}>
|
| {createUser.isPending || updateUser.isPending ? t('common.buttons.saving') : t('common.buttons.save')}
|
| </Button>
|
| </DialogFooter>
|
| </DialogContent>
|
| </Dialog>
|
| );
|
| }
|
|
|