From 19d8e045667768689b97e4156a01128aa5e1afda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:26:56 +0000 Subject: [PATCH 1/3] Initial plan From aa6e2fe00157ed924d42ae305c932b04ad2c1a81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:59:10 +0000 Subject: [PATCH 2/3] feat(auth): enable local user signup without SMTP, add admin user creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- .env.demo | 5 + app/api/admin_users.py | 153 +++++++++++--- app/api/local_auth.py | 82 +++++--- app/views/base.py | 4 + docs/BillingSetup.md | 14 +- frontend/static/js/common.js | 15 +- frontend/templates/admin_users.html | 304 +++++++++++++++++++++++++++- frontend/templates/base.html | 3 +- frontend/templates/signup.html | 7 +- tests/test_local_auth.py | 130 +++++++++++- 10 files changed, 645 insertions(+), 72 deletions(-) diff --git a/.env.demo b/.env.demo index 7fe4cd67..8e58c068 100644 --- a/.env.demo +++ b/.env.demo @@ -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) diff --git a/app/api/admin_users.py b/app/api/admin_users.py index f80d463f..0a4b7737 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -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,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]: - """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 - last_row = ( - db.query(FileRecord.created_at) - .filter(FileRecord.owner_id == user_id) - .order_by(FileRecord.created_at.desc()) - .first() +# --------------------------------------------------------------------------- +# 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, ) - 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 { - "user_id": user_id, - "display_name": profile.display_name if profile else None, - "daily_upload_limit": profile.daily_upload_limit if profile else None, - "notes": profile.notes if profile else None, - "is_blocked": profile.is_blocked if profile else False, - "subscription_tier": (profile.subscription_tier or "free") if profile else "free", - "subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") if profile else "monthly", - "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, + "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") @router.put("/{user_id:path}", summary="Create or update a user profile") def upsert_user_profile( user_id: str, diff --git a/app/api/local_auth.py b/app/api/local_auth.py index 8316f7d6..75f341bd 100644 --- a/app/api/local_auth.py +++ b/app/api/local_auth.py @@ -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) diff --git a/app/views/base.py b/app/views/base.py index 13835960..010d4f21 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -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: diff --git a/docs/BillingSetup.md b/docs/BillingSetup.md index 4a4462a6..00fa043e 100644 --- a/docs/BillingSetup.md +++ b/docs/BillingSetup.md @@ -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 ### 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. diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 2d9580fe..cd71b570 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -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 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 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); } } diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html index 84ed2042..04b87b44 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -23,6 +23,13 @@ > Add User Profile + @@ -399,6 +406,200 @@ + +
+
+
+

+ + Local User Accounts +

+

+ Email/password accounts created directly on this server. +

+
+ +
+
+ + + + + + + + + + + + + + + + + +
UsernameEmailDisplay NameStatusRoleCreatedActions
+
+
+ + + + + + +
{ 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; + } + }, }; } diff --git a/frontend/templates/base.html b/frontend/templates/base.html index af1c1414..ac16297d 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -34,7 +34,8 @@ + data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}" + data-allow-signup="{{ 'true' if allow_signup else 'false' }}"> diff --git a/frontend/templates/signup.html b/frontend/templates/signup.html index d33fd8b2..1cb3a043 100644 --- a/frontend/templates/signup.html +++ b/frontend/templates/signup.html @@ -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.'; diff --git a/tests/test_local_auth.py b/tests/test_local_auth.py index bed05ea0..c6a11c98 100644 --- a/tests/test_local_auth.py +++ b/tests/test_local_auth.py @@ -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) From 19c1ccb11c10698259fa01b408979b4fac158cd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:03:03 +0000 Subject: [PATCH 3/3] fix(auth): restore get_user function body lost in refactor; fix button period placement - Restore the get_user handler that was accidentally dropped when inserting the local user management routes before the /{user_id:path} catch-all - Move period inside the 'Create one' button text in admin_users.html Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/admin_users.py | 32 +++++++++++++++++++++++++++++ frontend/templates/admin_users.html | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/api/admin_users.py b/app/api/admin_users.py index 0a4b7737..3134e99b 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -341,6 +341,38 @@ def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> N @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.""" + doc_count = db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == user_id).scalar() or 0 + last_row = ( + db.query(FileRecord.created_at) + .filter(FileRecord.owner_id == user_id) + .order_by(FileRecord.created_at.desc()) + .first() + ) + last_upload = last_row[0].isoformat() if last_row and last_row[0] else None + + profile = _get_or_none(db, user_id) + + return { + "user_id": user_id, + "display_name": profile.display_name if profile else None, + "daily_upload_limit": profile.daily_upload_limit if profile else None, + "notes": profile.notes if profile else None, + "is_blocked": profile.is_blocked if profile else False, + "subscription_tier": (profile.subscription_tier or "free") if profile else "free", + "subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") if profile else "monthly", + "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.put("/{user_id:path}", summary="Create or update a user profile") def upsert_user_profile( user_id: str, diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html index 04b87b44..c2ffa15b 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -451,7 +451,7 @@ No local accounts yet. - . +