Add self-service profile email management
Magent CI/CD / verify (push) Canceled after 10m22s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-01 22:26:53 +12:00
parent c6d449dc17
commit ded794a819
4 changed files with 208 additions and 0 deletions
+44
View File
@@ -17,6 +17,7 @@ from ..db import (
set_last_login, set_last_login,
get_user_by_username, get_user_by_username,
get_users_by_username_ci, get_users_by_username_ci,
get_all_users,
set_user_password, set_user_password,
set_user_jellyseerr_id, set_user_jellyseerr_id,
set_user_email, set_user_email,
@@ -108,6 +109,18 @@ def _optional_recipient_email(value: object) -> str | None:
return _require_recipient_email(value) 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: def _auth_client_ip(request: Request) -> str:
direct_host = request.client.host if request.client else None direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host): 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") @router.get("/profile/invites")
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict: async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
username = str(current_user.get("username") or "").strip() username = str(current_user.get("username") or "").strip()
+42
View File
@@ -1384,6 +1384,48 @@ class SnapshotHistoryTests(TempDatabaseMixin, unittest.TestCase):
class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): 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: async def test_forgot_password_is_rate_limited(self) -> None:
request = _build_request(ip="10.1.2.3") request = _build_request(ip="10.1.2.3")
payload = {"identifier": "resetuser@example.com"} payload = {"identifier": "resetuser@example.com"}
+28
View File
@@ -5943,6 +5943,34 @@ textarea {
margin: 0; 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 { .profile-invites-section {
display: grid; display: grid;
gap: 12px; gap: 12px;
+94
View File
@@ -6,6 +6,7 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = { type ProfileInfo = {
username: string username: string
email?: string | null
role: string role: string
auth_provider: string auth_provider: string
invite_management_enabled?: boolean invite_management_enabled?: boolean
@@ -66,6 +67,8 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
const parseBrowser = (agent?: string | null) => { const parseBrowser = (agent?: string | null) => {
if (!agent) return 'Unknown' if (!agent) return 'Unknown'
const value = agent.toLowerCase() const value = agent.toLowerCase()
@@ -81,6 +84,9 @@ export default function ProfilePage() {
const [profile, setProfile] = useState<ProfileInfo | null>(null) const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null) const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null) const [activity, setActivity] = useState<ProfileActivity | null>(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 [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
@@ -124,6 +130,7 @@ export default function ProfilePage() {
const user = data?.user ?? {} const user = data?.user ?? {}
setProfile({ setProfile({
username: user?.username ?? 'Unknown', username: user?.username ?? 'Unknown',
email: user?.email ?? null,
role: user?.role ?? 'user', role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local', auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false), invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
@@ -133,6 +140,7 @@ export default function ProfilePage() {
? user.password_provider ? user.password_provider
: null, : null,
}) })
setEmail(user?.email ?? '')
setStats(data?.stats ?? null) setStats(data?.stats ?? null)
setActivity(data?.activity ?? null) setActivity(data?.activity ?? null)
} catch (err) { } 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 authProvider = profile?.auth_provider ?? 'local'
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local') const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled) const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
@@ -279,6 +338,41 @@ export default function ProfilePage() {
{activeTab === 'overview' && ( {activeTab === 'overview' && (
<section className="profile-section profile-tab-panel"> <section className="profile-section profile-tab-panel">
<div className="profile-quick-link-card profile-contact-card">
<div>
<h2>Contact email</h2>
<p className="lede">
Used for password recovery, invite messages, and updates about issues you report.
</p>
</div>
<form className="profile-contact-form" onSubmit={saveEmail}>
<label>
<span>Email address</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
autoComplete="email"
/>
</label>
{emailStatus ? (
<div className={emailStatus.tone === 'error' ? 'error-banner' : 'status-banner'}>
{emailStatus.message}
</div>
) : null}
<div className="admin-inline-actions">
<button type="submit" disabled={emailSaving || Boolean(email.trim() && !isValidEmail(email))}>
{emailSaving ? 'Saving…' : 'Save email'}
</button>
{profile?.email ? (
<button type="button" className="ghost-button" onClick={() => setEmail('')}>
Clear field
</button>
) : null}
</div>
</form>
</div>
{canManageInvites ? ( {canManageInvites ? (
<div className="profile-quick-link-card"> <div className="profile-quick-link-card">
<div> <div>