From 44ea43f9cf948aabe8f8ae98744cd41061ab8770 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Mar 2026 09:44:59 +0000
Subject: [PATCH] chore: update plan to include forgot-username and login label
clarification
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/admin_users.py | 154 +++++++++++++-
app/api/local_auth.py | 13 ++
frontend/templates/admin_users.html | 255 +++++++++++++++++++++++-
frontend/templates/forgot_password.html | 118 +++++++++++
frontend/templates/login.html | 5 +
5 files changed, 540 insertions(+), 5 deletions(-)
create mode 100644 frontend/templates/forgot_password.html
diff --git a/app/api/admin_users.py b/app/api/admin_users.py
index c0f92cbd..1e5fc221 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
@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
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 +123,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,7 +370,140 @@ def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> N
logger.info("Admin deleted local user account: %s", user.email)
-@router.get("/{user_id:path}", summary="Get details for a single user")
+@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:
+ user.display_name = body.display_name
+
+ 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.
+ """
+ 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:
+ 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}
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 75f341bd..f42cbe2b 100644
--- a/app/api/local_auth.py
+++ b/app/api/local_auth.py
@@ -107,6 +107,19 @@ async def verify_email_sent_page(request: Request) -> Any:
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
+@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."""
diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html
index be8e4fdc..874c1789 100644
--- a/frontend/templates/admin_users.html
+++ b/frontend/templates/admin_users.html
@@ -508,13 +508,38 @@
|
+
+
+
|
@@ -595,6 +620,116 @@
+
+
+
+
+
Edit Local Account
+
+
+
+
+
+
+
+
+
+
+
Set Temporary Password
+
+
+
+
+
+
({}));
+ 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 {
diff --git a/frontend/templates/forgot_password.html b/frontend/templates/forgot_password.html
new file mode 100644
index 00000000..148f01fe
--- /dev/null
+++ b/frontend/templates/forgot_password.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
DocuElevate - Forgot Password
+
+
+
+
+
+
+
+

+
+
+ Forgot your password?
+
+ Enter your email address and we'll send you a link to reset your password.
+
+
+
+
+
+
Check your inbox
+
+ If an account exists for that email address, a password reset link has been sent. The link expires in 24 hours.
+
+
Back to sign in
+
+
+
+
+
+
+
+
+ DocuElevate {{ app_version|default('', true) }}
+
+
+
diff --git a/frontend/templates/login.html b/frontend/templates/login.html
index 5ead49b9..0165035a 100644
--- a/frontend/templates/login.html
+++ b/frontend/templates/login.html
@@ -50,6 +50,11 @@
Sign in
+
{% if show_oauth %}