diff --git a/cloud_backend/routers/manager_auth.py b/cloud_backend/routers/manager_auth.py index e267a9d..435c77c 100644 --- a/cloud_backend/routers/manager_auth.py +++ b/cloud_backend/routers/manager_auth.py @@ -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() diff --git a/sysadmin_panel/src/api/client.js b/sysadmin_panel/src/api/client.js index c577939..da88f16 100644 --- a/sysadmin_panel/src/api/client.js +++ b/sysadmin_panel/src/api/client.js @@ -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 } }) diff --git a/sysadmin_panel/src/pages/SiteDetailPage.jsx b/sysadmin_panel/src/pages/SiteDetailPage.jsx index 5a7be57..3dabdfb 100644 --- a/sysadmin_panel/src/pages/SiteDetailPage.jsx +++ b/sysadmin_panel/src/pages/SiteDetailPage.jsx @@ -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() { + {/* Remote Managers */} +
+
+

Remote Managers

+ +
+ + {managersLoading && ( +

Loading…

+ )} + + {!managersLoading && managers.length === 0 && ( +

No managers linked to this site.

+ )} + + {!managersLoading && managers.length > 0 && ( +
+ {managers.map(mgr => ( +
+
+

{mgr.email}

+ {mgr.full_name && ( +

{mgr.full_name}

+ )} +
+
+ + {mgr.is_active ? 'Active' : 'Inactive'} + + +
+
+ ))} +
+ )} +
+ {/* Modals */} {modal === 'lock' && ( )} + + {modal === 'add_manager' && ( + { setModal(null); setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('') }} + onConfirm={doAddManager} + > +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ )} ) }