Creating & Edit profile

This commit is contained in:
2026-09-15 14:10:52 +03:30
parent 8dcf4ed5e9
commit 9ae176bec0
13 changed files with 767 additions and 77 deletions
@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "avatarStorageKey" TEXT;
+2
View File
@@ -197,6 +197,8 @@ model User {
passwordHash String
fullName String
role UserRole @default(sales)
/// مسیر فایل آواتار در فضای آپلود
avatarStorageKey String?
emailVerifiedAt DateTime?
mobileVerifiedAt DateTime?
organizationId String
+53 -2
View File
@@ -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 {
LoginDto,
@@ -6,10 +19,13 @@ import {
LoginOtpVerifyDto,
RegisterOrganizationDto,
RegisterVerifyOtpDto,
UpdateProfileDto,
} from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
type AuthActor = { userId: string; organizationId: string };
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@@ -44,7 +60,42 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: { userId: string }) {
me(@CurrentUser() user: AuthActor) {
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);
}
}
+158 -4
View File
@@ -3,25 +3,37 @@ import {
ConflictException,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import * as path from 'path';
import type { AuthUserDto, LoginResponse } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import {
LoginDto,
LoginOtpRequestDto,
LoginOtpVerifyDto,
RegisterOrganizationDto,
RegisterVerifyOtpDto,
UpdateProfileDto,
} from './dto';
const OTP_LENGTH = 6;
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 {
return (
@@ -82,6 +94,7 @@ export class AuthService {
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
private readonly files: LocalFileStorage,
) {}
/**
@@ -360,7 +373,146 @@ export class AuthService {
if (!user) {
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(
@@ -435,14 +587,14 @@ export class AuthService {
this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`);
}
private buildLoginResponse(user: {
private async buildLoginResponse(user: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): LoginResponse {
}): Promise<LoginResponse> {
const payload = {
sub: user.id,
organizationId: user.organizationId,
@@ -450,7 +602,7 @@ export class AuthService {
};
return {
accessToken: this.jwt.sign(payload),
user: this.toAuthUser(user),
user: await this.toAuthUserDto(user),
};
}
@@ -461,6 +613,7 @@ export class AuthService {
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
avatarStorageKey?: string | null;
}): AuthUserDto {
return {
id: user.id,
@@ -468,6 +621,7 @@ export class AuthService {
fullName: user.fullName,
role: user.role,
organizationId: user.organizationId,
hasAvatar: Boolean(user.avatarStorageKey),
...(user.mobile ? { mobile: user.mobile } : {}),
};
}
+15
View File
@@ -92,3 +92,18 @@ export class RegisterVerifyOtpDto {
@MinLength(4)
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;
}
+16 -1
View File
@@ -39,6 +39,7 @@ defineProps<{
| 'flag'
| 'refresh'
| 'x'
| 'x-circle'
| 'mic'
| 'trash'
| 'stop'
@@ -50,6 +51,7 @@ defineProps<{
| 'layers'
| 'target'
| 'pencil'
| 'edit'
| 'grid'
| 'list'
| 'eye'
@@ -61,7 +63,8 @@ defineProps<{
| 'medal'
| 'logout'
| 'pulse'
| 'sliders';
| 'sliders'
| 'camera';
size?: number;
}>();
</script>
@@ -253,6 +256,10 @@ defineProps<{
<template v-else-if="name === 'x'">
<path d="M6 6 18 18M18 6 6 18" />
</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'">
<rect x="9" y="3.5" width="6" height="10" rx="3" />
<path d="M7 11.5a5 5 0 0 0 10 0" />
@@ -291,6 +298,10 @@ defineProps<{
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</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'">
<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" />
@@ -359,6 +370,10 @@ defineProps<{
<path d="M4 7h16M4 17h16" />
<path d="M8 4.5v5M16 14.5v5" />
</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>
</template>
@@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
greeting: string;
@@ -18,6 +19,8 @@ const emit = defineEmits<{
ask: [];
}>();
const auth = useAuthStore();
type HeroChip = {
key: string;
tone: HomeDashboardAlertDto['tone'];
@@ -86,7 +89,7 @@ function alertIcon(tone: string) {
<div class="hero-body">
<div class="hero-greet">
<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" />
</span>
<div class="greet-text">
@@ -69,6 +69,12 @@ function close() {
open.value = false;
}
function scheduleClose() {
window.setTimeout(() => {
close();
}, 0);
}
function onSelect(key: string) {
emit('select', key);
nextTick(() => {
@@ -85,7 +91,7 @@ function onSelect(key: string) {
type="button"
aria-label="منوی بیشتر"
:aria-expanded="open"
@click="toggle"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
@@ -107,7 +113,7 @@ function onSelect(key: string) {
:class="{ danger: item.danger }"
role="menuitem"
:to="item.to"
@click.stop="close"
@click.stop="scheduleClose"
>
{{ item.label }}
</RouterLink>
+2 -2
View File
@@ -337,7 +337,7 @@ onUnmounted(() => {
<span class="avatar-wrap">
<span class="avatar">
<img
src="/brand/user-avatar.png"
:src="auth.avatarSrc"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
@@ -561,7 +561,7 @@ onUnmounted(() => {
<span class="avatar-wrap">
<span class="avatar">
<img
src="/brand/user-avatar.png"
:src="auth.avatarSrc"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
+75
View File
@@ -6,12 +6,14 @@ import type {
LoginResponse,
RegisterChallengeResponse,
RegisterOrganizationRequest,
UpdateProfileRequest,
} from '@mento/shared';
import { api } from '@/lib/api';
const TOKEN_KEY = 'mento_token';
const USER_KEY = 'mento_user';
const LEGACY_KEYS = ['ment_token', 'ment_user'] as const;
const DEFAULT_AVATAR = '/brand/user-avatar.png';
function readStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY) ?? localStorage.getItem('ment_token');
@@ -34,8 +36,38 @@ function clearLegacy() {
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(readStoredToken());
const user = ref<AuthUserDto | null>(readStoredUser());
const avatarObjectUrl = ref<string | null>(null);
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) {
token.value = payload.accessToken;
@@ -43,6 +75,7 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem(TOKEN_KEY, payload.accessToken);
localStorage.setItem(USER_KEY, JSON.stringify(payload.user));
clearLegacy();
void refreshAvatar();
}
async function login(identifier: string, password: string) {
@@ -92,20 +125,62 @@ export const useAuthStore = defineStore('auth', () => {
function logout() {
token.value = null;
user.value = null;
clearAvatarUrl();
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
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 {
token,
user,
isAuthenticated,
avatarSrc,
login,
requestLoginOtp,
verifyLoginOtp,
startRegister,
verifyRegister,
logout,
setUser,
hydrate,
updateProfile,
uploadAvatar,
removeAvatar,
};
});
+106 -39
View File
@@ -280,8 +280,21 @@ async function toggle(row: MessageAutomationRuleDto) {
}
}
async function remove(row: MessageAutomationRuleDto) {
if (!confirm(`قانون «${row.name}» حذف شود؟`)) return;
const pendingDelete = ref<MessageAutomationRuleDto | null>(null);
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 {
await api.delete(`/message-automations/${row.id}`);
if (editingId.value === row.id) resetForm();
@@ -410,7 +423,7 @@ onUnmounted(() => {
</button>
<div v-if="menuId === row.id" class="pop">
<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 class="m-copy">
@@ -1071,23 +1084,25 @@ onUnmounted(() => {
<td>{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}</td>
<td>{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}</td>
<td class="ops">
<button
class="icon-btn status"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
:title="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
>
<UiIcon :name="row.isActive ? 'check-plain' : 'x'" :size="16" />
</button>
<button class="icon-btn" type="button" title="ویرایش" aria-label="ویرایش" @click="startEdit(row)">
<UiIcon name="pencil" :size="16" />
</button>
<button class="icon-btn danger" type="button" title="حذف" aria-label="حذف" @click="remove(row)">
<UiIcon name="trash" :size="16" />
</button>
<div class="ops-row">
<button
class="icon-btn status"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
:title="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
>
<UiIcon :name="row.isActive ? 'check' : 'x-circle'" :size="24" />
</button>
<button class="icon-btn edit" type="button" title="ویرایش" aria-label="ویرایش" @click="startEdit(row)">
<UiIcon name="edit" :size="24" />
</button>
<button class="icon-btn danger" type="button" title="حذف" aria-label="حذف" @click="askRemove(row)">
<UiIcon name="trash" :size="24" />
</button>
</div>
</td>
</tr>
</tbody>
@@ -1123,12 +1138,24 @@ onUnmounted(() => {
<p class="preview">{{ row.description || row.body }}</p>
<div class="card-actions">
<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>
</article>
</div>
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
</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>
</template>
@@ -1678,6 +1705,37 @@ textarea {
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 {
position: relative;
z-index: 1;
@@ -1802,50 +1860,59 @@ textarea {
}
.desk-table .ops {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: nowrap;
display: table-cell;
width: 1%;
white-space: nowrap;
vertical-align: middle;
}
.ops-row {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon-btn {
display: grid;
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
flex: 0 0 2.75rem;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.icon-btn:hover {
background: #f8fafc;
background: transparent;
color: #0f172a;
}
.icon-btn.edit {
color: #2563eb;
}
.icon-btn.edit:hover {
color: #1d4ed8;
}
.icon-btn.danger {
color: #b91c1c;
border-color: #fecaca;
color: #dc2626;
}
.icon-btn.danger:hover {
background: #fef2f2;
color: #b91c1c;
background: transparent;
}
.icon-btn.status {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.icon-btn.status.on {
border-color: #bbf7d0;
background: #f0fdf4;
color: #16a34a;
}
+320 -26
View File
@@ -1,6 +1,7 @@
<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 axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import type { AskMentoResponse } from '@mento/shared';
@@ -68,13 +69,24 @@ const tab = ref<TabKey>('summary');
const aiBrief = ref('');
const moreItems: ProfileMoreItem[] = [
{ key: 'info', label: 'ویرایش اطلاعات', to: { name: 'profile', query: { tab: 'info' } } },
{ key: 'info', label: 'ویرایش اطلاعات' },
{ key: 'activity', label: 'فعالیت جدید', to: { name: 'activity-new' } },
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new' } },
{ key: 'quote', label: 'پیش‌فاکتور جدید', to: { name: 'quote-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 roleLabel = computed(() =>
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : 'کاربر',
@@ -270,7 +282,73 @@ function readTab() {
function setTab(next: TabKey) {
tab.value = next;
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() {
@@ -283,6 +361,47 @@ function onSmartCta() {
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() {
loading.value = true;
error.value = '';
@@ -324,6 +443,24 @@ async function loadAiBrief() {
watch(() => route.query.tab, readTab, { 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>
<template>
@@ -333,7 +470,7 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<UiIcon name="chevron" :size="18" />
</RouterLink>
<span class="nav-brand">mento</span>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" />
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
</header>
<p v-if="loading" class="muted pad">در حال بارگذاری…</p>
@@ -341,8 +478,25 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<template v-else>
<div class="identity">
<div class="avatar online">
<img src="/brand/user-avatar.png" :alt="name" width="76" height="76" />
<div
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 class="who">
<h1>{{ name }}</h1>
@@ -350,6 +504,16 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<div class="badges">
<span class="pill active">آنلاین</span>
</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>
@@ -531,11 +695,49 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
</div>
<div v-show="tab === 'info'" class="panel info-panel">
<dl>
<div><dt>نام</dt><dd>{{ name }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ auth.user?.email || '—' }}</dd></div>
<div><dt>نقش</dt><dd>{{ roleLabel }}</dd></div>
</dl>
<form class="info-form" @submit.prevent="saveInfo">
<label class="info-row">
<span>نام</span>
<input
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>
</template>
@@ -680,13 +882,23 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
min-width: 0;
}
.avatar {
.avatar-edit {
position: relative;
width: 76px;
height: 76px;
flex-shrink: 0;
}
.avatar-edit.editable {
cursor: pointer;
}
.avatar {
display: block;
width: 76px;
height: 76px;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
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;
}
.avatar.online::after {
content: '';
.online-dot {
position: absolute;
width: 14px;
height: 14px;
bottom: 2px;
inset-inline-start: 2px;
z-index: 2;
width: 16px;
height: 16px;
border-radius: 999px;
background: #22c55e;
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;
inset-inline-start: 4px;
bottom: 4px;
background: #0f766e;
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 {
@@ -1045,28 +1290,70 @@ h1 {
gap: 0.65rem;
}
.info-panel dl {
.info-form {
margin: 0;
background: #fff;
border-radius: 16px;
padding: 0.4rem 1rem;
padding: 0.4rem 1rem 1rem;
display: grid;
gap: 0;
}
.info-panel dl > div {
.info-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.6rem;
align-items: center;
padding: 0.7rem 0;
border-bottom: 1px solid #f1f5f9;
}
.info-panel dt {
.info-row span {
color: #94a3b8;
font-size: 0.8rem;
}
.info-panel dd {
margin: 0;
.info-row b {
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,
@@ -1171,11 +1458,18 @@ h1 {
gap: 0.2rem;
}
.avatar-edit,
.avatar {
width: 64px;
height: 64px;
}
.online-dot {
width: 14px;
height: 14px;
border-width: 2px;
}
.qa {
gap: 0.45rem;
}
@@ -1237,7 +1531,7 @@ h1 {
font-size: 0.8rem;
}
.info-panel dl > div {
.info-row {
grid-template-columns: 1fr;
gap: 0.12rem;
}
+7
View File
@@ -35,6 +35,7 @@ export interface AuthUserDto {
fullName: string;
role: UserRole;
organizationId: string;
hasAvatar?: boolean;
}
export interface OrgUserDto {
@@ -62,6 +63,12 @@ export interface UpdateUserRoleRequest {
role: UserRole;
}
export interface UpdateProfileRequest {
fullName: string;
email: string;
mobile: string;
}
export type DealStatus = "open" | "won" | "lost";
export interface ApiErrorBody {