Files
IdP/apps/frontend/app/admin/settings/page.tsx
2026-06-24 17:34:55 +03:00

276 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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', '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>
);
}