feat(auth): enable local user signup without SMTP, add admin user creation

- Remove SMTP hard-requirement from /api/auth/signup: when SMTP is not
  configured accounts are activated immediately (no email verification).
  When SMTP is configured the existing email-verification flow is kept.
- Inject allow_signup into global template context via app/views/base.py
- Add data-allow-signup attribute to base.html body tag
- Update common.js _renderLoggedOutAuth to show Sign Up (→ /signup) when
  signup is enabled, otherwise Get Started (→ /pricing)
- Add admin API endpoints before the /{user_id:path} catch-all:
    GET  /api/admin/users/local       – list all local accounts
    POST /api/admin/users/local       – admin-create local account (active immediately)
    DELETE /api/admin/users/local/{id} – delete local account + profile
- Add LocalUserCreate / LocalUserResponse Pydantic schemas
- Update admin_users.html with Local User Accounts section and modals
- Update .env.demo to document ALLOW_LOCAL_SIGNUP
- Update docs/BillingSetup.md: SMTP is optional, document both flows
- Update tests: test_signup_smtp_not_configured now asserts 201 + immediate
  activation; add 7 new integration tests for admin local user endpoints

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 19:59:10 +00:00
parent 19d8e04566
commit aa6e2fe001
10 changed files with 645 additions and 72 deletions
+5
View File
@@ -133,6 +133,11 @@ ADMIN_GROUP_NAME=admin
# When enabled, each user has their own document space with isolated uploads, # When enabled, each user has their own document space with isolated uploads,
# search, and file management. Requires AUTH_ENABLED=true. # search, and file management. Requires AUTH_ENABLED=true.
MULTI_USER_ENABLED=false MULTI_USER_ENABLED=false
# Allow users to self-register with an email address and password.
# Set to true to enable the /signup page. Requires MULTI_USER_ENABLED=true.
# When SMTP is configured, a verification email is sent before the account is activated.
# Without SMTP, accounts are activated immediately upon registration.
# ALLOW_LOCAL_SIGNUP=false
# Default upload limit per user per day (0 = unlimited) # Default upload limit per user per day (0 = unlimited)
DEFAULT_DAILY_UPLOAD_LIMIT=0 DEFAULT_DAILY_UPLOAD_LIMIT=0
# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false) # Show unowned documents (owner_id=NULL) to all users (true) or only admins (false)
+126 -27
View File
@@ -2,6 +2,8 @@
Provides CRUD operations for user profiles and aggregate statistics so that Provides CRUD operations for user profiles and aggregate statistics so that
administrators can inspect, configure, and manage users in multi-user mode. administrators can inspect, configure, and manage users in multi-user mode.
Also provides endpoints for admins to create and manage local (email/password)
user accounts directly, without requiring email verification.
""" """
import logging import logging
@@ -14,7 +16,8 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import FileRecord, UserProfile from app.models import FileRecord, LocalUser, UserProfile
from app.utils.local_auth import hash_password
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/users", tags=["admin-users"]) router = APIRouter(prefix="/admin/users", tags=["admin-users"])
@@ -97,6 +100,30 @@ class UserSummary(BaseModel):
last_upload: str | None last_upload: str | None
class LocalUserCreate(BaseModel):
"""Body for admin-creating a local (email/password) user account."""
email: str = Field(..., max_length=255, description="Email address for the new user")
username: str = Field(..., min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
display_name: str | None = Field(default=None, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
is_admin: bool = Field(default=False, description="Grant admin privileges")
class LocalUserResponse(BaseModel):
"""Summary of a local user account."""
id: int
email: str
username: str
display_name: str | None
is_active: bool
is_admin: bool
created_at: str | None
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -209,39 +236,111 @@ def list_users(
} }
@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]: # Local user management (admin-only)
"""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 # NOTE: These routes MUST be defined before /{user_id:path} to avoid being
last_row = ( # swallowed by the catch-all path parameter.
db.query(FileRecord.created_at) # ---------------------------------------------------------------------------
.filter(FileRecord.owner_id == user_id)
.order_by(FileRecord.created_at.desc())
.first() @router.get("/local", summary="List all local (email/password) user accounts")
def list_local_users(db: DbSession, _admin: AdminUser) -> list[dict[str, Any]]:
"""Return every local user account with basic metadata."""
users = db.query(LocalUser).order_by(LocalUser.created_at.desc()).all()
return [
{
"id": u.id,
"email": u.email,
"username": u.username,
"display_name": u.display_name,
"is_active": u.is_active,
"is_admin": u.is_admin,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
]
@router.post("/local", status_code=status.HTTP_201_CREATED, summary="Create a local user account")
def create_local_user(body: LocalUserCreate, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Create a new local (email/password) user account.
The account is immediately active — no email verification is required when
created by an administrator. A matching UserProfile row is also created.
Raises:
409: Email or username already registered.
"""
if db.query(LocalUser).filter(LocalUser.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.")
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
user = LocalUser(
email=body.email,
username=body.username,
display_name=body.display_name,
hashed_password=hash_password(body.password),
is_active=True,
is_admin=body.is_admin,
) )
last_upload = last_row[0].isoformat() if last_row and last_row[0] else None db.add(user)
profile = _get_or_none(db, user_id) # Ensure a UserProfile exists for the new user
if not db.query(UserProfile).filter(UserProfile.user_id == body.email).first():
db.add(UserProfile(user_id=body.email, display_name=body.display_name or body.username))
try:
db.commit()
db.refresh(user)
except Exception:
db.rollback()
raise
logger.info("Admin created local user account: %s", body.email)
return { return {
"user_id": user_id, "id": user.id,
"display_name": profile.display_name if profile else None, "email": user.email,
"daily_upload_limit": profile.daily_upload_limit if profile else None, "username": user.username,
"notes": profile.notes if profile else None, "display_name": user.display_name,
"is_blocked": profile.is_blocked if profile else False, "is_active": user.is_active,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free", "is_admin": user.is_admin,
"subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") if profile else "monthly", "created_at": user.created_at.isoformat() if user.created_at else None,
"subscription_period_start": profile.subscription_period_start.isoformat()
if (profile and profile.subscription_period_start)
else None,
"allow_overage": bool(profile.allow_overage) if profile else False,
"profile_id": profile.id if profile else None,
"document_count": doc_count,
"last_upload": last_upload,
"profile": _profile_to_dict(profile) if profile else None,
} }
@router.delete(
"/local/{local_user_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a local user account",
)
def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> None:
"""Delete a local user account by its numeric ID.
The associated UserProfile is also removed. Documents owned by this user
are **not** deleted.
"""
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.")
# Remove associated profile if present
profile = db.query(UserProfile).filter(UserProfile.user_id == user.email).first()
if profile:
db.delete(profile)
try:
db.delete(user)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Admin deleted local user account: %s", user.email)
@router.get("/{user_id:path}", summary="Get details for a single user")
@router.put("/{user_id:path}", summary="Create or update a user profile") @router.put("/{user_id:path}", summary="Create or update a user profile")
def upsert_user_profile( def upsert_user_profile(
user_id: str, user_id: str,
+29 -11
View File
@@ -128,15 +128,18 @@ async def reset_password_page(request: Request) -> Any:
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED) @router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]: async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str | bool]:
"""Create a new local user account and send a verification email. """Create a new local user account.
When SMTP is configured the account is inactive until the user clicks the
verification link sent to their email. When SMTP is **not** configured the
account is activated immediately so that deployments without email can still
use the self-registration flow.
The account is inactive until the user clicks the email link.
Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``. Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``.
Raises: Raises:
403: Multi-user mode or local signup is disabled. 403: Multi-user mode or local signup is disabled.
503: SMTP is not configured.
422: Passwords do not match. 422: Passwords do not match.
409: Email or username already registered. 409: Email or username already registered.
""" """
@@ -144,11 +147,6 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.")
if not settings.allow_local_signup: if not settings.allow_local_signup:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
if not settings.email_host:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email (SMTP) must be configured before local signup can be enabled.",
)
if body.password != body.password_confirm: if body.password != body.password_confirm:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
@@ -157,6 +155,9 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
if db.query(LocalUser).filter(LocalUser.username == body.username).first(): if db.query(LocalUser).filter(LocalUser.username == body.username).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
smtp_configured = bool(settings.email_host)
if smtp_configured:
token = generate_token() token = generate_token()
user = LocalUser( user = LocalUser(
email=body.email, email=body.email,
@@ -167,6 +168,17 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
email_verification_token=token, email_verification_token=token,
email_verification_sent_at=datetime.now(tz=timezone.utc), email_verification_sent_at=datetime.now(tz=timezone.utc),
) )
else:
# No SMTP configured — activate the account immediately.
token = None
user = LocalUser(
email=body.email,
username=body.username,
display_name=body.display_name,
hashed_password=hash_password(body.password),
is_active=True,
)
db.add(user) db.add(user)
profile = UserProfile( profile = UserProfile(
@@ -185,6 +197,7 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
db.rollback() db.rollback()
raise raise
if smtp_configured and token:
base_url = str(request.base_url).rstrip("/") base_url = str(request.base_url).rstrip("/")
try: try:
send_verification_email(body.email, body.username, token, base_url) send_verification_email(body.email, body.username, token, base_url)
@@ -195,12 +208,17 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
logger.warning("Signup email failed for %s: %s", body.email, exc) logger.warning("Signup email failed for %s: %s", body.email, exc)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."), detail=(
"Failed to send verification email. Please check that SMTP is correctly configured and try again."
),
) from exc ) from exc
db.commit() db.commit()
logger.info("New local user registered: %s", body.email) logger.info("New local user registered: %s", body.email)
return {"message": "Verification email sent. Please check your inbox."}
if smtp_configured:
return {"message": "Verification email sent. Please check your inbox.", "email_verification_required": True}
return {"message": "Account created successfully. You can now log in.", "email_verification_required": False}
@router.get("/verify-email", include_in_schema=False) @router.get("/verify-email", include_in_schema=False)
+4
View File
@@ -32,6 +32,10 @@ def _inject_global_context(ctx: dict) -> None:
ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False)) ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True)) ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True))
ctx.setdefault(
"allow_signup",
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
)
req = ctx.get("request") req = ctx.get("request")
if req is not None: if req is not None:
+12 -2
View File
@@ -19,14 +19,14 @@ This guide covers how to configure Stripe billing and local user sign-up in Docu
By default, user accounts are created by an administrator. To allow users to self-register with an email address and password, set `ALLOW_LOCAL_SIGNUP=true`. By default, user accounts are created by an administrator. To allow users to self-register with an email address and password, set `ALLOW_LOCAL_SIGNUP=true`.
> **Note:** SMTP must be configured before enabling local sign-up. New accounts require email verification before they can log in. > **Note:** SMTP is **optional** for local sign-up. When SMTP is configured, new accounts require email verification before they can log in. Without SMTP, accounts are activated immediately upon registration — useful for self-hosted deployments without email infrastructure.
### Configuration ### Configuration
```bash ```bash
ALLOW_LOCAL_SIGNUP=true ALLOW_LOCAL_SIGNUP=true
# SMTP (required for verification emails) # SMTP (optional — enables email verification and password reset)
EMAIL_HOST=smtp.example.com EMAIL_HOST=smtp.example.com
EMAIL_PORT=587 EMAIL_PORT=587
EMAIL_USERNAME=noreply@example.com EMAIL_USERNAME=noreply@example.com
@@ -37,11 +37,21 @@ EMAIL_SENDER=DocuElevate <noreply@example.com>
### Sign-up Flow ### Sign-up Flow
**With SMTP configured (recommended):**
1. User visits `/signup` and fills out the registration form. 1. User visits `/signup` and fills out the registration form.
2. DocuElevate sends a verification email with a 24-hour token link. 2. DocuElevate sends a verification email with a 24-hour token link.
3. User clicks the link — their account is activated and they are signed in. 3. User clicks the link — their account is activated and they are signed in.
4. First-time users are redirected to the onboarding wizard. 4. First-time users are redirected to the onboarding wizard.
**Without SMTP:**
1. User visits `/signup` and fills out the registration form.
2. Account is activated immediately — no email verification required.
3. User is redirected to the login page to sign in straight away.
### Admin-Created Accounts
Administrators can create local user accounts directly from the **Admin → User Management** page without requiring self-registration. Admin-created accounts are immediately active regardless of SMTP configuration.
### Password Reset Flow ### Password Reset Flow
1. User clicks "Forgot password?" on the login page. 1. User clicks "Forgot password?" on the login page.
+9 -6
View File
@@ -304,11 +304,14 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
/** /**
* Render the login / get-started buttons for unauthenticated visitors. * Render the login / get-started buttons for unauthenticated visitors.
* Reads the data-multi-user attribute that the server injects on <body> to * Reads the data-multi-user and data-allow-signup attributes that the server
* decide whether to show a prominent "Get Started" CTA alongside the login link. * injects on <body> to decide whether to show a prominent "Get Started" CTA
* alongside the login link, and whether it should link to /signup or /pricing.
*/ */
function _renderLoggedOutAuth(authSection, mobileAuthSection) { function _renderLoggedOutAuth(authSection, mobileAuthSection) {
const multiUser = document.body.getAttribute('data-multi-user') === 'true'; const multiUser = document.body.getAttribute('data-multi-user') === 'true';
const allowSignup = document.body.getAttribute('data-allow-signup') === 'true';
const startHref = allowSignup ? '/signup' : '/pricing';
if (authSection) { if (authSection) {
authSection.textContent = ''; authSection.textContent = '';
@@ -324,10 +327,10 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
if (multiUser) { if (multiUser) {
const startLink = document.createElement('a'); const startLink = document.createElement('a');
startLink.href = '/pricing'; startLink.href = startHref;
startLink.className = startLink.className =
'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500'; 'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500';
startLink.textContent = 'Get Started'; startLink.textContent = allowSignup ? 'Sign Up' : 'Get Started';
row.appendChild(startLink); row.appendChild(startLink);
} }
@@ -350,14 +353,14 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
if (multiUser) { if (multiUser) {
const startLink = document.createElement('a'); const startLink = document.createElement('a');
startLink.href = '/pricing'; startLink.href = startHref;
startLink.className = startLink.className =
'block px-3 py-3 rounded-md text-base font-medium text-white bg-blue-600 hover:text-white hover:bg-blue-700 mt-1'; 'block px-3 py-3 rounded-md text-base font-medium text-white bg-blue-600 hover:text-white hover:bg-blue-700 mt-1';
const startIcon = document.createElement('i'); const startIcon = document.createElement('i');
startIcon.className = 'fas fa-arrow-right mr-2'; startIcon.className = 'fas fa-arrow-right mr-2';
startIcon.setAttribute('aria-hidden', 'true'); startIcon.setAttribute('aria-hidden', 'true');
startLink.appendChild(startIcon); startLink.appendChild(startIcon);
startLink.appendChild(document.createTextNode('Get Started')); startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
mobileAuthSection.appendChild(startLink); mobileAuthSection.appendChild(startLink);
} }
} }
+303 -1
View File
@@ -23,6 +23,13 @@
> >
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile <i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
</button> </button>
<button
type="button"
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> Create Local Account
</button>
</div> </div>
<!-- ── Alert banner ───────────────────────────────────────────────────────── --> <!-- ── Alert banner ───────────────────────────────────────────────────────── -->
@@ -399,6 +406,200 @@
</div> </div>
</div> </div>
<!-- ── Local Accounts section ────────────────────────────────────────────── -->
<div class="bg-white shadow rounded-lg mt-8">
<div class="px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<i class="fas fa-user-lock text-green-600" aria-hidden="true"></i>
Local User Accounts
</h2>
<p class="text-sm text-gray-500 mt-0.5">
Email/password accounts created directly on this server.
</p>
</div>
<button
type="button"
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-plus mr-1" aria-hidden="true"></i> New Account
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="Local user accounts">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-if="localUsersLoading">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading…
</td>
</tr>
</template>
<template x-if="!localUsersLoading && localUsers.length === 0">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
No local accounts yet.
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">Create one</button>.
</td>
</tr>
</template>
<template x-for="lu in localUsers" :key="lu.id">
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-mono text-gray-800" x-text="lu.username"></td>
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.email"></td>
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.display_name || '—'"></td>
<td class="px-4 py-3 text-sm text-center">
<span
:class="lu.is_active ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_active ? 'Active' : 'Unverified'"
></span>
</td>
<td class="px-4 py-3 text-sm text-center">
<span
:class="lu.is_admin ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_admin ? 'Admin' : 'User'"
></span>
</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="confirmDeleteLocalUser(lu)"
class="text-red-600 hover:text-red-800 focus:outline-none"
:aria-label="`Delete account for ${lu.username}`"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ── Create local user modal ────────────────────────────────────────────── -->
<div
x-show="localUserModal.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="create-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="localUserModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">Create Local Account</h2>
<button type="button" @click="localUserModal.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="submitCreateLocalUser" class="px-6 py-5 space-y-4">
<div>
<label for="lu-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="lu-email" x-model="localUserModal.form.email" required autocomplete="off"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true">
</div>
<div>
<label for="lu-username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="text" id="lu-username" x-model="localUserModal.form.username" required autocomplete="off"
pattern="^[a-zA-Z0-9_-]+$" minlength="3" maxlength="64"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-username-hint">
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">364 characters. Letters, numbers, hyphens and underscores only.</p>
</div>
<div>
<label for="lu-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="lu-display-name" x-model="localUserModal.form.display_name" autocomplete="off" maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;">
</div>
<div>
<label for="lu-password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="password" id="lu-password" x-model="localUserModal.form.password" required autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-password-hint">
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="lu-is-admin" x-model="localUserModal.form.is_admin"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
<label for="lu-is-admin" class="text-sm text-gray-700">Grant admin privileges</label>
</div>
<div x-show="localUserModal.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="localUserModal.error">
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="localUserModal.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="localUserModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500 disabled:opacity-50">
<span x-show="!localUserModal.saving">Create Account</span>
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Creating…</span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Delete local user confirmation modal ───────────────────────────────── -->
<div
x-show="deleteLocalUserModal.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="delete-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteLocalUserModal.open = false">
<div class="px-6 py-4 border-b">
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">Delete Local Account</h2>
</div>
<div class="px-6 py-5">
<p class="text-sm text-gray-700">
Are you sure you want to delete the account for
<strong class="font-mono" x-text="deleteLocalUserModal.username"></strong>
(<span class="font-mono" x-text="deleteLocalUserModal.email"></span>)?
</p>
<p class="text-sm text-gray-500 mt-2">This cannot be undone. Documents owned by this user are <strong>not</strong> deleted.</p>
</div>
<div class="px-6 py-4 border-t flex justify-end gap-3">
<button type="button" @click="deleteLocalUserModal.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="button" @click="executeDeleteLocalUser()" :disabled="deleteLocalUserModal.deleting"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">
<span x-show="!deleteLocalUserModal.deleting">Delete Account</span>
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting…</span>
</button>
</div>
</div>
</div>
<!-- ── Delete confirmation modal ──────────────────────────────────────────── --> <!-- ── Delete confirmation modal ──────────────────────────────────────────── -->
<div <div
x-show="deleteModal.open" x-show="deleteModal.open"
@@ -481,6 +682,23 @@ function adminUsersApp() {
deleting: false, deleting: false,
}, },
// Local users
localUsers: [],
localUsersLoading: true,
localUserModal: {
open: false,
saving: false,
error: '',
form: { email: '', username: '', display_name: '', password: '', is_admin: false },
},
deleteLocalUserModal: {
open: false,
id: null,
username: '',
email: '',
deleting: false,
},
// Alert // Alert
alert: { show: false, type: 'success', title: '', message: '' }, alert: { show: false, type: 'success', title: '', message: '' },
@@ -491,7 +709,7 @@ function adminUsersApp() {
}, },
async init() { async init() {
await this.fetchUsers(1); await Promise.all([this.fetchUsers(1), this.fetchLocalUsers()]);
}, },
async fetchUsers(page) { async fetchUsers(page) {
@@ -633,6 +851,90 @@ function adminUsersApp() {
this.alert = { show: true, type, title, message }; this.alert = { show: true, type, title, message };
setTimeout(() => { this.alert.show = false; }, type === 'success' ? 5000 : 10000); setTimeout(() => { this.alert.show = false; }, type === 'success' ? 5000 : 10000);
}, },
async fetchLocalUsers() {
this.localUsersLoading = true;
try {
const resp = await fetch('/api/admin/users/local', {
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) {
this.showAlert('error', 'Failed to load local users', resp.statusText);
return;
}
this.localUsers = await resp.json();
} catch (e) {
this.showAlert('error', 'Network error', e.message);
} finally {
this.localUsersLoading = false;
}
},
openCreateLocalUserModal() {
this.localUserModal.form = { email: '', username: '', display_name: '', password: '', is_admin: false };
this.localUserModal.error = '';
this.localUserModal.saving = false;
this.localUserModal.open = true;
},
async submitCreateLocalUser() {
this.localUserModal.error = '';
this.localUserModal.saving = true;
try {
const resp = await fetch('/api/admin/users/local', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify(this.localUserModal.form),
});
if (resp.ok) {
this.localUserModal.open = false;
this.showAlert('success', 'Account created', `Local account for "${this.localUserModal.form.username}" was created successfully.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.localUserModal.error = err.detail || 'Failed to create account.';
}
} catch (e) {
this.localUserModal.error = 'Network error: ' + e.message;
} finally {
this.localUserModal.saving = false;
}
},
confirmDeleteLocalUser(lu) {
this.deleteLocalUserModal.id = lu.id;
this.deleteLocalUserModal.username = lu.username;
this.deleteLocalUserModal.email = lu.email;
this.deleteLocalUserModal.deleting = false;
this.deleteLocalUserModal.open = true;
},
async executeDeleteLocalUser() {
this.deleteLocalUserModal.deleting = true;
try {
const resp = await fetch(`/api/admin/users/local/${this.deleteLocalUserModal.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (resp.status === 204) {
this.deleteLocalUserModal.open = false;
this.showAlert('success', 'Deleted', `Account for "${this.deleteLocalUserModal.username}" has been removed.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
this.deleteLocalUserModal.open = false;
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.deleteLocalUserModal.open = false;
} finally {
this.deleteLocalUserModal.deleting = false;
}
},
}; };
} }
</script> </script>
+2 -1
View File
@@ -34,7 +34,8 @@
</head> </head>
<body class="bg-gray-50 min-h-screen flex flex-col" <body class="bg-gray-50 min-h-screen flex flex-col"
data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}"> data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}"
data-allow-signup="{{ 'true' if allow_signup else 'false' }}">
<!-- Skip to main content link for keyboard/screen reader users --> <!-- Skip to main content link for keyboard/screen reader users -->
<a href="#main-content" class="skip-link">Skip to main content</a> <a href="#main-content" class="skip-link">Skip to main content</a>
+5
View File
@@ -50,7 +50,12 @@
}) })
}); });
if (resp.ok) { if (resp.ok) {
const data = await resp.json();
if (data.email_verification_required) {
window.location.href = '/verify-email-sent'; window.location.href = '/verify-email-sent';
} else {
window.location.href = '/login?message=Account+created+successfully.+You+can+now+log+in.';
}
} else { } else {
const data = await resp.json(); const data = await resp.json();
this.error = data.detail || 'Registration failed. Please try again.'; this.error = data.detail || 'Registration failed. Please try again.';
+128 -2
View File
@@ -208,7 +208,7 @@ def test_signup_disabled(la_client):
@pytest.mark.integration @pytest.mark.integration
def test_signup_smtp_not_configured(la_client): def test_signup_smtp_not_configured(la_client):
"""POST /api/auth/signup returns 503 when SMTP is not configured.""" """POST /api/auth/signup succeeds without SMTP and activates the account immediately."""
with patch("app.api.local_auth.settings") as mock_settings: with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True mock_settings.multi_user_enabled = True
@@ -222,7 +222,10 @@ def test_signup_smtp_not_configured(la_client):
"password_confirm": "password1", "password_confirm": "password1",
}, },
) )
assert resp.status_code == 503 assert resp.status_code == 201
data = resp.json()
assert data["email_verification_required"] is False
assert "now log in" in data["message"]
@pytest.mark.integration @pytest.mark.integration
@@ -266,6 +269,7 @@ def test_signup_success(la_client):
) )
assert resp.status_code == 201 assert resp.status_code == 201
assert "Verification email sent" in resp.json()["message"] assert "Verification email sent" in resp.json()["message"]
assert resp.json()["email_verification_required"] is True
mock_send.assert_called_once() mock_send.assert_called_once()
@@ -675,3 +679,125 @@ async def test_single_user_mode_skips_local_user_table(la_session, active_user):
# Admin path sets is_admin=True and id="admin" # Admin path sets is_admin=True and id="admin"
assert mock_request.session["user"]["is_admin"] is True assert mock_request.session["user"]["is_admin"] is True
assert mock_request.session["user"]["id"] == "admin" assert mock_request.session["user"]["id"] == "admin"
# ---------------------------------------------------------------------------
# Integration tests: admin local user management
# ---------------------------------------------------------------------------
@pytest.fixture()
def admin_session_client(la_engine):
"""TestClient with admin access via dependency override."""
from app.api.admin_users import _require_admin
from app.main import app
Session = sessionmaker(bind=la_engine)
def override_get_db():
db = Session()
try:
yield db
finally:
db.close()
def override_require_admin():
return {"id": "admin@example.com", "is_admin": True, "display_name": "Admin"}
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[_require_admin] = override_require_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=True) as client:
yield client
app.dependency_overrides.pop(get_db, None)
app.dependency_overrides.pop(_require_admin, None)
@pytest.mark.integration
def test_admin_list_local_users_empty(admin_session_client):
"""GET /api/admin/users/local returns an empty list when no local users exist."""
resp = admin_session_client.get("/api/admin/users/local")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.integration
def test_admin_create_local_user(admin_session_client, la_session):
"""POST /api/admin/users/local creates a new active local user."""
resp = admin_session_client.post(
"/api/admin/users/local",
json={
"email": "newuser@example.com",
"username": "newuser",
"password": "password1",
"is_admin": False,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "newuser@example.com"
assert data["username"] == "newuser"
assert data["is_active"] is True
assert data["is_admin"] is False
user = la_session.query(LocalUser).filter(LocalUser.email == "newuser@example.com").first()
assert user is not None
assert user.is_active is True
@pytest.mark.integration
def test_admin_create_local_user_duplicate_email(admin_session_client, active_user):
"""POST /api/admin/users/local returns 409 when email already exists."""
resp = admin_session_client.post(
"/api/admin/users/local",
json={
"email": "active@example.com",
"username": "differentuser",
"password": "password1",
},
)
assert resp.status_code == 409
@pytest.mark.integration
def test_admin_create_local_user_duplicate_username(admin_session_client, active_user):
"""POST /api/admin/users/local returns 409 when username already taken."""
resp = admin_session_client.post(
"/api/admin/users/local",
json={
"email": "different@example.com",
"username": "activeuser",
"password": "password1",
},
)
assert resp.status_code == 409
@pytest.mark.integration
def test_admin_delete_local_user(admin_session_client, la_session, active_user):
"""DELETE /api/admin/users/local/{id} removes the account."""
user_id = active_user.id
resp = admin_session_client.delete(f"/api/admin/users/local/{user_id}")
assert resp.status_code == 204
user = la_session.query(LocalUser).filter(LocalUser.id == user_id).first()
assert user is None
@pytest.mark.integration
def test_admin_delete_local_user_not_found(admin_session_client):
"""DELETE /api/admin/users/local/{id} returns 404 for unknown ID."""
resp = admin_session_client.delete("/api/admin/users/local/99999")
assert resp.status_code == 404
@pytest.mark.integration
def test_admin_local_user_list_after_create(admin_session_client):
"""GET /api/admin/users/local returns the created user."""
admin_session_client.post(
"/api/admin/users/local",
json={"email": "listed@example.com", "username": "listeduser", "password": "password1"},
)
resp = admin_session_client.get("/api/admin/users/local")
assert resp.status_code == 200
users = resp.json()
assert any(u["email"] == "listed@example.com" for u in users)