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:
@@ -1,13 +1,15 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from models.manager_account import ManagerAccount
|
||||
from models.manager_account import ManagerAccount, manager_site_access
|
||||
from models.site import Site
|
||||
from auth_utils import get_current_admin
|
||||
from schemas.manager import (
|
||||
@@ -56,10 +58,18 @@ def register_manager(
|
||||
db: Session = Depends(get_db),
|
||||
_admin=Depends(get_current_admin),
|
||||
):
|
||||
if db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
|
||||
existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
|
||||
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
|
||||
|
||||
if existing:
|
||||
# Email already exists — just add the new site access links, don't recreate the account
|
||||
for site in sites:
|
||||
if site not in existing.sites:
|
||||
existing.sites.append(site)
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
manager = ManagerAccount(
|
||||
email=body.email,
|
||||
password_hash=_pwd.hash(body.password),
|
||||
@@ -89,3 +99,52 @@ def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
|
||||
@router.post("/refresh", response_model=ManagerTokenOut)
|
||||
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
|
||||
return ManagerTokenOut(access_token=_create_manager_token(manager))
|
||||
|
||||
|
||||
# ── Admin-only: list managers for a site ─────────────────────────────────────
|
||||
|
||||
class ManagerBySiteOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
is_active: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut])
|
||||
def get_managers_by_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_admin=Depends(get_current_admin),
|
||||
):
|
||||
site = db.query(Site).filter(Site.id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
return site.manager_accounts
|
||||
|
||||
|
||||
# ── Admin-only: remove a manager's access to a site ──────────────────────────
|
||||
|
||||
class SiteAccessRemoveRequest(BaseModel):
|
||||
manager_id: int
|
||||
site_id: int
|
||||
|
||||
|
||||
@router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_manager_site_access(
|
||||
body: SiteAccessRemoveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_admin=Depends(get_current_admin),
|
||||
):
|
||||
manager = db.query(ManagerAccount).filter(ManagerAccount.id == body.manager_id).first()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=404, detail="Manager not found")
|
||||
|
||||
site = db.query(Site).filter(Site.id == body.site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
if site in manager.sites:
|
||||
manager.sites.remove(site)
|
||||
db.commit()
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user