diff --git a/app/api/admin_users.py b/app/api/admin_users.py
index 1e5fc221..d73a1467 100644
--- a/app/api/admin_users.py
+++ b/app/api/admin_users.py
@@ -15,6 +15,7 @@ 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 generate_token, hash_password, send_password_reset_email
@@ -393,7 +394,8 @@ def update_local_user(local_user_id: int, body: LocalUserUpdate, db: DbSession,
user.email = body.email
if body.display_name is not None:
- user.display_name = body.display_name
+ # 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
@@ -431,9 +433,7 @@ def update_local_user(local_user_id: int, body: LocalUserUpdate, db: DbSession,
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]:
+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.
@@ -443,13 +443,11 @@ def admin_send_password_reset(
Raises:
404: Local user not found.
"""
- from app.config import settings as _settings
-
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:
+ 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."}
@@ -500,10 +498,10 @@ def admin_set_password(
db.rollback()
raise
- logger.info(
- "[SECURITY] ADMIN_SET_PASSWORD user=%s admin=%s", user.email, _admin.get("email", "unknown")
- )
+ logger.info("[SECURITY] ADMIN_SET_PASSWORD user=%s admin=%s", user.email, _admin.get("email", "unknown"))
return {"updated": True, "email": user.email}
+
+
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Return profile and document statistics for a specific user."""
doc_count = db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == user_id).scalar() or 0
diff --git a/app/api/local_auth.py b/app/api/local_auth.py
index f42cbe2b..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,19 @@ 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."""
@@ -350,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 874c1789..360dcb2c 100644
--- a/frontend/templates/admin_users.html
+++ b/frontend/templates/admin_users.html
@@ -1122,7 +1122,7 @@ function adminUsersApp() {
},
body: JSON.stringify({
email: this.editLocalUserModal.form.email || null,
- display_name: this.editLocalUserModal.form.display_name || null,
+ display_name: this.editLocalUserModal.form.display_name,
is_admin: this.editLocalUserModal.form.is_admin,
is_active: this.editLocalUserModal.form.is_active,
}),
diff --git a/frontend/templates/forgot_username.html b/frontend/templates/forgot_username.html
new file mode 100644
index 00000000..c8c9b8d2
--- /dev/null
+++ b/frontend/templates/forgot_username.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+ DocuElevate - Forgot Username
+
+
+
+
+
+
+
+
+
+
+
Forgot your username?
+
+ 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.
+
+
+
+
+
+
+
+
+
+
Check your inbox
+
+ If an account exists for that email address, your username has been sent.
+ Remember: you can also sign in using your email address directly.
+