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
|
||||
fullName String
|
||||
role UserRole @default(sales)
|
||||
/// مسیر فایل آواتار در فضای آپلود
|
||||
avatarStorageKey String?
|
||||
emailVerifiedAt DateTime?
|
||||
mobileVerifiedAt DateTime?
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user