Add admin user email management
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -101,8 +101,10 @@ export default function UserDetailPage() {
|
||||
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
|
||||
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<string | null>(null)
|
||||
const [lineage, setLineage] = useState<UserLineage>(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() {
|
||||
</div>
|
||||
|
||||
<div className="user-detail-side-column">
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Contact email</h2>
|
||||
<p className="lede">Used by Magent for account recovery and issue updates.</p>
|
||||
</div>
|
||||
<form
|
||||
className="user-detail-actions user-detail-actions--stacked"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void saveUserEmail()
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span className="user-bulk-label">Email address</span>
|
||||
<input
|
||||
type="email"
|
||||
value={emailInput}
|
||||
onChange={(event) => setEmailInput(event.target.value)}
|
||||
placeholder="person@example.com"
|
||||
autoComplete="off"
|
||||
disabled={savingEmail}
|
||||
/>
|
||||
</label>
|
||||
<div className="user-detail-helper">
|
||||
This updates Magent only. It does not change the user's Jellyfin or Seerr account.
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingEmail || !emailInput.trim() || emailInput.trim() === (user.email ?? '')}
|
||||
>
|
||||
{savingEmail ? 'Saving...' : user.email ? 'Save email' : 'Add email'}
|
||||
</button>
|
||||
{user.email && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void saveUserEmail(true)}
|
||||
disabled={savingEmail}
|
||||
>
|
||||
Remove email
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Access controls</h2>
|
||||
|
||||
Reference in New Issue
Block a user