diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 2482a92..b219748 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -17,6 +17,7 @@ from ..db import ( set_last_login, get_user_by_username, get_users_by_username_ci, + get_all_users, set_user_password, set_user_jellyseerr_id, set_user_email, @@ -108,6 +109,18 @@ def _optional_recipient_email(value: object) -> str | None: return _require_recipient_email(value) +def _optional_account_email(value: object) -> str | None: + if value is None or not str(value).strip(): + return None + normalized = normalize_delivery_email(value) + if normalized: + return normalized + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Enter a valid email address.", + ) + + def _auth_client_ip(request: Request) -> str: direct_host = request.client.host if request.client else None if request_trusts_forwarded_headers(direct_host): @@ -1165,6 +1178,37 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict: } +@router.put("/profile/email") +async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + username = str(current_user.get("username") or "").strip() + if not username: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") + + email = _optional_account_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=status.HTTP_409_CONFLICT, + detail="That email address is already assigned to another account.", + ) + + if not set_user_email(username, email): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + logger.info("User updated profile contact email: username=%s email_set=%s", username, bool(email)) + return {"status": "ok", "email": email} + + @router.get("/profile/invites") async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict: username = str(current_user.get("username") or "").strip() diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index d09068a..907ca22 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1384,6 +1384,48 @@ class SnapshotHistoryTests(TempDatabaseMixin, unittest.TestCase): class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + async def test_user_can_manage_own_profile_email(self) -> None: + db.create_user_if_missing("ProfileViewer", "password123", auth_provider="local") + current_user = {"username": "ProfileViewer", "role": "user"} + + saved = await auth_router.update_profile_email( + {"email": "viewer@example.com"}, current_user + ) + self.assertEqual(saved["email"], "viewer@example.com") + self.assertEqual( + db.get_user_by_username("profileviewer").get("email"), + "viewer@example.com", + ) + + cleared = await auth_router.update_profile_email({"email": None}, current_user) + self.assertIsNone(cleared["email"]) + self.assertIsNone(db.get_user_by_username("ProfileViewer").get("email")) + + async def test_user_cannot_claim_another_accounts_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 auth_router.update_profile_email( + {"email": "SHARED@example.com"}, + {"username": "SecondViewer", "role": "user"}, + ) + + self.assertEqual(context.exception.status_code, 409) + + async def test_profile_email_requires_valid_address(self) -> None: + db.create_user_if_missing("ProfileViewer", "password123", auth_provider="local") + + with self.assertRaises(HTTPException) as context: + await auth_router.update_profile_email( + {"email": "not-an-email"}, + {"username": "ProfileViewer", "role": "user"}, + ) + + self.assertEqual(context.exception.status_code, 400) + async def test_forgot_password_is_rate_limited(self) -> None: request = _build_request(ip="10.1.2.3") payload = {"identifier": "resetuser@example.com"} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 084d0a4..7e6ebe8 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -5943,6 +5943,34 @@ textarea { margin: 0; } +.profile-contact-card { + align-items: center; +} + +.profile-contact-form { + display: grid; + gap: 8px; + width: min(100%, 440px); +} + +.profile-contact-form label { + display: grid; + gap: 5px; +} + +.profile-contact-form label > span { + color: var(--muted); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.profile-contact-form .status-banner, +.profile-contact-form .error-banner { + margin: 0; +} + .profile-invites-section { display: grid; gap: 12px; diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index d227de7..efd1301 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -6,6 +6,7 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' type ProfileInfo = { username: string + email?: string | null role: string auth_provider: string invite_management_enabled?: boolean @@ -66,6 +67,8 @@ const formatDate = (value?: string | null) => { return date.toLocaleString() } +const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) + const parseBrowser = (agent?: string | null) => { if (!agent) return 'Unknown' const value = agent.toLowerCase() @@ -81,6 +84,9 @@ export default function ProfilePage() { const [profile, setProfile] = useState(null) const [stats, setStats] = useState(null) const [activity, setActivity] = useState(null) + const [email, setEmail] = useState('') + const [emailSaving, setEmailSaving] = useState(false) + const [emailStatus, setEmailStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') @@ -124,6 +130,7 @@ export default function ProfilePage() { const user = data?.user ?? {} setProfile({ username: user?.username ?? 'Unknown', + email: user?.email ?? null, role: user?.role ?? 'user', auth_provider: user?.auth_provider ?? 'local', invite_management_enabled: Boolean(user?.invite_management_enabled ?? false), @@ -133,6 +140,7 @@ export default function ProfilePage() { ? user.password_provider : null, }) + setEmail(user?.email ?? '') setStats(data?.stats ?? null) setActivity(data?.activity ?? null) } catch (err) { @@ -200,6 +208,57 @@ export default function ProfilePage() { } } + const saveEmail = async (event: React.FormEvent) => { + event.preventDefault() + const nextEmail = email.trim() + setEmailStatus(null) + if (nextEmail && !isValidEmail(nextEmail)) { + setEmailStatus({ tone: 'error', message: 'Enter a valid email address.' }) + return + } + setEmailSaving(true) + try { + const response = await authFetch(`${getApiBase()}/auth/profile/email`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: nextEmail || null }), + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + let detail = 'Could not save your email address.' + try { + const payload = await response.json() + if (typeof payload?.detail === 'string' && payload.detail.trim()) detail = payload.detail + } catch { + // Keep the plain fallback when the response is not JSON. + } + throw new Error(detail) + } + const data = await response.json() + const savedEmail = typeof data?.email === 'string' ? data.email : '' + setEmail(savedEmail) + setProfile((current) => current ? { ...current, email: savedEmail || null } : current) + setEmailStatus({ + tone: 'status', + message: savedEmail + ? 'Contact email saved. Magent can now use it for account and issue updates.' + : 'Contact email removed from your account.', + }) + } catch (err) { + console.error(err) + setEmailStatus({ + tone: 'error', + message: err instanceof Error ? err.message : 'Could not save your email address.', + }) + } finally { + setEmailSaving(false) + } + } + const authProvider = profile?.auth_provider ?? 'local' const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local') const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled) @@ -279,6 +338,41 @@ export default function ProfilePage() { {activeTab === 'overview' && (
+
+
+

Contact email

+

+ Used for password recovery, invite messages, and updates about issues you report. +

+
+
+ + {emailStatus ? ( +
+ {emailStatus.message} +
+ ) : null} +
+ + {profile?.email ? ( + + ) : null} +
+
+
{canManageInvites ? (