feat(admin): create users by mobile number instead of email

Admin user management now creates accounts with a required mobile
number and an optional contact email, matching mobile-only auth.
Admin-created accounts are pre-verified (phoneVerified) so the user
can sign in by password immediately. The user list and search now
include phone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-17 12:34:43 +03:30
parent 37f64435f6
commit a3b7e9055c
5 changed files with 63 additions and 15 deletions
+7 -2
View File
@@ -19,8 +19,8 @@ import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums'; import { UserRole } from '../common/enums';
class AdminCreateUserDto { class AdminCreateUserDto {
@IsEmail() @IsString()
email: string; phone: string;
@IsString() @IsString()
@MinLength(8) @MinLength(8)
@@ -35,6 +35,11 @@ class AdminCreateUserDto {
@MinLength(1) @MinLength(1)
lastName: string; lastName: string;
// Optional contact email (not a login identifier).
@IsOptional()
@IsEmail()
email?: string;
@IsOptional() @IsOptional()
@IsEnum(UserRole) @IsEnum(UserRole)
role?: UserRole; role?: UserRole;
+25 -5
View File
@@ -11,6 +11,7 @@ import { Repository, ILike } from 'typeorm';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
import { UserRole } from '../common/enums'; import { UserRole } from '../common/enums';
import { normalizeIranMobile } from '../common/phone.util';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -31,21 +32,37 @@ export class UsersService {
* Admin create user — hashes password, checks duplicate email * Admin create user — hashes password, checks duplicate email
*/ */
async adminCreate(data: { async adminCreate(data: {
email: string; phone: string;
password: string; password: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
email?: string;
role?: UserRole; role?: UserRole;
}): Promise<Omit<User, 'password'>> { }): Promise<Omit<User, 'password'>> {
const existing = await this.findByEmail(data.email); const phone = normalizeIranMobile(data.phone);
if (existing) { if (!phone) {
throw new ConflictException('Email already registered'); throw new BadRequestException('Invalid mobile number');
} }
if (await this.findByPhone(phone)) {
throw new ConflictException('Mobile number already registered');
}
const email = data.email ? data.email.trim().toLowerCase() : null;
if (email && (await this.findByEmail(email))) {
throw new ConflictException('Email already in use');
}
const hashedPassword = await bcrypt.hash(data.password, 12); const hashedPassword = await bcrypt.hash(data.password, 12);
const user = await this.create({ const user = await this.create({
...data, phone,
email,
firstName: data.firstName,
lastName: data.lastName,
password: hashedPassword, password: hashedPassword,
role: data.role || UserRole.USER, role: data.role || UserRole.USER,
// Admin-created accounts are trusted — the phone is pre-verified so the
// user can sign in by password immediately, no OTP step.
phoneVerified: true,
}); });
const { password, ...result } = user; const { password, ...result } = user;
return result as Omit<User, 'password'>; return result as Omit<User, 'password'>;
@@ -81,6 +98,7 @@ export class UsersService {
async findAll(search?: string): Promise<any[]> { async findAll(search?: string): Promise<any[]> {
const where = search const where = search
? [ ? [
{ phone: ILike(`%${search}%`) },
{ email: ILike(`%${search}%`) }, { email: ILike(`%${search}%`) },
{ firstName: ILike(`%${search}%`) }, { firstName: ILike(`%${search}%`) },
{ lastName: ILike(`%${search}%`) }, { lastName: ILike(`%${search}%`) },
@@ -91,6 +109,7 @@ export class UsersService {
where, where,
select: { select: {
id: true, id: true,
phone: true,
email: true, email: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
@@ -105,6 +124,7 @@ export class UsersService {
return users.map((u) => ({ return users.map((u) => ({
id: u.id, id: u.id,
phone: u.phone,
email: u.email, email: u.email,
firstName: u.firstName, firstName: u.firstName,
lastName: u.lastName, lastName: u.lastName,
@@ -31,6 +31,7 @@ export default function AdminUsersPage() {
const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null); const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null);
const [pwdModalPassword, setPwdModalPassword] = useState(''); const [pwdModalPassword, setPwdModalPassword] = useState('');
const [form, setForm] = useState({ const [form, setForm] = useState({
phone: '',
email: '', email: '',
password: '', password: '',
firstName: '', firstName: '',
@@ -50,7 +51,7 @@ export default function AdminUsersPage() {
queryClient.invalidateQueries({ queryKey: ['admin-users'] }); queryClient.invalidateQueries({ queryKey: ['admin-users'] });
notify.success(u.createdSuccess); notify.success(u.createdSuccess);
setShowForm(false); setShowForm(false);
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' }); setForm({ phone: '', email: '', password: '', firstName: '', lastName: '', role: 'user' });
}, },
onError: (err: any) => { onError: (err: any) => {
notify.error(err, u.createFailed); notify.error(err, u.createFailed);
@@ -141,10 +142,22 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{u.email}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{u.phone}</label>
<input
className="input-field"
type="tel"
dir="ltr"
placeholder={u.phonePlaceholder}
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{u.emailOptional}</label>
<input <input
className="input-field" className="input-field"
type="email" type="email"
dir="ltr"
placeholder={u.emailPlaceholder} placeholder={u.emailPlaceholder}
value={form.email} value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })} onChange={(e) => setForm({ ...form, email: e.target.value })}
@@ -179,7 +192,7 @@ export default function AdminUsersPage() {
</div> </div>
<button <button
onClick={() => createUser.mutate(form)} onClick={() => createUser.mutate(form)}
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending} disabled={!form.phone || !form.password || !form.firstName || !form.lastName || createUser.isPending}
className="btn-primary disabled:opacity-50" className="btn-primary disabled:opacity-50"
> >
{createUser.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> {u.creating}</> : u.createUser} {createUser.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> {u.creating}</> : u.createUser}
@@ -224,6 +237,7 @@ export default function AdminUsersPage() {
<thead className="bg-gray-50/80"> <thead className="bg-gray-50/80">
<tr> <tr>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colUser}</th> <th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colUser}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colPhone}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colEmail}</th> <th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colEmail}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colRole}</th> <th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colRole}</th>
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colStatus}</th> <th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{u.colStatus}</th>
@@ -238,7 +252,8 @@ export default function AdminUsersPage() {
<td className="px-6 py-4 text-sm font-medium text-gray-900"> <td className="px-6 py-4 text-sm font-medium text-gray-900">
{user.firstName} {user.lastName} {user.firstName} {user.lastName}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td> <td className="px-6 py-4 text-sm text-gray-600" dir="ltr">{user.phone || '—'}</td>
<td className="px-6 py-4 text-sm text-gray-600" dir="ltr">{user.email || '—'}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{isAdmin ? ( {isAdmin ? (
<Select <Select
@@ -306,7 +321,7 @@ export default function AdminUsersPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3> <h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
<p className="text-sm text-gray-500">{user.email}</p> <p className="text-sm text-gray-500" dir="ltr">{user.phone || user.email || '—'}</p>
</div> </div>
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}> <span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
{user.isActive ? u.active : u.inactive} {user.isActive ? u.active : u.inactive}
@@ -375,7 +390,7 @@ export default function AdminUsersPage() {
<strong> <strong>
{pwdModalUser.firstName} {pwdModalUser.lastName} {pwdModalUser.firstName} {pwdModalUser.lastName}
</strong>{' '} </strong>{' '}
({pwdModalUser.email}) (<span dir="ltr">{pwdModalUser.phone || pwdModalUser.email}</span>)
</p> </p>
<input <input
type="password" type="password"
+5 -1
View File
@@ -754,20 +754,24 @@ const en: Dictionary = {
createNewUser: 'Create New User', createNewUser: 'Create New User',
firstName: 'First Name', firstName: 'First Name',
lastName: 'Last Name', lastName: 'Last Name',
phone: 'Mobile number',
email: 'Email', email: 'Email',
emailOptional: 'Email (optional)',
password: 'Password', password: 'Password',
firstNamePlaceholder: 'John', firstNamePlaceholder: 'John',
lastNamePlaceholder: 'Doe', lastNamePlaceholder: 'Doe',
phonePlaceholder: '09123456789',
emailPlaceholder: 'john@example.com', emailPlaceholder: 'john@example.com',
passwordMin: 'Min 8 characters', passwordMin: 'Min 8 characters',
role: 'Role', role: 'Role',
roles: { user: 'User', admin: 'Admin', technical: 'Technical', sales: 'Sales' }, roles: { user: 'User', admin: 'Admin', technical: 'Technical', sales: 'Sales' },
creating: 'Creating...', creating: 'Creating...',
createUser: 'Create User', createUser: 'Create User',
searchPlaceholder: 'Search by name or email...', searchPlaceholder: 'Search by name, mobile or email...',
noUsersSearch: 'No users found matching your search.', noUsersSearch: 'No users found matching your search.',
noUsers: 'No users yet.', noUsers: 'No users yet.',
colUser: 'User', colUser: 'User',
colPhone: 'Mobile',
colEmail: 'Email', colEmail: 'Email',
colRole: 'Role', colRole: 'Role',
colStatus: 'Status', colStatus: 'Status',
+5 -1
View File
@@ -753,20 +753,24 @@ const fa = {
createNewUser: 'ساخت کاربر جدید', createNewUser: 'ساخت کاربر جدید',
firstName: 'نام', firstName: 'نام',
lastName: 'نام خانوادگی', lastName: 'نام خانوادگی',
phone: 'شماره موبایل',
email: 'ایمیل', email: 'ایمیل',
emailOptional: 'ایمیل (اختیاری)',
password: 'رمز عبور', password: 'رمز عبور',
firstNamePlaceholder: 'مثلاً علی', firstNamePlaceholder: 'مثلاً علی',
lastNamePlaceholder: 'مثلاً رضایی', lastNamePlaceholder: 'مثلاً رضایی',
phonePlaceholder: '09123456789',
emailPlaceholder: 'name@example.com', emailPlaceholder: 'name@example.com',
passwordMin: 'حداقل ۸ کاراکتر', passwordMin: 'حداقل ۸ کاراکتر',
role: 'نقش', role: 'نقش',
roles: { user: 'کاربر', admin: 'مدیر', technical: 'فنی', sales: 'فروش' }, roles: { user: 'کاربر', admin: 'مدیر', technical: 'فنی', sales: 'فروش' },
creating: 'در حال ساخت…', creating: 'در حال ساخت…',
createUser: 'ساخت کاربر', createUser: 'ساخت کاربر',
searchPlaceholder: 'جستجو بر اساس نام یا ایمیل…', searchPlaceholder: 'جستجو بر اساس نام، موبایل یا ایمیل…',
noUsersSearch: 'کاربری مطابق جستجو پیدا نشد.', noUsersSearch: 'کاربری مطابق جستجو پیدا نشد.',
noUsers: 'هنوز کاربری نیست.', noUsers: 'هنوز کاربری نیست.',
colUser: 'کاربر', colUser: 'کاربر',
colPhone: 'موبایل',
colEmail: 'ایمیل', colEmail: 'ایمیل',
colRole: 'نقش', colRole: 'نقش',
colStatus: 'وضعیت', colStatus: 'وضعیت',