fix idp on started
This commit is contained in:
@@ -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,27 +64,37 @@ export default function AdminSettingsPage() {
|
||||
return groups;
|
||||
}, [settings]);
|
||||
|
||||
async function saveSetting(setting: SystemSetting) {
|
||||
async function saveSettingsGroup(groupKey: string, groupSettings: SystemSetting[]) {
|
||||
if (!token) return;
|
||||
setSavingKey(setting.key);
|
||||
|
||||
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
|
||||
if (visibleSettings.length === 0) return;
|
||||
|
||||
setSavingGroup(groupKey);
|
||||
try {
|
||||
const updated = await apiFetch<SystemSetting>(
|
||||
'/admin/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(buildSystemSettingPayload(setting))
|
||||
},
|
||||
token
|
||||
);
|
||||
setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item))));
|
||||
if (updated.key === 'PROJECT_NAME' || updated.key === 'PROJECT_TAGLINE' || updated.key === 'LDAP_ENABLED' || updated.key === 'LDAP_USE_LDAPS') {
|
||||
let needsPublicRefresh = false;
|
||||
for (const setting of visibleSettings) {
|
||||
const updated = await apiFetch<SystemSetting>(
|
||||
'/admin/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(buildSystemSettingPayload(setting))
|
||||
},
|
||||
token
|
||||
);
|
||||
setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item))));
|
||||
if (PUBLIC_SETTINGS_REFRESH_KEYS.has(updated.key)) {
|
||||
needsPublicRefresh = true;
|
||||
}
|
||||
}
|
||||
if (needsPublicRefresh) {
|
||||
await refreshPublicSettings();
|
||||
}
|
||||
showToast('Настройка сохранена');
|
||||
showToast(`Блок «${GROUP_LABELS[groupKey] ?? groupKey}» сохранён`);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройку');
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройки блока');
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
setSavingGroup(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +113,14 @@ export default function AdminSettingsPage() {
|
||||
async function toggleProvider(provider: SocialProvider) {
|
||||
if (!token) return;
|
||||
try {
|
||||
const updated = await apiFetch<SocialProvider>('/admin/settings/oauth/providers', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
||||
}, token);
|
||||
const updated = await apiFetch<SocialProvider>(
|
||||
'/admin/settings/oauth/providers',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
||||
},
|
||||
token
|
||||
);
|
||||
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
||||
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
||||
} catch (error) {
|
||||
@@ -102,6 +128,22 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOAuthProvider(provider: SocialProvider) {
|
||||
if (!token) return;
|
||||
setSavingOAuthProvider(provider.providerName);
|
||||
try {
|
||||
await apiFetch('/admin/settings/oauth/providers', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(toProviderPayload(provider))
|
||||
}, token);
|
||||
showToast(`Провайдер ${provider.providerName} сохранён`);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Ошибка сохранения провайдера');
|
||||
} finally {
|
||||
setSavingOAuthProvider(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?.canManageSettings) {
|
||||
return (
|
||||
<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>
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
{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">
|
||||
{type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
updateSettingValue(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) => 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>
|
||||
))}
|
||||
<div>
|
||||
{visibleSettings.map((setting) => (
|
||||
<SettingsFieldRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
onChange={(value) => updateSettingValue(setting.key, value)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -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';
|
||||
<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}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold">{label}</div>
|
||||
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => onUpdateValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true')}
|
||||
>
|
||||
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
||||
</button>
|
||||
) : type === 'select' ? (
|
||||
<select
|
||||
className="h-10 rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
|
||||
value={setting.value}
|
||||
onChange={(event) => onUpdateValue(setting.key, event.target.value)}
|
||||
>
|
||||
{(meta?.options ?? []).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
||||
value={setting.value}
|
||||
className={setting.isSecret ? 'w-[240px] bg-white' : 'w-[220px] bg-white'}
|
||||
onChange={(event) => onUpdateValue(setting.key, event.target.value)}
|
||||
/>
|
||||
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button disabled={savingKey === setting.key} onClick={() => void onSave(setting)}>
|
||||
{savingKey === setting.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<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
|
||||
|
||||
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 { 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;
|
||||
|
||||
|
||||
@@ -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,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> {
|
||||
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${getApiUrl()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapFetchError(error);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await parseErrorResponse(response);
|
||||
@@ -579,4 +649,5 @@ export async function setChatRoomMuted(roomId: string, muted: boolean, token?: s
|
||||
return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token);
|
||||
}
|
||||
|
||||
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws';
|
||||
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */
|
||||
export const WS_URL = configuredWsUrl;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user