"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { v4 } from "uuid"; import { z } from "zod"; import { EditorSheet, FormCheckbox, FormInput, FormPassword, FormRoles, FormTextarea } from "../../../../components"; import { useCompanyContext } from "../../../../contexts"; import { Modules } from "../../../../core"; import { useI18nRouter } from "../../../../hooks"; import { Action } from "../../../../permissions"; import { getRoleId } from "../../../../roles"; import { CompanyInterface } from "../../../company/data/company.interface"; import { RoleInterface } from "../../../role"; import { RoleService } from "../../../role/data/role.service"; import { S3Interface } from "../../../s3"; import { S3Service } from "../../../s3/data/s3.service"; import { useCurrentUserContext } from "../../contexts"; import { UserInput, UserInterface } from "../../data"; import { UserService } from "../../data/user.service"; import { UserAvatarEditor } from "./UserAvatarEditor"; type UserEditorProps = { user?: UserInterface; propagateChanges?: (user: UserInterface) => void; adminCreated?: boolean; trigger?: ReactNode; onRevalidate?: (path: string) => Promise; forceShow?: boolean; onClose?: () => void; dialogOpen?: boolean; onDialogOpenChange?: (open: boolean) => void; }; function UserEditorInternal({ user, propagateChanges, adminCreated, trigger, onRevalidate, forceShow, onClose, dialogOpen, onDialogOpenChange, }: UserEditorProps) { const { company: userCompany, hasPermissionToModule, hasRole, currentUser, setUser, } = useCurrentUserContext(); const { company: companyFromContext } = useCompanyContext(); const router = useI18nRouter(); const [roles, setRoles] = useState([]); const [file, setFile] = useState(null); const [contentType, setContentType] = useState(null); const [resetImage, setResetImage] = useState(false); const t = useTranslations(); const [company, setCompany] = useState(companyFromContext || userCompany); useEffect(() => { if (!companyFromContext && userCompany) setCompany(userCompany); }, [company]); // Fetch roles when sheet opens const handleDialogOpenChange = useCallback( (open: boolean) => { if ( open && (company || currentUser?.roles.find((role: RoleInterface) => role.id === getRoleId().Administrator)) && roles.length === 0 ) { async function fetchRoles() { const allRoles = await RoleService.findAllRoles({}); const availableRoles = allRoles.filter( (role: RoleInterface) => role.id !== getRoleId().Administrator && (role.requiredFeature === undefined || company?.features.some((feature) => feature.id === role.requiredFeature?.id)), ); setRoles(availableRoles); } fetchRoles(); } onDialogOpenChange?.(open); }, [company, currentUser, roles.length, onDialogOpenChange], ); // Generate S3 path when file is selected useEffect(() => { if (file && company) { const id = form.getValues("id"); const fileType = file.type; const extension = fileType.split("/").pop() ?? ""; const timestamp = new Date().toISOString().replace(/[-:T]/g, "").split(".")[0]; const fileUrl = `companies/${company.id}/users/${id}/${id}.${timestamp}.${extension}`; form.setValue("avatar", fileUrl); setContentType(fileType); } else { setContentType(null); } }, [file, company]); const formSchema = useMemo( () => z.object({ // Any UUID version: on edit this is the PERSISTED id, and seeded/legacy // users can carry a non-v4 uuid (the migration-created Administrator is // v1). `z.uuidv4()` rejected those, and because `id` has no rendered // field the failure was invisible — handleSubmit simply never fired. // New users still get a v4 from `v4()` in getDefaultValues. id: z.uuid(), name: z.string().min(1, { message: t(`user.fields.name.error`) }), email: z.string().min(1, { message: t(`common.fields.email.error`) }), password: z.string().optional(), title: z.string().optional(), bio: z.string().optional(), phone: z.string().optional(), roleIds: z.array(z.string()).optional(), sendInvitationEmail: z.boolean().optional(), avatar: z.string().optional(), }), [t], ); const getDefaultValues = useCallback(() => { return { id: user?.id || v4(), name: user?.name || "", title: user?.title || "", bio: user?.bio || "", email: user?.email || "", phone: user?.phone || "", password: "", roleIds: user?.roles.map((role: RoleInterface) => role.id) || [], sendInvitationEmail: false, avatar: user?.avatarUrl || "", }; }, [user]); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: getDefaultValues(), }); const canChangeRoles = !(currentUser?.id === user?.id && hasRole(getRoleId().Administrator)) && (hasPermissionToModule({ module: Modules.User, action: Action.Update }) || hasRole(getRoleId().Administrator)); return ( { // Check for existing email on create if (!user) { try { const existingUser = await UserService.findByEmail({ email: values.email }); if (existingUser) { form.setError("email", { type: "manual", message: t(`user.errors.email_exists`), }); throw new Error(t(`user.errors.email_exists`)); } } catch (error) { if (error instanceof Error && error.message === t(`user.errors.email_exists`)) { throw error; } // User does not exist, proceed } } // Upload avatar to S3 if present if (values.avatar && contentType && file) { const s3: S3Interface = await S3Service.getPreSignedUrl({ key: values.avatar, contentType: contentType, isPublic: true, }); const response = await fetch(s3.url, { method: "PUT", headers: s3.headers, body: file, }); if (!response.ok) { throw new Error(`S3 upload failed: ${response.status}`); } } const payload: UserInput = { id: values.id, email: values.email, name: values.name, title: values.title, bio: values.bio, phone: values.phone, password: values.password, avatar: resetImage ? undefined : values.avatar, roleIds: values.roleIds, sendInvitationEmail: values.sendInvitationEmail, // Optional: `companyId` only sets the `x-companyid` header, and a user // can legitimately have no company (the seeded Administrator does), in // which case `company` is null here. `company!.id` threw a TypeError // and surfaced as a generic "update failed" toast. companyId: company?.id, adminCreated: adminCreated, }; const updatedUser = user ? ((await UserService.update(payload)) as UserInterface) : ((await UserService.create(payload)) as UserInterface); if (currentUser?.id === updatedUser.id) setUser(updatedUser); return updatedUser; }} onRevalidate={onRevalidate} onNavigate={(url) => router.push(url)} onReset={() => { setFile(null); setContentType(null); setResetImage(false); setRoles([]); return getDefaultValues(); }} onClose={onClose} trigger={trigger} forceShow={forceShow} dialogOpen={dialogOpen} onDialogOpenChange={handleDialogOpenChange} >
{canChangeRoles && (
{!user && ( <>
{t(`user.send_activation_email`)}
)}
)}
); } export function UserEditor(props: UserEditorProps) { const { hasPermissionToModule } = useCurrentUserContext(); if ( !hasPermissionToModule({ module: Modules.User, action: props.user ? Action.Update : Action.Create, data: props.user, }) ) return null; return ; }