fix idp on started

This commit is contained in:
lendry
2026-06-24 17:34:55 +03:00
parent e60d55f6bd
commit b6987f4aea
10 changed files with 556 additions and 225 deletions

View File

@@ -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<string, string> = {
...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<SystemSetting[]>([]);
const [providers, setProviders] = useState<SocialProvider[]>([]);
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [savingGroup, setSavingGroup] = useState<string | null>(null);
const [savingOAuthProvider, setSavingOAuthProvider] = useState<string | null>(null);
useEffect(() => {
if (!token || !user?.canManageSettings) return;
@@ -52,10 +64,16 @@ 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 {
let needsPublicRefresh = false;
for (const setting of visibleSettings) {
const updated = await apiFetch<SystemSetting>(
'/admin/settings',
{
@@ -65,14 +83,18 @@ export default function AdminSettingsPage() {
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') {
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<SocialProvider>('/admin/settings/oauth/providers', {
const updated = await apiFetch<SocialProvider>(
'/admin/settings/oauth/providers',
{
method: 'PUT',
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
}, token);
},
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 (
<AdminShell active="/admin/settings">
@@ -114,7 +156,9 @@ export default function AdminSettingsPage() {
<AdminShell active="/admin/settings">
<div className="mb-6">
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
<p className="mt-2 text-[#667085]">Параметры хранятся в SystemSetting и применяются сервисами без релиза frontend.</p>
<p className="mt-2 text-[#667085]">
Параметры сгруппированы по блокам измените нужные поля и сохраните весь блок одной кнопкой.
</p>
</div>
{loading ? (
@@ -124,112 +168,101 @@ export default function AdminSettingsPage() {
</div>
) : (
<>
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => (
<section key={groupKey} className="mb-10">
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
<div className="space-y-3">
{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 (
<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="flex items-center gap-2 font-semibold">
<Settings2 className="h-4 w-4 shrink-0" />
{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={() => {
updateSettingValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true');
}}
<section key={groupKey} className="mb-10">
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
<Button
disabled={saving || visibleSettings.length === 0}
onClick={() => void saveSettingsGroup(groupKey, groupSettings)}
>
{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) => 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">
<Input
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
value={setting.value}
className={setting.isSecret ? 'w-[220px] bg-white' : 'w-[180px] bg-white'}
onChange={(event) => updateSettingValue(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 saveSetting(setting)}>
{savingKey === setting.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Сохранить
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Сохранить блок
</Button>
</div>
<div>
{visibleSettings.map((setting) => (
<SettingsFieldRow
key={setting.key}
setting={setting}
onChange={(value) => updateSettingValue(setting.key, value)}
/>
))}
</div>
</div>
);
})}
</div>
</section>
))}
);
})}
<MessagingSettingsSection
settings={settings}
token={token}
savingKey={savingKey}
savingGroup={savingGroup}
onUpdateValue={updateSettingValue}
onSave={saveSetting}
onSaveGroup={saveSettingsGroup}
showToast={showToast}
/>
<section className="mt-10">
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{(providers.length ? providers : [{ id: 'google', providerName: 'google', clientId: '', isEnabled: false }, { id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false }]).map((provider) => (
<h3 className="mb-4 text-xl font-medium">OAuth Social Providers</h3>
<div className="grid gap-4 md:grid-cols-2">
{(providers.length
? providers
: [
{ id: 'google', providerName: 'google', clientId: '', isEnabled: false },
{ id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false }
]
).map((provider) => (
<div key={provider.providerName} className="rounded-[24px] bg-[#f4f5f8] p-5">
<div className="flex items-center justify-between">
{provider.providerName === 'google' ? <Chrome className="h-7 w-7" /> : <div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500 font-bold text-white">Я</div>}
{provider.providerName === 'google' ? (
<Chrome className="h-7 w-7" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500 font-bold text-white">
Я
</div>
)}
<button type="button" aria-label="Переключить провайдера" onClick={() => void toggleProvider(provider)}>
{provider.isEnabled ? <ToggleRight className="h-8 w-8 text-green-600" /> : <ToggleLeft className="h-8 w-8 text-[#a8adbc]" />}
{provider.isEnabled ? (
<ToggleRight className="h-8 w-8 text-green-600" />
) : (
<ToggleLeft className="h-8 w-8 text-[#a8adbc]" />
)}
</button>
</div>
<h4 className="mt-5 font-semibold capitalize">{provider.providerName}</h4>
<p className="mt-1 text-sm text-[#667085]">{provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'}</p>
<p className="mt-1 text-sm text-[#667085]">
{provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'}
</p>
<Input
className="mt-4 bg-white"
value={provider.clientId}
placeholder="Client ID"
onChange={(event) => 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
)
)
}
/>
<Button
className="mt-3"
variant="secondary"
onClick={() =>
void apiFetch('/admin/settings/oauth/providers', { method: 'PUT', body: JSON.stringify(toProviderPayload(provider)) }, token)
.then(() => showToast('Провайдер сохранён'))
.catch((error) => showToast(error instanceof Error ? error.message : 'Ошибка сохранения'))
}
disabled={savingOAuthProvider === provider.providerName}
onClick={() => void saveOAuthProvider(provider)}
>
Сохранить провайдера
{savingOAuthProvider === provider.providerName ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Сохранить блок
</Button>
</div>
))}

View File

@@ -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<void>;
onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise<void>;
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<void>;
}) {
const visibleSettings = blockSettings.filter((setting) => shouldShowMessagingField(setting, allSettings));
const saving = savingGroup === groupKey;
return (
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-start md:justify-between">
<div>
<h4 className="text-lg font-medium">{title}</h4>
<p className="mt-1 text-sm text-[#667085]">{description}</p>
</div>
<Button disabled={saving || visibleSettings.length === 0} onClick={() => void onSaveGroup(groupKey, blockSettings)}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Сохранить блок
</Button>
</div>
<div>
{visibleSettings.map((setting) => (
<SettingsFieldRow
key={setting.key}
setting={setting}
onChange={(value) => onUpdateValue(setting.key, value)}
/>
))}
</div>
</div>
);
}
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 (
<section className="mb-10">
@@ -100,67 +161,33 @@ export function MessagingSettingsSection({
<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)}
<div className="space-y-4">
<MessagingBlock
title="Email (SMTP)"
description="Отправка OTP-кодов на почту через SMTP-провайдера."
groupKey="messaging-email"
blockSettings={emailSettings}
allSettings={settings}
savingGroup={savingGroup}
onUpdateValue={onUpdateValue}
onSaveGroup={onSaveGroup}
/>
{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>
);
})}
<MessagingBlock
title="SMS"
description="Отправка OTP-кодов по SMS через sms.ru или smsc.ru."
groupKey="messaging-sms"
blockSettings={smsSettings}
allSettings={settings}
savingGroup={savingGroup}
onUpdateValue={onUpdateValue}
onSaveGroup={onSaveGroup}
/>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
@@ -170,7 +197,7 @@ export function MessagingSettingsSection({
Тест email
</div>
<p className="mt-2 text-sm text-[#667085]">
{emailEnabled ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала включите Email и сохраните настройки SMTP.'}
{emailReady ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала сохраните блок Email (SMTP).'}
</p>
<Input
className="mt-4 bg-[#f9fafb]"
@@ -179,7 +206,7 @@ export function MessagingSettingsSection({
value={testEmail}
onChange={(event) => setTestEmail(event.target.value)}
/>
<Button className="mt-3" disabled={!emailEnabled || testingChannel === 'email'} onClick={() => void sendTest('email')}>
<Button className="mt-3" disabled={!emailReady || testingChannel === 'email'} onClick={() => void sendTest('email')}>
{testingChannel === 'email' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Отправить тест
</Button>
@@ -191,7 +218,7 @@ export function MessagingSettingsSection({
Тест SMS
</div>
<p className="mt-2 text-sm text-[#667085]">
{smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала включите SMS и сохраните ключ API.'}
{smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала сохраните блок SMS.'}
</p>
<div className="mt-4">
<PhoneInput

View File

@@ -0,0 +1,68 @@
'use client';
import { ToggleLeft, ToggleRight } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { SystemSetting } from '@/lib/api';
import { getSettingMeta } from '@/lib/system-settings-catalog';
function parseBoolean(value: string) {
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
}
interface SettingsFieldRowProps {
setting: SystemSetting;
onChange: (value: string) => void;
}
export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
const meta = getSettingMeta(setting.key);
const label = meta?.label ?? setting.key;
const type = meta?.type ?? 'text';
return (
<div className="flex flex-col gap-3 border-b border-[#e8eaef] py-4 last:border-b-0 last:pb-0 first:pt-0 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 shrink-0 items-center gap-2">
{type === 'boolean' ? (
<button
type="button"
aria-label={label}
onClick={() => onChange(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 min-w-[220px] rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
value={setting.value}
onChange={(event) => onChange(event.target.value)}
>
{(meta?.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<>
<Input
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
value={setting.value}
className={setting.isSecret ? 'w-full min-w-[240px] bg-white md:w-[280px]' : 'w-full min-w-[180px] bg-white md:w-[220px]'}
onChange={(event) => onChange(event.target.value)}
/>
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
</>
)}
</div>
</div>
);
}

View File

@@ -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;

View File

@@ -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<RefreshSessionResponse> {
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,7 +385,9 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
const response = await fetch(`${API_URL}${path}`, {
let response: Response;
try {
response = await fetch(`${getApiUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
@@ -328,6 +395,9 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
...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;

View File

@@ -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<T extends { key: string }>(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));

View File

@@ -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"]

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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 <<EOF
location /idp-api/ws {
proxy_pass ${api_upstream}/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 86400s;
}
location /idp-api/ {
proxy_pass ${api_upstream}/;
proxy_http_version 1.1;
${nginx_proxy_headers}
}
EOF
}
write_nginx_site_frontend() {
local domain="$1"
local ssl_type="${2:-none}"
local conf upstream http_preamble ssl_block
local conf upstream http_preamble ssl_block idp_api_block
conf="$(nginx_conf_target frontend)"
upstream="$(nginx_upstream frontend)"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
idp_api_block="$(build_nginx_idp_api_proxy_block)"
if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF
@@ -1283,6 +1362,7 @@ server {
listen 80;
listen [::]:80;
server_name ${domain};
${idp_api_block}
location / {
proxy_pass ${upstream};
proxy_http_version 1.1;
@@ -1299,6 +1379,7 @@ ${http_preamble}server {
server_name ${domain};
${ssl_block}
client_max_body_size 50m;
${idp_api_block}
location / {
proxy_pass ${upstream};
proxy_http_version 1.1;
@@ -1667,8 +1748,9 @@ print_intranet_hint() {
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}"
echo ""
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
echo " Сертификаты: ${LOCAL_CERTS_DIR}/"
echo " Браузер покажет предупреждение — это нормально для локальных доменов."
echo " Сертификат: ${LOCAL_CERTS_DIR}/shared-intranet (общий для всех доменов)"
echo " Браузер покажет предупреждение — примите сертификат для ${DOMAIN_FRONTEND}."
echo " API вызывается через ${DOMAIN_FRONTEND}/idp-api — отдельно открывать ${DOMAIN_API} не нужно."
echo ""
fi
}
@@ -1682,7 +1764,7 @@ print_summary() {
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e " ${BOLD}Режим:${NC} ${INSTALL_MODE:-local} | SSL: ${SSL_TYPE:-none} | Nginx: ${NGINX_MODE:-auto}"
echo -e " ${BOLD}API / Swagger:${NC} ${PUBLIC_API_URL}/api"
echo -e " ${BOLD}API / Swagger:${NC} ${PUBLIC_API_URL}/docs"
echo -e " ${BOLD}Frontend:${NC} ${PUBLIC_FRONTEND_URL}"
echo -e " ${BOLD}Документация:${NC} ${PUBLIC_DOCS_URL}/docs"
echo -e " ${BOLD}WebSocket:${NC} ${PUBLIC_WS_URL}"
@@ -1726,7 +1808,7 @@ check_local_upstream() {
if curl -fsS --max-time 3 "$url" >/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
}