fix idp on started
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
|
import { ConnectLinkedAccountDto, TestMessagingDeliveryDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
|
||||||
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
|
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
|
||||||
|
|
||||||
const settingsWritePipe = new ValidationPipe({
|
const settingsWritePipe = new ValidationPipe({
|
||||||
@@ -97,6 +97,16 @@ export class SettingsController {
|
|||||||
return this.core.settings.DeleteSocialProvider({ providerName });
|
return this.core.settings.DeleteSocialProvider({ providerName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('messaging/test')
|
||||||
|
@UsePipes(settingsWritePipe)
|
||||||
|
@ApiOperation({ summary: 'Тест email/SMS', description: 'Отправляет тестовый OTP-код через настроенного провайдера.' })
|
||||||
|
@ApiBody({ type: TestMessagingDeliveryDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Тестовое сообщение отправлено' })
|
||||||
|
testMessaging(@Body() dto: TestMessagingDeliveryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
return this.core.settings.TestMessagingDelivery({ channel: dto.channel, target: dto.target });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('linked-accounts/users/:userId')
|
@Get('linked-accounts/users/:userId')
|
||||||
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpsertSettingDto {
|
export class UpsertSettingDto {
|
||||||
@ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
|
@ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
|
||||||
@@ -43,6 +43,17 @@ export class UpsertSocialProviderDto {
|
|||||||
isEnabled!: boolean;
|
isEnabled!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TestMessagingDeliveryDto {
|
||||||
|
@ApiProperty({ description: 'Канал доставки', enum: ['email', 'sms'] })
|
||||||
|
@IsIn(['email', 'sms'], { message: 'Канал должен быть email или sms' })
|
||||||
|
channel!: 'email' | 'sms';
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Email или номер телефона получателя', example: 'user@example.com' })
|
||||||
|
@IsString({ message: 'Получатель должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите email или телефон' })
|
||||||
|
target!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ConnectLinkedAccountDto {
|
export class ConnectLinkedAccountDto {
|
||||||
@ApiProperty({ description: 'Название провайдера', example: 'google' })
|
@ApiProperty({ description: 'Название провайдера', example: 'google' })
|
||||||
@IsString({ message: 'Название провайдера должно быть строкой' })
|
@IsString({ message: 'Название провайдера должно быть строкой' })
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useToast } from '@/components/id/toast-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
||||||
import { getSettingMeta, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog';
|
import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog';
|
||||||
|
import { MessagingSettingsSection } from '@/components/id/messaging-settings-section';
|
||||||
|
|
||||||
function parseBoolean(value: string) {
|
function parseBoolean(value: string) {
|
||||||
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||||
@@ -41,6 +42,7 @@ export default function AdminSettingsPage() {
|
|||||||
const groupedSettings = useMemo(() => {
|
const groupedSettings = useMemo(() => {
|
||||||
const groups = new Map<string, SystemSetting[]>();
|
const groups = new Map<string, SystemSetting[]>();
|
||||||
for (const setting of settings) {
|
for (const setting of settings) {
|
||||||
|
if (MESSAGING_SETTING_KEYS.includes(setting.key)) continue;
|
||||||
const meta = getSettingMeta(setting.key);
|
const meta = getSettingMeta(setting.key);
|
||||||
const group = meta?.group ?? 'other';
|
const group = meta?.group ?? 'other';
|
||||||
const list = groups.get(group) ?? [];
|
const list = groups.get(group) ?? [];
|
||||||
@@ -126,7 +128,7 @@ export default function AdminSettingsPage() {
|
|||||||
<section key={groupKey} className="mb-10">
|
<section key={groupKey} className="mb-10">
|
||||||
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{groupSettings.map((setting) => {
|
{groupSettings.filter((setting) => isSettingVisible(setting, settings)).map((setting) => {
|
||||||
const meta = getSettingMeta(setting.key);
|
const meta = getSettingMeta(setting.key);
|
||||||
const label = meta?.label ?? setting.key;
|
const label = meta?.label ?? setting.key;
|
||||||
const type = meta?.type ?? 'text';
|
const type = meta?.type ?? 'text';
|
||||||
@@ -153,6 +155,18 @@ export default function AdminSettingsPage() {
|
|||||||
>
|
>
|
||||||
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
||||||
</button>
|
</button>
|
||||||
|
) : type === 'select' ? (
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
|
||||||
|
value={setting.value}
|
||||||
|
onChange={(event) => updateSettingValue(setting.key, event.target.value)}
|
||||||
|
>
|
||||||
|
{(meta?.options ?? []).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -178,6 +192,15 @@ export default function AdminSettingsPage() {
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<MessagingSettingsSection
|
||||||
|
settings={settings}
|
||||||
|
token={token}
|
||||||
|
savingKey={savingKey}
|
||||||
|
onUpdateValue={updateSettingValue}
|
||||||
|
onSave={saveSetting}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
|
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
|||||||
212
apps/frontend/components/id/messaging-settings-section.tsx
Normal file
212
apps/frontend/components/id/messaging-settings-section.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2, Mail, MessageSquare, Save, Send, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { PhoneInput, phoneCountries } from '@/components/ui/phone-input';
|
||||||
|
import { apiFetch, buildSystemSettingPayload, SystemSetting } from '@/lib/api';
|
||||||
|
import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS } from '@/lib/system-settings-catalog';
|
||||||
|
|
||||||
|
function parseBoolean(value: string) {
|
||||||
|
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagingSettingsSectionProps {
|
||||||
|
settings: SystemSetting[];
|
||||||
|
token: string | null;
|
||||||
|
savingKey: string | null;
|
||||||
|
onUpdateValue: (key: string, value: string) => void;
|
||||||
|
onSave: (setting: SystemSetting) => Promise<void>;
|
||||||
|
showToast: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingValue(settings: SystemSetting[], key: string) {
|
||||||
|
return settings.find((item) => item.key === key)?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowMessagingField(setting: SystemSetting, settings: SystemSetting[]) {
|
||||||
|
if (!isSettingVisible(setting, settings)) return false;
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const emailProvider = getSettingValue(settings, 'EMAIL_PROVIDER');
|
||||||
|
if (setting.key.startsWith('EMAIL_SMTP_') || setting.key.startsWith('EMAIL_FROM_')) {
|
||||||
|
return parseBoolean(getSettingValue(settings, 'EMAIL_ENABLED')) && emailProvider === 'smtp';
|
||||||
|
}
|
||||||
|
if (setting.key === 'EMAIL_PROVIDER') {
|
||||||
|
return parseBoolean(getSettingValue(settings, 'EMAIL_ENABLED'));
|
||||||
|
}
|
||||||
|
if (setting.key === 'SMS_PROVIDER') {
|
||||||
|
return parseBoolean(getSettingValue(settings, 'SMS_ENABLED'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessagingSettingsSection({
|
||||||
|
settings,
|
||||||
|
token,
|
||||||
|
savingKey,
|
||||||
|
onUpdateValue,
|
||||||
|
onSave,
|
||||||
|
showToast
|
||||||
|
}: MessagingSettingsSectionProps) {
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
const [testPhone, setTestPhone] = useState('');
|
||||||
|
const [testPhoneCountry, setTestPhoneCountry] = useState(phoneCountries[0]);
|
||||||
|
const [testingChannel, setTestingChannel] = useState<'email' | 'sms' | null>(null);
|
||||||
|
|
||||||
|
const messagingSettings = useMemo(
|
||||||
|
() => settings.filter((item) => MESSAGING_SETTING_KEYS.includes(item.key)),
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function sendTest(channel: 'email' | 'sms') {
|
||||||
|
if (!token) return;
|
||||||
|
const target = channel === 'email' ? testEmail.trim() : `${testPhoneCountry.code}${testPhone}`;
|
||||||
|
if (!target) {
|
||||||
|
showToast(channel === 'email' ? 'Укажите email для теста' : 'Укажите номер телефона для теста');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestingChannel(channel);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch<{ message?: string }>(
|
||||||
|
'/admin/settings/messaging/test',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ channel, target })
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
showToast(response.message ?? 'Тестовое сообщение отправлено');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось отправить тестовое сообщение');
|
||||||
|
} finally {
|
||||||
|
setTestingChannel(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailEnabled = parseBoolean(getSettingValue(messagingSettings, 'EMAIL_ENABLED'));
|
||||||
|
const smsEnabled = parseBoolean(getSettingValue(messagingSettings, 'SMS_ENABLED'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<MessageSquare className="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium">Email и SMS провайдеры</h3>
|
||||||
|
<p className="mt-1 text-sm text-[#667085]">
|
||||||
|
Настройте SMTP и SMS — OTP-коды при входе и регистрации будут отправляться автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messagingSettings.filter((setting) => shouldShowMessagingField(setting, messagingSettings)).map((setting) => {
|
||||||
|
const meta = getSettingMeta(setting.key);
|
||||||
|
const label = meta?.label ?? setting.key;
|
||||||
|
const type = meta?.type ?? 'text';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-semibold">{label}</div>
|
||||||
|
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{type === 'boolean' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => onUpdateValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true')}
|
||||||
|
>
|
||||||
|
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
||||||
|
</button>
|
||||||
|
) : type === 'select' ? (
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
|
||||||
|
value={setting.value}
|
||||||
|
onChange={(event) => onUpdateValue(setting.key, event.target.value)}
|
||||||
|
>
|
||||||
|
{(meta?.options ?? []).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
||||||
|
value={setting.value}
|
||||||
|
className={setting.isSecret ? 'w-[240px] bg-white' : 'w-[220px] bg-white'}
|
||||||
|
onChange={(event) => onUpdateValue(setting.key, event.target.value)}
|
||||||
|
/>
|
||||||
|
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button disabled={savingKey === setting.key} onClick={() => void onSave(setting)}>
|
||||||
|
{savingKey === setting.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-[24px] border border-[#e4e7ec] bg-white p-5">
|
||||||
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Тест email
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-[#667085]">
|
||||||
|
{emailEnabled ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала включите Email и сохраните настройки SMTP.'}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
className="mt-4 bg-[#f9fafb]"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(event) => setTestEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button className="mt-3" disabled={!emailEnabled || testingChannel === 'email'} onClick={() => void sendTest('email')}>
|
||||||
|
{testingChannel === 'email' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Отправить тест
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] border border-[#e4e7ec] bg-white p-5">
|
||||||
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Тест SMS
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-[#667085]">
|
||||||
|
{smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала включите SMS и сохраните ключ API.'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<PhoneInput
|
||||||
|
country={testPhoneCountry}
|
||||||
|
value={testPhone}
|
||||||
|
onCountryChange={setTestPhoneCountry}
|
||||||
|
onValueChange={setTestPhone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="mt-3" disabled={!smsEnabled || testingChannel === 'sms'} onClick={() => void sendTest('sms')}>
|
||||||
|
{testingChannel === 'sms' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Отправить тест
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type SystemSettingFieldType = 'text' | 'number' | 'boolean';
|
export type SystemSettingFieldType = 'text' | 'number' | 'boolean' | 'select';
|
||||||
|
|
||||||
export interface SystemSettingMeta {
|
export interface SystemSettingMeta {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -7,6 +7,8 @@ export interface SystemSettingMeta {
|
|||||||
type: SystemSettingFieldType;
|
type: SystemSettingFieldType;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
showWhen?: { key: string; equals: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
||||||
@@ -14,6 +16,7 @@ export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
|||||||
pin: 'PIN-код и блокировка сессии',
|
pin: 'PIN-код и блокировка сессии',
|
||||||
auth: 'Аутентификация и регистрация',
|
auth: 'Аутентификация и регистрация',
|
||||||
ldap: 'LDAP / Active Directory',
|
ldap: 'LDAP / Active Directory',
|
||||||
|
messaging: 'Email и SMS',
|
||||||
limits: 'Лимиты и ограничения',
|
limits: 'Лимиты и ограничения',
|
||||||
media: 'Медиа и файлы'
|
media: 'Медиа и файлы'
|
||||||
};
|
};
|
||||||
@@ -47,6 +50,42 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
|
|||||||
{ key: 'LDAP_USERNAME_ATTR', label: 'Атрибут логина', group: 'ldap', type: 'text' },
|
{ key: 'LDAP_USERNAME_ATTR', label: 'Атрибут логина', group: 'ldap', type: 'text' },
|
||||||
{ key: 'LDAP_EMAIL_ATTR', label: 'Атрибут почты', group: 'ldap', type: 'text' },
|
{ key: 'LDAP_EMAIL_ATTR', label: 'Атрибут почты', group: 'ldap', type: 'text' },
|
||||||
{ key: 'LDAP_DISPLAY_NAME_ATTR', label: 'Атрибут имени', group: 'ldap', type: 'text' },
|
{ key: 'LDAP_DISPLAY_NAME_ATTR', label: 'Атрибут имени', group: 'ldap', type: 'text' },
|
||||||
|
{ key: 'EMAIL_ENABLED', label: 'Email включён', group: 'messaging', type: 'boolean', hint: 'Отправка OTP-кодов на почту через SMTP' },
|
||||||
|
{
|
||||||
|
key: 'EMAIL_PROVIDER',
|
||||||
|
label: 'Email-провайдер',
|
||||||
|
group: 'messaging',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'smtp', label: 'SMTP (Yandex, Gmail, Mail.ru и др.)' },
|
||||||
|
{ value: 'console', label: 'Только лог (отладка)' }
|
||||||
|
],
|
||||||
|
showWhen: { key: 'EMAIL_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{ key: 'EMAIL_SMTP_HOST', label: 'SMTP-хост', group: 'messaging', type: 'text', hint: 'smtp.yandex.ru, smtp.gmail.com', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'EMAIL_SMTP_PORT', label: 'SMTP-порт', group: 'messaging', type: 'number', unit: 'порт', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'EMAIL_SMTP_SECURE', label: 'SMTP SSL/TLS', group: 'messaging', type: 'boolean', hint: 'Включите для порта 465', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'EMAIL_SMTP_USER', label: 'SMTP-логин', group: 'messaging', type: 'text', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'EMAIL_SMTP_PASSWORD', label: 'SMTP-пароль', group: 'messaging', type: 'text', hint: 'App-password для Gmail/Yandex', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'EMAIL_FROM_ADDRESS', label: 'Email отправителя', group: 'messaging', type: 'text', hint: 'noreply@yourdomain.ru', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'EMAIL_FROM_NAME', label: 'Имя отправителя', group: 'messaging', type: 'text', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'SMS_ENABLED', label: 'SMS включён', group: 'messaging', type: 'boolean', hint: 'Отправка OTP-кодов по SMS' },
|
||||||
|
{
|
||||||
|
key: 'SMS_PROVIDER',
|
||||||
|
label: 'SMS-провайдер',
|
||||||
|
group: 'messaging',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'smsru', label: 'sms.ru' },
|
||||||
|
{ value: 'smsc', label: 'smsc.ru' },
|
||||||
|
{ value: 'console', label: 'Только лог (отладка)' }
|
||||||
|
],
|
||||||
|
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{ key: 'SMS_API_KEY', label: 'API-ключ sms.ru', group: 'messaging', type: 'text', hint: 'api_id из личного кабинета sms.ru', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'SMS_LOGIN', label: 'Логин smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'SMS_PASSWORD', label: 'Пароль smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||||
|
{ key: 'SMS_SENDER', label: 'Имя отправителя SMS', group: 'messaging', type: 'text', hint: 'Опционально, если одобрено провайдером', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||||
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
|
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
|
||||||
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
|
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
|
||||||
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
|
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
|
||||||
@@ -56,6 +95,20 @@ export function getSettingMeta(key: string) {
|
|||||||
return SYSTEM_SETTING_CATALOG.find((item) => item.key === key);
|
return SYSTEM_SETTING_CATALOG.find((item) => item.key === key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSettingVisible(setting: { key: string; value: string }, allSettings: Array<{ key: string; value: string }>) {
|
||||||
|
const meta = getSettingMeta(setting.key);
|
||||||
|
if (!meta?.showWhen) return true;
|
||||||
|
const parent = allSettings.find((item) => item.key === meta.showWhen!.key);
|
||||||
|
const parentValue = parent?.value ?? 'false';
|
||||||
|
return parseBooleanValue(parentValue) === parseBooleanValue(meta.showWhen.equals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBooleanValue(value: string) {
|
||||||
|
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MESSAGING_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'messaging').map((item) => item.key);
|
||||||
|
|
||||||
export function sortSettingsByCatalog<T extends { key: string }>(settings: T[]) {
|
export function sortSettingsByCatalog<T extends { key: string }>(settings: T[]) {
|
||||||
const order = new Map(SYSTEM_SETTING_CATALOG.map((item, index) => [item.key, index]));
|
const order = new Map(SYSTEM_SETTING_CATALOG.map((item, index) => [item.key, index]));
|
||||||
return [...settings].sort((a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999));
|
return [...settings].sort((a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999));
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"amqplib": "^0.10.9",
|
"amqplib": "^0.10.9",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
|
"nodemailer": "^7.0.6",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"@nestjs/testing": "^11.1.9",
|
"@nestjs/testing": "^11.1.9",
|
||||||
"@types/amqplib": "^0.10.8",
|
"@types/amqplib": "^0.10.8",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/nodemailer": "^7.0.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"grpc-tools": "^1.13.1",
|
"grpc-tools": "^1.13.1",
|
||||||
"prisma": "^7.1.0",
|
"prisma": "^7.1.0",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ChatService } from './domain/chat.service';
|
|||||||
import { NotificationPublisherService } from './infra/notification-publisher.service';
|
import { NotificationPublisherService } from './infra/notification-publisher.service';
|
||||||
import { MinioService } from './infra/minio.service';
|
import { MinioService } from './infra/minio.service';
|
||||||
import { LdapClientService } from './infra/ldap-client.service';
|
import { LdapClientService } from './infra/ldap-client.service';
|
||||||
|
import { MessagingService } from './infra/messaging.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -62,7 +63,8 @@ import { LdapClientService } from './infra/ldap-client.service';
|
|||||||
ChatService,
|
ChatService,
|
||||||
NotificationPublisherService,
|
NotificationPublisherService,
|
||||||
MinioService,
|
MinioService,
|
||||||
LdapClientService
|
LdapClientService,
|
||||||
|
MessagingService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, UseFilters } from '@nestjs/common';
|
import { BadRequestException, Controller, UseFilters } from '@nestjs/common';
|
||||||
import { GrpcMethod } from '@nestjs/microservices';
|
import { GrpcMethod } from '@nestjs/microservices';
|
||||||
import { GrpcExceptionFilter } from '../infra/grpc-exception.filter';
|
import { GrpcExceptionFilter } from '../infra/grpc-exception.filter';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
@@ -20,6 +20,7 @@ import { FamilyService } from './family.service';
|
|||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { ChatService } from './chat.service';
|
import { ChatService } from './chat.service';
|
||||||
|
import { MessagingService } from '../infra/messaging.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
@UseFilters(new GrpcExceptionFilter())
|
@UseFilters(new GrpcExceptionFilter())
|
||||||
@@ -41,7 +42,8 @@ export class AuthGrpcController {
|
|||||||
private readonly family: FamilyService,
|
private readonly family: FamilyService,
|
||||||
private readonly media: MediaService,
|
private readonly media: MediaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly chat: ChatService
|
private readonly chat: ChatService,
|
||||||
|
private readonly messaging: MessagingService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@GrpcMethod('AuthService', 'Register')
|
@GrpcMethod('AuthService', 'Register')
|
||||||
@@ -437,6 +439,15 @@ export class AuthGrpcController {
|
|||||||
return this.settings.deleteSocialProvider(command.providerName);
|
return this.settings.deleteSocialProvider(command.providerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('SettingsService', 'TestMessagingDelivery')
|
||||||
|
async testMessagingDelivery(command: { channel: string; target: string }) {
|
||||||
|
const channel = command.channel === 'sms' ? 'sms' : 'email';
|
||||||
|
if (!command.target?.trim()) {
|
||||||
|
throw new BadRequestException('Укажите email или номер телефона для тестовой отправки');
|
||||||
|
}
|
||||||
|
return this.messaging.sendTestDelivery(channel, command.target.trim());
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('ProfileService', 'GetProfile')
|
@GrpcMethod('ProfileService', 'GetProfile')
|
||||||
getProfile(command: { userId: string }) {
|
getProfile(command: { userId: string }) {
|
||||||
return this.profile.getProfile(command.userId);
|
return this.profile.getProfile(command.userId);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { normalizeLdapUserFilter, resolveLdapBindIdentity, resolveLdapPort, vali
|
|||||||
import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
|
import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
import { MessagingService } from '../infra/messaging.service';
|
||||||
|
|
||||||
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
|
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@ export class AuthService {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly access: AccessService,
|
private readonly access: AccessService,
|
||||||
private readonly settings: SettingsService,
|
private readonly settings: SettingsService,
|
||||||
private readonly ldapClient: LdapClientService
|
private readonly ldapClient: LdapClientService,
|
||||||
|
private readonly messaging: MessagingService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(command: RegisterCommand): Promise<PublicUser> {
|
async register(command: RegisterCommand): Promise<PublicUser> {
|
||||||
@@ -131,19 +133,27 @@ export class AuthService {
|
|||||||
async sendOtp(recipient: string, channel?: string) {
|
async sendOtp(recipient: string, channel?: string) {
|
||||||
const existingUser = await this.findUserByRecipient(recipient);
|
const existingUser = await this.findUserByRecipient(recipient);
|
||||||
const target = this.resolveOtpTarget(recipient, channel, existingUser);
|
const target = this.resolveOtpTarget(recipient, channel, existingUser);
|
||||||
|
const deliveryChannel = target.includes('@') ? 'email' : 'sms';
|
||||||
|
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
|
||||||
const code = String(randomInt(100000, 999999));
|
const code = String(randomInt(100000, 999999));
|
||||||
const expiresAt = new Date(Date.now() + 5 * 60_000);
|
const expiresAt = new Date(Date.now() + expiryMinutes * 60_000);
|
||||||
await this.prisma.authCode.create({
|
await this.prisma.authCode.create({
|
||||||
data: {
|
data: {
|
||||||
userId: existingUser?.id,
|
userId: existingUser?.id,
|
||||||
target,
|
target,
|
||||||
channel: target.includes('@') ? 'email' : 'sms',
|
channel: deliveryChannel,
|
||||||
purpose: 'passwordless-login',
|
purpose: 'passwordless-login',
|
||||||
codeHash: await bcrypt.hash(code, 10),
|
codeHash: await bcrypt.hash(code, 10),
|
||||||
expiresAt
|
expiresAt
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log('[OTP MOCK] Code for', target, 'is:', code);
|
await this.messaging.sendVerificationCode({
|
||||||
|
channel: deliveryChannel as 'email' | 'sms',
|
||||||
|
target,
|
||||||
|
code,
|
||||||
|
expiryMinutes
|
||||||
|
});
|
||||||
|
await this.redis.client.del(this.otpAttemptKey(target));
|
||||||
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
|
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +170,20 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!authCode) throw new UnauthorizedException('Код входа не найден или истек');
|
if (!authCode) throw new UnauthorizedException('Код входа не найден или истек');
|
||||||
|
|
||||||
|
const attemptKey = this.otpAttemptKey(authCode.target);
|
||||||
|
const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5);
|
||||||
|
const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0);
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
throw new UnauthorizedException('Превышено число попыток ввода кода');
|
||||||
|
}
|
||||||
|
|
||||||
const matches = await bcrypt.compare(command.code, authCode.codeHash);
|
const matches = await bcrypt.compare(command.code, authCode.codeHash);
|
||||||
if (!matches) throw new UnauthorizedException('Неверный код входа');
|
if (!matches) {
|
||||||
|
await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', 15 * 60);
|
||||||
|
throw new UnauthorizedException('Неверный код входа');
|
||||||
|
}
|
||||||
|
await this.redis.client.del(attemptKey);
|
||||||
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
|
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
|
||||||
|
|
||||||
const user = await this.findOrCreatePasswordlessUser(command.recipient);
|
const user = await this.findOrCreatePasswordlessUser(command.recipient);
|
||||||
@@ -183,6 +205,10 @@ export class AuthService {
|
|||||||
return map[channel] ?? recipient;
|
return map[channel] ?? recipient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private otpAttemptKey(target: string) {
|
||||||
|
return `otp:attempts:passwordless-login:${target.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
private maskTarget(value: string) {
|
private maskTarget(value: string) {
|
||||||
if (value.includes('@')) {
|
if (value.includes('@')) {
|
||||||
const [name, domain] = value.split('@');
|
const [name, domain] = value.split('@');
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import * as bcrypt from 'bcryptjs';
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
import { MessagingService } from '../infra/messaging.service';
|
||||||
|
import { RedisService } from '../infra/redis.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OtpService {
|
export class OtpService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly settings: SettingsService
|
private readonly settings: SettingsService,
|
||||||
|
private readonly messaging: MessagingService,
|
||||||
|
private readonly redis: RedisService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async send(command: { target: string; channel: string; purpose: string; userId?: string }) {
|
async send(command: { target: string; channel: string; purpose: string; userId?: string }) {
|
||||||
@@ -25,7 +29,14 @@ export class OtpService {
|
|||||||
expiresAt
|
expiresAt
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`OTP ${command.channel} для ${command.target}: ${code}`);
|
const channel = command.channel === 'sms' ? 'sms' : 'email';
|
||||||
|
await this.messaging.sendVerificationCode({
|
||||||
|
channel,
|
||||||
|
target: command.target,
|
||||||
|
code,
|
||||||
|
expiryMinutes
|
||||||
|
});
|
||||||
|
await this.redis.client.del(this.attemptKey(command.target, command.purpose));
|
||||||
return { sent: true, expiresAt: expiresAt.toISOString() };
|
return { sent: true, expiresAt: expiresAt.toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +52,25 @@ export class OtpService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!authCode) throw new BadRequestException('Код подтверждения не найден или истек');
|
if (!authCode) throw new BadRequestException('Код подтверждения не найден или истек');
|
||||||
|
|
||||||
|
const attemptKey = this.attemptKey(command.target, command.purpose);
|
||||||
|
const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5);
|
||||||
|
const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0);
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
throw new UnauthorizedException('Превышено число попыток ввода кода');
|
||||||
|
}
|
||||||
|
|
||||||
const matches = await bcrypt.compare(command.code, authCode.codeHash);
|
const matches = await bcrypt.compare(command.code, authCode.codeHash);
|
||||||
if (!matches) throw new UnauthorizedException('Неверный код подтверждения');
|
if (!matches) {
|
||||||
|
await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', 15 * 60);
|
||||||
|
throw new UnauthorizedException('Неверный код подтверждения');
|
||||||
|
}
|
||||||
|
await this.redis.client.del(attemptKey);
|
||||||
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
|
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
|
||||||
return { verified: true };
|
return { verified: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private attemptKey(target: string, purpose: string) {
|
||||||
|
return `otp:attempts:${purpose}:${target.toLowerCase()}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,25 @@ export const DEFAULT_SYSTEM_SETTINGS = [
|
|||||||
},
|
},
|
||||||
{ key: 'LDAP_USERNAME_ATTR', value: 'sAMAccountName', description: 'Атрибут LDAP с логином пользователя' },
|
{ key: 'LDAP_USERNAME_ATTR', value: 'sAMAccountName', description: 'Атрибут LDAP с логином пользователя' },
|
||||||
{ key: 'LDAP_EMAIL_ATTR', value: 'mail', description: 'Атрибут LDAP с почтой пользователя' },
|
{ key: 'LDAP_EMAIL_ATTR', value: 'mail', description: 'Атрибут LDAP с почтой пользователя' },
|
||||||
{ key: 'LDAP_DISPLAY_NAME_ATTR', value: 'displayName', description: 'Атрибут LDAP с отображаемым именем' }
|
{ key: 'LDAP_DISPLAY_NAME_ATTR', value: 'displayName', description: 'Атрибут LDAP с отображаемым именем' },
|
||||||
|
{ key: 'EMAIL_ENABLED', value: 'false', description: 'Отправлять OTP-коды на email через настроенный SMTP' },
|
||||||
|
{ key: 'EMAIL_PROVIDER', value: 'smtp', description: 'Провайдер email: smtp или console (только лог)' },
|
||||||
|
{ key: 'EMAIL_SMTP_HOST', value: '', description: 'SMTP-сервер, например smtp.yandex.ru или smtp.gmail.com' },
|
||||||
|
{ key: 'EMAIL_SMTP_PORT', value: '587', description: 'Порт SMTP (587 — STARTTLS, 465 — SSL)' },
|
||||||
|
{ key: 'EMAIL_SMTP_SECURE', value: 'false', description: 'SSL/TLS при подключении (true для порта 465)' },
|
||||||
|
{ key: 'EMAIL_SMTP_USER', value: '', description: 'Логин SMTP (обычно совпадает с адресом отправителя)' },
|
||||||
|
{ key: 'EMAIL_SMTP_PASSWORD', value: '', description: 'Пароль или app-password SMTP' },
|
||||||
|
{ key: 'EMAIL_FROM_ADDRESS', value: '', description: 'Адрес отправителя, например noreply@example.com' },
|
||||||
|
{ key: 'EMAIL_FROM_NAME', value: '', description: 'Имя отправителя в письме (пусто = название проекта)' },
|
||||||
|
{ key: 'SMS_ENABLED', value: 'false', description: 'Отправлять OTP-коды по SMS через настроенного провайдера' },
|
||||||
|
{ key: 'SMS_PROVIDER', value: 'smsru', description: 'SMS-провайдер: smsru, smsc или console (только лог)' },
|
||||||
|
{ key: 'SMS_API_KEY', value: '', description: 'API-ключ sms.ru (api_id)' },
|
||||||
|
{ key: 'SMS_LOGIN', value: '', description: 'Логин smsc.ru' },
|
||||||
|
{ key: 'SMS_PASSWORD', value: '', description: 'Пароль smsc.ru' },
|
||||||
|
{ key: 'SMS_SENDER', value: '', description: 'Имя отправителя SMS (если поддерживается провайдером)' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SECRET_SETTING_KEYS = new Set(['LDAP_BIND_PASSWORD']);
|
const SECRET_SETTING_KEYS = new Set(['LDAP_BIND_PASSWORD', 'EMAIL_SMTP_PASSWORD', 'SMS_API_KEY', 'SMS_PASSWORD']);
|
||||||
|
|
||||||
export const PUBLIC_SETTING_KEYS = [
|
export const PUBLIC_SETTING_KEYS = [
|
||||||
'PROJECT_NAME',
|
'PROJECT_NAME',
|
||||||
|
|||||||
213
apps/sso-core/src/infra/messaging.service.ts
Normal file
213
apps/sso-core/src/infra/messaging.service.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { SettingsService } from '../domain/settings.service';
|
||||||
|
|
||||||
|
type MessagingChannel = 'email' | 'sms';
|
||||||
|
|
||||||
|
interface VerificationPayload {
|
||||||
|
channel: MessagingChannel;
|
||||||
|
target: string;
|
||||||
|
code: string;
|
||||||
|
expiryMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingService {
|
||||||
|
private readonly logger = new Logger(MessagingService.name);
|
||||||
|
|
||||||
|
constructor(private readonly settings: SettingsService) {}
|
||||||
|
|
||||||
|
async sendVerificationCode(payload: VerificationPayload): Promise<void> {
|
||||||
|
if (payload.channel === 'email') {
|
||||||
|
await this.sendEmailCode(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sendSmsCode(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTestDelivery(channel: MessagingChannel, target: string) {
|
||||||
|
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
await this.sendVerificationCode({ channel, target, code, expiryMinutes });
|
||||||
|
return {
|
||||||
|
sent: true,
|
||||||
|
channel,
|
||||||
|
target,
|
||||||
|
message: `Тестовое сообщение отправлено на ${this.maskTarget(target, channel)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendEmailCode(payload: VerificationPayload) {
|
||||||
|
const enabled = await this.settings.getBoolean('EMAIL_ENABLED', false);
|
||||||
|
const provider = (await this.settings.getValue('EMAIL_PROVIDER', 'smtp')).trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!enabled || provider === 'console') {
|
||||||
|
this.logConsoleFallback('email', payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider !== 'smtp') {
|
||||||
|
throw new BadRequestException(`Неизвестный email-провайдер: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = (await this.settings.getValue('EMAIL_SMTP_HOST', '')).trim();
|
||||||
|
const port = await this.settings.getNumber('EMAIL_SMTP_PORT', 587);
|
||||||
|
const secure = await this.settings.getBoolean('EMAIL_SMTP_SECURE', false);
|
||||||
|
const user = (await this.settings.getValue('EMAIL_SMTP_USER', '')).trim();
|
||||||
|
const password = await this.settings.getValue('EMAIL_SMTP_PASSWORD', '');
|
||||||
|
const fromAddress = (await this.settings.getValue('EMAIL_FROM_ADDRESS', '')).trim();
|
||||||
|
const fromName = (await this.settings.getValue('EMAIL_FROM_NAME', '')).trim();
|
||||||
|
const projectName = await this.settings.getValue('PROJECT_NAME', 'MVK ID');
|
||||||
|
|
||||||
|
if (!host || !fromAddress) {
|
||||||
|
throw new BadRequestException('Email не настроен: укажите SMTP-хост и адрес отправителя в админке');
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = fromName ? `"${fromName}" <${fromAddress}>` : fromAddress;
|
||||||
|
const subject = `${projectName}: код подтверждения`;
|
||||||
|
const text = this.buildCodeText(projectName, payload.code, payload.expiryMinutes);
|
||||||
|
const html = this.buildCodeHtml(projectName, payload.code, payload.expiryMinutes);
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: user ? { user, pass: password } : undefined,
|
||||||
|
tls: secure ? undefined : { rejectUnauthorized: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to: payload.target,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Неизвестная ошибка SMTP';
|
||||||
|
this.logger.error(`SMTP ошибка для ${payload.target}: ${message}`);
|
||||||
|
throw new BadRequestException(`Не удалось отправить письмо: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendSmsCode(payload: VerificationPayload) {
|
||||||
|
const enabled = await this.settings.getBoolean('SMS_ENABLED', false);
|
||||||
|
const provider = (await this.settings.getValue('SMS_PROVIDER', 'smsru')).trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!enabled || provider === 'console') {
|
||||||
|
this.logConsoleFallback('sms', payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = await this.settings.getValue('PROJECT_NAME', 'MVK ID');
|
||||||
|
const phone = this.normalizePhone(payload.target);
|
||||||
|
const text = `${projectName}: код ${payload.code}. Действует ${payload.expiryMinutes} мин.`;
|
||||||
|
|
||||||
|
if (provider === 'smsru') {
|
||||||
|
await this.sendViaSmsRu(phone, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'smsc') {
|
||||||
|
await this.sendViaSmsc(phone, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(`Неизвестный SMS-провайдер: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendViaSmsRu(phone: string, text: string) {
|
||||||
|
const apiId = (await this.settings.getValue('SMS_API_KEY', '')).trim();
|
||||||
|
const sender = (await this.settings.getValue('SMS_SENDER', '')).trim();
|
||||||
|
|
||||||
|
if (!apiId) {
|
||||||
|
throw new BadRequestException('SMS не настроен: укажите API-ключ sms.ru в админке');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
api_id: apiId,
|
||||||
|
to: phone,
|
||||||
|
msg: text,
|
||||||
|
json: '1'
|
||||||
|
});
|
||||||
|
if (sender) params.set('from', sender);
|
||||||
|
|
||||||
|
const response = await fetch(`https://sms.ru/sms/send?${params.toString()}`);
|
||||||
|
const body = (await response.json()) as { status?: string; status_code?: number; status_text?: string };
|
||||||
|
|
||||||
|
if (!response.ok || body.status !== 'OK') {
|
||||||
|
const reason = body.status_text ?? `HTTP ${response.status}`;
|
||||||
|
throw new BadRequestException(`sms.ru: ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendViaSmsc(phone: string, text: string) {
|
||||||
|
const login = (await this.settings.getValue('SMS_LOGIN', '')).trim();
|
||||||
|
const password = (await this.settings.getValue('SMS_PASSWORD', '')).trim();
|
||||||
|
const sender = (await this.settings.getValue('SMS_SENDER', '')).trim();
|
||||||
|
|
||||||
|
if (!login || !password) {
|
||||||
|
throw new BadRequestException('SMS не настроен: укажите логин и пароль smsc.ru в админке');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
login,
|
||||||
|
psw: password,
|
||||||
|
phones: phone,
|
||||||
|
mes: text,
|
||||||
|
fmt: '3',
|
||||||
|
charset: 'utf-8'
|
||||||
|
});
|
||||||
|
if (sender) params.set('sender', sender);
|
||||||
|
|
||||||
|
const response = await fetch(`https://smsc.ru/sys/send.php?${params.toString()}`);
|
||||||
|
const body = (await response.json()) as { error?: string; error_code?: number; id?: number };
|
||||||
|
|
||||||
|
if (!response.ok || body.error) {
|
||||||
|
throw new BadRequestException(`smsc.ru: ${body.error ?? `HTTP ${response.status}`}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePhone(value: string) {
|
||||||
|
const digits = value.replace(/\D/g, '');
|
||||||
|
if (digits.length === 11 && digits.startsWith('8')) return `7${digits.slice(1)}`;
|
||||||
|
if (digits.length === 10) return `7${digits}`;
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCodeText(projectName: string, code: string, expiryMinutes: number) {
|
||||||
|
return [
|
||||||
|
`Ваш код подтверждения ${projectName}: ${code}`,
|
||||||
|
'',
|
||||||
|
`Код действителен ${expiryMinutes} мин.`,
|
||||||
|
'Если вы не запрашивали код — проигнорируйте это сообщение.'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCodeHtml(projectName: string, code: string, expiryMinutes: number) {
|
||||||
|
return `
|
||||||
|
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#101828">
|
||||||
|
<p>Ваш код подтверждения <strong>${projectName}</strong>:</p>
|
||||||
|
<p style="font-size:28px;font-weight:700;letter-spacing:4px">${code}</p>
|
||||||
|
<p>Код действителен ${expiryMinutes} мин.</p>
|
||||||
|
<p style="color:#667085;font-size:13px">Если вы не запрашивали код — проигнорируйте это письмо.</p>
|
||||||
|
</div>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private logConsoleFallback(channel: MessagingChannel, payload: VerificationPayload) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[${channel.toUpperCase()}] Код для ${payload.target}: ${payload.code} (провайдер отключён — включите EMAIL_ENABLED/SMS_ENABLED в админке)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskTarget(target: string, channel: MessagingChannel) {
|
||||||
|
if (channel === 'email' && target.includes('@')) {
|
||||||
|
const [name, domain] = target.split('@');
|
||||||
|
return `${name.slice(0, 1)}***@${domain}`;
|
||||||
|
}
|
||||||
|
const digits = target.replace(/\D/g, '');
|
||||||
|
return `***${digits.slice(-4)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
install.sh
84
install.sh
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_VERSION="2.2.1"
|
SCRIPT_VERSION="2.2.2"
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
@@ -228,7 +228,9 @@ lendry_host_nginx_configured() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_docker_nginx() {
|
stop_docker_nginx() {
|
||||||
if docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null | grep -q .; then
|
local cid
|
||||||
|
cid="$(docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null || true)"
|
||||||
|
if [[ -n "$cid" ]]; then
|
||||||
log "Останавливаем контейнер lendry-id-nginx (используется системный Nginx)..."
|
log "Останавливаем контейнер lendry-id-nginx (используется системный Nginx)..."
|
||||||
docker_cmd compose --env-file .env --profile proxy stop nginx 2>/dev/null || true
|
docker_cmd compose --env-file .env --profile proxy stop nginx 2>/dev/null || true
|
||||||
docker_cmd rm -f lendry-id-nginx 2>/dev/null || true
|
docker_cmd rm -f lendry-id-nginx 2>/dev/null || true
|
||||||
@@ -325,6 +327,14 @@ compose_uses_proxy_profile() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Заполняет глобальный массив COMPOSE_STACK_CMD для docker compose
|
||||||
|
build_compose_stack_cmd() {
|
||||||
|
COMPOSE_STACK_CMD=(compose --env-file .env)
|
||||||
|
if compose_uses_proxy_profile; then
|
||||||
|
COMPOSE_STACK_CMD+=(--profile proxy)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
install_packages_apt() {
|
install_packages_apt() {
|
||||||
run_root apt-get update -qq
|
run_root apt-get update -qq
|
||||||
run_root apt-get install -y -qq ca-certificates curl git openssl gnupg lsb-release jq
|
run_root apt-get install -y -qq ca-certificates curl git openssl gnupg lsb-release jq
|
||||||
@@ -968,11 +978,15 @@ recreate_host_port_services() {
|
|||||||
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
|
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
|
||||||
|
|
||||||
log "Пересоздание сервисов с пробросом портов на 127.0.0.1..."
|
log "Пересоздание сервисов с пробросом портов на 127.0.0.1..."
|
||||||
local compose_cmd=(compose --env-file .env)
|
build_compose_stack_cmd
|
||||||
if compose_uses_proxy_profile; then
|
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}"
|
||||||
compose_cmd+=(--profile proxy)
|
}
|
||||||
fi
|
|
||||||
docker_cmd "${compose_cmd[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}"
|
docker_publishes_port() {
|
||||||
|
local container="$1"
|
||||||
|
local host_port="$2"
|
||||||
|
docker_cmd port "$container" 2>/dev/null | grep -q "127.0.0.1:${host_port}" || \
|
||||||
|
docker_cmd port "$container" 2>/dev/null | grep -q ":${host_port}->"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_host_upstreams() {
|
ensure_host_upstreams() {
|
||||||
@@ -1470,8 +1484,11 @@ write_all_nginx_configs() {
|
|||||||
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO_CONSOLE" minio-console minio-console "$ssl_type"
|
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO_CONSOLE" minio-console minio-console "$ssl_type"
|
||||||
|
|
||||||
if [[ "$NGINX_MODE" == "host" ]]; then
|
if [[ "$NGINX_MODE" == "host" ]]; then
|
||||||
run_root nginx -t
|
if run_root nginx -t; then
|
||||||
run_root systemctl reload nginx
|
run_root systemctl reload nginx || warn "Не удалось перезагрузить Nginx (systemctl reload nginx)"
|
||||||
|
else
|
||||||
|
warn "Конфигурация Nginx содержит ошибки — проверьте сертификаты в ${LOCAL_CERTS_DIR}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
ok "Конфиги Nginx: ${LOCAL_CONF_DIR}"
|
ok "Конфиги Nginx: ${LOCAL_CONF_DIR}"
|
||||||
fi
|
fi
|
||||||
@@ -1613,13 +1630,10 @@ deploy_stack() {
|
|||||||
prepare_compose_stack
|
prepare_compose_stack
|
||||||
verify_postgres_auth
|
verify_postgres_auth
|
||||||
|
|
||||||
local compose_cmd=(compose --env-file .env)
|
build_compose_stack_cmd
|
||||||
if compose_uses_proxy_profile; then
|
|
||||||
compose_cmd+=(--profile proxy)
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Сборка и запуск контейнеров (10–20 минут при первом запуске)..."
|
log "Сборка и запуск контейнеров (10–20 минут при первом запуске)..."
|
||||||
docker_cmd "${compose_cmd[@]}" up -d --build
|
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build
|
||||||
recreate_host_port_services
|
recreate_host_port_services
|
||||||
ensure_host_upstreams
|
ensure_host_upstreams
|
||||||
|
|
||||||
@@ -1696,9 +1710,14 @@ refresh_host_nginx_configs() {
|
|||||||
local ssl_type
|
local ssl_type
|
||||||
ssl_type="$(env_get SSL_TYPE none)"
|
ssl_type="$(env_get SSL_TYPE none)"
|
||||||
case "$ssl_type" in
|
case "$ssl_type" in
|
||||||
none) write_nginx_http_only ;;
|
none) write_nginx_http_only || warn "Не удалось записать HTTP-конфиги Nginx (нужен sudo?)" ;;
|
||||||
selfsigned) write_all_nginx_configs "selfsigned" ;;
|
selfsigned)
|
||||||
letsencrypt) write_all_nginx_configs "letsencrypt" ;;
|
generate_all_self_signed_certs
|
||||||
|
write_all_nginx_configs "selfsigned" || warn "Не удалось обновить Nginx (нужен sudo?)"
|
||||||
|
;;
|
||||||
|
letsencrypt)
|
||||||
|
write_all_nginx_configs "letsencrypt" || warn "Не удалось обновить Nginx (нужен sudo?)"
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1817,23 +1836,25 @@ action_renew_ssl() {
|
|||||||
action_fix_host_proxy() {
|
action_fix_host_proxy() {
|
||||||
[[ -f .env ]] || fail "Файл .env не найден — сначала выполните ./install.sh --install"
|
[[ -f .env ]] || fail "Файл .env не найден — сначала выполните ./install.sh --install"
|
||||||
load_env
|
load_env
|
||||||
NGINX_MODE="host"
|
|
||||||
env_set NGINX_MODE "host"
|
env_set NGINX_MODE "host"
|
||||||
|
NGINX_MODE="host"
|
||||||
NON_INTERACTIVE=1
|
NON_INTERACTIVE=1
|
||||||
|
|
||||||
log "Исправление прокси: системный Nginx + проброс портов на 127.0.0.1..."
|
log "Исправление прокси (host Nginx + порты 127.0.0.1)..."
|
||||||
stop_docker_nginx
|
|
||||||
prepare_compose_stack
|
|
||||||
refresh_host_nginx_configs
|
|
||||||
|
|
||||||
local compose_cmd=(compose --env-file .env)
|
log "Шаг 1/4: остановка Docker Nginx..."
|
||||||
docker_cmd "${compose_cmd[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}"
|
stop_docker_nginx
|
||||||
|
|
||||||
|
log "Шаг 2/4: удаление override (мог блокировать порты)..."
|
||||||
|
rm -f "$COMPOSE_OVERRIDE"
|
||||||
|
|
||||||
|
log "Шаг 3/4: пересоздание api-gateway, frontend, docs, media-ws..."
|
||||||
|
build_compose_stack_cmd
|
||||||
|
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}"
|
||||||
ensure_host_upstreams
|
ensure_host_upstreams
|
||||||
|
|
||||||
if system_nginx_is_active; then
|
log "Шаг 4/4: конфиги системного Nginx..."
|
||||||
run_root nginx -t
|
refresh_host_nginx_configs
|
||||||
run_root systemctl reload nginx
|
|
||||||
fi
|
|
||||||
|
|
||||||
ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002"
|
ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002"
|
||||||
show_status
|
show_status
|
||||||
@@ -1843,11 +1864,8 @@ action_restart() {
|
|||||||
load_env
|
load_env
|
||||||
prepare_compose_stack
|
prepare_compose_stack
|
||||||
refresh_host_nginx_configs
|
refresh_host_nginx_configs
|
||||||
local compose_cmd=(compose --env-file .env)
|
build_compose_stack_cmd
|
||||||
if compose_uses_proxy_profile; then
|
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build
|
||||||
compose_cmd+=(--profile proxy)
|
|
||||||
fi
|
|
||||||
docker_cmd "${compose_cmd[@]}" up -d --build
|
|
||||||
recreate_host_port_services
|
recreate_host_port_services
|
||||||
ensure_host_upstreams
|
ensure_host_upstreams
|
||||||
ok "Контейнеры перезапущены"
|
ok "Контейнеры перезапущены"
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -187,6 +187,7 @@
|
|||||||
"amqplib": "^0.10.9",
|
"amqplib": "^0.10.9",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
|
"nodemailer": "^7.0.6",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
@@ -198,6 +199,7 @@
|
|||||||
"@nestjs/testing": "^11.1.9",
|
"@nestjs/testing": "^11.1.9",
|
||||||
"@types/amqplib": "^0.10.8",
|
"@types/amqplib": "^0.10.8",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/nodemailer": "^7.0.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"grpc-tools": "^1.13.1",
|
"grpc-tools": "^1.13.1",
|
||||||
"prisma": "^7.1.0",
|
"prisma": "^7.1.0",
|
||||||
@@ -5682,6 +5684,16 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.12.tgz",
|
||||||
|
"integrity": "sha512-80vKwiIsVSyFA1rRovH59jNPLBOuc6dRZIHEu40gXTkBkZnQv8vog1xSGEb9j5q/tdMAs5ivvDR2pLTU0hGHXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
@@ -9009,6 +9021,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
|
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ service SettingsService {
|
|||||||
rpc ListSocialProviders (Empty) returns (ListSocialProvidersResponse);
|
rpc ListSocialProviders (Empty) returns (ListSocialProvidersResponse);
|
||||||
rpc UpsertSocialProvider (UpsertSocialProviderRequest) returns (SocialProvider);
|
rpc UpsertSocialProvider (UpsertSocialProviderRequest) returns (SocialProvider);
|
||||||
rpc DeleteSocialProvider (ProviderNameRequest) returns (MutationCountResponse);
|
rpc DeleteSocialProvider (ProviderNameRequest) returns (MutationCountResponse);
|
||||||
|
rpc TestMessagingDelivery (TestMessagingDeliveryRequest) returns (TestMessagingDeliveryResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message Empty {}
|
message Empty {}
|
||||||
@@ -216,3 +217,15 @@ message ListPublicSettingsResponse {
|
|||||||
message MutationCountResponse {
|
message MutationCountResponse {
|
||||||
int32 count = 1;
|
int32 count = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message TestMessagingDeliveryRequest {
|
||||||
|
string channel = 1;
|
||||||
|
string target = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TestMessagingDeliveryResponse {
|
||||||
|
bool sent = 1;
|
||||||
|
string channel = 2;
|
||||||
|
string target = 3;
|
||||||
|
optional string message = 4;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user