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:
commit
130c5382cd
108 files changed
+7146
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user