Merge pull request #509 from christianlouis/copilot/add-password-reset-functionality

fix(admin-users): restore missing @router.get decorator on get_user endpoint
This commit is contained in:
Christian Krakau-Louis
2026-03-08 12:14:04 +01:00
committed by GitHub
11 changed files with 1269 additions and 8 deletions
+149 -2
View File
@@ -7,7 +7,7 @@ user accounts directly, without requiring email verification.
"""
import logging
from datetime import datetime
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -15,9 +15,10 @@ from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import FileRecord, LocalUser, UserProfile
from app.utils.local_auth import hash_password
from app.utils.local_auth import generate_token, hash_password, send_password_reset_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
@@ -123,6 +124,21 @@ class LocalUserCreate(BaseModel):
is_admin: bool = Field(default=False, description="Grant admin privileges")
class LocalUserUpdate(BaseModel):
"""Body for admin-updating a local (email/password) user account."""
email: str | None = Field(default=None, max_length=255, description="New email address")
display_name: str | None = Field(default=None, max_length=255, description="New display name")
is_admin: bool | None = Field(default=None, description="Grant or revoke admin privileges")
is_active: bool | None = Field(default=None, description="Activate or deactivate the account")
class LocalUserSetPassword(BaseModel):
"""Body for admin setting a temporary password for a local user."""
password: str = Field(..., min_length=8, max_length=128, description="New temporary password")
class LocalUserResponse(BaseModel):
"""Summary of a local user account."""
@@ -355,6 +371,137 @@ def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> N
logger.info("Admin deleted local user account: %s", user.email)
@router.patch("/local/{local_user_id}", summary="Update a local user account")
def update_local_user(local_user_id: int, body: LocalUserUpdate, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update the email address, display name, admin flag, or active status of a local user account.
Only fields explicitly provided (non-None) are modified. If the email is changed
the associated UserProfile row is also updated to keep ``user_id`` in sync.
Raises:
404: Local user not found.
409: The new email is already taken by another account.
"""
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
old_email = user.email
if body.email is not None and body.email != user.email:
if db.query(LocalUser).filter(LocalUser.email == body.email, LocalUser.id != local_user_id).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.")
user.email = body.email
if body.display_name is not None:
# Normalise empty string to None so that clearing the field removes the display name
user.display_name = body.display_name or None
if body.is_admin is not None:
user.is_admin = body.is_admin
if body.is_active is not None:
user.is_active = body.is_active
try:
db.flush()
# Keep UserProfile.user_id in sync when email changes
if body.email is not None and body.email != old_email:
profile = db.query(UserProfile).filter(UserProfile.user_id == old_email).first()
if profile:
profile.user_id = body.email
db.commit()
db.refresh(user)
except Exception:
db.rollback()
raise
logger.info("Admin updated local user %s (id=%d)", user.email, user.id)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"display_name": user.display_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@router.post(
"/local/{local_user_id}/send-password-reset",
status_code=status.HTTP_200_OK,
summary="Send a password reset email to a local user",
)
def admin_send_password_reset(local_user_id: int, request: Request, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Generate a password reset token and email the reset link to the local user.
This is a last-resort tool for admins to help users who are locked out.
Returns ``{"sent": true}`` on success and ``{"sent": false, "reason": "..."}`` when
SMTP is not configured or sending fails.
Raises:
404: Local user not found.
"""
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
if not settings.email_host:
logger.warning("Admin requested password reset for %s but SMTP is not configured", user.email)
return {"sent": False, "reason": "SMTP is not configured on this server."}
token = generate_token()
user.password_reset_token = token
user.password_reset_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_password_reset_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Admin-triggered password reset email failed for %s: %s", user.email, exc)
return {"sent": False, "reason": str(exc)}
logger.info("[SECURITY] ADMIN_PASSWORD_RESET_EMAIL user=%s admin=%s", user.email, _admin.get("email", "unknown"))
return {"sent": True, "email": user.email}
@router.post(
"/local/{local_user_id}/set-password",
status_code=status.HTTP_200_OK,
summary="Set a temporary password for a local user account",
)
def admin_set_password(
local_user_id: int, body: LocalUserSetPassword, db: DbSession, _admin: AdminUser
) -> dict[str, Any]:
"""Directly set a new password for a local user without requiring an email token.
Use this as a last resort when email delivery is unavailable. The user
should be advised to change their password after logging in.
Raises:
404: Local user not found.
"""
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
user.hashed_password = hash_password(body.password)
# Clear any outstanding reset tokens
user.password_reset_token = None
user.password_reset_sent_at = None
try:
db.commit()
except Exception:
db.rollback()
raise
logger.info("[SECURITY] ADMIN_SET_PASSWORD user=%s admin=%s", user.email, _admin.get("email", "unknown"))
return {"updated": True, "email": user.email}
@router.get("/{user_id:path}", summary="Get details for a single user")
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Return profile and document statistics for a specific user."""
+49
View File
@@ -31,6 +31,7 @@ from app.utils.local_auth import (
generate_token,
hash_password,
is_token_expired,
send_forgot_username_email,
send_password_reset_email,
send_verification_email,
)
@@ -79,6 +80,12 @@ class PasswordResetBody(BaseModel):
new_password_confirm: str
class ForgotUsernameBody(BaseModel):
"""Body for the forgot-username endpoint."""
email: str
# ---------------------------------------------------------------------------
# Page routes (return HTML)
# ---------------------------------------------------------------------------
@@ -107,6 +114,32 @@ async def verify_email_sent_page(request: Request) -> Any:
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
@router.get("/forgot-username", include_in_schema=False)
async def forgot_username_page(request: Request) -> Any:
"""Render the forgot-username page where users can request a username reminder email."""
return templates.TemplateResponse(
"forgot_username.html",
{
"request": request,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
@router.get("/forgot-password", include_in_schema=False)
async def forgot_password_page(request: Request) -> Any:
"""Render the forgot-password page where users can request a reset email."""
return templates.TemplateResponse(
"forgot_password.html",
{
"request": request,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
@router.get("/reset-password", include_in_schema=False)
async def reset_password_page(request: Request) -> Any:
"""Render the password reset form page."""
@@ -337,3 +370,19 @@ async def reset_password(body: PasswordResetBody, db: DbSession) -> dict[str, st
logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email)
return {"message": "Password updated successfully."}
@router.post("/api/auth/forgot-username")
async def forgot_username(body: ForgotUsernameBody, db: DbSession) -> dict[str, str]:
"""Send a username reminder email.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if user:
try:
send_forgot_username_email(user.email, user.username)
except Exception as exc:
logger.warning("Failed to send forgot-username email to %s: %s", user.email, exc)
return {"message": "Username reminder sent if account exists."}
+35
View File
@@ -164,6 +164,41 @@ def send_password_reset_email(email: str, username: str, token: str, base_url: s
_smtp_send(subject, html_body, plain_body, email)
def send_forgot_username_email(email: str, username: str) -> None:
"""Send an email reminding the user of their username.
Args:
email: Recipient email address.
username: The user's username to include in the message.
"""
subject = "Your DocuElevate username"
html_body = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f5;margin:0;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
<h1 style="color:#4f46e5;font-size:24px;margin-bottom:8px;">Your Username</h1>
<p style="color:#374151;">You requested a reminder of your DocuElevate username.</p>
<div style="text-align:center;margin:32px 0;background:#f3f4f6;border-radius:8px;padding:20px;">
<p style="color:#6b7280;font-size:13px;margin-bottom:4px;">Your username is:</p>
<p style="color:#111827;font-size:22px;font-weight:700;font-family:monospace;">{username}</p>
</div>
<p style="color:#374151;font-size:14px;">You can sign in using your username <strong>or</strong> your email address.</p>
<p style="color:#6b7280;font-size:13px;margin-top:16px;">If you did not request this reminder, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">DocuElevate &middot; Intelligent Document Processing</p>
</div>
</body>
</html>"""
plain_body = (
f"You requested a reminder of your DocuElevate username.\n\n"
f"Your username is: {username}\n\n"
"You can sign in using your username or your email address.\n\n"
"If you did not request this, please ignore this email."
)
_smtp_send(subject, html_body, plain_body, email)
def build_session_user(user: object) -> dict:
"""Build the session user dict for a LocalUser, matching the OAuth session format.
+125
View File
@@ -844,6 +844,131 @@ problem.
---
**GET** `/api/admin/users/local`
List all local (email/password) user accounts with basic metadata.
---
**POST** `/api/admin/users/local`
Create a new local user account (admin-only, immediately active — no email verification required).
**Request body**:
```json
{
"email": "user@example.com",
"username": "alice",
"display_name": "Alice Smith",
"password": "securepassword",
"is_admin": false
}
```
---
**PATCH** `/api/admin/users/local/{local_user_id}`
Update an existing local user account. Only the provided (non-null) fields are modified.
If the email is changed, the associated `UserProfile.user_id` is also updated automatically.
**Request body** (all fields optional):
```json
{
"email": "newemail@example.com",
"display_name": "Alice Wonderland",
"is_admin": true,
"is_active": false
}
```
**Error Responses**:
- `404`: Local user not found
- `409`: New email already taken by another account
---
**POST** `/api/admin/users/local/{local_user_id}/send-password-reset`
Send a password reset email to a local user on their behalf. Useful when a user is locked out.
Returns `{"sent": true}` on success or `{"sent": false, "reason": "..."}` when SMTP is not
configured or sending fails (never returns an error status so the admin always gets feedback).
**Error Responses**:
- `404`: Local user not found
---
**POST** `/api/admin/users/local/{local_user_id}/set-password`
Directly set a new password for a local user without requiring an email token (last resort when
email delivery is unavailable). The user should be advised to change their password after logging in.
**Request body**:
```json
{
"password": "temporarypassword"
}
```
**Error Responses**:
- `404`: Local user not found
- `422`: Password shorter than 8 characters
---
**DELETE** `/api/admin/users/local/{local_user_id}`
Delete a local user account by numeric ID. The associated `UserProfile` is also removed. Documents
owned by this user are **not** deleted. Returns `204 No Content` on success.
---
### Local Authentication (self-service)
These endpoints are for local (email/password) users and do not require authentication.
**POST** `/api/auth/request-password-reset`
Send a password reset email. Always returns 200 to avoid leaking whether an email is registered.
**Request body**:
```json
{ "email": "user@example.com" }
```
---
**POST** `/api/auth/reset-password`
Set a new password using a valid reset token (received via email).
**Request body**:
```json
{
"token": "the-token-from-email",
"new_password": "newpassword",
"new_password_confirm": "newpassword"
}
```
**Error Responses**:
- `400`: Token is invalid or expired
- `422`: Passwords do not match
---
**POST** `/api/auth/forgot-username`
Send a username reminder email. Always returns 200 to avoid leaking whether an email is registered.
**Request body**:
```json
{ "email": "user@example.com" }
```
---
### Settings Suggestions (Autocomplete)
**GET** `/api/settings/{key}/suggestions`
+23 -2
View File
@@ -28,8 +28,29 @@ If OpenID Connect authentication is configured:
3. Log in with your existing credentials on that platform
4. You'll be redirected back to DocuElevate after successful authentication
#### User Sessions
- Once authenticated, your session will remain active until you log out or it expires
#### Local User Accounts
If your administrator has created a local (email/password) account for you:
1. You'll see a "Sign in with username" form on the login page
2. Enter your **username or email address** — both are accepted
3. Enter your password and click **Sign in**
##### Forgot your password?
If you can't remember your password:
1. Click **Forgot password?** below the sign-in form
2. Enter your email address and click **Send reset link**
3. Check your inbox for a password reset email (valid for 24 hours)
4. Click the link in the email and enter your new password
##### Forgot your username?
If you can't remember your username:
1. Click **Forgot username?** below the sign-in form
2. Enter your email address and click **Send username reminder**
3. You'll receive an email with your username
> **Tip:** You can always sign in with your email address directly — you don't need to look up your username.
- Click the "Logout" button in the top navigation bar to end your session
- For security, sessions automatically expire after a period of inactivity
+253 -2
View File
@@ -508,13 +508,38 @@
</td>
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap" x-text="lu.created_at ? formatDate(lu.created_at) : '—'"></td>
<td class="px-4 py-3 text-sm text-right">
<button
type="button"
@click="openEditLocalUserModal(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 mr-1"
:aria-label="`Edit account for ${lu.username}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i> Edit
</button>
<button
type="button"
@click="openSetPasswordModal(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-yellow-300 text-yellow-700 bg-white hover:bg-yellow-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-400 mr-1"
:aria-label="`Set password for ${lu.username}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i> Password
</button>
<button
type="button"
@click="sendPasswordReset(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-indigo-300 text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 mr-1"
:aria-label="`Send password reset email to ${lu.username}`"
title="Send password reset email"
>
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> Reset
</button>
<button
type="button"
@click="confirmDeleteLocalUser(lu)"
class="text-red-600 hover:text-red-800 focus:outline-none"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-red-300 text-red-600 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-400"
:aria-label="`Delete account for ${lu.username}`"
>
<i class="fas fa-trash" aria-hidden="true"></i>
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
</button>
</td>
</tr>
@@ -595,6 +620,116 @@
</div>
</div>
<!-- ── Edit local user modal ────────────────────────────────────────────── -->
<div
x-show="editLocalUserModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="edit-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="editLocalUserModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="edit-local-user-title" class="text-lg font-semibold text-gray-900">Edit Local Account</h2>
<button type="button" @click="editLocalUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitEditLocalUser" class="px-6 py-5 space-y-4">
<div>
<label for="elu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="email" id="elu-email" x-model="editLocalUserModal.form.email" required autocomplete="off"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true">
</div>
<div>
<label for="elu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
<input type="text" id="elu-display-name" x-model="editLocalUserModal.form.display_name" autocomplete="off" maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="elu-is-admin" x-model="editLocalUserModal.form.is_admin"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="elu-is-admin" class="text-sm text-gray-700">Admin privileges</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="elu-is-active" x-model="editLocalUserModal.form.is_active"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="elu-is-active" class="text-sm text-gray-700">Account active</label>
</div>
<div x-show="editLocalUserModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
role="alert" aria-live="assertive" x-text="editLocalUserModal.error">
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="editLocalUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit" :disabled="editLocalUserModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 disabled:opacity-50">
<span x-show="!editLocalUserModal.saving">Save Changes</span>
<span x-show="editLocalUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Set password modal ────────────────────────────────────────────────── -->
<div
x-show="setPasswordModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="set-password-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="setPasswordModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="set-password-title" class="text-lg font-semibold text-gray-900">Set Temporary Password</h2>
<button type="button" @click="setPasswordModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitSetPassword" class="px-6 py-5 space-y-4">
<p class="text-sm text-gray-600">
Set a new password directly for <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
The user should change this password after logging in.
</p>
<div>
<label for="sp-password" class="block text-sm font-medium text-gray-700">New Password <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="password" id="sp-password" x-model="setPasswordModal.password" required autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-yellow-500 focus:ring focus:ring-yellow-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="sp-password-hint">
<p id="sp-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div x-show="setPasswordModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
role="alert" aria-live="assertive" x-text="setPasswordModal.error">
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="setPasswordModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit" :disabled="setPasswordModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-500 disabled:opacity-50">
<span x-show="!setPasswordModal.saving">Set Password</span>
<span x-show="setPasswordModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Setting…</span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Delete local user confirmation modal ───────────────────────────────── -->
<div
x-show="deleteLocalUserModal.open"
@@ -724,6 +859,21 @@ function adminUsersApp() {
error: '',
form: { email: '', username: '', display_name: '', password: '', is_admin: false },
},
editLocalUserModal: {
open: false,
id: null,
saving: false,
error: '',
form: { email: '', display_name: '', is_admin: false, is_active: true },
},
setPasswordModal: {
open: false,
id: null,
username: '',
saving: false,
error: '',
password: '',
},
deleteLocalUserModal: {
open: false,
id: null,
@@ -947,6 +1097,107 @@ function adminUsersApp() {
this.deleteLocalUserModal.open = true;
},
openEditLocalUserModal(lu) {
this.editLocalUserModal.id = lu.id;
this.editLocalUserModal.form = {
email: lu.email,
display_name: lu.display_name || '',
is_admin: !!lu.is_admin,
is_active: !!lu.is_active,
};
this.editLocalUserModal.error = '';
this.editLocalUserModal.saving = false;
this.editLocalUserModal.open = true;
},
async submitEditLocalUser() {
this.editLocalUserModal.error = '';
this.editLocalUserModal.saving = true;
try {
const resp = await fetch(`/api/admin/users/local/${this.editLocalUserModal.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify({
email: this.editLocalUserModal.form.email || null,
display_name: this.editLocalUserModal.form.display_name,
is_admin: this.editLocalUserModal.form.is_admin,
is_active: this.editLocalUserModal.form.is_active,
}),
});
if (resp.ok) {
this.editLocalUserModal.open = false;
this.showAlert('success', 'Updated', `Account has been updated.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.editLocalUserModal.error = err.detail || 'Failed to update account.';
}
} catch (e) {
this.editLocalUserModal.error = 'Network error: ' + e.message;
} finally {
this.editLocalUserModal.saving = false;
}
},
openSetPasswordModal(lu) {
this.setPasswordModal.id = lu.id;
this.setPasswordModal.username = lu.username;
this.setPasswordModal.password = '';
this.setPasswordModal.error = '';
this.setPasswordModal.saving = false;
this.setPasswordModal.open = true;
},
async submitSetPassword() {
this.setPasswordModal.error = '';
this.setPasswordModal.saving = true;
try {
const resp = await fetch(`/api/admin/users/local/${this.setPasswordModal.id}/set-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify({ password: this.setPasswordModal.password }),
});
if (resp.ok) {
this.setPasswordModal.open = false;
this.showAlert('success', 'Password set', `Password for "${this.setPasswordModal.username}" has been updated.`);
} else {
const err = await resp.json().catch(() => ({}));
this.setPasswordModal.error = err.detail || 'Failed to set password.';
}
} catch (e) {
this.setPasswordModal.error = 'Network error: ' + e.message;
} finally {
this.setPasswordModal.saving = false;
}
},
async sendPasswordReset(lu) {
try {
const resp = await fetch(`/api/admin/users/local/${lu.id}/send-password-reset`, {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.sent) {
this.showAlert('success', 'Email sent', `Password reset email sent to "${lu.email}".`);
} else if (resp.ok && !data.sent) {
this.showAlert('error', 'Email not sent', data.reason || 'SMTP is not configured.');
} else {
this.showAlert('error', 'Failed', data.detail || resp.statusText);
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
}
},
async executeDeleteLocalUser() {
this.deleteLocalUserModal.deleting = true;
try {
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Forgot your password?</h1>
<p class="text-center text-gray-500 text-sm mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
<div
x-data="{
email: '',
loading: false,
error: '',
success: false,
async submit() {
this.error = '';
this.loading = true;
try {
const resp = await fetch('/api/auth/request-password-reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify({ email: this.email })
});
if (resp.ok) {
this.success = true;
} else {
const data = await resp.json().catch(() => ({}));
this.error = data.detail || 'Something went wrong. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="success" x-cloak class="text-center py-4">
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-envelope-open-text text-green-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<p class="text-green-700 font-semibold mb-2">Check your inbox</p>
<p class="text-gray-500 text-sm mb-4">
If an account exists for that email address, a password reset link has been sent. The link expires in 24 hours.
</p>
<a href="/login"
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>Back to sign in</a>
</div>
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email address <span aria-hidden="true" class="text-red-500">*</span>
</label>
<input
type="email" id="email" name="email" required
x-model="email"
autocomplete="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
style="min-height:44px;"
aria-required="true"
placeholder="you@example.com"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Send reset link</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
<i class="fas fa-arrow-left mr-1" aria-hidden="true"></i> Back to sign in
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Username</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Forgot your username?</h1>
<p class="text-center text-gray-500 text-sm mb-6">
Enter the email address associated with your account and we'll send you your username.
You can also sign in directly with your email address.
</p>
<div
x-data="{
email: '',
loading: false,
error: '',
success: false,
async submit() {
this.error = '';
this.loading = true;
try {
const resp = await fetch('/api/auth/forgot-username', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify({ email: this.email })
});
if (resp.ok) {
this.success = true;
} else {
const data = await resp.json().catch(() => ({}));
this.error = data.detail || 'Something went wrong. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="success" x-cloak class="text-center py-4">
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-envelope-open-text text-green-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<p class="text-green-700 font-semibold mb-2">Check your inbox</p>
<p class="text-gray-500 text-sm mb-4">
If an account exists for that email address, your username has been sent.
Remember: you can also sign in using your email address directly.
</p>
<a href="/login"
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>Back to sign in</a>
</div>
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-700">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
<strong>Tip:</strong> You can sign in with either your username <em>or</em> your email address — no lookup needed.
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email address <span aria-hidden="true" class="text-red-500">*</span>
</label>
<input
type="email" id="email" name="email" required
x-model="email"
autocomplete="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
style="min-height:44px;"
aria-required="true"
placeholder="you@example.com"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Send username reminder</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
<i class="fas fa-arrow-left mr-1" aria-hidden="true"></i> Back to sign in
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+11 -1
View File
@@ -35,8 +35,9 @@
<form method="POST" action="/auth" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('', true) }}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<label for="username" class="block text-sm font-medium text-gray-700">Username or Email</label>
<input type="text" id="username" name="username" required
autocomplete="username"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
</div>
@@ -50,6 +51,15 @@
Sign in
</button>
</form>
<div class="mt-3 text-center space-x-3">
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">
Forgot password?
</a>
<span class="text-gray-300" aria-hidden="true">|</span>
<a href="/forgot-username" class="text-sm text-blue-600 hover:text-blue-500">
Forgot username?
</a>
</div>
</div>
{% if show_oauth %}
+265 -1
View File
@@ -10,6 +10,7 @@ Covers:
- Pagination and search filtering
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
@@ -20,7 +21,7 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import FileRecord, UserProfile
from app.models import FileRecord, LocalUser, UserProfile
# ---------------------------------------------------------------------------
# Fixtures
@@ -663,3 +664,266 @@ class TestEnsureUserProfileAdmin:
# No profile should have been created
count = au_session.query(UserProfile).count()
assert count == 0
# ---------------------------------------------------------------------------
# Local user admin management: update, send-password-reset, set-password
# ---------------------------------------------------------------------------
def _make_local_user(session, email: str = "lu@example.com", username: str = "luuser", **kwargs) -> LocalUser:
"""Insert a LocalUser row and return it."""
from app.utils.local_auth import hash_password
defaults = {
"hashed_password": hash_password("password123"),
"is_active": True,
"is_admin": False,
}
defaults.update(kwargs)
user = LocalUser(email=email, username=username, **defaults)
session.add(user)
session.commit()
session.refresh(user)
return user
class TestAdminUpdateLocalUser:
"""Tests for PATCH /api/admin/users/local/{id}."""
@pytest.mark.unit
def test_update_email(self, au_client, au_session):
"""PATCH can change the email address of a local user."""
user = _make_local_user(au_session, email="old@example.com", username="updateemail")
resp = au_client.patch(
f"/api/admin/users/local/{user.id}",
json={"email": "new@example.com"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "new@example.com"
au_session.refresh(user)
assert user.email == "new@example.com"
@pytest.mark.unit
def test_update_email_syncs_user_profile(self, au_client, au_session):
"""PATCH email also updates UserProfile.user_id for the matching profile."""
user = _make_local_user(au_session, email="synced@example.com", username="synceduser")
_make_profile(au_session, "synced@example.com")
au_client.patch(
f"/api/admin/users/local/{user.id}",
json={"email": "synced_new@example.com"},
)
from app.models import UserProfile
old_profile = au_session.query(UserProfile).filter_by(user_id="synced@example.com").first()
new_profile = au_session.query(UserProfile).filter_by(user_id="synced_new@example.com").first()
assert old_profile is None
assert new_profile is not None
@pytest.mark.unit
def test_update_email_conflict_returns_409(self, au_client, au_session):
"""PATCH returns 409 when the new email is already taken."""
_make_local_user(au_session, email="taken@example.com", username="takenuser")
user = _make_local_user(au_session, email="mine@example.com", username="myuser")
resp = au_client.patch(
f"/api/admin/users/local/{user.id}",
json={"email": "taken@example.com"},
)
assert resp.status_code == 409
@pytest.mark.unit
def test_update_is_admin(self, au_client, au_session):
"""PATCH can grant or revoke admin privileges."""
user = _make_local_user(au_session, email="grantadmin@example.com", username="grantadmin")
assert user.is_admin is False
resp = au_client.patch(
f"/api/admin/users/local/{user.id}",
json={"is_admin": True},
)
assert resp.status_code == 200
assert resp.json()["is_admin"] is True
au_session.refresh(user)
assert user.is_admin is True
@pytest.mark.unit
def test_update_is_active(self, au_client, au_session):
"""PATCH can deactivate a user account."""
user = _make_local_user(au_session, email="deactivate@example.com", username="deactivateuser")
resp = au_client.patch(
f"/api/admin/users/local/{user.id}",
json={"is_active": False},
)
assert resp.status_code == 200
assert resp.json()["is_active"] is False
au_session.refresh(user)
assert user.is_active is False
@pytest.mark.unit
def test_update_display_name(self, au_client, au_session):
"""PATCH can update the display name."""
user = _make_local_user(au_session, email="displayname@example.com", username="displaynameuser")
resp = au_client.patch(
f"/api/admin/users/local/{user.id}",
json={"display_name": "Alice Wonderland"},
)
assert resp.status_code == 200
assert resp.json()["display_name"] == "Alice Wonderland"
@pytest.mark.unit
def test_update_nonexistent_user_returns_404(self, au_client):
"""PATCH on unknown ID returns 404."""
resp = au_client.patch("/api/admin/users/local/99999", json={"email": "x@example.com"})
assert resp.status_code == 404
class TestAdminSendPasswordReset:
"""Tests for POST /api/admin/users/local/{id}/send-password-reset."""
@pytest.mark.unit
def test_send_reset_email_success(self, au_client, au_session):
"""Returns sent=True when SMTP is configured and sending succeeds."""
from unittest.mock import patch
user = _make_local_user(au_session, email="resetme@example.com", username="resetmeuser")
with (
patch("app.api.admin_users.settings") as mock_settings,
patch("app.api.admin_users.send_password_reset_email") as mock_send,
):
mock_settings.email_host = "smtp.example.com"
mock_settings.version = "test"
resp = au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset")
assert resp.status_code == 200
data = resp.json()
assert data["sent"] is True
assert data["email"] == "resetme@example.com"
mock_send.assert_called_once()
@pytest.mark.unit
def test_send_reset_email_no_smtp_returns_not_sent(self, au_client, au_session):
"""Returns sent=False with reason when SMTP is not configured."""
from unittest.mock import patch
user = _make_local_user(au_session, email="nosmtp@example.com", username="nosmtpuser")
with patch("app.api.admin_users.settings") as mock_settings:
mock_settings.email_host = ""
resp = au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset")
assert resp.status_code == 200
data = resp.json()
assert data["sent"] is False
assert "smtp" in data["reason"].lower()
@pytest.mark.unit
def test_send_reset_email_smtp_failure_returns_not_sent(self, au_client, au_session):
"""Returns sent=False with reason when SMTP sending fails."""
from unittest.mock import patch
user = _make_local_user(au_session, email="smtperr@example.com", username="smtperruser")
with (
patch("app.api.admin_users.settings") as mock_settings,
patch("app.api.admin_users.send_password_reset_email", side_effect=RuntimeError("connection refused")),
):
mock_settings.email_host = "smtp.example.com"
resp = au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset")
assert resp.status_code == 200
assert resp.json()["sent"] is False
@pytest.mark.unit
def test_send_reset_email_unknown_user_returns_404(self, au_client):
"""Returns 404 for unknown local_user_id."""
resp = au_client.post("/api/admin/users/local/99999/send-password-reset")
assert resp.status_code == 404
@pytest.mark.unit
def test_send_reset_stores_token(self, au_client, au_session):
"""Password reset token is persisted to the DB."""
from unittest.mock import patch
user = _make_local_user(au_session, email="tokenstore@example.com", username="tokenstoreuser")
assert user.password_reset_token is None
with (
patch("app.api.admin_users.settings") as mock_settings,
patch("app.api.admin_users.send_password_reset_email"),
):
mock_settings.email_host = "smtp.example.com"
au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset")
au_session.refresh(user)
assert user.password_reset_token is not None
assert user.password_reset_sent_at is not None
class TestAdminSetPassword:
"""Tests for POST /api/admin/users/local/{id}/set-password."""
@pytest.mark.unit
def test_set_password_success(self, au_client, au_session):
"""Returns updated=True and changes the hashed password."""
from app.utils.local_auth import verify_password
user = _make_local_user(au_session, email="setpw@example.com", username="setpwuser")
resp = au_client.post(
f"/api/admin/users/local/{user.id}/set-password",
json={"password": "brandnewpassword"},
)
assert resp.status_code == 200
assert resp.json()["updated"] is True
au_session.refresh(user)
assert verify_password("brandnewpassword", user.hashed_password)
@pytest.mark.unit
def test_set_password_too_short_returns_422(self, au_client, au_session):
"""Returns 422 when password is shorter than 8 characters."""
user = _make_local_user(au_session, email="shortpw@example.com", username="shortpwuser")
resp = au_client.post(
f"/api/admin/users/local/{user.id}/set-password",
json={"password": "short"},
)
assert resp.status_code == 422
@pytest.mark.unit
def test_set_password_clears_reset_token(self, au_client, au_session):
"""Setting a password clears any outstanding password_reset_token."""
from app.utils.local_auth import generate_token
user = _make_local_user(au_session, email="cleartok@example.com", username="cleartokuser")
user.password_reset_token = generate_token()
user.password_reset_sent_at = datetime.now(tz=timezone.utc)
au_session.commit()
au_client.post(
f"/api/admin/users/local/{user.id}/set-password",
json={"password": "clearedpassword"},
)
au_session.refresh(user)
assert user.password_reset_token is None
assert user.password_reset_sent_at is None
@pytest.mark.unit
def test_set_password_unknown_user_returns_404(self, au_client):
"""Returns 404 for unknown local_user_id."""
resp = au_client.post(
"/api/admin/users/local/99999/set-password",
json={"password": "doesnotmatter"},
)
assert resp.status_code == 404
+116
View File
@@ -6,9 +6,12 @@ Covers:
- POST /api/auth/resend-verification
- POST /api/auth/request-password-reset
- POST /api/auth/reset-password
- POST /api/auth/forgot-username
- GET /signup (page route)
- GET /verify-email-sent (page route)
- GET /reset-password (page route)
- GET /forgot-password (page route)
- GET /forgot-username (page route)
- app/utils/local_auth utility functions
- auth() login flow with LocalUser
"""
@@ -801,3 +804,116 @@ def test_admin_local_user_list_after_create(admin_session_client):
assert resp.status_code == 200
users = resp.json()
assert any(u["email"] == "listed@example.com" for u in users)
# ---------------------------------------------------------------------------
# Integration tests: forgot-username endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_forgot_username_returns_200_for_existing_email(la_client, la_session):
"""POST /api/auth/forgot-username returns 200 and sends email when account exists."""
la_session.add(
LocalUser(
email="remindme@example.com",
username="remindmeuser",
hashed_password=hash_password("pw123456"),
is_active=True,
)
)
la_session.commit()
with patch("app.api.local_auth.send_forgot_username_email") as mock_send:
resp = la_client.post("/api/auth/forgot-username", json={"email": "remindme@example.com"})
assert resp.status_code == 200
assert "reminder" in resp.json()["message"].lower()
mock_send.assert_called_once_with("remindme@example.com", "remindmeuser")
@pytest.mark.integration
def test_forgot_username_returns_200_for_unknown_email(la_client):
"""POST /api/auth/forgot-username always returns 200 (no info leak)."""
with patch("app.api.local_auth.send_forgot_username_email") as mock_send:
resp = la_client.post("/api/auth/forgot-username", json={"email": "nobody@example.com"})
assert resp.status_code == 200
mock_send.assert_not_called()
@pytest.mark.integration
def test_forgot_username_smtp_failure_does_not_raise(la_client, la_session):
"""POST /api/auth/forgot-username returns 200 even when SMTP fails."""
la_session.add(
LocalUser(
email="smtpfail@example.com",
username="smtpfailuser",
hashed_password=hash_password("pw123456"),
is_active=True,
)
)
la_session.commit()
with patch("app.api.local_auth.send_forgot_username_email", side_effect=RuntimeError("SMTP down")):
resp = la_client.post("/api/auth/forgot-username", json={"email": "smtpfail@example.com"})
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Integration tests: new page routes
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_forgot_password_page(la_client):
"""GET /forgot-password returns 200."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.version = "test"
resp = la_client.get("/forgot-password")
assert resp.status_code == 200
assert b"password" in resp.content.lower()
@pytest.mark.integration
def test_forgot_username_page(la_client):
"""GET /forgot-username returns 200."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.version = "test"
resp = la_client.get("/forgot-username")
assert resp.status_code == 200
assert b"username" in resp.content.lower()
# ---------------------------------------------------------------------------
# Unit tests: send_forgot_username_email utility
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_send_forgot_username_email_calls_smtp():
"""send_forgot_username_email calls _smtp_send with the username."""
from app.utils.local_auth import send_forgot_username_email
with patch("app.utils.local_auth._smtp_send") as mock_smtp:
send_forgot_username_email("u@example.com", "myusername")
mock_smtp.assert_called_once()
args = mock_smtp.call_args[0]
# subject, html_body, plain_body, recipient
assert "myusername" in args[1] # HTML body
assert "myusername" in args[2] # plain body
assert args[3] == "u@example.com"
@pytest.mark.unit
def test_send_forgot_username_email_no_smtp_raises():
"""send_forgot_username_email raises RuntimeError when EMAIL_HOST is not set."""
from app.utils.local_auth import send_forgot_username_email
with patch("app.utils.local_auth.settings") as mock_settings:
mock_settings.email_host = ""
with pytest.raises(RuntimeError, match="SMTP"):
send_forgot_username_email("u@example.com", "myusername")