Creating & Edit profile
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "avatarStorageKey" TEXT;
|
||||||
@@ -197,6 +197,8 @@ model User {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
fullName String
|
fullName String
|
||||||
role UserRole @default(sales)
|
role UserRole @default(sales)
|
||||||
|
/// مسیر فایل آواتار در فضای آپلود
|
||||||
|
avatarStorageKey String?
|
||||||
emailVerifiedAt DateTime?
|
emailVerifiedAt DateTime?
|
||||||
mobileVerifiedAt DateTime?
|
mobileVerifiedAt DateTime?
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
@@ -6,10 +19,13 @@ import {
|
|||||||
LoginOtpVerifyDto,
|
LoginOtpVerifyDto,
|
||||||
RegisterOrganizationDto,
|
RegisterOrganizationDto,
|
||||||
RegisterVerifyOtpDto,
|
RegisterVerifyOtpDto,
|
||||||
|
UpdateProfileDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { CurrentUser } from './current-user.decorator';
|
import { CurrentUser } from './current-user.decorator';
|
||||||
|
|
||||||
|
type AuthActor = { userId: string; organizationId: string };
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
@@ -44,7 +60,42 @@ export class AuthController {
|
|||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@CurrentUser() user: { userId: string }) {
|
me(@CurrentUser() user: AuthActor) {
|
||||||
return this.authService.me(user.userId);
|
return this.authService.me(user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Patch('me')
|
||||||
|
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
|
||||||
|
return this.authService.updateMe(user.userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** عکس پروفایل کاربر جاری */
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('me/avatar')
|
||||||
|
async avatar(@CurrentUser() user: AuthActor) {
|
||||||
|
const file = await this.authService.openAvatar(user.userId);
|
||||||
|
return new StreamableFile(file.stream, { type: file.mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('me/avatar')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
uploadAvatar(
|
||||||
|
@CurrentUser() user: AuthActor,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.authService.saveAvatar(user.userId, user.organizationId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete('me/avatar')
|
||||||
|
removeAvatar(@CurrentUser() user: AuthActor) {
|
||||||
|
return this.authService.removeAvatar(user.userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,37 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import * as path from 'path';
|
||||||
import type { AuthUserDto, LoginResponse } from '@mento/shared';
|
import type { AuthUserDto, LoginResponse } from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsService } from '../common/sms.service';
|
||||||
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
LoginOtpRequestDto,
|
LoginOtpRequestDto,
|
||||||
LoginOtpVerifyDto,
|
LoginOtpVerifyDto,
|
||||||
RegisterOrganizationDto,
|
RegisterOrganizationDto,
|
||||||
RegisterVerifyOtpDto,
|
RegisterVerifyOtpDto,
|
||||||
|
UpdateProfileDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
const OTP_LENGTH = 6;
|
const OTP_LENGTH = 6;
|
||||||
const OTP_TTL_SECONDS = 5 * 60;
|
const OTP_TTL_SECONDS = 5 * 60;
|
||||||
|
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
const AVATAR_EXT: Record<string, string> = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/jpg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
};
|
||||||
|
|
||||||
function slugify(input: string): string {
|
function slugify(input: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -82,6 +94,7 @@ export class AuthService {
|
|||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly files: LocalFileStorage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -360,7 +373,146 @@ export class AuthService {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
return this.toAuthUser(user);
|
return this.toAuthUserDto(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMe(userId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
|
||||||
|
const fullName = dto.fullName.trim();
|
||||||
|
if (fullName.length < 2) {
|
||||||
|
throw new BadRequestException('نام باید حداقل دو حرف باشد');
|
||||||
|
}
|
||||||
|
const email = dto.email.trim().toLowerCase();
|
||||||
|
const mobile = normalizeMobile(dto.mobile);
|
||||||
|
if (!mobile) {
|
||||||
|
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email !== existing.email) {
|
||||||
|
const taken = await this.prisma.user.findFirst({
|
||||||
|
where: { organizationId: existing.organizationId, email, id: { not: userId } },
|
||||||
|
});
|
||||||
|
if (taken) {
|
||||||
|
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mobile !== existing.mobile) {
|
||||||
|
const taken = await this.prisma.user.findFirst({
|
||||||
|
where: { mobile, id: { not: userId } },
|
||||||
|
});
|
||||||
|
if (taken) {
|
||||||
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { fullName, email, mobile },
|
||||||
|
});
|
||||||
|
return this.toAuthUserDto(user);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException('این ایمیل یا شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAvatar(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
file: Express.Multer.File | undefined,
|
||||||
|
): Promise<AuthUserDto> {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('فایل تصویر ارسال نشده');
|
||||||
|
}
|
||||||
|
if (file.size > AVATAR_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد');
|
||||||
|
}
|
||||||
|
const mime = (file.mimetype || '').toLowerCase();
|
||||||
|
const ext = AVATAR_EXT[mime];
|
||||||
|
if (!ext) {
|
||||||
|
throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
const prevKey = await this.readAvatarKey(userId);
|
||||||
|
const storageKey = path
|
||||||
|
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||||
|
.replace(/\\/g, '/');
|
||||||
|
await this.files.save(storageKey, file.buffer);
|
||||||
|
if (prevKey && prevKey !== storageKey) {
|
||||||
|
await this.files.remove(prevKey);
|
||||||
|
}
|
||||||
|
await this.writeAvatarKey(userId, storageKey);
|
||||||
|
return this.toAuthUserDto(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAvatar(userId: string): Promise<AuthUserDto> {
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
const prevKey = await this.readAvatarKey(userId);
|
||||||
|
if (prevKey) await this.files.remove(prevKey);
|
||||||
|
await this.writeAvatarKey(userId, null);
|
||||||
|
return this.toAuthUserDto(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openAvatar(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
const storageKey = await this.readAvatarKey(userId);
|
||||||
|
if (!storageKey) {
|
||||||
|
throw new NotFoundException('عکس پروفایل تنظیم نشده');
|
||||||
|
}
|
||||||
|
const ext = path.extname(storageKey).slice(1).toLowerCase();
|
||||||
|
const mime =
|
||||||
|
Object.entries(AVATAR_EXT).find(([, value]) => value === ext)?.[0] ??
|
||||||
|
'image/jpeg';
|
||||||
|
return {
|
||||||
|
stream: this.files.openReadStream(storageKey),
|
||||||
|
mimeType: mime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAvatarKey(userId: string): Promise<string | null> {
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ avatarStorageKey: string | null }>>`
|
||||||
|
SELECT "avatarStorageKey" FROM "User" WHERE "id" = ${userId}
|
||||||
|
`;
|
||||||
|
return rows[0]?.avatarStorageKey ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeAvatarKey(userId: string, storageKey: string | null) {
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
UPDATE "User" SET "avatarStorageKey" = ${storageKey} WHERE "id" = ${userId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toAuthUserDto(user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
mobile?: string | null;
|
||||||
|
fullName: string;
|
||||||
|
role: AuthUserDto['role'];
|
||||||
|
organizationId: string;
|
||||||
|
}): Promise<AuthUserDto> {
|
||||||
|
return this.toAuthUser({
|
||||||
|
...user,
|
||||||
|
avatarStorageKey: await this.readAvatarKey(user.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async throttleChallenges(
|
private async throttleChallenges(
|
||||||
@@ -435,14 +587,14 @@ export class AuthService {
|
|||||||
this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`);
|
this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildLoginResponse(user: {
|
private async buildLoginResponse(user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
mobile?: string | null;
|
mobile?: string | null;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
role: AuthUserDto['role'];
|
role: AuthUserDto['role'];
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}): LoginResponse {
|
}): Promise<LoginResponse> {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
@@ -450,7 +602,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwt.sign(payload),
|
accessToken: this.jwt.sign(payload),
|
||||||
user: this.toAuthUser(user),
|
user: await this.toAuthUserDto(user),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +613,7 @@ export class AuthService {
|
|||||||
fullName: string;
|
fullName: string;
|
||||||
role: AuthUserDto['role'];
|
role: AuthUserDto['role'];
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
avatarStorageKey?: string | null;
|
||||||
}): AuthUserDto {
|
}): AuthUserDto {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -468,6 +621,7 @@ export class AuthService {
|
|||||||
fullName: user.fullName,
|
fullName: user.fullName,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
|
hasAvatar: Boolean(user.avatarStorageKey),
|
||||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,3 +92,18 @@ export class RegisterVerifyOtpDto {
|
|||||||
@MinLength(4)
|
@MinLength(4)
|
||||||
mobileCode!: string;
|
mobileCode!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateProfileDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
fullName!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
|
||||||
|
message: 'شماره موبایل معتبر نیست',
|
||||||
|
})
|
||||||
|
mobile!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ defineProps<{
|
|||||||
| 'flag'
|
| 'flag'
|
||||||
| 'refresh'
|
| 'refresh'
|
||||||
| 'x'
|
| 'x'
|
||||||
|
| 'x-circle'
|
||||||
| 'mic'
|
| 'mic'
|
||||||
| 'trash'
|
| 'trash'
|
||||||
| 'stop'
|
| 'stop'
|
||||||
@@ -50,6 +51,7 @@ defineProps<{
|
|||||||
| 'layers'
|
| 'layers'
|
||||||
| 'target'
|
| 'target'
|
||||||
| 'pencil'
|
| 'pencil'
|
||||||
|
| 'edit'
|
||||||
| 'grid'
|
| 'grid'
|
||||||
| 'list'
|
| 'list'
|
||||||
| 'eye'
|
| 'eye'
|
||||||
@@ -61,7 +63,8 @@ defineProps<{
|
|||||||
| 'medal'
|
| 'medal'
|
||||||
| 'logout'
|
| 'logout'
|
||||||
| 'pulse'
|
| 'pulse'
|
||||||
| 'sliders';
|
| 'sliders'
|
||||||
|
| 'camera';
|
||||||
size?: number;
|
size?: number;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -253,6 +256,10 @@ defineProps<{
|
|||||||
<template v-else-if="name === 'x'">
|
<template v-else-if="name === 'x'">
|
||||||
<path d="M6 6 18 18M18 6 6 18" />
|
<path d="M6 6 18 18M18 6 6 18" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="name === 'x-circle'">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="m9 9 6 6M15 9l-6 6" />
|
||||||
|
</template>
|
||||||
<template v-else-if="name === 'mic'">
|
<template v-else-if="name === 'mic'">
|
||||||
<rect x="9" y="3.5" width="6" height="10" rx="3" />
|
<rect x="9" y="3.5" width="6" height="10" rx="3" />
|
||||||
<path d="M7 11.5a5 5 0 0 0 10 0" />
|
<path d="M7 11.5a5 5 0 0 0 10 0" />
|
||||||
@@ -291,6 +298,10 @@ defineProps<{
|
|||||||
<path d="M12 20h9" />
|
<path d="M12 20h9" />
|
||||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="name === 'edit'">
|
||||||
|
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
|
||||||
|
</template>
|
||||||
<template v-else-if="name === 'grid'">
|
<template v-else-if="name === 'grid'">
|
||||||
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
|
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
|
||||||
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
|
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
|
||||||
@@ -359,6 +370,10 @@ defineProps<{
|
|||||||
<path d="M4 7h16M4 17h16" />
|
<path d="M4 7h16M4 17h16" />
|
||||||
<path d="M8 4.5v5M16 14.5v5" />
|
<path d="M8 4.5v5M16 14.5v5" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="name === 'camera'">
|
||||||
|
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
|
||||||
|
<circle cx="12" cy="13" r="3" />
|
||||||
|
</template>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router';
|
|||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
||||||
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
|
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
greeting: string;
|
greeting: string;
|
||||||
@@ -18,6 +19,8 @@ const emit = defineEmits<{
|
|||||||
ask: [];
|
ask: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
type HeroChip = {
|
type HeroChip = {
|
||||||
key: string;
|
key: string;
|
||||||
tone: HomeDashboardAlertDto['tone'];
|
tone: HomeDashboardAlertDto['tone'];
|
||||||
@@ -86,7 +89,7 @@ function alertIcon(tone: string) {
|
|||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="hero-greet">
|
<div class="hero-greet">
|
||||||
<span class="hero-avatar">
|
<span class="hero-avatar">
|
||||||
<img src="/brand/user-avatar.png" :alt="firstName" width="72" height="72" />
|
<img :src="auth.avatarSrc" :alt="firstName" width="72" height="72" />
|
||||||
<i class="online" />
|
<i class="online" />
|
||||||
</span>
|
</span>
|
||||||
<div class="greet-text">
|
<div class="greet-text">
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ function close() {
|
|||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleClose() {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
close();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function onSelect(key: string) {
|
function onSelect(key: string) {
|
||||||
emit('select', key);
|
emit('select', key);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -85,7 +91,7 @@ function onSelect(key: string) {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="منوی بیشتر"
|
aria-label="منوی بیشتر"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
@click="toggle"
|
@click.stop="toggle"
|
||||||
>
|
>
|
||||||
<UiIcon name="dots-v" :size="18" />
|
<UiIcon name="dots-v" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
@@ -107,7 +113,7 @@ function onSelect(key: string) {
|
|||||||
:class="{ danger: item.danger }"
|
:class="{ danger: item.danger }"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
@click.stop="close"
|
@click.stop="scheduleClose"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ onUnmounted(() => {
|
|||||||
<span class="avatar-wrap">
|
<span class="avatar-wrap">
|
||||||
<span class="avatar">
|
<span class="avatar">
|
||||||
<img
|
<img
|
||||||
src="/brand/user-avatar.png"
|
:src="auth.avatarSrc"
|
||||||
:alt="auth.user?.fullName || 'کاربر'"
|
:alt="auth.user?.fullName || 'کاربر'"
|
||||||
width="36"
|
width="36"
|
||||||
height="36"
|
height="36"
|
||||||
@@ -561,7 +561,7 @@ onUnmounted(() => {
|
|||||||
<span class="avatar-wrap">
|
<span class="avatar-wrap">
|
||||||
<span class="avatar">
|
<span class="avatar">
|
||||||
<img
|
<img
|
||||||
src="/brand/user-avatar.png"
|
:src="auth.avatarSrc"
|
||||||
:alt="auth.user?.fullName || 'کاربر'"
|
:alt="auth.user?.fullName || 'کاربر'"
|
||||||
width="36"
|
width="36"
|
||||||
height="36"
|
height="36"
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import type {
|
|||||||
LoginResponse,
|
LoginResponse,
|
||||||
RegisterChallengeResponse,
|
RegisterChallengeResponse,
|
||||||
RegisterOrganizationRequest,
|
RegisterOrganizationRequest,
|
||||||
|
UpdateProfileRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
const TOKEN_KEY = 'mento_token';
|
const TOKEN_KEY = 'mento_token';
|
||||||
const USER_KEY = 'mento_user';
|
const USER_KEY = 'mento_user';
|
||||||
const LEGACY_KEYS = ['ment_token', 'ment_user'] as const;
|
const LEGACY_KEYS = ['ment_token', 'ment_user'] as const;
|
||||||
|
const DEFAULT_AVATAR = '/brand/user-avatar.png';
|
||||||
|
|
||||||
function readStoredToken(): string | null {
|
function readStoredToken(): string | null {
|
||||||
return localStorage.getItem(TOKEN_KEY) ?? localStorage.getItem('ment_token');
|
return localStorage.getItem(TOKEN_KEY) ?? localStorage.getItem('ment_token');
|
||||||
@@ -34,8 +36,38 @@ function clearLegacy() {
|
|||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(readStoredToken());
|
const token = ref<string | null>(readStoredToken());
|
||||||
const user = ref<AuthUserDto | null>(readStoredUser());
|
const user = ref<AuthUserDto | null>(readStoredUser());
|
||||||
|
const avatarObjectUrl = ref<string | null>(null);
|
||||||
|
|
||||||
const isAuthenticated = computed(() => Boolean(token.value));
|
const isAuthenticated = computed(() => Boolean(token.value));
|
||||||
|
const avatarSrc = computed(() => avatarObjectUrl.value || DEFAULT_AVATAR);
|
||||||
|
|
||||||
|
function clearAvatarUrl() {
|
||||||
|
if (avatarObjectUrl.value) {
|
||||||
|
URL.revokeObjectURL(avatarObjectUrl.value);
|
||||||
|
avatarObjectUrl.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAvatar() {
|
||||||
|
if (!token.value || !user.value?.hasAvatar) {
|
||||||
|
clearAvatarUrl();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<Blob>('/auth/me/avatar', {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
if (!data || data.type.includes('json') || data.size === 0) {
|
||||||
|
clearAvatarUrl();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = URL.createObjectURL(data);
|
||||||
|
clearAvatarUrl();
|
||||||
|
avatarObjectUrl.value = next;
|
||||||
|
} catch {
|
||||||
|
clearAvatarUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function persist(payload: LoginResponse) {
|
function persist(payload: LoginResponse) {
|
||||||
token.value = payload.accessToken;
|
token.value = payload.accessToken;
|
||||||
@@ -43,6 +75,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
localStorage.setItem(TOKEN_KEY, payload.accessToken);
|
localStorage.setItem(TOKEN_KEY, payload.accessToken);
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(payload.user));
|
localStorage.setItem(USER_KEY, JSON.stringify(payload.user));
|
||||||
clearLegacy();
|
clearLegacy();
|
||||||
|
void refreshAvatar();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(identifier: string, password: string) {
|
async function login(identifier: string, password: string) {
|
||||||
@@ -92,20 +125,62 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
function logout() {
|
function logout() {
|
||||||
token.value = null;
|
token.value = null;
|
||||||
user.value = null;
|
user.value = null;
|
||||||
|
clearAvatarUrl();
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
clearLegacy();
|
clearLegacy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setUser(next: AuthUserDto) {
|
||||||
|
user.value = next;
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(next));
|
||||||
|
void refreshAvatar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrate() {
|
||||||
|
if (!token.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<AuthUserDto>('/auth/me');
|
||||||
|
setUser(data);
|
||||||
|
} catch {
|
||||||
|
/* interceptor handles 401 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(payload: UpdateProfileRequest) {
|
||||||
|
const { data } = await api.patch<AuthUserDto>('/auth/me', payload);
|
||||||
|
setUser(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(file: File) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('file', file);
|
||||||
|
const { data } = await api.post<AuthUserDto>('/auth/me/avatar', body);
|
||||||
|
setUser(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAvatar() {
|
||||||
|
const { data } = await api.delete<AuthUserDto>('/auth/me/avatar');
|
||||||
|
setUser(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.value) void hydrate();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
avatarSrc,
|
||||||
login,
|
login,
|
||||||
requestLoginOtp,
|
requestLoginOtp,
|
||||||
verifyLoginOtp,
|
verifyLoginOtp,
|
||||||
startRegister,
|
startRegister,
|
||||||
verifyRegister,
|
verifyRegister,
|
||||||
logout,
|
logout,
|
||||||
|
setUser,
|
||||||
|
hydrate,
|
||||||
|
updateProfile,
|
||||||
|
uploadAvatar,
|
||||||
|
removeAvatar,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -280,8 +280,21 @@ async function toggle(row: MessageAutomationRuleDto) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(row: MessageAutomationRuleDto) {
|
const pendingDelete = ref<MessageAutomationRuleDto | null>(null);
|
||||||
if (!confirm(`قانون «${row.name}» حذف شود؟`)) return;
|
|
||||||
|
function askRemove(row: MessageAutomationRuleDto) {
|
||||||
|
menuId.value = null;
|
||||||
|
pendingDelete.value = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRemove() {
|
||||||
|
pendingDelete.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemove() {
|
||||||
|
const row = pendingDelete.value;
|
||||||
|
if (!row) return;
|
||||||
|
pendingDelete.value = null;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/message-automations/${row.id}`);
|
await api.delete(`/message-automations/${row.id}`);
|
||||||
if (editingId.value === row.id) resetForm();
|
if (editingId.value === row.id) resetForm();
|
||||||
@@ -410,7 +423,7 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
<div v-if="menuId === row.id" class="pop">
|
<div v-if="menuId === row.id" class="pop">
|
||||||
<button type="button" @click="openMobileEdit(row)">ویرایش</button>
|
<button type="button" @click="openMobileEdit(row)">ویرایش</button>
|
||||||
<button type="button" class="danger" @click="remove(row)">حذف</button>
|
<button type="button" class="danger" @click="askRemove(row)">حذف</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-copy">
|
<div class="m-copy">
|
||||||
@@ -1071,23 +1084,25 @@ onUnmounted(() => {
|
|||||||
<td>{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}</td>
|
<td>{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}</td>
|
||||||
<td>{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}</td>
|
<td>{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}</td>
|
||||||
<td class="ops">
|
<td class="ops">
|
||||||
<button
|
<div class="ops-row">
|
||||||
class="icon-btn status"
|
<button
|
||||||
type="button"
|
class="icon-btn status"
|
||||||
:class="{ on: row.isActive }"
|
type="button"
|
||||||
:aria-pressed="row.isActive"
|
:class="{ on: row.isActive }"
|
||||||
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
|
:aria-pressed="row.isActive"
|
||||||
:title="row.isActive ? 'فعال' : 'غیرفعال'"
|
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
|
||||||
@click="toggle(row)"
|
:title="row.isActive ? 'فعال' : 'غیرفعال'"
|
||||||
>
|
@click="toggle(row)"
|
||||||
<UiIcon :name="row.isActive ? 'check-plain' : 'x'" :size="16" />
|
>
|
||||||
</button>
|
<UiIcon :name="row.isActive ? 'check' : 'x-circle'" :size="24" />
|
||||||
<button class="icon-btn" type="button" title="ویرایش" aria-label="ویرایش" @click="startEdit(row)">
|
</button>
|
||||||
<UiIcon name="pencil" :size="16" />
|
<button class="icon-btn edit" type="button" title="ویرایش" aria-label="ویرایش" @click="startEdit(row)">
|
||||||
</button>
|
<UiIcon name="edit" :size="24" />
|
||||||
<button class="icon-btn danger" type="button" title="حذف" aria-label="حذف" @click="remove(row)">
|
</button>
|
||||||
<UiIcon name="trash" :size="16" />
|
<button class="icon-btn danger" type="button" title="حذف" aria-label="حذف" @click="askRemove(row)">
|
||||||
</button>
|
<UiIcon name="trash" :size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1123,12 +1138,24 @@ onUnmounted(() => {
|
|||||||
<p class="preview">{{ row.description || row.body }}</p>
|
<p class="preview">{{ row.description || row.body }}</p>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
|
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
|
||||||
<button class="btn danger" type="button" @click="remove(row)">حذف</button>
|
<button class="btn danger" type="button" @click="askRemove(row)">حذف</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
|
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pendingDelete" class="sheet-layer">
|
||||||
|
<button class="dim" type="button" aria-label="انصراف" @click="cancelRemove" />
|
||||||
|
<section class="confirm-box" role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title">
|
||||||
|
<h3 id="delete-confirm-title">حذف قانون</h3>
|
||||||
|
<p>آیا از حذف مطمئن هستید؟</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="btn danger" type="button" @click="confirmRemove">حذف</button>
|
||||||
|
<button class="btn ghost" type="button" @click="cancelRemove">انصراف</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1678,6 +1705,37 @@ textarea {
|
|||||||
background: rgba(15, 23, 42, 0.32);
|
background: rgba(15, 23, 42, 0.32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-box {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(22rem, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1.15rem 1.2rem 1.1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-box h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sheet {
|
.sheet {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -1802,50 +1860,59 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desk-table .ops {
|
.desk-table .ops {
|
||||||
display: flex;
|
display: table-cell;
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
width: 1%;
|
width: 1%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
display: grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 36px;
|
flex: 0 0 2.75rem;
|
||||||
height: 36px;
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid #e2e8f0;
|
border: 0;
|
||||||
border-radius: var(--radius);
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: transparent;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn:hover {
|
.icon-btn:hover {
|
||||||
background: #f8fafc;
|
background: transparent;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-btn.edit {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.edit:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn.danger {
|
.icon-btn.danger {
|
||||||
color: #b91c1c;
|
color: #dc2626;
|
||||||
border-color: #fecaca;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn.danger:hover {
|
.icon-btn.danger:hover {
|
||||||
background: #fef2f2;
|
color: #b91c1c;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn.status {
|
.icon-btn.status {
|
||||||
border-color: #fecaca;
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn.status.on {
|
.icon-btn.status.on {
|
||||||
border-color: #bbf7d0;
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #16a34a;
|
color: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
|
import axios from 'axios';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { AskMentoResponse } from '@mento/shared';
|
import type { AskMentoResponse } from '@mento/shared';
|
||||||
@@ -68,13 +69,24 @@ const tab = ref<TabKey>('summary');
|
|||||||
const aiBrief = ref('');
|
const aiBrief = ref('');
|
||||||
|
|
||||||
const moreItems: ProfileMoreItem[] = [
|
const moreItems: ProfileMoreItem[] = [
|
||||||
{ key: 'info', label: 'ویرایش اطلاعات', to: { name: 'profile', query: { tab: 'info' } } },
|
{ key: 'info', label: 'ویرایش اطلاعات' },
|
||||||
{ key: 'activity', label: 'فعالیت جدید', to: { name: 'activity-new' } },
|
{ key: 'activity', label: 'فعالیت جدید', to: { name: 'activity-new' } },
|
||||||
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new' } },
|
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new' } },
|
||||||
{ key: 'quote', label: 'پیشفاکتور جدید', to: { name: 'quote-new' } },
|
{ key: 'quote', label: 'پیشفاکتور جدید', to: { name: 'quote-new' } },
|
||||||
{ key: 'ticket', label: 'تیکت جدید', to: { name: 'ticket-new' } },
|
{ key: 'ticket', label: 'تیکت جدید', to: { name: 'ticket-new' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const infoFullName = ref('');
|
||||||
|
const infoMobile = ref('');
|
||||||
|
const infoEmail = ref('');
|
||||||
|
const infoSaving = ref(false);
|
||||||
|
const infoError = ref('');
|
||||||
|
const infoOk = ref('');
|
||||||
|
const nameInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const avatarInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const avatarBusy = ref(false);
|
||||||
|
const avatarError = ref('');
|
||||||
|
|
||||||
const name = computed(() => auth.user?.fullName || 'کاربر');
|
const name = computed(() => auth.user?.fullName || 'کاربر');
|
||||||
const roleLabel = computed(() =>
|
const roleLabel = computed(() =>
|
||||||
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : 'کاربر',
|
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : 'کاربر',
|
||||||
@@ -270,7 +282,73 @@ function readTab() {
|
|||||||
function setTab(next: TabKey) {
|
function setTab(next: TabKey) {
|
||||||
tab.value = next;
|
tab.value = next;
|
||||||
moreOpen.value = false;
|
moreOpen.value = false;
|
||||||
void router.replace({ query: { ...route.query, tab: next === 'summary' ? undefined : next } });
|
const query = { ...route.query };
|
||||||
|
if (next === 'summary') delete query.tab;
|
||||||
|
else query.tab = next;
|
||||||
|
void router.replace({ query });
|
||||||
|
if (next === 'info') revealInfoForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMoreSelect(key: string) {
|
||||||
|
if (key === 'info') setTab('info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealInfoForm() {
|
||||||
|
void nextTick(() => {
|
||||||
|
nameInput.value?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
nameInput.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorMessage(e: unknown, fallback: string) {
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||||
|
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncInfoForm() {
|
||||||
|
infoFullName.value = auth.user?.fullName ?? '';
|
||||||
|
infoMobile.value = auth.user?.mobile ?? '';
|
||||||
|
infoEmail.value = auth.user?.email ?? '';
|
||||||
|
infoError.value = '';
|
||||||
|
infoOk.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInfo() {
|
||||||
|
const fullName = infoFullName.value.trim();
|
||||||
|
const mobile = infoMobile.value.trim();
|
||||||
|
const email = infoEmail.value.trim();
|
||||||
|
if (fullName.length < 2) {
|
||||||
|
infoError.value = 'نام باید حداقل دو حرف باشد';
|
||||||
|
infoOk.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/.test(mobile.replace(/[\s\-()]/g, ''))) {
|
||||||
|
infoError.value = 'شماره موبایل معتبر نیست';
|
||||||
|
infoOk.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!email.includes('@')) {
|
||||||
|
infoError.value = 'ایمیل معتبر نیست';
|
||||||
|
infoOk.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
infoSaving.value = true;
|
||||||
|
infoError.value = '';
|
||||||
|
infoOk.value = '';
|
||||||
|
try {
|
||||||
|
await auth.updateProfile({ fullName, mobile, email });
|
||||||
|
infoFullName.value = auth.user?.fullName ?? fullName;
|
||||||
|
infoMobile.value = auth.user?.mobile ?? mobile;
|
||||||
|
infoEmail.value = auth.user?.email ?? email;
|
||||||
|
infoOk.value = 'اطلاعات ذخیره شد';
|
||||||
|
} catch (e) {
|
||||||
|
infoError.value = apiErrorMessage(e, 'ذخیره ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
infoSaving.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenus() {
|
function closeMenus() {
|
||||||
@@ -283,6 +361,47 @@ function onSmartCta() {
|
|||||||
void router.push({ name: 'activity-new', query: { type: 'call' } });
|
void router.push({ name: 'activity-new', query: { type: 'call' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAvatarPicker() {
|
||||||
|
if (tab.value !== 'info' || avatarBusy.value) return;
|
||||||
|
avatarInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAvatarFile(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
avatarError.value = 'فقط فایل تصویر انتخاب کنید';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
avatarError.value = 'حجم تصویر باید کمتر از ۲ مگابایت باشد';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
avatarBusy.value = true;
|
||||||
|
avatarError.value = '';
|
||||||
|
try {
|
||||||
|
await auth.uploadAvatar(file);
|
||||||
|
} catch (e) {
|
||||||
|
avatarError.value = apiErrorMessage(e, 'آپلود عکس ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
avatarBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAvatar() {
|
||||||
|
avatarBusy.value = true;
|
||||||
|
avatarError.value = '';
|
||||||
|
try {
|
||||||
|
await auth.removeAvatar();
|
||||||
|
} catch (e) {
|
||||||
|
avatarError.value = apiErrorMessage(e, 'حذف عکس ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
avatarBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -324,6 +443,24 @@ async function loadAiBrief() {
|
|||||||
|
|
||||||
watch(() => route.query.tab, readTab, { immediate: true });
|
watch(() => route.query.tab, readTab, { immediate: true });
|
||||||
watch(() => auth.user?.id, () => void load(), { immediate: true });
|
watch(() => auth.user?.id, () => void load(), { immediate: true });
|
||||||
|
watch(
|
||||||
|
() => tab.value,
|
||||||
|
(next) => {
|
||||||
|
if (next === 'info') syncInfoForm();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
[auth.user?.fullName, auth.user?.mobile, auth.user?.email] as const,
|
||||||
|
() => {
|
||||||
|
if (tab.value === 'info' && !infoSaving.value) {
|
||||||
|
infoFullName.value = auth.user?.fullName ?? '';
|
||||||
|
infoMobile.value = auth.user?.mobile ?? '';
|
||||||
|
infoEmail.value = auth.user?.email ?? '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -333,7 +470,7 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
|
|||||||
<UiIcon name="chevron" :size="18" />
|
<UiIcon name="chevron" :size="18" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<span class="nav-brand">mento</span>
|
<span class="nav-brand">mento</span>
|
||||||
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" />
|
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p v-if="loading" class="muted pad">در حال بارگذاری…</p>
|
<p v-if="loading" class="muted pad">در حال بارگذاری…</p>
|
||||||
@@ -341,8 +478,25 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="identity">
|
<div class="identity">
|
||||||
<div class="avatar online">
|
<div
|
||||||
<img src="/brand/user-avatar.png" :alt="name" width="76" height="76" />
|
class="avatar-edit"
|
||||||
|
:class="{ editable: tab === 'info' }"
|
||||||
|
@click.stop="openAvatarPicker"
|
||||||
|
>
|
||||||
|
<span class="avatar">
|
||||||
|
<img :src="auth.avatarSrc" :alt="name" width="76" height="76" />
|
||||||
|
</span>
|
||||||
|
<i class="online-dot" title="آنلاین" aria-hidden="true" />
|
||||||
|
<span v-if="tab === 'info'" class="camera-badge" aria-hidden="true">
|
||||||
|
<UiIcon name="camera" :size="13" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
hidden
|
||||||
|
@change="onAvatarFile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="who">
|
<div class="who">
|
||||||
<h1>{{ name }}</h1>
|
<h1>{{ name }}</h1>
|
||||||
@@ -350,6 +504,16 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
|
|||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span class="pill active">آنلاین</span>
|
<span class="pill active">آنلاین</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="avatarError && tab === 'info'" class="error">{{ avatarError }}</p>
|
||||||
|
<button
|
||||||
|
v-if="tab === 'info' && auth.user?.hasAvatar"
|
||||||
|
class="avatar-clear"
|
||||||
|
type="button"
|
||||||
|
:disabled="avatarBusy"
|
||||||
|
@click.stop="clearAvatar"
|
||||||
|
>
|
||||||
|
حذف عکس
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -531,11 +695,49 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="tab === 'info'" class="panel info-panel">
|
<div v-show="tab === 'info'" class="panel info-panel">
|
||||||
<dl>
|
<form class="info-form" @submit.prevent="saveInfo">
|
||||||
<div><dt>نام</dt><dd>{{ name }}</dd></div>
|
<label class="info-row">
|
||||||
<div><dt>ایمیل</dt><dd>{{ auth.user?.email || '—' }}</dd></div>
|
<span>نام</span>
|
||||||
<div><dt>نقش</dt><dd>{{ roleLabel }}</dd></div>
|
<input
|
||||||
</dl>
|
ref="nameInput"
|
||||||
|
v-model="infoFullName"
|
||||||
|
type="text"
|
||||||
|
autocomplete="name"
|
||||||
|
maxlength="80"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="info-row">
|
||||||
|
<span>موبایل</span>
|
||||||
|
<input
|
||||||
|
v-model="infoMobile"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
dir="ltr"
|
||||||
|
autocomplete="tel"
|
||||||
|
maxlength="14"
|
||||||
|
placeholder="09121234567"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="info-row">
|
||||||
|
<span>ایمیل</span>
|
||||||
|
<input
|
||||||
|
v-model="infoEmail"
|
||||||
|
type="email"
|
||||||
|
dir="ltr"
|
||||||
|
autocomplete="email"
|
||||||
|
maxlength="120"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="info-row">
|
||||||
|
<span>نقش</span>
|
||||||
|
<b>{{ roleLabel }}</b>
|
||||||
|
</div>
|
||||||
|
<p v-if="infoError" class="error">{{ infoError }}</p>
|
||||||
|
<p v-else-if="infoOk" class="ok">{{ infoOk }}</p>
|
||||||
|
<button class="save-btn" type="submit" :disabled="infoSaving">
|
||||||
|
{{ infoSaving ? 'در حال ذخیره…' : 'ذخیره تغییرات' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -680,13 +882,23 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar-edit {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 76px;
|
width: 76px;
|
||||||
height: 76px;
|
height: 76px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-edit.editable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: block;
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
|
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,16 +909,49 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar.online::after {
|
.online-dot {
|
||||||
content: '';
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 14px;
|
bottom: 2px;
|
||||||
height: 14px;
|
inset-inline-start: 2px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
background: #22c55e;
|
background: #22c55e;
|
||||||
border: 3px solid #eef2f6;
|
border: 3px solid #eef2f6;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-badge {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 3;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
inset-inline-start: 4px;
|
background: #0f766e;
|
||||||
bottom: 4px;
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
box-shadow: 0 0 0 3px #eef2f6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-clear {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-clear:hover {
|
||||||
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.who {
|
.who {
|
||||||
@@ -1045,28 +1290,70 @@ h1 {
|
|||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-panel dl {
|
.info-form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-panel dl > div {
|
.info-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 7.5rem 1fr;
|
grid-template-columns: 7.5rem 1fr;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
padding: 0.7rem 0;
|
padding: 0.7rem 0;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-panel dt {
|
.info-row span {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-panel dd {
|
.info-row b {
|
||||||
margin: 0;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-form .error,
|
||||||
|
.info-form .ok {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 2.6rem;
|
||||||
|
padding: 0 1.1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #14b8a6;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty,
|
.empty,
|
||||||
@@ -1171,11 +1458,18 @@ h1 {
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-edit,
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.online-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.qa {
|
.qa {
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
@@ -1237,7 +1531,7 @@ h1 {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-panel dl > div {
|
.info-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.12rem;
|
gap: 0.12rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface AuthUserDto {
|
|||||||
fullName: string;
|
fullName: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
hasAvatar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrgUserDto {
|
export interface OrgUserDto {
|
||||||
@@ -62,6 +63,12 @@ export interface UpdateUserRoleRequest {
|
|||||||
role: UserRole;
|
role: UserRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
mobile: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type DealStatus = "open" | "won" | "lost";
|
export type DealStatus = "open" | "won" | "lost";
|
||||||
|
|
||||||
export interface ApiErrorBody {
|
export interface ApiErrorBody {
|
||||||
|
|||||||
Reference in New Issue
Block a user