Files
pn-new-crm/src/components/admin/users/user-form.tsx
Matt 93989b1e1d
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m24s
feat(admin): single Sales role, welcome-email password setup, Director=sales
- 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>
2026-06-22 12:40:55 +02:00

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 &amp; 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&apos;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&apos;s sign-in email?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;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>
);
}