284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from '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 { 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';
|
||
|
||
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',
|
||
'PUBLIC_API_URL',
|
||
'PUBLIC_FRONTEND_URL',
|
||
'ONE_TAP_ENABLED',
|
||
'LDAP_ENABLED',
|
||
'LDAP_USE_LDAPS'
|
||
]);
|
||
|
||
export default function AdminSettingsPage() {
|
||
const { token, user } = useAuth();
|
||
const { showToast } = useToast();
|
||
const { refreshPublicSettings } = usePublicSettings();
|
||
const [settings, setSettings] = useState<SystemSetting[]>([]);
|
||
const [providers, setProviders] = useState<SocialProvider[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [savingGroup, setSavingGroup] = useState<string | null>(null);
|
||
const [savingOAuthProvider, setSavingOAuthProvider] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!token || !user?.canManageSettings) return;
|
||
Promise.all([
|
||
apiFetch<{ settings: SystemSetting[] }>('/admin/settings', {}, token),
|
||
apiFetch<{ providers: SocialProvider[] }>('/admin/settings/oauth/providers', {}, token)
|
||
])
|
||
.then(([settingsResponse, providersResponse]) => {
|
||
setSettings(sortSettingsByCatalog(settingsResponse.settings ?? []));
|
||
setProviders(providersResponse.providers ?? []);
|
||
})
|
||
.catch((error) => showToast(error instanceof Error ? error.message : 'Не удалось загрузить настройки'))
|
||
.finally(() => setLoading(false));
|
||
}, [showToast, token, user?.canManageSettings]);
|
||
|
||
const groupedSettings = useMemo(() => {
|
||
const groups = new Map<string, SystemSetting[]>();
|
||
for (const setting of settings) {
|
||
if (MESSAGING_SETTING_KEYS.includes(setting.key)) continue;
|
||
const meta = getSettingMeta(setting.key);
|
||
const group = meta?.group ?? 'other';
|
||
const list = groups.get(group) ?? [];
|
||
list.push(setting);
|
||
groups.set(group, list);
|
||
}
|
||
return groups;
|
||
}, [settings]);
|
||
|
||
async function saveSettingsGroup(groupKey: string, groupSettings: SystemSetting[]) {
|
||
if (!token) return;
|
||
|
||
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
|
||
if (visibleSettings.length === 0) return;
|
||
|
||
setSavingGroup(groupKey);
|
||
try {
|
||
let needsPublicRefresh = false;
|
||
for (const setting of visibleSettings) {
|
||
const updated = await apiFetch<SystemSetting>(
|
||
'/admin/settings',
|
||
{
|
||
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(`Блок «${GROUP_LABELS[groupKey] ?? groupKey}» сохранён`);
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройки блока');
|
||
} finally {
|
||
setSavingGroup(null);
|
||
}
|
||
}
|
||
|
||
function updateSettingValue(key: string, value: string) {
|
||
setSettings((current) => current.map((item) => (item.key === key ? { ...item, value } : item)));
|
||
}
|
||
|
||
function toProviderPayload(provider: SocialProvider) {
|
||
return {
|
||
providerName: provider.providerName,
|
||
clientId: provider.clientId,
|
||
isEnabled: provider.isEnabled
|
||
};
|
||
}
|
||
|
||
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
|
||
);
|
||
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
||
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось обновить провайдера');
|
||
}
|
||
}
|
||
|
||
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">
|
||
<p className="text-[#667085]">У вас нет прав на изменение глобальных настроек.</p>
|
||
</AdminShell>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AdminShell active="/admin/settings">
|
||
<div className="mb-6">
|
||
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
|
||
<p className="mt-2 text-[#667085]">
|
||
Параметры сгруппированы по блокам — измените нужные поля и сохраните весь блок одной кнопкой.
|
||
</p>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
Загрузка...
|
||
</div>
|
||
) : (
|
||
<>
|
||
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => {
|
||
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
|
||
const saving = savingGroup === groupKey;
|
||
|
||
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>
|
||
{visibleSettings.map((setting) => (
|
||
<SettingsFieldRow
|
||
key={setting.key}
|
||
setting={setting}
|
||
onChange={(value) => updateSettingValue(setting.key, value)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
|
||
<MessagingSettingsSection
|
||
settings={settings}
|
||
token={token}
|
||
savingGroup={savingGroup}
|
||
onUpdateValue={updateSettingValue}
|
||
onSaveGroup={saveSettingsGroup}
|
||
showToast={showToast}
|
||
/>
|
||
|
||
<section className="mt-10">
|
||
<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>
|
||
)}
|
||
<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]" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
<h4 className="mt-5 font-semibold capitalize">{provider.providerName}</h4>
|
||
<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
|
||
)
|
||
)
|
||
}
|
||
/>
|
||
<Button
|
||
className="mt-3"
|
||
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>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
</AdminShell>
|
||
);
|
||
}
|