fix idp on started
This commit is contained in:
@@ -1,20 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { useAuth } from '@/components/id/auth-provider';
|
||||||
import { AdminShell } from '@/components/id/admin-shell';
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
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 { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog';
|
|
||||||
import { MessagingSettingsSection } from '@/components/id/messaging-settings-section';
|
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) {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
...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() {
|
export default function AdminSettingsPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
@@ -23,7 +34,8 @@ export default function AdminSettingsPage() {
|
|||||||
const [settings, setSettings] = useState<SystemSetting[]>([]);
|
const [settings, setSettings] = useState<SystemSetting[]>([]);
|
||||||
const [providers, setProviders] = useState<SocialProvider[]>([]);
|
const [providers, setProviders] = useState<SocialProvider[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!token || !user?.canManageSettings) return;
|
if (!token || !user?.canManageSettings) return;
|
||||||
@@ -52,27 +64,37 @@ export default function AdminSettingsPage() {
|
|||||||
return groups;
|
return groups;
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
async function saveSetting(setting: SystemSetting) {
|
async function saveSettingsGroup(groupKey: string, groupSettings: SystemSetting[]) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setSavingKey(setting.key);
|
|
||||||
|
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
|
||||||
|
if (visibleSettings.length === 0) return;
|
||||||
|
|
||||||
|
setSavingGroup(groupKey);
|
||||||
try {
|
try {
|
||||||
const updated = await apiFetch<SystemSetting>(
|
let needsPublicRefresh = false;
|
||||||
'/admin/settings',
|
for (const setting of visibleSettings) {
|
||||||
{
|
const updated = await apiFetch<SystemSetting>(
|
||||||
method: 'PUT',
|
'/admin/settings',
|
||||||
body: JSON.stringify(buildSystemSettingPayload(setting))
|
{
|
||||||
},
|
method: 'PUT',
|
||||||
token
|
body: JSON.stringify(buildSystemSettingPayload(setting))
|
||||||
);
|
},
|
||||||
setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item))));
|
token
|
||||||
if (updated.key === 'PROJECT_NAME' || updated.key === 'PROJECT_TAGLINE' || updated.key === 'LDAP_ENABLED' || updated.key === 'LDAP_USE_LDAPS') {
|
);
|
||||||
|
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();
|
await refreshPublicSettings();
|
||||||
}
|
}
|
||||||
showToast('Настройка сохранена');
|
showToast(`Блок «${GROUP_LABELS[groupKey] ?? groupKey}» сохранён`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройку');
|
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройки блока');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingKey(null);
|
setSavingGroup(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +113,14 @@ export default function AdminSettingsPage() {
|
|||||||
async function toggleProvider(provider: SocialProvider) {
|
async function toggleProvider(provider: SocialProvider) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const updated = await apiFetch<SocialProvider>('/admin/settings/oauth/providers', {
|
const updated = await apiFetch<SocialProvider>(
|
||||||
method: 'PUT',
|
'/admin/settings/oauth/providers',
|
||||||
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
{
|
||||||
}, token);
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
||||||
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
||||||
} catch (error) {
|
} 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) {
|
if (!user?.canManageSettings) {
|
||||||
return (
|
return (
|
||||||
<AdminShell active="/admin/settings">
|
<AdminShell active="/admin/settings">
|
||||||
@@ -114,7 +156,9 @@ export default function AdminSettingsPage() {
|
|||||||
<AdminShell active="/admin/settings">
|
<AdminShell active="/admin/settings">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
|
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
|
||||||
<p className="mt-2 text-[#667085]">Параметры хранятся в SystemSetting и применяются сервисами без релиза frontend.</p>
|
<p className="mt-2 text-[#667085]">
|
||||||
|
Параметры сгруппированы по блокам — измените нужные поля и сохраните весь блок одной кнопкой.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -124,112 +168,101 @@ export default function AdminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => (
|
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => {
|
||||||
<section key={groupKey} className="mb-10">
|
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
|
||||||
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
const saving = savingGroup === groupKey;
|
||||||
<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';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
<section key={groupKey} className="mb-10">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
<h3 className="text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
||||||
<Settings2 className="h-4 w-4 shrink-0" />
|
<Button
|
||||||
{label}
|
disabled={saving || visibleSettings.length === 0}
|
||||||
</div>
|
onClick={() => void saveSettingsGroup(groupKey, groupSettings)}
|
||||||
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
>
|
||||||
</div>
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
Сохранить блок
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div>
|
||||||
{type === 'boolean' ? (
|
{visibleSettings.map((setting) => (
|
||||||
<button
|
<SettingsFieldRow
|
||||||
type="button"
|
key={setting.key}
|
||||||
aria-label={label}
|
setting={setting}
|
||||||
onClick={() => {
|
onChange={(value) => updateSettingValue(setting.key, value)}
|
||||||
updateSettingValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true');
|
/>
|
||||||
}}
|
))}
|
||||||
>
|
</div>
|
||||||
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
</div>
|
||||||
</button>
|
</section>
|
||||||
) : 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" />}
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<MessagingSettingsSection
|
<MessagingSettingsSection
|
||||||
settings={settings}
|
settings={settings}
|
||||||
token={token}
|
token={token}
|
||||||
savingKey={savingKey}
|
savingGroup={savingGroup}
|
||||||
onUpdateValue={updateSettingValue}
|
onUpdateValue={updateSettingValue}
|
||||||
onSave={saveSetting}
|
onSaveGroup={saveSettingsGroup}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
|
<h3 className="mb-4 text-xl font-medium">OAuth Social Providers</h3>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<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) => (
|
{(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 key={provider.providerName} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||||
<div className="flex items-center justify-between">
|
<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)}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="mt-5 font-semibold capitalize">{provider.providerName}</h4>
|
<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
|
<Input
|
||||||
className="mt-4 bg-white"
|
className="mt-4 bg-white"
|
||||||
value={provider.clientId}
|
value={provider.clientId}
|
||||||
placeholder="Client ID"
|
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
|
<Button
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
variant="secondary"
|
disabled={savingOAuthProvider === provider.providerName}
|
||||||
onClick={() =>
|
onClick={() => void saveOAuthProvider(provider)}
|
||||||
void apiFetch('/admin/settings/oauth/providers', { method: 'PUT', body: JSON.stringify(toProviderPayload(provider)) }, token)
|
|
||||||
.then(() => showToast('Провайдер сохранён'))
|
|
||||||
.catch((error) => showToast(error instanceof Error ? error.message : 'Ошибка сохранения'))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Сохранить провайдера
|
{savingOAuthProvider === provider.providerName ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Сохранить блок
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
'use client';
|
'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 { useMemo, useState } from 'react';
|
||||||
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 { PhoneInput, phoneCountries } from '@/components/ui/phone-input';
|
import { PhoneInput, phoneCountries } from '@/components/ui/phone-input';
|
||||||
import { apiFetch, buildSystemSettingPayload, SystemSetting } from '@/lib/api';
|
import { apiFetch, SystemSetting } from '@/lib/api';
|
||||||
import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS } from '@/lib/system-settings-catalog';
|
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) {
|
function parseBoolean(value: string) {
|
||||||
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||||
@@ -15,9 +20,9 @@ function parseBoolean(value: string) {
|
|||||||
interface MessagingSettingsSectionProps {
|
interface MessagingSettingsSectionProps {
|
||||||
settings: SystemSetting[];
|
settings: SystemSetting[];
|
||||||
token: string | null;
|
token: string | null;
|
||||||
savingKey: string | null;
|
savingGroup: string | null;
|
||||||
onUpdateValue: (key: string, value: string) => void;
|
onUpdateValue: (key: string, value: string) => void;
|
||||||
onSave: (setting: SystemSetting) => Promise<void>;
|
onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise<void>;
|
||||||
showToast: (message: string) => void;
|
showToast: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +51,60 @@ function shouldShowMessagingField(setting: SystemSetting, settings: SystemSettin
|
|||||||
return true;
|
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({
|
export function MessagingSettingsSection({
|
||||||
settings,
|
settings,
|
||||||
token,
|
token,
|
||||||
savingKey,
|
savingGroup,
|
||||||
onUpdateValue,
|
onUpdateValue,
|
||||||
onSave,
|
onSaveGroup,
|
||||||
showToast
|
showToast
|
||||||
}: MessagingSettingsSectionProps) {
|
}: MessagingSettingsSectionProps) {
|
||||||
const [testEmail, setTestEmail] = useState('');
|
const [testEmail, setTestEmail] = useState('');
|
||||||
@@ -59,8 +112,12 @@ export function MessagingSettingsSection({
|
|||||||
const [testPhoneCountry, setTestPhoneCountry] = useState(phoneCountries[0]);
|
const [testPhoneCountry, setTestPhoneCountry] = useState(phoneCountries[0]);
|
||||||
const [testingChannel, setTestingChannel] = useState<'email' | 'sms' | null>(null);
|
const [testingChannel, setTestingChannel] = useState<'email' | 'sms' | null>(null);
|
||||||
|
|
||||||
const messagingSettings = useMemo(
|
const emailSettings = useMemo(
|
||||||
() => settings.filter((item) => MESSAGING_SETTING_KEYS.includes(item.key)),
|
() => settings.filter((item) => EMAIL_SETTING_KEYS.includes(item.key)),
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
const smsSettings = useMemo(
|
||||||
|
() => settings.filter((item) => SMS_SETTING_KEYS.includes(item.key)),
|
||||||
[settings]
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,8 +147,12 @@ export function MessagingSettingsSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailEnabled = parseBoolean(getSettingValue(messagingSettings, 'EMAIL_ENABLED'));
|
const emailEnabled = parseBoolean(getSettingValue(emailSettings, 'EMAIL_ENABLED'));
|
||||||
const smsEnabled = parseBoolean(getSettingValue(messagingSettings, 'SMS_ENABLED'));
|
const smsEnabled = parseBoolean(getSettingValue(smsSettings, 'SMS_ENABLED'));
|
||||||
|
const emailReady =
|
||||||
|
emailEnabled ||
|
||||||
|
(Boolean(getSettingValue(emailSettings, 'EMAIL_SMTP_HOST')) &&
|
||||||
|
Boolean(getSettingValue(emailSettings, 'EMAIL_FROM_ADDRESS')));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
@@ -100,67 +161,33 @@ export function MessagingSettingsSection({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-medium">Email и SMS провайдеры</h3>
|
<h3 className="text-xl font-medium">Email и SMS провайдеры</h3>
|
||||||
<p className="mt-1 text-sm text-[#667085]">
|
<p className="mt-1 text-sm text-[#667085]">
|
||||||
Настройте SMTP и SMS — OTP-коды при входе и регистрации будут отправляться автоматически.
|
Измените параметры блока и нажмите «Сохранить блок» — все поля блока сохранятся одним действием.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{messagingSettings.filter((setting) => shouldShowMessagingField(setting, messagingSettings)).map((setting) => {
|
<MessagingBlock
|
||||||
const meta = getSettingMeta(setting.key);
|
title="Email (SMTP)"
|
||||||
const label = meta?.label ?? setting.key;
|
description="Отправка OTP-кодов на почту через SMTP-провайдера."
|
||||||
const type = meta?.type ?? 'text';
|
groupKey="messaging-email"
|
||||||
|
blockSettings={emailSettings}
|
||||||
|
allSettings={settings}
|
||||||
|
savingGroup={savingGroup}
|
||||||
|
onUpdateValue={onUpdateValue}
|
||||||
|
onSaveGroup={onSaveGroup}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
<MessagingBlock
|
||||||
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
title="SMS"
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
description="Отправка OTP-кодов по SMS через sms.ru или smsc.ru."
|
||||||
<div className="min-w-0 flex-1">
|
groupKey="messaging-sms"
|
||||||
<div className="font-semibold">{label}</div>
|
blockSettings={smsSettings}
|
||||||
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
allSettings={settings}
|
||||||
</div>
|
savingGroup={savingGroup}
|
||||||
|
onUpdateValue={onUpdateValue}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
onSaveGroup={onSaveGroup}
|
||||||
{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>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
@@ -170,7 +197,7 @@ export function MessagingSettingsSection({
|
|||||||
Тест email
|
Тест email
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-[#667085]">
|
<p className="mt-2 text-sm text-[#667085]">
|
||||||
{emailEnabled ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала включите Email и сохраните настройки SMTP.'}
|
{emailReady ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала сохраните блок Email (SMTP).'}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
className="mt-4 bg-[#f9fafb]"
|
className="mt-4 bg-[#f9fafb]"
|
||||||
@@ -179,7 +206,7 @@ export function MessagingSettingsSection({
|
|||||||
value={testEmail}
|
value={testEmail}
|
||||||
onChange={(event) => setTestEmail(event.target.value)}
|
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" />}
|
{testingChannel === 'email' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
Отправить тест
|
Отправить тест
|
||||||
</Button>
|
</Button>
|
||||||
@@ -191,7 +218,7 @@ export function MessagingSettingsSection({
|
|||||||
Тест SMS
|
Тест SMS
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-[#667085]">
|
<p className="mt-2 text-sm text-[#667085]">
|
||||||
{smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала включите SMS и сохраните ключ API.'}
|
{smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала сохраните блок SMS.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
|
|||||||
68
apps/frontend/components/id/settings-field-row.tsx
Normal file
68
apps/frontend/components/id/settings-field-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
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 {
|
export interface RealtimeEvent {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -58,7 +58,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const url = `${WS_URL}?token=${encodeURIComponent(token!)}`;
|
const url = `${getWsUrl()}?token=${encodeURIComponent(token!)}`;
|
||||||
const socket = new WebSocket(url);
|
const socket = new WebSocket(url);
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export interface PublicUser {
|
||||||
id: string;
|
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;
|
type PinRequiredHandler = (sessionId: string) => void;
|
||||||
|
|
||||||
export function setPinRequiredHandler(handler: PinRequiredHandler | null) {
|
export function setPinRequiredHandler(handler: PinRequiredHandler | null) {
|
||||||
@@ -212,7 +277,7 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
|
|||||||
throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH');
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined })
|
body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined })
|
||||||
@@ -320,14 +385,19 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
|
|||||||
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
|
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 resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
let response: Response;
|
||||||
...options,
|
try {
|
||||||
headers: {
|
response = await fetch(`${getApiUrl()}${path}`, {
|
||||||
'Content-Type': 'application/json',
|
...options,
|
||||||
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
headers: {
|
||||||
...options.headers
|
'Content-Type': 'application/json',
|
||||||
}
|
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
||||||
});
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw mapFetchError(error);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await parseErrorResponse(response);
|
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);
|
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;
|
||||||
|
|||||||
@@ -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 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[]) {
|
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));
|
||||||
|
|||||||
@@ -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 proto:generate \
|
||||||
&& npm --workspace @lendry/sso-core run build
|
&& npm --workspace @lendry/sso-core run build
|
||||||
|
|
||||||
COPY apps/sso-core/docker-entrypoint.sh /app/apps/sso-core/docker-entrypoint.sh
|
COPY apps/sso-core/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /app/apps/sso-core/docker-entrypoint.sh
|
RUN sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3001 50051
|
EXPOSE 3001 50051
|
||||||
|
|
||||||
CMD ["/app/apps/sso-core/docker-entrypoint.sh"]
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upsert(command: UpsertSettingCommand) {
|
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({
|
return this.prisma.systemSetting.upsert({
|
||||||
where: { key: command.key },
|
where: { key: command.key },
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
@@ -40,22 +40,36 @@ export class MessagingService {
|
|||||||
private async sendEmailCode(payload: VerificationPayload) {
|
private async sendEmailCode(payload: VerificationPayload) {
|
||||||
const enabled = await this.settings.getBoolean('EMAIL_ENABLED', false);
|
const enabled = await this.settings.getBoolean('EMAIL_ENABLED', false);
|
||||||
const provider = (await this.settings.getValue('EMAIL_PROVIDER', 'smtp')).trim().toLowerCase();
|
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') {
|
if (provider === 'console') {
|
||||||
this.logConsoleFallback('email', payload);
|
this.logConsoleFallback('email', payload, 'выбран режим «Только лог» (EMAIL_PROVIDER=console)');
|
||||||
return;
|
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') {
|
if (provider !== 'smtp') {
|
||||||
throw new BadRequestException(`Неизвестный email-провайдер: ${provider}`);
|
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 port = await this.settings.getNumber('EMAIL_SMTP_PORT', 587);
|
||||||
const secure = await this.settings.getBoolean('EMAIL_SMTP_SECURE', false);
|
const secure = await this.settings.getBoolean('EMAIL_SMTP_SECURE', false);
|
||||||
const user = (await this.settings.getValue('EMAIL_SMTP_USER', '')).trim();
|
const user = (await this.settings.getValue('EMAIL_SMTP_USER', '')).trim();
|
||||||
const password = await this.settings.getValue('EMAIL_SMTP_PASSWORD', '');
|
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 fromName = (await this.settings.getValue('EMAIL_FROM_NAME', '')).trim();
|
||||||
const projectName = await this.settings.getValue('PROJECT_NAME', 'MVK ID');
|
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();
|
const provider = (await this.settings.getValue('SMS_PROVIDER', 'smsru')).trim().toLowerCase();
|
||||||
|
|
||||||
if (!enabled || provider === 'console') {
|
if (!enabled || provider === 'console') {
|
||||||
this.logConsoleFallback('sms', payload);
|
this.logConsoleFallback(
|
||||||
|
'sms',
|
||||||
|
payload,
|
||||||
|
!enabled
|
||||||
|
? 'SMS_ENABLED=false — включите SMS в админке и нажмите «Сохранить»'
|
||||||
|
: 'выбран режим «Только лог» (SMS_PROVIDER=console)'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +216,8 @@ export class MessagingService {
|
|||||||
`.trim();
|
`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private logConsoleFallback(channel: MessagingChannel, payload: VerificationPayload) {
|
private logConsoleFallback(channel: MessagingChannel, payload: VerificationPayload, reason: string) {
|
||||||
this.logger.warn(
|
this.logger.warn(`[${channel.toUpperCase()}] Код для ${payload.target}: ${payload.code} (${reason})`);
|
||||||
`[${channel.toUpperCase()}] Код для ${payload.target}: ${payload.code} (провайдер отключён — включите EMAIL_ENABLED/SMS_ENABLED в админке)`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private maskTarget(target: string, channel: MessagingChannel) {
|
private maskTarget(target: string, channel: MessagingChannel) {
|
||||||
|
|||||||
136
install.sh
136
install.sh
@@ -614,6 +614,10 @@ derive_public_urls() {
|
|||||||
MINIO_USE_SSL=$([[ "$scheme" == "https" ]] && echo true || echo false)
|
MINIO_USE_SSL=$([[ "$scheme" == "https" ]] && echo true || echo false)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$ssl_type" == "selfsigned" && -n "${DOMAIN_FRONTEND:-}" ]]; then
|
||||||
|
apply_same_origin_api_proxy_urls "$scheme"
|
||||||
|
fi
|
||||||
|
|
||||||
SSL_TYPE="$ssl_type"
|
SSL_TYPE="$ssl_type"
|
||||||
USE_NGINX_SSL=$([[ "$ssl_type" == "none" ]] && echo false || echo true)
|
USE_NGINX_SSL=$([[ "$ssl_type" == "none" ]] && echo false || echo true)
|
||||||
|
|
||||||
@@ -628,6 +632,18 @@ derive_public_urls() {
|
|||||||
fi
|
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() {
|
choose_ssl_type_interactive() {
|
||||||
local default="${1:-none}"
|
local default="${1:-none}"
|
||||||
|
|
||||||
@@ -1063,6 +1079,12 @@ nginx_cert_base_path() {
|
|||||||
local ssl_type="$2"
|
local ssl_type="$2"
|
||||||
if [[ "$ssl_type" == "letsencrypt" ]]; then
|
if [[ "$ssl_type" == "letsencrypt" ]]; then
|
||||||
echo "/etc/letsencrypt/live/${domain}"
|
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
|
elif [[ "$NGINX_MODE" == "docker" ]]; then
|
||||||
echo "/etc/nginx/certs/${domain}"
|
echo "/etc/nginx/certs/${domain}"
|
||||||
else
|
else
|
||||||
@@ -1070,26 +1092,60 @@ nginx_cert_base_path() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
generate_self_signed_cert() {
|
intranet_wildcard_base_domain() {
|
||||||
local domain="$1"
|
local domain="${1:-}"
|
||||||
local cert_dir="${LOCAL_CERTS_DIR}/${domain}"
|
[[ -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"
|
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
|
if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Самоподписанный сертификат: ${domain}"
|
log "Общий самоподписанный сертификат: ${cn} (${sans})"
|
||||||
if has_cmd openssl; then
|
if has_cmd openssl; then
|
||||||
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
||||||
-keyout "${cert_dir}/privkey.pem" \
|
-keyout "${cert_dir}/privkey.pem" \
|
||||||
-out "${cert_dir}/fullchain.pem" \
|
-out "${cert_dir}/fullchain.pem" \
|
||||||
-subj "/CN=${domain}/O=Lendry ID Intranet/C=RU" \
|
-subj "/CN=${cn}/O=Lendry ID Intranet/C=RU" \
|
||||||
-addext "subjectAltName=DNS:${domain}" 2>/dev/null || \
|
-addext "subjectAltName=${sans}" 2>/dev/null || \
|
||||||
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
|
||||||
-keyout "${cert_dir}/privkey.pem" \
|
-keyout "${cert_dir}/privkey.pem" \
|
||||||
-out "${cert_dir}/fullchain.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
|
chmod 600 "${cert_dir}/privkey.pem" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
fail "openssl не найден — нужен для самоподписанных сертификатов"
|
fail "openssl не найден — нужен для самоподписанных сертификатов"
|
||||||
@@ -1097,16 +1153,13 @@ generate_self_signed_cert() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generate_all_self_signed_certs() {
|
generate_all_self_signed_certs() {
|
||||||
|
local force="${1:-}"
|
||||||
load_env
|
load_env
|
||||||
generate_self_signed_cert "$DOMAIN_API"
|
if [[ "$force" == "--force" ]]; then
|
||||||
generate_self_signed_cert "$DOMAIN_FRONTEND"
|
rm -f "${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" "${LOCAL_CERTS_DIR}/shared-intranet/privkey.pem"
|
||||||
generate_self_signed_cert "$DOMAIN_DOCS"
|
|
||||||
if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then
|
|
||||||
generate_self_signed_cert "$DOMAIN_WS"
|
|
||||||
fi
|
fi
|
||||||
[[ -n "${DOMAIN_MINIO:-}" ]] && generate_self_signed_cert "$DOMAIN_MINIO"
|
generate_shared_self_signed_cert
|
||||||
[[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && generate_self_signed_cert "$DOMAIN_MINIO_CONSOLE"
|
ok "Самоподписанный сертификат создан: ${LOCAL_CERTS_DIR}/shared-intranet"
|
||||||
ok "Самоподписанные сертификаты созданы в ${LOCAL_CERTS_DIR}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nginx_conf_target() {
|
nginx_conf_target() {
|
||||||
@@ -1269,13 +1322,39 @@ EOF
|
|||||||
nginx_publish_config "api"
|
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() {
|
write_nginx_site_frontend() {
|
||||||
local domain="$1"
|
local domain="$1"
|
||||||
local ssl_type="${2:-none}"
|
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)"
|
conf="$(nginx_conf_target frontend)"
|
||||||
upstream="$(nginx_upstream frontend)"
|
upstream="$(nginx_upstream frontend)"
|
||||||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||||||
|
idp_api_block="$(build_nginx_idp_api_proxy_block)"
|
||||||
|
|
||||||
if [[ "$ssl_type" == "none" ]]; then
|
if [[ "$ssl_type" == "none" ]]; then
|
||||||
nginx_write_conf "$conf" <<EOF
|
nginx_write_conf "$conf" <<EOF
|
||||||
@@ -1283,6 +1362,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name ${domain};
|
server_name ${domain};
|
||||||
|
${idp_api_block}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass ${upstream};
|
proxy_pass ${upstream};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -1299,6 +1379,7 @@ ${http_preamble}server {
|
|||||||
server_name ${domain};
|
server_name ${domain};
|
||||||
${ssl_block}
|
${ssl_block}
|
||||||
client_max_body_size 50m;
|
client_max_body_size 50m;
|
||||||
|
${idp_api_block}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass ${upstream};
|
proxy_pass ${upstream};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -1667,8 +1748,9 @@ print_intranet_hint() {
|
|||||||
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}"
|
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}"
|
||||||
echo ""
|
echo ""
|
||||||
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
|
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
|
||||||
echo " Сертификаты: ${LOCAL_CERTS_DIR}/"
|
echo " Сертификат: ${LOCAL_CERTS_DIR}/shared-intranet (общий для всех доменов)"
|
||||||
echo " Браузер покажет предупреждение — это нормально для локальных доменов."
|
echo " Браузер покажет предупреждение — примите сертификат для ${DOMAIN_FRONTEND}."
|
||||||
|
echo " API вызывается через ${DOMAIN_FRONTEND}/idp-api — отдельно открывать ${DOMAIN_API} не нужно."
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -1682,7 +1764,7 @@ print_summary() {
|
|||||||
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
|
echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BOLD}Режим:${NC} ${INSTALL_MODE:-local} | SSL: ${SSL_TYPE:-none} | Nginx: ${NGINX_MODE:-auto}"
|
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}Frontend:${NC} ${PUBLIC_FRONTEND_URL}"
|
||||||
echo -e " ${BOLD}Документация:${NC} ${PUBLIC_DOCS_URL}/docs"
|
echo -e " ${BOLD}Документация:${NC} ${PUBLIC_DOCS_URL}/docs"
|
||||||
echo -e " ${BOLD}WebSocket:${NC} ${PUBLIC_WS_URL}"
|
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
|
if curl -fsS --max-time 3 "$url" >/dev/null 2>&1; then
|
||||||
echo -e " ${GREEN}✔${NC} ${label}: ${url}"
|
echo -e " ${GREEN}✔${NC} ${label}: ${url}"
|
||||||
else
|
else
|
||||||
echo -e " ${RED}✘${NC} ${label}: ${url} — недоступен (502 Bad Gateway)"
|
echo -e " ${RED}✘${NC} ${label}: ${url} — недоступен"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1737,7 +1819,8 @@ show_upstream_health() {
|
|||||||
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
|
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
|
||||||
|
|
||||||
echo " Upstream (host Nginx → localhost):"
|
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 "Frontend" "http://127.0.0.1:3002"
|
||||||
check_local_upstream "Docs" "http://127.0.0.1:3003/docs"
|
check_local_upstream "Docs" "http://127.0.0.1:3003/docs"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1823,7 +1906,7 @@ action_renew_ssl() {
|
|||||||
ok "Let's Encrypt сертификаты обновлены"
|
ok "Let's Encrypt сертификаты обновлены"
|
||||||
;;
|
;;
|
||||||
selfsigned)
|
selfsigned)
|
||||||
generate_all_self_signed_certs
|
generate_all_self_signed_certs --force
|
||||||
write_all_nginx_configs "selfsigned"
|
write_all_nginx_configs "selfsigned"
|
||||||
ok "Самоподписанные сертификаты пересозданы"
|
ok "Самоподписанные сертификаты пересозданы"
|
||||||
;;
|
;;
|
||||||
@@ -1856,6 +1939,15 @@ action_fix_host_proxy() {
|
|||||||
log "Шаг 4/4: конфиги системного Nginx..."
|
log "Шаг 4/4: конфиги системного Nginx..."
|
||||||
refresh_host_nginx_configs
|
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"
|
ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002"
|
||||||
show_status
|
show_status
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user