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:
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'وضعیت',
|
||||||
|
|||||||
Reference in New Issue
Block a user