- Collapse the two sales roles in the create-user dropdown to one "Sales" (sales_manager relabelled). Hide super_admin + sales_agent from selection via NON_ASSIGNABLE_ROLE_NAMES; the form keeps a user's *current* role even if hidden so existing assignments stay editable. - Director becomes a senior-title twin of Sales: DIRECTOR_PERMISSIONS now equals SALES_MANAGER_PERMISSIONS (no admin/settings — Super-Admin only). Migration 0097 updates the existing global director row (idempotent, data-only; 0 users assigned on prod, so no blast radius). - Admin create-user defaults to emailing a set-password link instead of an inline password (manual entry still available via a toggle). createUserSchema: password optional + sendSetupEmail; createUser provisions with a throwaway password then triggers the set-password email. - New users get a dedicated, unique WELCOME email (crmWelcomeEmail), not the self-service "reset your password" email. A pending-welcome flag routes the shared better-auth sendResetPassword callback via account-setup-email.ts. - Phone confirmed already optional for staff accounts (no change needed). Tests: +welcome-routing, +create-user-setup; permission-matrix director block realigned to no-admin. 1662 vitest pass; tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
'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 (
|
|
<UserFormBody key={props.open ? `open:${props.user?.userId ?? 'new'}` : 'closed'} {...props} />
|
|
);
|
|
}
|
|
|
|
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<PhoneInputValue | null>(
|
|
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<string | null>(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 (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="overflow-y-auto">
|
|
<SheetHeader>
|
|
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<Tabs defaultValue="profile" className="mt-6">
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="profile" className="flex-1">
|
|
Profile & role
|
|
</TabsTrigger>
|
|
<TabsTrigger value="permissions" className="flex-1" disabled={!isEdit}>
|
|
Permissions
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="permissions" className="mt-4">
|
|
{isEdit ? (
|
|
<UserPermissionMatrix userId={user.userId} />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Save the new user first, then return here to fine-tune their permissions.
|
|
</p>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="profile" className="mt-4">
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-first-name">First name</Label>
|
|
<Input
|
|
id="user-first-name"
|
|
value={firstName}
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
placeholder="Jane"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-last-name">Last name</Label>
|
|
<Input
|
|
id="user-last-name"
|
|
value={lastName}
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
placeholder="Doe"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-display-name">Display name</Label>
|
|
<Input
|
|
id="user-display-name"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
placeholder={fullName || 'Jane Doe'}
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
How this user appears across the app - usually their full name, but they can pick
|
|
a nickname.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-email">Email</Label>
|
|
<Input
|
|
id="user-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="user@example.com"
|
|
required
|
|
/>
|
|
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
|
|
<p className="text-xs text-amber-600">
|
|
You'll be asked to confirm - the original address will receive an automated
|
|
notice that you, the admin, changed their sign-in email.
|
|
</p>
|
|
) : isEdit ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Changing this address is an admin-only override; the user will be notified at
|
|
the old address.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{!isEdit && (
|
|
<>
|
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
<div>
|
|
<Label htmlFor="user-setup-email">Email a set-password link</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
The user gets an email to choose their own password. Turn off to set one
|
|
here instead.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="user-setup-email"
|
|
checked={sendSetupEmail}
|
|
onCheckedChange={setSendSetupEmail}
|
|
/>
|
|
</div>
|
|
|
|
{!sendSetupEmail && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-password">Password</Label>
|
|
<Input
|
|
id="user-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Min 12 characters"
|
|
minLength={12}
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-phone">Phone</Label>
|
|
<PhoneInput
|
|
id="user-phone"
|
|
value={phoneValue}
|
|
onChange={setPhoneValue}
|
|
placeholder="Phone number"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="user-role">Role</Label>
|
|
<Select value={roleId} onValueChange={setRoleId} required>
|
|
<SelectTrigger id="user-role">
|
|
<SelectValue placeholder="Select a role" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectableRoles.map((r) => (
|
|
<SelectItem key={r.id} value={r.id}>
|
|
{formatRole(r.name)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
<div>
|
|
<Label htmlFor="user-residential">Residential access</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Grant this user access to residential clients and interests in addition to their
|
|
primary role.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="user-residential"
|
|
checked={residentialAccess}
|
|
onCheckedChange={setResidentialAccess}
|
|
/>
|
|
</div>
|
|
|
|
{isEdit && (
|
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
<div>
|
|
<Label htmlFor="user-active">Account active</Label>
|
|
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
|
|
</div>
|
|
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
|
|
|
<SheetFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={loading}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
|
|
{loading ? 'Saving…' : isEdit ? 'Save changes' : 'Create user'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</form>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<AlertDialog open={emailConfirmOpen} onOpenChange={setEmailConfirmOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Change this user's sign-in email?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
You're about to change <span className="font-medium">{originalEmail}</span> to{' '}
|
|
<span className="font-medium">{email}</span>. 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.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setEmailConfirmOpen(false);
|
|
void persist();
|
|
}}
|
|
disabled={loading}
|
|
>
|
|
Confirm change
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|