diff --git a/app/api/admin_users.py b/app/api/admin_users.py
index c0f92cbd..67d453ef 100644
--- a/app/api/admin_users.py
+++ b/app/api/admin_users.py
@@ -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."""
diff --git a/app/api/local_auth.py b/app/api/local_auth.py
index 75f341bd..be6df664 100644
--- a/app/api/local_auth.py
+++ b/app/api/local_auth.py
@@ -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."}
diff --git a/app/utils/local_auth.py b/app/utils/local_auth.py
index 669d6819..20a54719 100644
--- a/app/utils/local_auth.py
+++ b/app/utils/local_auth.py
@@ -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"""
+
+
+
+
+
Your Username
+
You requested a reminder of your DocuElevate username.
+
+
Your username is:
+
{username}
+
+
You can sign in using your username or your email address.
+
If you did not request this reminder, you can safely ignore this email.
+
+
DocuElevate · Intelligent Document Processing
+
+
+"""
+ 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.
diff --git a/docs/API.md b/docs/API.md
index e3bca54a..809bc0a6 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -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`
diff --git a/docs/UserGuide.md b/docs/UserGuide.md
index 59ffdc40..a6332bc9 100644
--- a/docs/UserGuide.md
+++ b/docs/UserGuide.md
@@ -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
diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html
index be8e4fdc..360dcb2c 100644
--- a/frontend/templates/admin_users.html
+++ b/frontend/templates/admin_users.html
@@ -508,13 +508,38 @@