diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 953fe24..7f87311 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1358,6 +1358,35 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, return {"status": "ok", "username": username, "role": role} +@router.post("/users/{username}/email") +async def update_user_email(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: + user = get_user_by_username(username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + + email = _optional_recipient_email(payload.get("email")) + if email: + duplicate = next( + ( + candidate + for candidate in get_all_users() + if str(candidate.get("username") or "").casefold() != username.casefold() + and str(candidate.get("email") or "").strip().casefold() == email.casefold() + ), + None, + ) + if duplicate: + raise HTTPException(status_code=409, detail="That email address is already assigned to another user") + + if not set_user_email(username, email): + raise HTTPException(status_code=404, detail="User not found") + refreshed = get_user_by_username(username) + logger.info("Admin updated user contact email: username=%s email_set=%s", username, bool(email)) + return {"status": "ok", "user": refreshed, "email": email} + + @router.post("/users/{username}/auto-search") async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: enabled = payload.get("enabled") if isinstance(payload, dict) else None diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index fbebe69..44eb950 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1320,6 +1320,39 @@ class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase): self.assertEqual(stored.get("email"), "mixed@example.com") +class AdminUserEmailTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + async def test_admin_can_add_and_remove_user_email(self) -> None: + db.create_user_if_missing("Viewer", "password123", auth_provider="local") + + saved = await admin_router.update_user_email("viewer", {"email": "viewer@example.com"}) + self.assertEqual(saved["user"]["email"], "viewer@example.com") + + cleared = await admin_router.update_user_email("VIEWER", {"email": None}) + self.assertIsNone(cleared["user"]["email"]) + + async def test_admin_cannot_assign_duplicate_user_email(self) -> None: + db.create_user_if_missing( + "FirstViewer", "password123", email="shared@example.com", auth_provider="local" + ) + db.create_user_if_missing("SecondViewer", "password123", auth_provider="local") + + with self.assertRaises(HTTPException) as context: + await admin_router.update_user_email( + "SecondViewer", {"email": "SHARED@example.com"} + ) + + self.assertEqual(context.exception.status_code, 409) + self.assertIn("another user", str(context.exception.detail)) + + async def test_admin_user_email_requires_valid_address(self) -> None: + db.create_user_if_missing("Viewer", "password123", auth_provider="local") + + with self.assertRaises(HTTPException) as context: + await admin_router.update_user_email("Viewer", {"email": "not-an-email"}) + + self.assertEqual(context.exception.status_code, 400) + + class SnapshotHistoryTests(TempDatabaseMixin, unittest.TestCase): def test_duplicate_snapshots_are_not_saved_and_download_evidence_is_retained(self) -> None: snapshot = Snapshot( diff --git a/frontend/app/users/[id]/page.tsx b/frontend/app/users/[id]/page.tsx index d1fed33..cc9a00e 100644 --- a/frontend/app/users/[id]/page.tsx +++ b/frontend/app/users/[id]/page.tsx @@ -101,8 +101,10 @@ export default function UserDetailPage() { const [profiles, setProfiles] = useState([]) const [profileSelection, setProfileSelection] = useState('') const [expiryInput, setExpiryInput] = useState('') + const [emailInput, setEmailInput] = useState('') const [savingProfile, setSavingProfile] = useState(false) const [savingExpiry, setSavingExpiry] = useState(false) + const [savingEmail, setSavingEmail] = useState(false) const [systemActionBusy, setSystemActionBusy] = useState(false) const [actionStatus, setActionStatus] = useState(null) const [lineage, setLineage] = useState(null) @@ -165,6 +167,7 @@ export default function UserDetailPage() { : String(nextUser.profile_id) ) setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at)) + setEmailInput(nextUser?.email ?? '') setError(null) } catch (err) { console.error(err) @@ -218,6 +221,47 @@ export default function UserDetailPage() { } } + const saveUserEmail = async (clear = false) => { + if (!user) return + const email = clear ? '' : emailInput.trim() + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setError('Enter a valid email address.') + setActionStatus(null) + return + } + setSavingEmail(true) + setError(null) + setActionStatus(null) + try { + const response = await authFetch( + `${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email || null }), + } + ) + const text = await response.text() + let data: any = null + try { + data = text ? JSON.parse(text) : null + } catch { + data = null + } + if (!response.ok) { + throw new Error(data?.detail || text || 'Email update failed') + } + setEmailInput(data?.user?.email ?? '') + await loadUser() + setActionStatus(email ? 'Contact email saved.' : 'Contact email removed.') + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not update the contact email.') + } finally { + setSavingEmail(false) + } + } + const updateAutoSearchEnabled = async (enabled: boolean) => { if (!user) return try { @@ -546,6 +590,53 @@ export default function UserDetailPage() {
+
+
+

Contact email

+

Used by Magent for account recovery and issue updates.

+
+
{ + event.preventDefault() + void saveUserEmail() + }} + > + +
+ This updates Magent only. It does not change the user's Jellyfin or Seerr account. +
+
+ + {user.email && ( + + )} +
+
+
+

Access controls