diff --git a/apps/frontend/app/admin/settings/page.tsx b/apps/frontend/app/admin/settings/page.tsx index 312b219..9016f04 100644 --- a/apps/frontend/app/admin/settings/page.tsx +++ b/apps/frontend/app/admin/settings/page.tsx @@ -1,20 +1,31 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { Chrome, Loader2, Save, Settings2, ToggleLeft, ToggleRight } from 'lucide-react'; +import { Chrome, Loader2, Save, ToggleLeft, ToggleRight } from 'lucide-react'; import { useAuth } from '@/components/id/auth-provider'; import { AdminShell } from '@/components/id/admin-shell'; import { usePublicSettings } from '@/components/id/public-settings-provider'; import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api'; -import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog'; import { MessagingSettingsSection } from '@/components/id/messaging-settings-section'; +import { SettingsFieldRow } from '@/components/id/settings-field-row'; +import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api'; +import { + getSettingMeta, + isSettingVisible, + MESSAGING_SETTING_KEYS, + sortSettingsByCatalog, + SYSTEM_SETTING_GROUPS +} from '@/lib/system-settings-catalog'; -function parseBoolean(value: string) { - return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); -} +const GROUP_LABELS: Record = { + ...SYSTEM_SETTING_GROUPS, + 'messaging-email': 'Email (SMTP)', + 'messaging-sms': 'SMS' +}; + +const PUBLIC_SETTINGS_REFRESH_KEYS = new Set(['PROJECT_NAME', 'PROJECT_TAGLINE', 'LDAP_ENABLED', 'LDAP_USE_LDAPS']); export default function AdminSettingsPage() { const { token, user } = useAuth(); @@ -23,7 +34,8 @@ export default function AdminSettingsPage() { const [settings, setSettings] = useState([]); const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); - const [savingKey, setSavingKey] = useState(null); + const [savingGroup, setSavingGroup] = useState(null); + const [savingOAuthProvider, setSavingOAuthProvider] = useState(null); useEffect(() => { if (!token || !user?.canManageSettings) return; @@ -52,27 +64,37 @@ export default function AdminSettingsPage() { return groups; }, [settings]); - async function saveSetting(setting: SystemSetting) { + async function saveSettingsGroup(groupKey: string, groupSettings: SystemSetting[]) { if (!token) return; - setSavingKey(setting.key); + + const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings)); + if (visibleSettings.length === 0) return; + + setSavingGroup(groupKey); try { - const updated = await apiFetch( - '/admin/settings', - { - method: 'PUT', - body: JSON.stringify(buildSystemSettingPayload(setting)) - }, - token - ); - setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item)))); - if (updated.key === 'PROJECT_NAME' || updated.key === 'PROJECT_TAGLINE' || updated.key === 'LDAP_ENABLED' || updated.key === 'LDAP_USE_LDAPS') { + let needsPublicRefresh = false; + for (const setting of visibleSettings) { + const updated = await apiFetch( + '/admin/settings', + { + method: 'PUT', + body: JSON.stringify(buildSystemSettingPayload(setting)) + }, + token + ); + setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item)))); + if (PUBLIC_SETTINGS_REFRESH_KEYS.has(updated.key)) { + needsPublicRefresh = true; + } + } + if (needsPublicRefresh) { await refreshPublicSettings(); } - showToast('Настройка сохранена'); + showToast(`Блок «${GROUP_LABELS[groupKey] ?? groupKey}» сохранён`); } catch (error) { - showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройку'); + showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройки блока'); } finally { - setSavingKey(null); + setSavingGroup(null); } } @@ -91,10 +113,14 @@ export default function AdminSettingsPage() { async function toggleProvider(provider: SocialProvider) { if (!token) return; try { - const updated = await apiFetch('/admin/settings/oauth/providers', { - method: 'PUT', - body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled }) - }, token); + const updated = await apiFetch( + '/admin/settings/oauth/providers', + { + method: 'PUT', + body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled }) + }, + token + ); setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item))); showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён'); } catch (error) { @@ -102,6 +128,22 @@ export default function AdminSettingsPage() { } } + async function saveOAuthProvider(provider: SocialProvider) { + if (!token) return; + setSavingOAuthProvider(provider.providerName); + try { + await apiFetch('/admin/settings/oauth/providers', { + method: 'PUT', + body: JSON.stringify(toProviderPayload(provider)) + }, token); + showToast(`Провайдер ${provider.providerName} сохранён`); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Ошибка сохранения провайдера'); + } finally { + setSavingOAuthProvider(null); + } + } + if (!user?.canManageSettings) { return ( @@ -114,7 +156,9 @@ export default function AdminSettingsPage() {

Глобальные настройки

-

Параметры хранятся в SystemSetting и применяются сервисами без релиза frontend.

+

+ Параметры сгруппированы по блокам — измените нужные поля и сохраните весь блок одной кнопкой. +

{loading ? ( @@ -124,112 +168,101 @@ export default function AdminSettingsPage() { ) : ( <> - {[...groupedSettings.entries()].map(([groupKey, groupSettings]) => ( -
-

{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}

-
- {groupSettings.filter((setting) => isSettingVisible(setting, settings)).map((setting) => { - const meta = getSettingMeta(setting.key); - const label = meta?.label ?? setting.key; - const type = meta?.type ?? 'text'; + {[...groupedSettings.entries()].map(([groupKey, groupSettings]) => { + const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings)); + const saving = savingGroup === groupKey; - return ( -
-
-
-
- - {label} -
-

{meta?.hint ?? setting.description ?? setting.key}

-
+ return ( +
+
+
+

{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}

+ +
-
- {type === 'boolean' ? ( - - ) : type === 'select' ? ( - - ) : ( -
- updateSettingValue(setting.key, event.target.value)} - /> - {meta?.unit ? {meta.unit} : null} -
- )} - - -
-
-
- ); - })} -
-
- ))} +
+ {visibleSettings.map((setting) => ( + updateSettingValue(setting.key, value)} + /> + ))} +
+ + + ); + })}
-

OAuth Social Providers

-
- {(providers.length ? providers : [{ id: 'google', providerName: 'google', clientId: '', isEnabled: false }, { id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false }]).map((provider) => ( +

OAuth Social Providers

+
+ {(providers.length + ? providers + : [ + { id: 'google', providerName: 'google', clientId: '', isEnabled: false }, + { id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false } + ] + ).map((provider) => (
- {provider.providerName === 'google' ? :
Я
} + {provider.providerName === 'google' ? ( + + ) : ( +
+ Я +
+ )}

{provider.providerName}

-

{provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'}

+

+ {provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'} +

setProviders((current) => current.map((item) => (item.providerName === provider.providerName ? { ...item, clientId: event.target.value } : item)))} + onChange={(event) => + setProviders((current) => + current.map((item) => + item.providerName === provider.providerName ? { ...item, clientId: event.target.value } : item + ) + ) + } />
))} diff --git a/apps/frontend/components/id/messaging-settings-section.tsx b/apps/frontend/components/id/messaging-settings-section.tsx index 52d058a..5a7644d 100644 --- a/apps/frontend/components/id/messaging-settings-section.tsx +++ b/apps/frontend/components/id/messaging-settings-section.tsx @@ -1,12 +1,17 @@ 'use client'; -import { Loader2, Mail, MessageSquare, Save, Send, ToggleLeft, ToggleRight } from 'lucide-react'; +import { Loader2, Mail, MessageSquare, Save, Send } 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'; +import { apiFetch, SystemSetting } from '@/lib/api'; +import { + EMAIL_SETTING_KEYS, + isSettingVisible, + SMS_SETTING_KEYS +} from '@/lib/system-settings-catalog'; +import { SettingsFieldRow } from '@/components/id/settings-field-row'; function parseBoolean(value: string) { return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); @@ -15,9 +20,9 @@ function parseBoolean(value: string) { interface MessagingSettingsSectionProps { settings: SystemSetting[]; token: string | null; - savingKey: string | null; + savingGroup: string | null; onUpdateValue: (key: string, value: string) => void; - onSave: (setting: SystemSetting) => Promise; + onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise; showToast: (message: string) => void; } @@ -46,12 +51,60 @@ function shouldShowMessagingField(setting: SystemSetting, settings: SystemSettin return true; } +function MessagingBlock({ + title, + description, + groupKey, + blockSettings, + allSettings, + savingGroup, + onUpdateValue, + onSaveGroup +}: { + title: string; + description: string; + groupKey: string; + blockSettings: SystemSetting[]; + allSettings: SystemSetting[]; + savingGroup: string | null; + onUpdateValue: (key: string, value: string) => void; + onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise; +}) { + const visibleSettings = blockSettings.filter((setting) => shouldShowMessagingField(setting, allSettings)); + const saving = savingGroup === groupKey; + + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ +
+ {visibleSettings.map((setting) => ( + onUpdateValue(setting.key, value)} + /> + ))} +
+
+ ); +} + export function MessagingSettingsSection({ settings, token, - savingKey, + savingGroup, onUpdateValue, - onSave, + onSaveGroup, showToast }: MessagingSettingsSectionProps) { const [testEmail, setTestEmail] = useState(''); @@ -59,8 +112,12 @@ export function MessagingSettingsSection({ 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)), + const emailSettings = useMemo( + () => settings.filter((item) => EMAIL_SETTING_KEYS.includes(item.key)), + [settings] + ); + const smsSettings = useMemo( + () => settings.filter((item) => SMS_SETTING_KEYS.includes(item.key)), [settings] ); @@ -90,8 +147,12 @@ export function MessagingSettingsSection({ } } - const emailEnabled = parseBoolean(getSettingValue(messagingSettings, 'EMAIL_ENABLED')); - const smsEnabled = parseBoolean(getSettingValue(messagingSettings, 'SMS_ENABLED')); + const emailEnabled = parseBoolean(getSettingValue(emailSettings, 'EMAIL_ENABLED')); + const smsEnabled = parseBoolean(getSettingValue(smsSettings, 'SMS_ENABLED')); + const emailReady = + emailEnabled || + (Boolean(getSettingValue(emailSettings, 'EMAIL_SMTP_HOST')) && + Boolean(getSettingValue(emailSettings, 'EMAIL_FROM_ADDRESS'))); return (
@@ -100,67 +161,33 @@ export function MessagingSettingsSection({

Email и SMS провайдеры

- Настройте SMTP и SMS — OTP-коды при входе и регистрации будут отправляться автоматически. + Измените параметры блока и нажмите «Сохранить блок» — все поля блока сохранятся одним действием.

-
- {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 ( -
-
-
-
{label}
-

{meta?.hint ?? setting.description ?? setting.key}

-
- -
- {type === 'boolean' ? ( - - ) : type === 'select' ? ( - - ) : ( -
- onUpdateValue(setting.key, event.target.value)} - /> - {meta?.unit ? {meta.unit} : null} -
- )} - - -
-
-
- ); - })} +
@@ -170,7 +197,7 @@ export function MessagingSettingsSection({ Тест email

- {emailEnabled ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала включите Email и сохраните настройки SMTP.'} + {emailReady ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала сохраните блок Email (SMTP).'}

setTestEmail(event.target.value)} /> - @@ -191,7 +218,7 @@ export function MessagingSettingsSection({ Тест SMS

- {smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала включите SMS и сохраните ключ API.'} + {smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала сохраните блок SMS.'}

void; +} + +export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) { + const meta = getSettingMeta(setting.key); + const label = meta?.label ?? setting.key; + const type = meta?.type ?? 'text'; + + return ( +
+
+
{label}
+

{meta?.hint ?? setting.description ?? setting.key}

+
+ +
+ {type === 'boolean' ? ( + + ) : type === 'select' ? ( + + ) : ( + <> + onChange(event.target.value)} + /> + {meta?.unit ? {meta.unit} : null} + + )} +
+
+ ); +} diff --git a/apps/frontend/components/notifications/realtime-provider.tsx b/apps/frontend/components/notifications/realtime-provider.tsx index d84184d..f6b51ba 100644 --- a/apps/frontend/components/notifications/realtime-provider.tsx +++ b/apps/frontend/components/notifications/realtime-provider.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useAuth } from '@/components/id/auth-provider'; -import { WS_URL, type ChatMessage } from '@/lib/api'; +import { getWsUrl, type ChatMessage } from '@/lib/api'; export interface RealtimeEvent { userId?: string; @@ -58,7 +58,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { function connect() { if (cancelled) return; - const url = `${WS_URL}?token=${encodeURIComponent(token!)}`; + const url = `${getWsUrl()}?token=${encodeURIComponent(token!)}`; const socket = new WebSocket(url); socketRef.current = socket; diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 4c560f1..ecb8c37 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -1,4 +1,52 @@ -export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; +const API_ORIGIN_PROXY_PREFIX = '/idp-api'; +const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, ''); +const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, ''); + +function resolveBrowserApiBaseUrl(): string { + if (typeof window === 'undefined') { + return configuredApiUrl; + } + + try { + const configured = new URL(configuredApiUrl); + if (configured.origin !== window.location.origin) { + return `${window.location.origin}${API_ORIGIN_PROXY_PREFIX}`; + } + } catch { + // keep configured URL + } + + return configuredApiUrl; +} + +function resolveBrowserWsBaseUrl(): string { + if (typeof window === 'undefined') { + return configuredWsUrl; + } + + try { + const configured = new URL(configuredWsUrl); + if (configured.origin !== window.location.origin) { + const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; + } + } catch { + // keep configured URL + } + + return configuredWsUrl; +} + +export function getApiUrl(): string { + return resolveBrowserApiBaseUrl(); +} + +export function getWsUrl(): string { + return resolveBrowserWsBaseUrl(); +} + +/** @deprecated Используйте getApiUrl() — URL может переключаться на same-origin /idp-api в браузере */ +export const API_URL = configuredApiUrl; export interface PublicUser { id: string; @@ -141,6 +189,23 @@ export class ApiError extends Error { } } +function mapFetchError(error: unknown): ApiError { + if (error instanceof TypeError) { + const message = error.message.toLowerCase(); + if (message.includes('failed to fetch') || message.includes('networkerror')) { + return new ApiError( + 'Нет связи с сервером. При самоподписанном HTTPS примите сертификат сайта или выполните ./install.sh --fix-proxy на сервере.', + 0, + 'NETWORK_ERROR' + ); + } + } + if (error instanceof ApiError) { + return error; + } + return new ApiError(error instanceof Error ? error.message : 'Не удалось выполнить запрос', 0, 'UNKNOWN'); +} + type PinRequiredHandler = (sessionId: string) => void; export function setPinRequiredHandler(handler: PinRequiredHandler | null) { @@ -212,7 +277,7 @@ export async function refreshAuthSession(): Promise { throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH'); } - const response = await fetch(`${API_URL}/auth/refresh`, { + const response = await fetch(`${getApiUrl()}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined }) @@ -320,14 +385,19 @@ export function buildSystemSettingPayload(setting: Pick(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise { const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null); - const response = await fetch(`${API_URL}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}), - ...options.headers - } - }); + let response: Response; + try { + response = await fetch(`${getApiUrl()}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}), + ...options.headers + } + }); + } catch (error) { + throw mapFetchError(error); + } if (!response.ok) { const error = await parseErrorResponse(response); @@ -579,4 +649,5 @@ export async function setChatRoomMuted(roomId: string, muted: boolean, token?: s return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token); } -export const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws'; +/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */ +export const WS_URL = configuredWsUrl; diff --git a/apps/frontend/lib/system-settings-catalog.ts b/apps/frontend/lib/system-settings-catalog.ts index 5235eb5..5a292c1 100644 --- a/apps/frontend/lib/system-settings-catalog.ts +++ b/apps/frontend/lib/system-settings-catalog.ts @@ -109,6 +109,14 @@ function parseBooleanValue(value: string) { export const MESSAGING_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'messaging').map((item) => item.key); +export const EMAIL_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => + item.key.startsWith('EMAIL_') +).map((item) => item.key); + +export const SMS_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => + item.key.startsWith('SMS_') +).map((item) => item.key); + export function sortSettingsByCatalog(settings: T[]) { 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)); diff --git a/apps/sso-core/Dockerfile b/apps/sso-core/Dockerfile index 61b8572..96dc8c2 100644 --- a/apps/sso-core/Dockerfile +++ b/apps/sso-core/Dockerfile @@ -41,9 +41,9 @@ RUN npm --workspace @lendry/sso-core run prisma:generate \ && npm --workspace @lendry/sso-core run proto:generate \ && npm --workspace @lendry/sso-core run build -COPY apps/sso-core/docker-entrypoint.sh /app/apps/sso-core/docker-entrypoint.sh -RUN chmod +x /app/apps/sso-core/docker-entrypoint.sh +COPY apps/sso-core/docker-entrypoint.sh /docker-entrypoint.sh +RUN sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh EXPOSE 3001 50051 -CMD ["/app/apps/sso-core/docker-entrypoint.sh"] +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/apps/sso-core/src/domain/settings.service.ts b/apps/sso-core/src/domain/settings.service.ts index ca85d21..8d2f42d 100644 --- a/apps/sso-core/src/domain/settings.service.ts +++ b/apps/sso-core/src/domain/settings.service.ts @@ -41,6 +41,20 @@ export class SettingsService { } async upsert(command: UpsertSettingCommand) { + const trimmedValue = command.value?.trim() ?? ''; + if (command.isSecret && trimmedValue === '') { + const existing = await this.prisma.systemSetting.findUnique({ where: { key: command.key } }); + if (existing) { + return this.prisma.systemSetting.update({ + where: { key: command.key }, + data: { + description: command.description, + isSecret: command.isSecret ?? existing.isSecret + } + }); + } + } + return this.prisma.systemSetting.upsert({ where: { key: command.key }, create: { diff --git a/apps/sso-core/src/infra/messaging.service.ts b/apps/sso-core/src/infra/messaging.service.ts index ad9014b..250e1f3 100644 --- a/apps/sso-core/src/infra/messaging.service.ts +++ b/apps/sso-core/src/infra/messaging.service.ts @@ -40,22 +40,36 @@ export class MessagingService { 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(); + const host = (await this.settings.getValue('EMAIL_SMTP_HOST', '')).trim(); + const fromAddress = (await this.settings.getValue('EMAIL_FROM_ADDRESS', '')).trim(); + const smtpConfigured = Boolean(host && fromAddress); - if (!enabled || provider === 'console') { - this.logConsoleFallback('email', payload); + if (provider === 'console') { + this.logConsoleFallback('email', payload, 'выбран режим «Только лог» (EMAIL_PROVIDER=console)'); return; } + if (!enabled && !smtpConfigured) { + this.logConsoleFallback( + 'email', + payload, + 'EMAIL_ENABLED=false — включите Email в админке и нажмите «Сохранить»' + ); + return; + } + + if (!enabled && smtpConfigured) { + this.logger.warn('[EMAIL] EMAIL_ENABLED=false, но SMTP заполнен — отправляем письмо'); + } + 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'); @@ -96,7 +110,13 @@ export class MessagingService { const provider = (await this.settings.getValue('SMS_PROVIDER', 'smsru')).trim().toLowerCase(); if (!enabled || provider === 'console') { - this.logConsoleFallback('sms', payload); + this.logConsoleFallback( + 'sms', + payload, + !enabled + ? 'SMS_ENABLED=false — включите SMS в админке и нажмите «Сохранить»' + : 'выбран режим «Только лог» (SMS_PROVIDER=console)' + ); return; } @@ -196,10 +216,8 @@ export class MessagingService { `.trim(); } - private logConsoleFallback(channel: MessagingChannel, payload: VerificationPayload) { - this.logger.warn( - `[${channel.toUpperCase()}] Код для ${payload.target}: ${payload.code} (провайдер отключён — включите EMAIL_ENABLED/SMS_ENABLED в админке)` - ); + private logConsoleFallback(channel: MessagingChannel, payload: VerificationPayload, reason: string) { + this.logger.warn(`[${channel.toUpperCase()}] Код для ${payload.target}: ${payload.code} (${reason})`); } private maskTarget(target: string, channel: MessagingChannel) { diff --git a/install.sh b/install.sh index 221cc91..c27521b 100644 --- a/install.sh +++ b/install.sh @@ -614,6 +614,10 @@ derive_public_urls() { MINIO_USE_SSL=$([[ "$scheme" == "https" ]] && echo true || echo false) fi + if [[ "$ssl_type" == "selfsigned" && -n "${DOMAIN_FRONTEND:-}" ]]; then + apply_same_origin_api_proxy_urls "$scheme" + fi + SSL_TYPE="$ssl_type" USE_NGINX_SSL=$([[ "$ssl_type" == "none" ]] && echo false || echo true) @@ -628,6 +632,18 @@ derive_public_urls() { fi } +apply_same_origin_api_proxy_urls() { + local scheme="${1:-https}" + PUBLIC_API_URL="${PUBLIC_FRONTEND_URL%/}/idp-api" + if [[ -z "${DOMAIN_WS:-}" || "${DOMAIN_WS}" == "${DOMAIN_API}" ]]; then + if [[ "$scheme" == "https" ]]; then + PUBLIC_WS_URL="wss://${DOMAIN_FRONTEND}/idp-api/ws" + else + PUBLIC_WS_URL="ws://${DOMAIN_FRONTEND}/idp-api/ws" + fi + fi +} + choose_ssl_type_interactive() { local default="${1:-none}" @@ -1063,6 +1079,12 @@ nginx_cert_base_path() { local ssl_type="$2" if [[ "$ssl_type" == "letsencrypt" ]]; then echo "/etc/letsencrypt/live/${domain}" + elif [[ "$ssl_type" == "selfsigned" ]]; then + if [[ "$NGINX_MODE" == "docker" ]]; then + echo "/etc/nginx/certs/shared-intranet" + else + echo "${LOCAL_CERTS_DIR}/shared-intranet" + fi elif [[ "$NGINX_MODE" == "docker" ]]; then echo "/etc/nginx/certs/${domain}" else @@ -1070,26 +1092,60 @@ nginx_cert_base_path() { fi } -generate_self_signed_cert() { - local domain="$1" - local cert_dir="${LOCAL_CERTS_DIR}/${domain}" +intranet_wildcard_base_domain() { + local domain="${1:-}" + [[ -z "$domain" ]] && return 0 + if [[ "$domain" == *.*.* ]]; then + echo "${domain#*.}" + else + echo "$domain" + fi +} + +collect_self_signed_sans() { + local -a sans=() + local base domain + + load_env + for domain in "$DOMAIN_API" "$DOMAIN_FRONTEND" "$DOMAIN_DOCS" "${DOMAIN_WS:-}" "${DOMAIN_MINIO:-}" "${DOMAIN_MINIO_CONSOLE:-}"; do + [[ -z "$domain" ]] && continue + sans+=("DNS:${domain}") + done + + base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")" + if [[ -n "$base" ]]; then + sans+=("DNS:*.${base}") + sans+=("DNS:${base}") + fi + + (IFS=,; echo "${sans[*]}") +} + +generate_shared_self_signed_cert() { + local cert_dir="${LOCAL_CERTS_DIR}/shared-intranet" + local base sans cn mkdir -p "$cert_dir" + base="$(intranet_wildcard_base_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")" + [[ -z "$base" ]] && base="${DOMAIN_FRONTEND:-$DOMAIN_API}" + cn="*.${base}" + sans="$(collect_self_signed_sans)" + if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" ]]; then return 0 fi - log "Самоподписанный сертификат: ${domain}" + log "Общий самоподписанный сертификат: ${cn} (${sans})" if has_cmd openssl; then openssl req -x509 -nodes -days 825 -newkey rsa:2048 \ -keyout "${cert_dir}/privkey.pem" \ -out "${cert_dir}/fullchain.pem" \ - -subj "/CN=${domain}/O=Lendry ID Intranet/C=RU" \ - -addext "subjectAltName=DNS:${domain}" 2>/dev/null || \ + -subj "/CN=${cn}/O=Lendry ID Intranet/C=RU" \ + -addext "subjectAltName=${sans}" 2>/dev/null || \ openssl req -x509 -nodes -days 825 -newkey rsa:2048 \ -keyout "${cert_dir}/privkey.pem" \ -out "${cert_dir}/fullchain.pem" \ - -subj "/CN=${domain}/O=Lendry ID Intranet/C=RU" + -subj "/CN=${cn}/O=Lendry ID Intranet/C=RU" chmod 600 "${cert_dir}/privkey.pem" 2>/dev/null || true else fail "openssl не найден — нужен для самоподписанных сертификатов" @@ -1097,16 +1153,13 @@ generate_self_signed_cert() { } generate_all_self_signed_certs() { + local force="${1:-}" load_env - generate_self_signed_cert "$DOMAIN_API" - generate_self_signed_cert "$DOMAIN_FRONTEND" - generate_self_signed_cert "$DOMAIN_DOCS" - if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then - generate_self_signed_cert "$DOMAIN_WS" + if [[ "$force" == "--force" ]]; then + rm -f "${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" "${LOCAL_CERTS_DIR}/shared-intranet/privkey.pem" fi - [[ -n "${DOMAIN_MINIO:-}" ]] && generate_self_signed_cert "$DOMAIN_MINIO" - [[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && generate_self_signed_cert "$DOMAIN_MINIO_CONSOLE" - ok "Самоподписанные сертификаты созданы в ${LOCAL_CERTS_DIR}" + generate_shared_self_signed_cert + ok "Самоподписанный сертификат создан: ${LOCAL_CERTS_DIR}/shared-intranet" } nginx_conf_target() { @@ -1269,13 +1322,39 @@ EOF nginx_publish_config "api" } +build_nginx_idp_api_proxy_block() { + local api_upstream + api_upstream="$(nginx_upstream api)" + cat </dev/null 2>&1; then echo -e " ${GREEN}✔${NC} ${label}: ${url}" else - echo -e " ${RED}✘${NC} ${label}: ${url} — недоступен (502 Bad Gateway)" + echo -e " ${RED}✘${NC} ${label}: ${url} — недоступен" fi } @@ -1737,7 +1819,8 @@ show_upstream_health() { [[ -n "$(env_get DOMAIN_API "")" ]] || return 0 echo " Upstream (host Nginx → localhost):" - check_local_upstream "API" "http://127.0.0.1:3000/api" + check_local_upstream "API" "http://127.0.0.1:3000/health" + check_local_upstream "Swagger" "http://127.0.0.1:3000/docs" check_local_upstream "Frontend" "http://127.0.0.1:3002" check_local_upstream "Docs" "http://127.0.0.1:3003/docs" echo "" @@ -1823,7 +1906,7 @@ action_renew_ssl() { ok "Let's Encrypt сертификаты обновлены" ;; selfsigned) - generate_all_self_signed_certs + generate_all_self_signed_certs --force write_all_nginx_configs "selfsigned" ok "Самоподписанные сертификаты пересозданы" ;; @@ -1856,6 +1939,15 @@ action_fix_host_proxy() { log "Шаг 4/4: конфиги системного Nginx..." refresh_host_nginx_configs + if [[ "$(env_get SSL_TYPE none)" == "selfsigned" && -n "$(env_get DOMAIN_FRONTEND "")" ]]; then + derive_public_urls "selfsigned" + env_set PUBLIC_API_URL "${PUBLIC_API_URL}" + env_set PUBLIC_WS_URL "${PUBLIC_WS_URL}" + log "Шаг 5/5: пересборка frontend (same-origin API для HTTPS)..." + build_compose_stack_cmd + docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build frontend + fi + ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002" show_status }