fix idp on started

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

View File

@@ -1,20 +1,31 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Chrome, Loader2, Save, Settings2, ToggleLeft, ToggleRight } from 'lucide-react';
import { Chrome, Loader2, Save, ToggleLeft, ToggleRight } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { AdminShell } from '@/components/id/admin-shell';
import { usePublicSettings } from '@/components/id/public-settings-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog';
import { MessagingSettingsSection } from '@/components/id/messaging-settings-section';
import { SettingsFieldRow } from '@/components/id/settings-field-row';
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
import {
getSettingMeta,
isSettingVisible,
MESSAGING_SETTING_KEYS,
sortSettingsByCatalog,
SYSTEM_SETTING_GROUPS
} from '@/lib/system-settings-catalog';
function parseBoolean(value: string) {
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
}
const GROUP_LABELS: Record<string, string> = {
...SYSTEM_SETTING_GROUPS,
'messaging-email': 'Email (SMTP)',
'messaging-sms': 'SMS'
};
const PUBLIC_SETTINGS_REFRESH_KEYS = new Set(['PROJECT_NAME', 'PROJECT_TAGLINE', 'LDAP_ENABLED', 'LDAP_USE_LDAPS']);
export default function AdminSettingsPage() {
const { token, user } = useAuth();
@@ -23,7 +34,8 @@ export default function AdminSettingsPage() {
const [settings, setSettings] = useState<SystemSetting[]>([]);
const [providers, setProviders] = useState<SocialProvider[]>([]);
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [savingGroup, setSavingGroup] = useState<string | null>(null);
const [savingOAuthProvider, setSavingOAuthProvider] = useState<string | null>(null);
useEffect(() => {
if (!token || !user?.canManageSettings) return;
@@ -52,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>
))}

View File

@@ -1,12 +1,17 @@
'use client';
import { Loader2, Mail, MessageSquare, Save, Send, ToggleLeft, ToggleRight } from 'lucide-react';
import { Loader2, Mail, MessageSquare, Save, Send } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { PhoneInput, phoneCountries } from '@/components/ui/phone-input';
import { apiFetch, buildSystemSettingPayload, SystemSetting } from '@/lib/api';
import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS } from '@/lib/system-settings-catalog';
import { apiFetch, SystemSetting } from '@/lib/api';
import {
EMAIL_SETTING_KEYS,
isSettingVisible,
SMS_SETTING_KEYS
} from '@/lib/system-settings-catalog';
import { SettingsFieldRow } from '@/components/id/settings-field-row';
function parseBoolean(value: string) {
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
@@ -15,9 +20,9 @@ function parseBoolean(value: string) {
interface MessagingSettingsSectionProps {
settings: SystemSetting[];
token: string | null;
savingKey: string | null;
savingGroup: string | null;
onUpdateValue: (key: string, value: string) => void;
onSave: (setting: SystemSetting) => Promise<void>;
onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise<void>;
showToast: (message: string) => void;
}
@@ -46,12 +51,60 @@ function shouldShowMessagingField(setting: SystemSetting, settings: SystemSettin
return true;
}
function MessagingBlock({
title,
description,
groupKey,
blockSettings,
allSettings,
savingGroup,
onUpdateValue,
onSaveGroup
}: {
title: string;
description: string;
groupKey: string;
blockSettings: SystemSetting[];
allSettings: SystemSetting[];
savingGroup: string | null;
onUpdateValue: (key: string, value: string) => void;
onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise<void>;
}) {
const visibleSettings = blockSettings.filter((setting) => shouldShowMessagingField(setting, allSettings));
const saving = savingGroup === groupKey;
return (
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-start md:justify-between">
<div>
<h4 className="text-lg font-medium">{title}</h4>
<p className="mt-1 text-sm text-[#667085]">{description}</p>
</div>
<Button disabled={saving || visibleSettings.length === 0} onClick={() => void onSaveGroup(groupKey, blockSettings)}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Сохранить блок
</Button>
</div>
<div>
{visibleSettings.map((setting) => (
<SettingsFieldRow
key={setting.key}
setting={setting}
onChange={(value) => onUpdateValue(setting.key, value)}
/>
))}
</div>
</div>
);
}
export function MessagingSettingsSection({
settings,
token,
savingKey,
savingGroup,
onUpdateValue,
onSave,
onSaveGroup,
showToast
}: MessagingSettingsSectionProps) {
const [testEmail, setTestEmail] = useState('');
@@ -59,8 +112,12 @@ export function MessagingSettingsSection({
const [testPhoneCountry, setTestPhoneCountry] = useState(phoneCountries[0]);
const [testingChannel, setTestingChannel] = useState<'email' | 'sms' | null>(null);
const messagingSettings = useMemo(
() => settings.filter((item) => MESSAGING_SETTING_KEYS.includes(item.key)),
const emailSettings = useMemo(
() => settings.filter((item) => EMAIL_SETTING_KEYS.includes(item.key)),
[settings]
);
const smsSettings = useMemo(
() => settings.filter((item) => SMS_SETTING_KEYS.includes(item.key)),
[settings]
);
@@ -90,8 +147,12 @@ export function MessagingSettingsSection({
}
}
const emailEnabled = parseBoolean(getSettingValue(messagingSettings, 'EMAIL_ENABLED'));
const smsEnabled = parseBoolean(getSettingValue(messagingSettings, 'SMS_ENABLED'));
const emailEnabled = parseBoolean(getSettingValue(emailSettings, 'EMAIL_ENABLED'));
const smsEnabled = parseBoolean(getSettingValue(smsSettings, 'SMS_ENABLED'));
const emailReady =
emailEnabled ||
(Boolean(getSettingValue(emailSettings, 'EMAIL_SMTP_HOST')) &&
Boolean(getSettingValue(emailSettings, 'EMAIL_FROM_ADDRESS')));
return (
<section className="mb-10">
@@ -100,67 +161,33 @@ export function MessagingSettingsSection({
<div>
<h3 className="text-xl font-medium">Email и SMS провайдеры</h3>
<p className="mt-1 text-sm text-[#667085]">
Настройте SMTP и SMS OTP-коды при входе и регистрации будут отправляться автоматически.
Измените параметры блока и нажмите «Сохранить блок» все поля блока сохранятся одним действием.
</p>
</div>
</div>
<div className="space-y-3">
{messagingSettings.filter((setting) => shouldShowMessagingField(setting, messagingSettings)).map((setting) => {
const meta = getSettingMeta(setting.key);
const label = meta?.label ?? setting.key;
const type = meta?.type ?? 'text';
<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

View File

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

View File

@@ -2,7 +2,7 @@
import * as React from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { WS_URL, type ChatMessage } from '@/lib/api';
import { getWsUrl, type ChatMessage } from '@/lib/api';
export interface RealtimeEvent {
userId?: string;
@@ -58,7 +58,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
function connect() {
if (cancelled) return;
const url = `${WS_URL}?token=${encodeURIComponent(token!)}`;
const url = `${getWsUrl()}?token=${encodeURIComponent(token!)}`;
const socket = new WebSocket(url);
socketRef.current = socket;

View File

@@ -1,4 +1,52 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
const API_ORIGIN_PROXY_PREFIX = '/idp-api';
const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, '');
const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, '');
function resolveBrowserApiBaseUrl(): string {
if (typeof window === 'undefined') {
return configuredApiUrl;
}
try {
const configured = new URL(configuredApiUrl);
if (configured.origin !== window.location.origin) {
return `${window.location.origin}${API_ORIGIN_PROXY_PREFIX}`;
}
} catch {
// keep configured URL
}
return configuredApiUrl;
}
function resolveBrowserWsBaseUrl(): string {
if (typeof window === 'undefined') {
return configuredWsUrl;
}
try {
const configured = new URL(configuredWsUrl);
if (configured.origin !== window.location.origin) {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`;
}
} catch {
// keep configured URL
}
return configuredWsUrl;
}
export function getApiUrl(): string {
return resolveBrowserApiBaseUrl();
}
export function getWsUrl(): string {
return resolveBrowserWsBaseUrl();
}
/** @deprecated Используйте getApiUrl() — URL может переключаться на same-origin /idp-api в браузере */
export const API_URL = configuredApiUrl;
export interface PublicUser {
id: string;
@@ -141,6 +189,23 @@ export class ApiError extends Error {
}
}
function mapFetchError(error: unknown): ApiError {
if (error instanceof TypeError) {
const message = error.message.toLowerCase();
if (message.includes('failed to fetch') || message.includes('networkerror')) {
return new ApiError(
'Нет связи с сервером. При самоподписанном HTTPS примите сертификат сайта или выполните ./install.sh --fix-proxy на сервере.',
0,
'NETWORK_ERROR'
);
}
}
if (error instanceof ApiError) {
return error;
}
return new ApiError(error instanceof Error ? error.message : 'Не удалось выполнить запрос', 0, 'UNKNOWN');
}
type PinRequiredHandler = (sessionId: string) => void;
export function setPinRequiredHandler(handler: PinRequiredHandler | null) {
@@ -212,7 +277,7 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH');
}
const response = await fetch(`${API_URL}/auth/refresh`, {
const response = await fetch(`${getApiUrl()}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined })
@@ -320,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;

View File

@@ -109,6 +109,14 @@ function parseBooleanValue(value: string) {
export const MESSAGING_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'messaging').map((item) => item.key);
export const EMAIL_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) =>
item.key.startsWith('EMAIL_')
).map((item) => item.key);
export const SMS_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) =>
item.key.startsWith('SMS_')
).map((item) => item.key);
export function sortSettingsByCatalog<T extends { key: string }>(settings: T[]) {
const order = new Map(SYSTEM_SETTING_CATALOG.map((item, index) => [item.key, index]));
return [...settings].sort((a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999));