feat: manage-users edge function, admin CRUD via API instead of direct supabase.auth.admin
This commit is contained in:
parent
b9b227e524
commit
9009ffdfb0
|
|
@ -3,7 +3,7 @@ import { Panel } from '../UI/Panel';
|
||||||
import { Badge } from '../UI/Badge';
|
import { Badge } from '../UI/Badge';
|
||||||
import { Input } from '../UI/Input';
|
import { Input } from '../UI/Input';
|
||||||
|
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase, supabaseUrl } from '../../supabaseClient';
|
||||||
|
|
||||||
const ROLES = ['admin', 'driver', 'logistician', 'manager', 'mega_admin'];
|
const ROLES = ['admin', 'driver', 'logistician', 'manager', 'mega_admin'];
|
||||||
|
|
||||||
|
|
@ -23,19 +23,45 @@ const ROLE_TONES = {
|
||||||
driver: 'accent',
|
driver: 'accent',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Call manage-users edge function ── */
|
||||||
|
async function adminApi(method, body) {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error('Не авторизован');
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (body && method !== 'DELETE') opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const url = method === 'DELETE' && body?.id
|
||||||
|
? `${supabaseUrl}/functions/v1/manage-users?id=${body.id}`
|
||||||
|
: `${supabaseUrl}/functions/v1/manage-users`;
|
||||||
|
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error || `Ошибка ${res.status}`);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Custom dropdown (matches app design system) ── */
|
/* ── Custom dropdown (matches app design system) ── */
|
||||||
function RoleDropdown({ value, onChange, onClose }) {
|
function RoleDropdown({ value, onChange }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); onClose?.(); } };
|
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
const onKey = (e) => { if (e.key === 'Escape') { setOpen(false); onClose?.(); } };
|
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
document.addEventListener('pointerdown', onDown);
|
document.addEventListener('pointerdown', onDown);
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
return () => { document.removeEventListener('pointerdown', onDown); document.removeEventListener('keydown', onKey); };
|
return () => { document.removeEventListener('pointerdown', onDown); document.removeEventListener('keydown', onKey); };
|
||||||
}, [open, onClose]);
|
}, [open]);
|
||||||
|
|
||||||
const pick = (role) => { onChange(role); setOpen(false); };
|
const pick = (role) => { onChange(role); setOpen(false); };
|
||||||
|
|
||||||
|
|
@ -50,7 +76,7 @@ function RoleDropdown({ value, onChange, onClose }) {
|
||||||
<span className="ml-1 text-[var(--color-text-muted)] text-xs">▾</span>
|
<span className="ml-1 text-[var(--color-text-muted)] text-xs">▾</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft min-w-[180px]">
|
<div className="absolute left-0 top-full z-30 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft min-w-[180px]">
|
||||||
{ROLES.map((r) => {
|
{ROLES.map((r) => {
|
||||||
const sel = r === value;
|
const sel = r === value;
|
||||||
return (
|
return (
|
||||||
|
|
@ -59,7 +85,7 @@ function RoleDropdown({ value, onChange, onClose }) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => pick(r)}
|
onClick={() => pick(r)}
|
||||||
className={[
|
className={[
|
||||||
'flex w-full items-center justify-between px-4 py-3 text-left text-sm transition',
|
'flex w-full items-center justify-between px-4 py-2.5 text-left text-sm transition',
|
||||||
sel ? 'bg-[var(--color-accent-soft)] text-[var(--color-text)]' : 'text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]',
|
sel ? 'bg-[var(--color-accent-soft)] text-[var(--color-text)]' : 'text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|
@ -119,24 +145,14 @@ export default function UserManagementPanel() {
|
||||||
return match ? match.id : null;
|
return match ? match.id : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Add ── */
|
/* ── Add via edge function ── */
|
||||||
const handleAddUser = async (e) => {
|
const handleAddUser = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setAddError(null);
|
setAddError(null);
|
||||||
if (!addForm.name || !addForm.email || !addForm.role) { setAddError('Все поля обязательны.'); return; }
|
if (!addForm.name || !addForm.email || !addForm.role) { setAddError('Все поля обязательны.'); return; }
|
||||||
setAddSubmitting(true);
|
setAddSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const roleId = getRoleId(addForm.role);
|
await adminApi('POST', { email: addForm.email, name: addForm.name, role: addForm.role });
|
||||||
const { data: authData, error: authErr } = await supabase.auth.admin.createUser({
|
|
||||||
email: addForm.email,
|
|
||||||
email_confirm: true,
|
|
||||||
user_metadata: { name: addForm.name },
|
|
||||||
});
|
|
||||||
if (authErr) throw authErr;
|
|
||||||
const { error: insertErr } = await supabase
|
|
||||||
.from('users')
|
|
||||||
.insert({ id: authData.user.id, email: addForm.email, name: addForm.name, role_id: roleId });
|
|
||||||
if (insertErr) throw insertErr;
|
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setAddForm({ name: '', email: '', role: '' });
|
setAddForm({ name: '', email: '', role: '' });
|
||||||
|
|
@ -145,7 +161,7 @@ export default function UserManagementPanel() {
|
||||||
} finally { setAddSubmitting(false); }
|
} finally { setAddSubmitting(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Edit ── */
|
/* ── Edit via edge function ── */
|
||||||
const startEdit = (user) => {
|
const startEdit = (user) => {
|
||||||
setEditingId(user.id);
|
setEditingId(user.id);
|
||||||
setEditForm({ name: user.name || '', email: user.email || '', role: getRoleName(user) });
|
setEditForm({ name: user.name || '', email: user.email || '', role: getRoleName(user) });
|
||||||
|
|
@ -155,24 +171,17 @@ export default function UserManagementPanel() {
|
||||||
const saveEdit = async () => {
|
const saveEdit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const roleId = getRoleId(editForm.role);
|
await adminApi('PATCH', { id: editingId, name: editForm.name, email: editForm.email, role: editForm.role });
|
||||||
const { error: err } = await supabase
|
|
||||||
.from('users')
|
|
||||||
.update({ name: editForm.name, email: editForm.email, role_id: roleId })
|
|
||||||
.eq('id', editingId);
|
|
||||||
if (err) throw err;
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
} catch (err) { setError(err.message || 'Не удалось сохранить.'); }
|
} catch (err) { setError(err.message || 'Не удалось сохранить.'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Delete ── */
|
/* ── Delete via edge function ── */
|
||||||
const handleDeleteUser = async (userId) => {
|
const handleDeleteUser = async (userId) => {
|
||||||
try {
|
try {
|
||||||
const { error: err } = await supabase.from('users').delete().eq('id', userId);
|
await adminApi('DELETE', { id: userId });
|
||||||
if (err) throw err;
|
|
||||||
try { await supabase.auth.admin.deleteUser(userId); } catch {}
|
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
} catch (err) { setError(err.message || 'Не удалось удалить.'); }
|
} catch (err) { setError(err.message || 'Не удалось удалить.'); }
|
||||||
|
|
@ -197,38 +206,34 @@ export default function UserManagementPanel() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add form */}
|
{/* Add form — compact column */}
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<form onSubmit={handleAddUser} className="mb-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-3">
|
<form onSubmit={handleAddUser} className="mb-4 inline-flex flex-col gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
||||||
<div className="flex flex-wrap gap-3">
|
<Input
|
||||||
<Input
|
placeholder="Имя"
|
||||||
placeholder="Имя"
|
value={addForm.name}
|
||||||
value={addForm.name}
|
onChange={(e) => setAddForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
onChange={(e) => setAddForm((f) => ({ ...f, name: e.target.value }))}
|
className="w-[220px]!"
|
||||||
className="flex-1 min-w-[140px]!"
|
/>
|
||||||
/>
|
<Input
|
||||||
<Input
|
placeholder="Email"
|
||||||
placeholder="Email"
|
type="email"
|
||||||
type="email"
|
value={addForm.email}
|
||||||
value={addForm.email}
|
onChange={(e) => setAddForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
onChange={(e) => setAddForm((f) => ({ ...f, email: e.target.value }))}
|
className="w-[260px]!"
|
||||||
className="flex-1 min-w-[180px]!"
|
/>
|
||||||
/>
|
<RoleDropdown
|
||||||
<RoleDropdown
|
value={addForm.role || 'manager'}
|
||||||
value={addForm.role || 'manager'}
|
onChange={(r) => setAddForm((f) => ({ ...f, role: r }))}
|
||||||
onChange={(r) => setAddForm((f) => ({ ...f, role: r }))}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{addError && <div className="text-sm text-[var(--color-danger)]">{addError}</div>}
|
{addError && <div className="text-sm text-[var(--color-danger)]">{addError}</div>}
|
||||||
<div className="flex justify-end">
|
<button
|
||||||
<button
|
type="submit"
|
||||||
type="submit"
|
disabled={addSubmitting}
|
||||||
disabled={addSubmitting}
|
className="mt-1 self-start rounded-full bg-[var(--color-accent)] px-5 py-2 text-sm font-semibold text-white hover:opacity-90 transition disabled:opacity-50"
|
||||||
className="rounded-full bg-[var(--color-accent)] px-5 py-2 text-sm font-semibold text-white hover:opacity-90 transition disabled:opacity-50"
|
>
|
||||||
>
|
{addSubmitting ? 'Добавление…' : 'Добавить'}
|
||||||
{addSubmitting ? 'Добавление…' : 'Добавить'}
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -252,29 +257,28 @@ export default function UserManagementPanel() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className="rounded-[22px] border border-[var(--color-accent)] bg-[var(--color-surface-strong)] p-4 space-y-3"
|
className="rounded-[22px] border border-[var(--color-accent)] bg-[var(--color-surface-strong)] p-4 space-y-2"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Имя"
|
placeholder="Имя"
|
||||||
value={editForm.name}
|
value={editForm.name}
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
className="flex-1 min-w-[140px]!"
|
className="w-[200px]!"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
type="email"
|
type="email"
|
||||||
value={editForm.email}
|
value={editForm.email}
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
|
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
className="flex-1 min-w-[180px]!"
|
className="w-[240px]!"
|
||||||
/>
|
/>
|
||||||
<RoleDropdown
|
<RoleDropdown
|
||||||
value={editForm.role}
|
value={editForm.role}
|
||||||
onChange={(r) => setEditForm((f) => ({ ...f, role: r }))}
|
onChange={(r) => setEditForm((f) => ({ ...f, role: r }))}
|
||||||
onClose={undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={cancelEdit}
|
onClick={cancelEdit}
|
||||||
className="rounded-lg border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] transition"
|
className="rounded-lg border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] transition"
|
||||||
|
|
@ -292,32 +296,29 @@ export default function UserManagementPanel() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
className="flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium truncate">{user.name || '—'}</div>
|
<span className="font-medium">{user.name || '—'}</span>
|
||||||
<div className="text-sm text-[var(--color-text-muted)] truncate">{user.email}</div>
|
<span className="text-sm text-[var(--color-text-muted)] ml-2">{user.email}</span>
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">{fmtDate(user.created_at)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
<Badge tone={ROLE_TONES[rn] || 'neutral'}>{ROLE_LABELS[rn] || rn}</Badge>
|
<Badge tone={ROLE_TONES[rn] || 'neutral'}>{ROLE_LABELS[rn] || rn}</Badge>
|
||||||
<div className="flex gap-2">
|
<button
|
||||||
|
onClick={() => startEdit(user)}
|
||||||
|
className="rounded-lg border border-[var(--color-accent)] px-2.5 py-1 text-xs font-semibold text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition"
|
||||||
|
>Изменить</button>
|
||||||
|
{deleteConfirmId === user.id ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => handleDeleteUser(user.id)} className="rounded-lg bg-[var(--color-danger)] px-2.5 py-1 text-xs font-semibold text-white transition">Да</button>
|
||||||
|
<button onClick={() => setDeleteConfirmId(null)} className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs text-[var(--color-text-muted)] transition">Нет</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => startEdit(user)}
|
onClick={() => setDeleteConfirmId(user.id)}
|
||||||
className="rounded-lg border border-[var(--color-accent)] px-3 py-1.5 text-xs font-semibold text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition"
|
className="rounded-lg border border-[var(--color-danger)] px-2.5 py-1 text-xs font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)] transition"
|
||||||
>Изменить</button>
|
>Удалить</button>
|
||||||
{deleteConfirmId === user.id ? (
|
)}
|
||||||
<>
|
|
||||||
<button onClick={() => handleDeleteUser(user.id)} className="rounded-lg bg-[var(--color-danger)] px-3 py-1.5 text-xs font-semibold text-white transition">Да</button>
|
|
||||||
<button onClick={() => setDeleteConfirmId(null)} className="rounded-lg border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-muted)] transition">Нет</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirmId(user.id)}
|
|
||||||
className="rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-xs font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)] transition"
|
|
||||||
>Удалить</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue