Merge pull request #497 from christianlouis/copilot/fix-user-sign-up-functionality
feat(auth): enable local user self-registration without SMTP + admin account creation
This commit is contained in:
@@ -133,6 +133,11 @@ ADMIN_GROUP_NAME=admin
|
||||
# When enabled, each user has their own document space with isolated uploads,
|
||||
# search, and file management. Requires AUTH_ENABLED=true.
|
||||
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_DAILY_UPLOAD_LIMIT=0
|
||||
# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false)
|
||||
|
||||
+132
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
Provides CRUD operations for user profiles and aggregate statistics so that
|
||||
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
|
||||
@@ -14,7 +16,8 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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__)
|
||||
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
|
||||
@@ -97,6 +100,30 @@ class UserSummary(BaseModel):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -209,6 +236,110 @@ def list_users(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local user management (admin-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
# NOTE: These routes MUST be defined before /{user_id:path} to avoid being
|
||||
# swallowed by the catch-all path parameter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# 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 {
|
||||
"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.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")
|
||||
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Return profile and document statistics for a specific user."""
|
||||
|
||||
+50
-32
@@ -128,15 +128,18 @@ async def reset_password_page(request: Request) -> Any:
|
||||
|
||||
|
||||
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
|
||||
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]:
|
||||
"""Create a new local user account and send a verification email.
|
||||
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str | bool]:
|
||||
"""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``.
|
||||
|
||||
Raises:
|
||||
403: Multi-user mode or local signup is disabled.
|
||||
503: SMTP is not configured.
|
||||
422: Passwords do not match.
|
||||
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.")
|
||||
if not settings.allow_local_signup:
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
|
||||
|
||||
@@ -157,16 +155,30 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||
|
||||
token = generate_token()
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=False,
|
||||
email_verification_token=token,
|
||||
email_verification_sent_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
smtp_configured = bool(settings.email_host)
|
||||
|
||||
if smtp_configured:
|
||||
token = generate_token()
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=False,
|
||||
email_verification_token=token,
|
||||
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)
|
||||
|
||||
profile = UserProfile(
|
||||
@@ -185,22 +197,28 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
try:
|
||||
send_verification_email(body.email, body.username, token, base_url)
|
||||
except Exception as exc:
|
||||
# Email failed — roll back so no unverifiable user row persists.
|
||||
# The user can simply try registering again once SMTP is fixed.
|
||||
db.rollback()
|
||||
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."),
|
||||
) from exc
|
||||
if smtp_configured and token:
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
try:
|
||||
send_verification_email(body.email, body.username, token, base_url)
|
||||
except Exception as exc:
|
||||
# Email failed — roll back so no unverifiable user row persists.
|
||||
# The user can simply try registering again once SMTP is fixed.
|
||||
db.rollback()
|
||||
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"Failed to send verification email. Please check that SMTP is correctly configured and try again."
|
||||
),
|
||||
) from exc
|
||||
|
||||
db.commit()
|
||||
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)
|
||||
|
||||
@@ -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("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
|
||||
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")
|
||||
if req is not None:
|
||||
|
||||
+12
-2
@@ -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`.
|
||||
|
||||
> **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
|
||||
|
||||
```bash
|
||||
ALLOW_LOCAL_SIGNUP=true
|
||||
|
||||
# SMTP (required for verification emails)
|
||||
# SMTP (optional — enables email verification and password reset)
|
||||
EMAIL_HOST=smtp.example.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USERNAME=noreply@example.com
|
||||
@@ -37,11 +37,21 @@ EMAIL_SENDER=DocuElevate <noreply@example.com>
|
||||
|
||||
### Sign-up Flow
|
||||
|
||||
**With SMTP configured (recommended):**
|
||||
1. User visits `/signup` and fills out the registration form.
|
||||
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.
|
||||
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
|
||||
|
||||
1. User clicks "Forgot password?" on the login page.
|
||||
|
||||
@@ -304,11 +304,14 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
|
||||
/**
|
||||
* Render the login / get-started buttons for unauthenticated visitors.
|
||||
* Reads the data-multi-user attribute that the server injects on <body> to
|
||||
* decide whether to show a prominent "Get Started" CTA alongside the login link.
|
||||
* Reads the data-multi-user and data-allow-signup attributes that the server
|
||||
* 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) {
|
||||
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) {
|
||||
authSection.textContent = '';
|
||||
@@ -324,10 +327,10 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
|
||||
if (multiUser) {
|
||||
const startLink = document.createElement('a');
|
||||
startLink.href = '/pricing';
|
||||
startLink.href = startHref;
|
||||
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';
|
||||
startLink.textContent = 'Get Started';
|
||||
startLink.textContent = allowSignup ? 'Sign Up' : 'Get Started';
|
||||
row.appendChild(startLink);
|
||||
}
|
||||
|
||||
@@ -350,14 +353,14 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
|
||||
if (multiUser) {
|
||||
const startLink = document.createElement('a');
|
||||
startLink.href = '/pricing';
|
||||
startLink.href = startHref;
|
||||
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';
|
||||
const startIcon = document.createElement('i');
|
||||
startIcon.className = 'fas fa-arrow-right mr-2';
|
||||
startIcon.setAttribute('aria-hidden', 'true');
|
||||
startLink.appendChild(startIcon);
|
||||
startLink.appendChild(document.createTextNode('Get Started'));
|
||||
startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
|
||||
mobileAuthSection.appendChild(startLink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
>
|
||||
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
|
||||
</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>
|
||||
|
||||
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
|
||||
@@ -399,6 +406,200 @@
|
||||
</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">3–64 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 ──────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="deleteModal.open"
|
||||
@@ -481,6 +682,23 @@ function adminUsersApp() {
|
||||
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: { show: false, type: 'success', title: '', message: '' },
|
||||
|
||||
@@ -491,7 +709,7 @@ function adminUsersApp() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.fetchUsers(1);
|
||||
await Promise.all([this.fetchUsers(1), this.fetchLocalUsers()]);
|
||||
},
|
||||
|
||||
async fetchUsers(page) {
|
||||
@@ -633,6 +851,90 @@ function adminUsersApp() {
|
||||
this.alert = { show: true, type, title, message };
|
||||
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>
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
</head>
|
||||
|
||||
<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 -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
|
||||
@@ -50,7 +50,12 @@
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
window.location.href = '/verify-email-sent';
|
||||
const data = await resp.json();
|
||||
if (data.email_verification_required) {
|
||||
window.location.href = '/verify-email-sent';
|
||||
} else {
|
||||
window.location.href = '/login?message=Account+created+successfully.+You+can+now+log+in.';
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.error = data.detail || 'Registration failed. Please try again.';
|
||||
|
||||
+128
-2
@@ -208,7 +208,7 @@ def test_signup_disabled(la_client):
|
||||
|
||||
@pytest.mark.integration
|
||||
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:
|
||||
mock_settings.allow_local_signup = True
|
||||
mock_settings.multi_user_enabled = True
|
||||
@@ -222,7 +222,10 @@ def test_signup_smtp_not_configured(la_client):
|
||||
"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
|
||||
@@ -266,6 +269,7 @@ def test_signup_success(la_client):
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert "Verification email sent" in resp.json()["message"]
|
||||
assert resp.json()["email_verification_required"] is True
|
||||
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"
|
||||
assert mock_request.session["user"]["is_admin"] is True
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user