'use client';
import { formatErrorBanner } from '@/lib/api/toast-error';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { UserPermissionMatrix } from './user-permission-matrix';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { apiFetch } from '@/lib/api/client';
import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
interface Role {
id: string;
name: string;
}
interface UserFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: {
userId: string;
displayName: string;
fullName?: string | null;
firstName?: string | null;
lastName?: string | null;
email: string;
phone: string | null;
isActive: boolean;
role: { id: string; name: string };
residentialAccess?: boolean;
} | null;
onSuccess: () => void;
}
export function UserForm(props: UserFormProps) {
return (
);
}
function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
// Derive initial first/last names from the user payload.
const initialNames = (() => {
if (!user) return { first: '', last: '' };
if (user.firstName || user.lastName) {
return { first: user.firstName ?? '', last: user.lastName ?? '' };
}
const source = user.fullName ?? user.displayName;
const parts = source.split(/\s+/);
return { first: parts[0] ?? '', last: parts.slice(1).join(' ') };
})();
// useQuery replaces the prior useEffect(fetch+setRoles) pattern.
const rolesQuery = useQuery<{ data: Role[] }>({
queryKey: ['admin', 'roles'],
queryFn: () => apiFetch('/api/v1/admin/roles'),
enabled: open,
});
const roles = rolesQuery.data?.data ?? [];
// Hide retired/owner-only system roles from the picker, but always keep the
// role the user being edited already holds so their record stays editable.
const selectableRoles = roles.filter(
(r) => !NON_ASSIGNABLE_ROLE_NAMES.has(r.name) || r.id === user?.role.id,
);
const [firstName, setFirstName] = useState(initialNames.first);
const [lastName, setLastName] = useState(initialNames.last);
const [email, setEmail] = useState(user?.email ?? '');
const [originalEmail] = useState(user?.email ?? '');
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
const [password, setPassword] = useState('');
// New users: email them a set-password link by default rather than typing a
// password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [phoneValue, setPhoneValue] = useState(
user?.phone ? { e164: user.phone, country: 'US' } : null,
);
const [roleId, setRoleId] = useState(user?.role.id ?? '');
const [isActive, setIsActive] = useState(user?.isActive ?? true);
const [residentialAccess, setResidentialAccess] = useState(user?.residentialAccess ?? false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const isEdit = !!user;
const fullName = `${firstName} ${lastName}`.trim();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Admin email change for an existing user goes through a confirmation
// dialog because it locks the original sign-in identity out - the
// submit path runs after the admin acknowledges. New-user creation
// and same-email saves go straight through.
if (isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase()) {
setEmailConfirmOpen(true);
return;
}
void persist();
}
async function persist() {
setError(null);
setLoading(true);
const phoneE164 = phoneValue?.e164 ?? null;
try {
if (isEdit) {
const emailChanged = email.trim().toLowerCase() !== originalEmail.toLowerCase();
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: {
firstName: firstName || null,
lastName: lastName || null,
fullName: fullName || displayName,
displayName,
email: emailChanged ? email.trim() : undefined,
phone: phoneE164,
roleId,
isActive,
residentialAccess,
notifyEmailChange: emailChanged ? true : undefined,
},
});
} else {
await apiFetch('/api/v1/admin/users', {
method: 'POST',
body: {
name: fullName || displayName,
firstName: firstName || null,
lastName: lastName || null,
email,
// Email mode omits the password entirely; manual mode sends it.
password: sendSetupEmail ? undefined : password,
sendSetupEmail,
displayName,
phone: phoneE164 ?? undefined,
roleId,
residentialAccess,
},
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = formatErrorBanner(err);
setError(message);
} finally {
setLoading(false);
}
}
return (
{isEdit ? 'Edit User' : 'New User'}
Profile & role
Permissions
{isEdit ? (
) : (
Save the new user first, then return here to fine-tune their permissions.
)}
Change this user's sign-in email?
You're about to change {originalEmail} to{' '}
{email}. From now on, they must sign in with
the new address. The original address will receive an automated notification
explaining that an administrator made the change.
Cancel {
e.preventDefault();
setEmailConfirmOpen(false);
void persist();
}}
disabled={loading}
>
Confirm change
);
}