Edit login suoerAdmin

This commit is contained in:
2026-09-19 22:42:51 +03:30
parent b016b4754f
commit 605e1d3340
19 changed files with 717 additions and 43 deletions
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "PlatformAdmin" ADD COLUMN IF NOT EXISTS "mobile" TEXT;
-- Make email optional (existing rows keep their email)
ALTER TABLE "PlatformAdmin" ALTER COLUMN "email" DROP NOT NULL;
-- Unique mobile
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_mobile_key" ON "PlatformAdmin"("mobile");
+3 -1
View File
@@ -282,7 +282,9 @@ model Membership {
/// حساب سوپرادمین پلتفرم — جدا از User سازمانی
model PlatformAdmin {
id String @id @default(cuid())
email String @unique
/// ورود اصلی با موبایل نرمال‌شده (مثلاً 0912…)
mobile String? @unique
email String? @unique
passwordHash String
fullName String
isActive Boolean @default(true)
+36 -8
View File
@@ -14,16 +14,18 @@ const DEFAULT_STAGES = [
] as const;
async function seedPlatformAdmin() {
const envMobile = (process.env.PLATFORM_ADMIN_MOBILE ?? '').trim();
const envEmail = (process.env.PLATFORM_ADMIN_EMAIL ?? '').trim().toLowerCase();
const envPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? '';
const envName = (process.env.PLATFORM_ADMIN_NAME ?? 'سوپرادمین').trim() || 'سوپرادمین';
const isProd = process.env.NODE_ENV === 'production';
const email = envEmail || (isProd ? '' : 'platform@demo.local');
const mobileRaw = envMobile || (isProd ? '' : '09120000000');
const password = envPassword || (isProd ? '' : 'demo1234');
const email = envEmail || null;
if (!email || !password) {
if (!mobileRaw || !password) {
console.log(
'Platform admin seed skipped (set PLATFORM_ADMIN_EMAIL and PLATFORM_ADMIN_PASSWORD)',
'Platform admin seed skipped (set PLATFORM_ADMIN_MOBILE and PLATFORM_ADMIN_PASSWORD)',
);
return;
}
@@ -32,17 +34,43 @@ async function seedPlatformAdmin() {
return;
}
const existing = await prisma.platformAdmin.findUnique({ where: { email } });
if (existing) {
console.log('Platform admin exists:', email);
// normalize: 09xxxxxxxxx
let mobile = mobileRaw.replace(/[\s\-()]/g, '');
if (mobile.startsWith('+98')) mobile = `0${mobile.slice(3)}`;
if (mobile.startsWith('98') && mobile.length === 12) mobile = `0${mobile.slice(2)}`;
if (/^9\d{9}$/.test(mobile)) mobile = `0${mobile}`;
if (!/^09\d{9}$/.test(mobile)) {
console.log('Platform admin seed skipped (PLATFORM_ADMIN_MOBILE invalid)');
return;
}
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
if (existingByMobile) {
console.log('Platform admin exists:', mobile);
return;
}
if (email) {
const existingByEmail = await prisma.platformAdmin.findUnique({ where: { email } });
if (existingByEmail) {
await prisma.platformAdmin.update({
where: { id: existingByEmail.id },
data: { mobile, ...(envName ? { fullName: envName } : {}) },
});
console.log('Platform admin linked mobile:', mobile, '→', email);
return;
}
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.platformAdmin.create({
data: { email, passwordHash, fullName: envName },
data: {
mobile,
email,
passwordHash,
fullName: envName,
},
});
console.log('Seeded platform admin:', email);
console.log('Seeded platform admin:', mobile);
}
async function main() {
+32 -2
View File
@@ -11,14 +11,44 @@ import {
import { USER_ROLES, type UserRole } from '@mento/shared';
export class PlatformLoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(6)
password!: string;
}
export class CreatePlatformAdminDto {
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
@IsOptional()
@IsEmail()
email?: string;
}
export class ResetPlatformAdminPasswordDto {
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
}
export class UpdatePlatformStatusDto {
@IsBoolean()
isActive!: boolean;
+24 -5
View File
@@ -1,7 +1,13 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformLoginDto } from './dto';
@@ -13,9 +19,12 @@ export class PlatformAuthService {
) {}
async login(dto: PlatformLoginDto): Promise<PlatformLoginResponse> {
const email = dto.email.trim().toLowerCase();
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
const admin = await this.prisma.platformAdmin.findUnique({
where: { email },
where: { mobile },
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
@@ -47,15 +56,25 @@ export class PlatformAuthService {
return this.toDto(admin);
}
private toDto(admin: {
toDto(admin: {
id: string;
email: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive?: boolean;
lastLoginAt?: Date | null;
createdAt?: Date;
}): PlatformAdminDto {
return {
id: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
...(admin.isActive !== undefined ? { isActive: admin.isActive } : {}),
...(admin.lastLoginAt !== undefined
? { lastLoginAt: admin.lastLoginAt?.toISOString() ?? null }
: {}),
...(admin.createdAt ? { createdAt: admin.createdAt.toISOString() } : {}),
};
}
}
@@ -11,7 +11,8 @@ export type PlatformJwtPayload = {
export type PlatformActor = {
platformAdminId: string;
email: string;
mobile: string | null;
email: string | null;
fullName: string;
};
@@ -39,6 +40,7 @@ export class PlatformJwtStrategy extends PassportStrategy(
where: { id: payload.sub },
select: {
id: true,
mobile: true,
email: true,
fullName: true,
isActive: true,
@@ -49,6 +51,7 @@ export class PlatformJwtStrategy extends PassportStrategy(
}
return {
platformAdminId: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
};
@@ -8,14 +8,18 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdatePlatformMembershipRoleDto,
UpdatePlatformOrganizationDto,
UpdatePlatformStatusDto,
} from './dto';
import { PlatformAuthGuard } from './platform-auth.guard';
import type { PlatformActor } from './platform-jwt.strategy';
import { PlatformService } from './platform.service';
@Controller('platform')
@@ -28,6 +32,33 @@ export class PlatformController {
return this.platform.stats();
}
@Get('admins')
listAdmins() {
return this.platform.listAdmins();
}
@Post('admins')
createAdmin(@Body() dto: CreatePlatformAdminDto) {
return this.platform.createAdmin(dto);
}
@Patch('admins/:id')
setAdminActive(
@Param('id') id: string,
@Body() dto: UpdatePlatformStatusDto,
@CurrentUser() actor: PlatformActor,
) {
return this.platform.setAdminActive(id, dto.isActive, actor.platformAdminId);
}
@Patch('admins/:id/password')
resetAdminPassword(
@Param('id') id: string,
@Body() dto: ResetPlatformAdminPasswordDto,
) {
return this.platform.resetAdminPassword(id, dto);
}
@Get('organizations')
listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q);
+130
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import type {
PlatformAdminDto,
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
@@ -19,7 +20,9 @@ import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdatePlatformOrganizationDto,
} from './dto';
@@ -472,6 +475,133 @@ export class PlatformService {
return this.getUser(row.userId);
}
async listAdmins(): Promise<PlatformAdminDto[]> {
const rows = await this.prisma.platformAdmin.findMany({
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => this.adminToDto(row));
}
async createAdmin(dto: CreatePlatformAdminDto): Promise<PlatformAdminDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const email = dto.email?.trim().toLowerCase() || null;
const existingMobile = await this.prisma.platformAdmin.findUnique({
where: { mobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const existingEmail = await this.prisma.platformAdmin.findUnique({
where: { email },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const created = await this.prisma.platformAdmin.create({
data: {
fullName,
mobile,
email,
passwordHash,
},
});
return this.adminToDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async setAdminActive(
id: string,
isActive: boolean,
actorId: string,
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (!isActive && id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
if (!isActive) {
const activeCount = await this.prisma.platformAdmin.count({
where: { isActive: true, id: { not: id } },
});
if (activeCount < 1) {
throw new BadRequestException('حداقل یک سوپرادمین فعال لازم است');
}
}
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { isActive },
});
return this.adminToDto(updated);
}
async resetAdminPassword(
id: string,
dto: ResetPlatformAdminPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.platformAdmin.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
private adminToDto(row: {
id: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
}): PlatformAdminDto {
return {
id: row.id,
mobile: row.mobile,
email: row.email,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
private orgToDto(row: {
id: string;
name: string;
+5 -1
View File
@@ -41,6 +41,10 @@ function onLogout() {
<UiIcon name="users" :size="18" />
<span>کاربران</span>
</RouterLink>
<RouterLink :to="{ name: 'adm-admins' }" class="nav-item">
<UiIcon name="lock" :size="18" />
<span>ادمین‌ها</span>
</RouterLink>
</nav>
<div class="side-foot">
@@ -160,7 +164,7 @@ function onLogout() {
}
.nav {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
flex: none;
}
+5
View File
@@ -56,6 +56,11 @@ export const router = createRouter({
name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'),
},
{
path: 'admins',
name: 'adm-admins',
component: () => import('@/views/adm/AdmAdminsView.vue'),
},
],
},
{
+2 -2
View File
@@ -28,10 +28,10 @@ export const usePlatformAuthStore = defineStore('platform-auth', () => {
localStorage.setItem(ADMIN_KEY, JSON.stringify(payload.admin));
}
async function login(email: string, password: string) {
async function login(mobile: string, password: string) {
const { data } = await platformApi.post<PlatformLoginResponse>(
'/platform/auth/login',
{ email, password },
{ mobile, password },
);
persist(data);
}
+386
View File
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type { CreatePlatformAdminRequest, PlatformAdminDto } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
const platform = usePlatformAuthStore();
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformAdminDto[]>([]);
const busyId = ref('');
const showCreate = ref(false);
const creating = ref(false);
const resetId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
const createForm = reactive<CreatePlatformAdminRequest>({
fullName: '',
mobile: '',
password: '',
email: '',
});
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری ادمین‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
function resetCreateForm() {
createForm.fullName = '';
createForm.mobile = '';
createForm.password = '';
createForm.email = '';
}
async function createAdmin() {
creating.value = true;
error.value = '';
success.value = '';
try {
const body: CreatePlatformAdminRequest = {
fullName: createForm.fullName.trim(),
mobile: createForm.mobile.trim(),
password: createForm.password,
email: createForm.email?.trim() || undefined,
};
const { data } = await platformApi.post<PlatformAdminDto>('/platform/admins', body);
items.value = [...items.value, data];
showCreate.value = false;
resetCreateForm();
success.value = `ادمین «${data.fullName}» اضافه شد`;
} catch (e) {
error.value = apiError(e, 'افزودن ادمین ناموفق بود');
} finally {
creating.value = false;
}
}
async function setActive(admin: PlatformAdminDto, isActive: boolean) {
busyId.value = admin.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformAdminDto>(
`/platform/admins/${admin.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === admin.id ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ناموفق بود');
} finally {
busyId.value = '';
}
}
function startReset(id: string) {
resetId.value = resetId.value === id ? null : id;
resetPassword.value = '';
error.value = '';
success.value = '';
}
async function submitReset(id: string) {
resetting.value = true;
error.value = '';
success.value = '';
try {
await platformApi.patch(`/platform/admins/${id}/password`, {
password: resetPassword.value,
});
success.value = 'رمز عبور به‌روز شد';
resetId.value = null;
resetPassword.value = '';
} catch (e) {
error.value = apiError(e, 'ریست رمز ناموفق بود');
} finally {
resetting.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<div>
<h1>سوپرادمین‌ها</h1>
<p>افزودن ادمین با موبایل و رمز ثابت، فعال/غیرفعال و ریست رمز.</p>
</div>
<button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
</button>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<form v-if="showCreate" class="panel" @submit.prevent="createAdmin">
<h2>افزودن سوپرادمین</h2>
<div class="grid">
<label>
<span>نام</span>
<input v-model="createForm.fullName" required minlength="2" />
</label>
<label>
<span>موبایل</span>
<input v-model="createForm.mobile" type="tel" required dir="ltr" placeholder="09xxxxxxxxx" />
</label>
<label>
<span>رمز عبور</span>
<input v-model="createForm.password" type="password" required minlength="8" />
</label>
<label>
<span>ایمیل (اختیاری)</span>
<input v-model="createForm.email" type="email" dir="ltr" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="creating">
{{ creating ? 'در حال ذخیره…' : 'افزودن' }}
</button>
</div>
</form>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">ادمینی ثبت نشده.</p>
<div v-else class="list">
<article v-for="admin in items" :key="admin.id" class="row">
<div class="row-main">
<div>
<strong>
{{ admin.fullName }}
<em v-if="admin.id === platform.admin?.id" class="you">شما</em>
</strong>
<span>
{{ admin.mobile || '—' }}
<template v-if="admin.email"> · {{ admin.email }}</template>
</span>
</div>
<div class="meta">
<em :class="admin.isActive ? 'on' : 'off'">
{{ admin.isActive ? 'فعال' : 'غیرفعال' }}
</em>
<small v-if="admin.lastLoginAt">
آخرین ورود {{ formatJalaliNumeric(new Date(admin.lastLoginAt)) }}
</small>
<small v-else>هنوز وارد نشده</small>
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
ریست رمز
</button>
<button
class="btn"
type="button"
:disabled="busyId === admin.id || admin.id === platform.admin?.id"
@click="setActive(admin, !admin.isActive)"
>
{{ admin.isActive ? 'غیرفعال' : 'فعال' }}
</button>
</div>
<form
v-if="resetId === admin.id"
class="reset-panel"
@submit.prevent="submitReset(admin.id)"
>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input
v-model="resetPassword"
type="password"
required
minlength="8"
autocomplete="new-password"
/>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="resetting">ذخیره رمز</button>
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
انصراف
</button>
</div>
</form>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 1040px;
}
.head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.head h1 {
margin: 0 0 0.3rem;
}
.head p,
.muted {
margin: 0;
color: var(--muted);
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel,
.reset-panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 1rem;
display: grid;
gap: 0.85rem;
}
.panel h2 {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
label {
display: grid;
gap: 0.35rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
input {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 0.85rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.you {
margin-inline-start: 0.4rem;
font-style: normal;
font-size: 0.75rem;
color: var(--accent-deep);
font-weight: 800;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.reset-panel {
grid-column: 1 / -1;
background: #f8fafc;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+8 -1
View File
@@ -82,6 +82,13 @@ onMounted(() => {
<span>نقش، دسترسی و ریست رمز عبور</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'adm-admins' }" class="card">
<UiIcon name="lock" :size="18" />
<div>
<strong>سوپرادمین‌ها</strong>
<span>افزودن ادمین با موبایل و رمز</span>
</div>
</RouterLink>
</div>
</div>
</template>
@@ -156,7 +163,7 @@ onMounted(() => {
.shortcuts {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
+9 -8
View File
@@ -12,7 +12,7 @@ import { brand } from '@mento/shared';
const platform = usePlatformAuthStore();
const router = useRouter();
const email = ref('');
const mobile = ref('');
const password = ref('');
const showPassword = ref(false);
const error = ref('');
@@ -33,7 +33,7 @@ async function submit() {
error.value = '';
loading.value = true;
try {
await platform.login(email.value.trim(), password.value);
await platform.login(mobile.value.trim(), password.value);
await router.push({ name: 'adm-dashboard' });
} catch (e) {
error.value = apiError(e, 'ورود ناموفق بود');
@@ -64,19 +64,20 @@ async function submit() {
<form class="form" @submit.prevent="submit">
<div class="field">
<label for="adm-email">ایمیل</label>
<label for="adm-mobile">شماره موبایل</label>
<div class="input-wrap">
<input
id="adm-email"
v-model="email"
type="email"
id="adm-mobile"
v-model="mobile"
type="tel"
required
autocomplete="username"
placeholder="ایمیل سوپرادمین را وارد کنید"
placeholder="09xxxxxxxxx"
class="ltr-value"
dir="ltr"
/>
<span class="field-ico" aria-hidden="true">
<UiIcon name="mail" :size="18" />
<UiIcon name="phone" :size="18" />
</span>
</div>
</div>