diff --git a/apps/api-gateway/src/controllers/profile.controller.ts b/apps/api-gateway/src/controllers/profile.controller.ts index e53b075..0493266 100644 --- a/apps/api-gateway/src/controllers/profile.controller.ts +++ b/apps/api-gateway/src/controllers/profile.controller.ts @@ -1,83 +1,128 @@ -import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { CoreGrpcService } from '../core-grpc.service'; -import { getAuthorizedUserId } from '../document-access'; -import { SetPasswordDto, UpdateAvatarDto, UpdateContactsDto, UpdateProfileDto } from '../dto/profile.dto'; - -@ApiTags('Профиль и биометрия') -@ApiBearerAuth() -@Controller('profile/users/:userId') -export class ProfileController { - constructor( - private readonly core: CoreGrpcService, - private readonly jwt: JwtService - ) {} - - private async assertSelfAccess(authorization: string | undefined, userId: string) { - const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization); - if (requesterId !== userId) { - throw new ForbiddenException('Можно изменять только свой профиль'); - } - return requesterId; - } - - @Get() - @ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' }) - @ApiParam({ name: 'userId', description: 'ID пользователя' }) - @ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' }) - @ApiResponse({ status: 404, description: 'Пользователь не найден' }) - getProfile(@Param('userId') userId: string) { - return this.core.profile.GetProfile({ userId }); - } - - @Patch('avatar') - @ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' }) - @ApiParam({ name: 'userId', description: 'ID пользователя' }) - @ApiBody({ type: UpdateAvatarDto }) - @ApiResponse({ status: 200, description: 'Аватар обновлен' }) - updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) { - return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey }); - } - - @Patch() - @ApiOperation({ summary: 'Обновить профиль', description: 'Обновляет firstName, lastName, bio, age и gender в UserProfile, а displayName синхронизирует с именем и фамилией.' }) - @ApiParam({ name: 'userId', description: 'ID пользователя' }) - @ApiBody({ type: UpdateProfileDto }) - @ApiResponse({ status: 200, description: 'Профиль обновлен' }) - updateProfile(@Param('userId') userId: string, @Body() dto: UpdateProfileDto) { - return this.core.profile.UpdateProfile({ userId, ...dto }); - } - - @Patch('contacts') - @ApiOperation({ summary: 'Обновить контакты', description: 'Обновляет основную и резервную почту/телефон в модели User.' }) - @ApiParam({ name: 'userId', description: 'ID пользователя' }) - @ApiBody({ type: UpdateContactsDto }) - @ApiResponse({ status: 200, description: 'Контакты обновлены' }) - @ApiResponse({ status: 400, description: 'Переданы некорректные контактные данные' }) - updateContacts(@Param('userId') userId: string, @Body() dto: UpdateContactsDto) { - return this.core.profile.UpdateContacts({ userId, ...dto }); - } - - @Post('password') - @ApiOperation({ summary: 'Установить пароль', description: 'Устанавливает пароль для аккаунта. После этого пароль становится доступным как альтернативный способ входа.' }) - @ApiParam({ name: 'userId', description: 'ID пользователя' }) - @ApiBody({ type: SetPasswordDto }) - @ApiResponse({ status: 201, description: 'Пароль установлен' }) - setPassword(@Param('userId') userId: string, @Body() dto: SetPasswordDto) { - return this.core.profile.SetPassword({ userId, password: dto.password }); - } - - @Post('self-delete') - @ApiOperation({ - summary: 'Удалить свой профиль', - description: 'Мягко удаляет профиль: помечает аккаунт как удалённый, освобождает логин и контакты для повторной регистрации, сбрасывает роли и завершает все сессии.' - }) - @ApiParam({ name: 'userId', description: 'ID пользователя' }) - @ApiResponse({ status: 201, description: 'Профиль удалён' }) - async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { - await this.assertSelfAccess(authorization, userId); - return this.core.profile.SoftDeleteProfile({ userId }); - } -} - \ No newline at end of file +import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { + ChangePasswordDto, + PasswordVerificationDto, + SendPasswordVerificationOtpDto, + SetPasswordDto, + UpdateAvatarDto, + UpdateContactsDto, + UpdateProfileDto +} from '../dto/profile.dto'; +@ApiTags('Профиль и биометрия') +@ApiBearerAuth() +@Controller('profile/users/:userId') +export class ProfileController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + private async assertSelfAccess(authorization: string | undefined, userId: string) { + const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization); + if (requesterId !== userId) { + throw new ForbiddenException('Можно изменять только свой профиль'); + } + return requesterId; + } + + @Get() + @ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' }) + @ApiResponse({ status: 404, description: 'Пользователь не найден' }) + getProfile(@Param('userId') userId: string) { + return this.core.profile.GetProfile({ userId }); + } + + @Patch('avatar') + @ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpdateAvatarDto }) + @ApiResponse({ status: 200, description: 'Аватар обновлен' }) + updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) { + return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey }); + } + + @Patch() + @ApiOperation({ summary: 'Обновить профиль', description: 'Обновляет firstName, lastName, bio, age и gender в UserProfile, а displayName синхронизирует с именем и фамилией.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpdateProfileDto }) + @ApiResponse({ status: 200, description: 'Профиль обновлен' }) + updateProfile(@Param('userId') userId: string, @Body() dto: UpdateProfileDto) { + return this.core.profile.UpdateProfile({ userId, ...dto }); + } + + @Patch('contacts') + @ApiOperation({ summary: 'Обновить контакты', description: 'Обновляет основную и резервную почту/телефон в модели User.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpdateContactsDto }) + @ApiResponse({ status: 200, description: 'Контакты обновлены' }) + @ApiResponse({ status: 400, description: 'Переданы некорректные контактные данные' }) + updateContacts(@Param('userId') userId: string, @Body() dto: UpdateContactsDto) { + return this.core.profile.UpdateContacts({ userId, ...dto }); + } + + @Post('password') + @ApiOperation({ summary: 'Установить пароль', description: 'Устанавливает пароль для аккаунта без ранее заданного пароля.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: SetPasswordDto }) + @ApiResponse({ status: 201, description: 'Пароль установлен' }) + async setPassword( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Body() dto: SetPasswordDto + ) { + await this.assertSelfAccess(authorization, userId); + return this.core.profile.SetPassword({ userId, password: dto.password }); + } + + @Post('password/otp') + @ApiOperation({ summary: 'Отправить OTP для смены пароля' }) + async sendPasswordVerificationOtp( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Body() dto: SendPasswordVerificationOtpDto + ) { + await this.assertSelfAccess(authorization, userId); + return this.core.profile.SendPasswordVerificationOtp({ userId, channel: dto.channel }); + } + + @Patch('password') + @ApiOperation({ summary: 'Сменить пароль с подтверждением личности' }) + async changePassword( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Body() dto: ChangePasswordDto + ) { + await this.assertSelfAccess(authorization, userId); + return this.core.profile.ChangePassword({ userId, ...dto }); + } + + @Delete('password') + @ApiOperation({ summary: 'Удалить пароль с подтверждением личности' }) + async removePassword( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Body() dto: PasswordVerificationDto + ) { + await this.assertSelfAccess(authorization, userId); + return this.core.profile.RemovePassword({ userId, ...dto }); + } + + @Post('self-delete') + @ApiOperation({ + summary: 'Удалить свой профиль', + description: 'Мягко удаляет профиль: помечает аккаунт как удалённый, освобождает логин и контакты для повторной регистрации, сбрасывает роли и завершает все сессии.' + }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 201, description: 'Профиль удалён' }) + async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + await this.assertSelfAccess(authorization, userId); + return this.core.profile.SoftDeleteProfile({ userId }); + } +} + diff --git a/apps/api-gateway/src/dto/profile.dto.ts b/apps/api-gateway/src/dto/profile.dto.ts index 5927970..fc48ee3 100644 --- a/apps/api-gateway/src/dto/profile.dto.ts +++ b/apps/api-gateway/src/dto/profile.dto.ts @@ -76,6 +76,41 @@ export class SetPasswordDto { password!: string; } +export class SendPasswordVerificationOtpDto { + @ApiProperty({ description: 'Канал OTP', enum: ['sms', 'email'] }) + @IsString({ message: 'Канал должен быть строкой' }) + channel!: 'sms' | 'email'; +} + +export class PasswordVerificationDto { + @ApiPropertyOptional({ description: 'Текущий пароль' }) + @IsOptional() + @IsString({ message: 'Текущий пароль должен быть строкой' }) + currentPassword?: string; + + @ApiPropertyOptional({ description: 'OTP-код из SMS или почты' }) + @IsOptional() + @IsString({ message: 'OTP-код должен быть строкой' }) + otpCode?: string; + + @ApiPropertyOptional({ description: 'Канал OTP', enum: ['sms', 'email'] }) + @IsOptional() + @IsString({ message: 'Канал OTP должен быть строкой' }) + otpChannel?: 'sms' | 'email'; + + @ApiPropertyOptional({ description: 'Код из приложения-аутентификатора' }) + @IsOptional() + @IsString({ message: 'Код аутентификатора должен быть строкой' }) + totpCode?: string; +} + +export class ChangePasswordDto extends PasswordVerificationDto { + @ApiProperty({ description: 'Новый пароль', minLength: 8 }) + @IsString({ message: 'Пароль должен быть строкой' }) + @MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' }) + newPassword!: string; +} + export class UserIdParamDto { @ApiProperty({ description: 'ID пользователя' }) @IsString({ message: 'ID пользователя должен быть строкой' }) diff --git a/apps/frontend/app/security/page.tsx b/apps/frontend/app/security/page.tsx index 3800a26..2175ee1 100644 --- a/apps/frontend/app/security/page.tsx +++ b/apps/frontend/app/security/page.tsx @@ -24,7 +24,8 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { OtpInput } from '@/components/ui/otp-input'; -import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api'; +import { ActiveDevice, apiFetch, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api'; +import { cn } from '@/lib/utils'; import { QRCodeSVG } from 'qrcode.react'; import { useRequireAuth } from '@/hooks/use-require-auth'; @@ -56,8 +57,13 @@ const dialogConfig: Record([]); @@ -70,6 +76,17 @@ export default function SecurityPage() { const [totpEnabled, setTotpEnabled] = useState(false); const [totpSetup, setTotpSetup] = useState(null); const [totpCode, setTotpCode] = useState(''); + const [passwordVerifyMethod, setPasswordVerifyMethod] = useState('current'); + const [currentPassword, setCurrentPassword] = useState(''); + const [passwordOtpCode, setPasswordOtpCode] = useState(''); + const [passwordOtpSent, setPasswordOtpSent] = useState(false); + const [passwordOtpMasked, setPasswordOtpMasked] = useState(''); + const [passwordTotpCode, setPasswordTotpCode] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [passwordOtpSending, setPasswordOtpSending] = useState(false); + + const hasSmsContact = Boolean(user?.phone || user?.backupPhone); + const hasEmailContact = Boolean(user?.email || user?.backupEmail); const loadSecurity = useCallback(async () => { if (!user || !token) return; @@ -178,6 +195,108 @@ export default function SecurityPage() { } } + function buildPasswordVerification() { + if (passwordVerifyMethod === 'current') { + return { currentPassword: currentPassword.trim() }; + } + if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') { + return { otpCode: passwordOtpCode.trim(), otpChannel: passwordVerifyMethod }; + } + return { totpCode: passwordTotpCode.trim() }; + } + + function isPasswordVerificationReady() { + if (passwordVerifyMethod === 'current') return currentPassword.trim().length > 0; + if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') { + return passwordOtpSent && passwordOtpCode.trim().length === 6; + } + return passwordTotpCode.trim().length === 6; + } + + async function sendPasswordVerificationOtp() { + if (!user || !token || (passwordVerifyMethod !== 'sms' && passwordVerifyMethod !== 'email')) return; + setPasswordOtpSending(true); + try { + const response = await apiFetch<{ maskedTarget?: string }>( + `/profile/users/${user.id}/password/otp`, + { method: 'POST', body: JSON.stringify({ channel: passwordVerifyMethod }) }, + token + ); + setPasswordOtpSent(true); + setPasswordOtpMasked(response.maskedTarget ?? ''); + setPasswordOtpCode(''); + showToast(`Код отправлен${response.maskedTarget ? ` на ${response.maskedTarget}` : ''}`); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось отправить код') ?? 'Ошибка'); + } finally { + setPasswordOtpSending(false); + } + } + + async function submitPasswordChange() { + if (!user || !token || !newPassword.trim()) { + showToast('Укажите новый пароль'); + return; + } + if (!isPasswordVerificationReady()) { + showToast('Подтвердите личность перед сменой пароля'); + return; + } + setIsDialogSaving(true); + try { + await apiFetch( + `/profile/users/${user.id}/password`, + { + method: 'PATCH', + body: JSON.stringify({ newPassword: newPassword.trim(), ...buildPasswordVerification() }) + }, + token + ); + showToast('Пароль изменён'); + resetPasswordDialog(); + setDialogMode(null); + await refreshProfile(); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось сменить пароль') ?? 'Ошибка'); + } finally { + setIsDialogSaving(false); + } + } + + async function submitPasswordRemove() { + if (!user || !token) return; + if (!isPasswordVerificationReady()) { + showToast('Подтвердите личность перед удалением пароля'); + return; + } + setIsDialogSaving(true); + try { + await apiFetch( + `/profile/users/${user.id}/password`, + { method: 'DELETE', body: JSON.stringify(buildPasswordVerification()) }, + token + ); + showToast('Пароль удалён'); + resetPasswordDialog(); + setDialogMode(null); + await refreshProfile(); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось удалить пароль') ?? 'Ошибка'); + } finally { + setIsDialogSaving(false); + } + } + + function resetPasswordDialog() { + setPasswordVerifyMethod('current'); + setCurrentPassword(''); + setPasswordOtpCode(''); + setPasswordOtpSent(false); + setPasswordOtpMasked(''); + setPasswordTotpCode(''); + setNewPassword(''); + } + async function submitDialog() { if (!user || !token || !dialogMode) return; setIsDialogSaving(true); @@ -188,6 +307,7 @@ export default function SecurityPage() { } else if (dialogMode === 'password') { await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token); showToast('Пароль установлен'); + await refreshProfile(); } else if (dialogMode === 'backupEmail') { await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupEmail: dialogValue.trim() }) }, token); showToast('Резервная почта добавлена'); @@ -206,6 +326,9 @@ export default function SecurityPage() { function openDialog(mode: Exclude) { setDialogValue(''); + if (mode === 'password') { + resetPasswordDialog(); + } setDialogMode(mode); } @@ -280,7 +403,11 @@ export default function SecurityPage() {

Пароль

-

{user?.email || user?.phone ? 'Добавить пароль как способ входа' : 'Установить пароль'}

+

+ {user?.hasPassword + ? 'Пароль установлен. Для смены или удаления потребуется подтверждение.' + : 'Добавить пароль как способ входа'} +

@@ -335,6 +462,7 @@ export default function SecurityPage() { setTotpSetup(null); } setTotpCode(''); + resetPasswordDialog(); } }}> @@ -369,21 +497,133 @@ export default function SecurityPage() { {dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? ( <> - {dialogConfig[dialogMode].title} + + {dialogMode === 'password' && user?.hasPassword ? 'Пароль установлен' : dialogConfig[dialogMode].title} + -
- - setDialogValue(event.target.value)} - /> -
- + {dialogMode === 'password' && user?.hasPassword ? ( +
+

{passwordChangeHint}

+ +
+ {[ + { id: 'current' as const, label: 'Текущий пароль' }, + ...(hasSmsContact ? [{ id: 'sms' as const, label: 'SMS' }] : []), + ...(hasEmailContact ? [{ id: 'email' as const, label: 'Почта' }] : []), + ...(totpEnabled ? [{ id: 'totp' as const, label: 'Аутентификатор' }] : []) + ].map((method) => ( + + ))} +
+ + {passwordVerifyMethod === 'current' ? ( +
+ setCurrentPassword(event.target.value)} + /> +
+ ) : null} + + {passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email' ? ( +
+ + {passwordOtpSent ? ( +

+ Код отправлен{passwordOtpMasked ? ` на ${passwordOtpMasked}` : ''} +

+ ) : null} + +
+ ) : null} + + {passwordVerifyMethod === 'totp' ? ( +
+

Введите код из приложения-аутентификатора

+ +
+ ) : null} + +
+ setNewPassword(event.target.value)} + /> +
+ +
+ + +
+
+ ) : ( + <> +
+ + setDialogValue(event.target.value)} + /> +
+ + + )} ) : null}
diff --git a/apps/frontend/components/id/messaging-settings-section.tsx b/apps/frontend/components/id/messaging-settings-section.tsx index 5a7644d..a286016 100644 --- a/apps/frontend/components/id/messaging-settings-section.tsx +++ b/apps/frontend/components/id/messaging-settings-section.tsx @@ -36,6 +36,14 @@ function shouldShowMessagingField(setting: SystemSetting, settings: SystemSettin const provider = getSettingValue(settings, 'SMS_PROVIDER'); if (setting.key === 'SMS_API_KEY') return provider === 'smsru'; if (setting.key === 'SMS_LOGIN' || setting.key === 'SMS_PASSWORD') return provider === 'smsc'; + if ( + setting.key === 'SMS_GATEWAY_URL' || + setting.key === 'SMS_GATEWAY_USERNAME' || + setting.key === 'SMS_GATEWAY_PASSWORD' || + setting.key === 'SMS_GATEWAY_MESSAGE_TEMPLATE' + ) { + return provider === 'huawei_modem'; + } const emailProvider = getSettingValue(settings, 'EMAIL_PROVIDER'); if (setting.key.startsWith('EMAIL_SMTP_') || setting.key.startsWith('EMAIL_FROM_')) { @@ -180,7 +188,7 @@ export function MessagingSettingsSection({
))} + ) : type === 'textarea' ? ( +