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>
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatRole } from '@/lib/constants';
|
||||
import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
@@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
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,
|
||||
@@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
email,
|
||||
password,
|
||||
// Email mode omits the password entirely; manual mode sends it.
|
||||
password: sendSetupEmail ? undefined : password,
|
||||
sendSetupEmail,
|
||||
displayName,
|
||||
phone: phoneE164 ?? undefined,
|
||||
roleId,
|
||||
@@ -250,18 +260,37 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<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="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">
|
||||
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
{selectableRoles.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{formatRole(r.name)}
|
||||
</SelectItem>
|
||||
|
||||
Reference in New Issue
Block a user