Initial commit: TopSysOps site + admin panel

Multi-page public site (Next.js), Node/Express backend with Postgres,
and Vite admin/account app, restructured from a single-page layout
into dedicated service, promotions, cases, about, FAQ, and blog pages
for SEO.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-25 23:12:16 +03:00
commit 130c5382cd
108 files changed
+7146

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useState } from 'react';
import { Trash2, UserPlus } from 'lucide-react';
import { api } from '../lib/api.js';
const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700';
function PasswordForm() {
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [message, setMessage] = useState(null); // { type: 'ok' | 'error', text }
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setMessage(null);
if (form.newPassword !== form.confirm) {
setMessage({ type: 'error', text: 'Новые пароли не совпадают' });
return;
}
if (form.newPassword.length < 8) {
setMessage({ type: 'error', text: 'Новый пароль должен быть не короче 8 символов' });
return;
}
setBusy(true);
try {
await api.changePassword(form.currentPassword, form.newPassword);
setMessage({ type: 'ok', text: 'Пароль изменён' });
setForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (err) {
setMessage({
type: 'error',
text: err.message === 'current password is incorrect' ? 'Текущий пароль указан неверно' : 'Не удалось изменить пароль',
});
} finally {
setBusy(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-1">Сменить пароль</h2>
<div>
<label className="text-xs block mb-1">Текущий пароль</label>
<input
type="password"
required
value={form.currentPassword}
onChange={(e) => setForm({ ...form, currentPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Новый пароль</label>
<input
type="password"
required
value={form.newPassword}
onChange={(e) => setForm({ ...form, newPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Повторите новый пароль</label>
<input
type="password"
required
value={form.confirm}
onChange={(e) => setForm({ ...form, confirm: e.target.value })}
className={fieldCls}
/>
</div>
{message && (
<p className={message.type === 'ok' ? 'text-xs text-emerald-500' : 'text-xs text-rose-500'}>{message.text}</p>
)}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
);
}
function AdminsSection() {
const [admins, setAdmins] = useState([]);
const [me, setMe] = useState(null);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ username: '', password: '' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function load() {
setLoading(true);
const [list, meData] = await Promise.all([api.adminGetAdmins(), api.me()]);
setAdmins(list);
setMe(meData);
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function handleCreate(e) {
e.preventDefault();
setError('');
if (!form.username.trim()) {
setError('Укажите логин');
return;
}
if (form.password.length < 8) {
setError('Пароль должен быть не короче 8 символов');
return;
}
setBusy(true);
try {
await api.adminCreateAdminUser(form.username.trim(), form.password);
setForm({ username: '', password: '' });
load();
} catch (err) {
setError(err.message === 'this username is already taken' ? 'Такой логин уже занят' : 'Не удалось создать администратора');
} finally {
setBusy(false);
}
}
async function handleDelete(id) {
if (!window.confirm('Удалить этого администратора без возможности восстановления?')) return;
try {
await api.adminDeleteAdminUser(id);
load();
} catch (err) {
window.alert(
err.message === 'you cannot delete your own account while logged in'
? 'Нельзя удалить самого себя, пока вы вошли под этим аккаунтом'
: err.message === 'cannot delete the last remaining admin'
? 'Нельзя удалить последнего администратора - должен остаться хотя бы один'
: 'Не удалось удалить'
);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем список администраторов...</p>;
return (
<div className="max-w-sm rounded-lg border p-5 space-y-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium">Администраторы</h2>
<div className="space-y-2">
{admins.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-2 text-sm">
<span>
{a.username}
{me?.id === a.id && <span className="text-slate-400 dark:text-slate-500"> (вы)</span>}
</span>
{me?.id !== a.id && (
<button
onClick={() => handleDelete(a.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
title="Удалить администратора"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
<form onSubmit={handleCreate} className="space-y-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<label className="text-xs block mb-1">Новый администратор</label>
<input
placeholder="Логин"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className={fieldCls}
/>
<input
type="password"
placeholder="Пароль (от 8 символов)"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={fieldCls}
/>
{error && <p className="text-xs text-rose-500">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-60 inline-flex items-center justify-center gap-1.5"
>
<UserPlus className="h-3.5 w-3.5" /> {busy ? 'Создаём...' : 'Добавить'}
</button>
</form>
</div>
);
}
export default function Settings() {
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Настройки</h1>
<div className="grid sm:grid-cols-2 gap-5 items-start">
<PasswordForm />
<AdminsSection />
</div>
</div>
);
}