feat(sysadmin): Part 3 — Remote Managers section in SiteDetailPage

Backend (manager_auth.py):
- register: upsert instead of 409 — if email exists, just add site access
- GET /api/manager/by-site/{site_id}: list managers linked to a site (admin auth)
- DELETE /api/manager/site-access: remove a manager's access to a site (admin auth)

Sysadmin API client:
- getManagersBySite, addManagerToSite, removeManagerSiteAccess

SiteDetailPage.jsx:
- Remote Managers section: lists all linked managers with email, name, active badge
- Add Manager modal: email + full name + password form (password ignored if account exists)
- Remove access button with optimistic removal from list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 10:04:28 +03:00
parent b45a93a559
commit ac58d150ea
3 changed files with 223 additions and 7 deletions

View File

@@ -22,3 +22,16 @@ client.interceptors.response.use(
)
export default client
// ── Remote Manager accounts ───────────────────────────────────────────────────
export const getManagersBySite = (siteId) =>
client.get(`/api/manager/by-site/${siteId}`)
export const addManagerToSite = (email, fullName, password, siteId) =>
client.post('/api/manager/register', {
email, full_name: fullName, password, site_ids: [siteId],
})
export const removeManagerSiteAccess = (managerId, siteId) =>
client.delete('/api/manager/site-access', { data: { manager_id: managerId, site_id: siteId } })

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
import client, { getManagersBySite, addManagerToSite, removeManagerSiteAccess } from '../api/client'
import LicenseStatus from '../components/LicenseStatus'
import ConfirmModal from '../components/ConfirmModal'
@@ -26,12 +26,21 @@ export default function SiteDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain'
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager'
const [lockReason, setLockReason] = useState('')
const [newExpiry, setNewExpiry] = useState('')
const [newDomain, setNewDomain] = useState('')
const [acting, setActing] = useState(false)
// Remote Managers state
const [managers, setManagers] = useState([])
const [managersLoading, setManagersLoading] = useState(false)
const [newMgrEmail, setNewMgrEmail] = useState('')
const [newMgrName, setNewMgrName] = useState('')
const [newMgrPass, setNewMgrPass] = useState('')
const [addingMgr, setAddingMgr] = useState(false)
const [removingMgrId, setRemovingMgrId] = useState(null)
useEffect(() => {
async function load() {
try {
@@ -47,6 +56,49 @@ export default function SiteDetailPage() {
load()
}, [siteId])
const fetchManagers = useCallback(async () => {
setManagersLoading(true)
try {
const { data } = await getManagersBySite(siteId)
setManagers(data)
} catch {
toast.error('Failed to load managers')
} finally {
setManagersLoading(false)
}
}, [siteId])
useEffect(() => { fetchManagers() }, [fetchManagers])
async function doAddManager() {
if (!newMgrEmail.trim() || !newMgrPass.trim()) return
setAddingMgr(true)
try {
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), Number(siteId))
toast.success('Manager added')
setModal(null)
setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('')
fetchManagers()
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to add manager')
} finally {
setAddingMgr(false)
}
}
async function doRemoveManager(managerId) {
setRemovingMgrId(managerId)
try {
await removeManagerSiteAccess(managerId, Number(siteId))
toast.success('Access removed')
setManagers(prev => prev.filter(m => m.id !== managerId))
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to remove access')
} finally {
setRemovingMgrId(null)
}
}
async function doLock() {
if (!lockReason.trim()) return
setActing(true)
@@ -270,6 +322,56 @@ export default function SiteDetailPage() {
</div>
</div>
{/* Remote Managers */}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Remote Managers</h2>
<button
onClick={() => setModal('add_manager')}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
+ Add Manager
</button>
</div>
{managersLoading && (
<p className="text-xs text-gray-500">Loading</p>
)}
{!managersLoading && managers.length === 0 && (
<p className="text-xs text-gray-600 italic">No managers linked to this site.</p>
)}
{!managersLoading && managers.length > 0 && (
<div className="space-y-2">
{managers.map(mgr => (
<div key={mgr.id} className="flex items-center justify-between gap-3 bg-gray-800 rounded-lg px-3 py-2">
<div className="min-w-0">
<p className="text-sm text-gray-200 font-medium truncate">{mgr.email}</p>
{mgr.full_name && (
<p className="text-xs text-gray-500 truncate">{mgr.full_name}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
mgr.is_active ? 'bg-emerald-900/50 text-emerald-400' : 'bg-gray-700 text-gray-500'
}`}>
{mgr.is_active ? 'Active' : 'Inactive'}
</span>
<button
onClick={() => doRemoveManager(mgr.id)}
disabled={removingMgrId === mgr.id}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
{removingMgrId === mgr.id ? '…' : 'Remove'}
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modals */}
{modal === 'lock' && (
<ConfirmModal
@@ -349,6 +451,48 @@ export default function SiteDetailPage() {
</div>
</ConfirmModal>
)}
{modal === 'add_manager' && (
<ConfirmModal
title="Add Remote Manager"
confirmLabel={addingMgr ? 'Adding…' : 'Add Manager'}
onCancel={() => { setModal(null); setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('') }}
onConfirm={doAddManager}
>
<div className="space-y-3 mb-2">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Email *</label>
<input
type="email"
value={newMgrEmail}
onChange={e => setNewMgrEmail(e.target.value)}
placeholder="manager@example.com"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Full name</label>
<input
type="text"
value={newMgrName}
onChange={e => setNewMgrName(e.target.value)}
placeholder="Jane Smith"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Password * <span className="text-gray-600">(ignored if account already exists)</span></label>
<input
type="password"
value={newMgrPass}
onChange={e => setNewMgrPass(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
</div>
</ConfirmModal>
)}
</div>
)
}