-{% if multi_user_enabled %}
+{% if multi_user_enabled and not is_logged_in %}
{# ══════════════════════════════════════════════════════════════════════════ #}
-{# MULTI-USER / SAAS DASHBOARD #}
+{# PUBLIC LANDING PAGE (multi-user, visitor not signed in) #}
+{# ══════════════════════════════════════════════════════════════════════════ #}
+
+
+
+
+
+ Intelligent Document Processing
+
+
+ From upload to insight — automatically.
+
+
+ DocuElevate ingests your documents, runs OCR, extracts metadata with AI, and routes files to
+ Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more — all in one seamless pipeline.
+
+
+{% elif multi_user_enabled %}
+{# ══════════════════════════════════════════════════════════════════════════ #}
+{# MULTI-USER / SAAS DASHBOARD (logged-in user) #}
{# ══════════════════════════════════════════════════════════════════════════ #}
From a7d428d00982995fb1f9ae8d2999ce2532ae3915 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 7 Mar 2026 19:55:53 +0000
Subject: [PATCH 04/16] Initial plan
From 8a55860e86b73076ac1a9f8f982dcf258d73f917 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 7 Mar 2026 19:58:48 +0000
Subject: [PATCH 05/16] 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 06/16] 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.
+
+
+
+
+
+
+
+
+
Username
+
Email
+
Display Name
+
Status
+
Role
+
Created
+
Actions
+
+
+
+
+
+
+ Loading…
+
+
+
+
+
+
+ No local accounts yet.
+ .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Create Local Account
+
+
+
+
+
+
+
+
+
+
+
Delete Local Account
+
+
+
+ Are you sure you want to delete the account for
+
+ ()?
+
+
This cannot be undone. Documents owned by this user are not deleted.
From 9d11d741f4e6c0f29529d7dafc93980eea955aeb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 7 Mar 2026 20:12:28 +0000
Subject: [PATCH 08/16] fix(ui): update plan descriptions to reflect per-user
pricing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Rewrite taglines and feature lists for all four tiers with
concrete, use-case-driven copy (freelancers, knowledge workers,
power users — no team or enterprise framing)
- Rename 'Business' display name to 'Power' (plan_id stays
'business' for backwards DB compatibility)
- Replace 'enterprise' language in pricing page hero with
'per person, per month' copy
- Swap fa-building icon for fa-bolt on the Power tier
- Fix support level for Power tier to 'Priority' (was 'Dedicated')
- Update docs/SubscriptionTiers.md with new names, table, and
intended-use-case section
- Add test_business_tier_display_name_is_power assertion
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/utils/subscription.py | 32 ++++++++++++++++------------
docs/SubscriptionTiers.md | 13 ++++++++++-
frontend/templates/pricing.html | 4 ++--
frontend/templates/subscription.html | 2 +-
tests/test_subscription.py | 12 ++++++++---
5 files changed, 42 insertions(+), 21 deletions(-)
diff --git a/app/utils/subscription.py b/app/utils/subscription.py
index 6f7ea841..40dd8123 100644
--- a/app/utils/subscription.py
+++ b/app/utils/subscription.py
@@ -1,11 +1,12 @@
"""
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
+All plans are priced per user per month (or per year with ~20 % discount).
Four tiers (prices ex-VAT; German customers +19 % MwSt):
- free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest
- starter $2.99/mo — 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
- professional $5.99/mo — 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
- - business $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
+ - power $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
Limits use 0 to represent "unlimited".
All paid tiers include a 30-day free trial (trial_days field).
@@ -14,14 +15,14 @@ All paid tiers include a 30-day free trial (trial_days field).
Infrastructure: CX32 (app+Redis €7.59) + CX22 (worker €3.79) + BX21 (storage €7.22) ≈ $24/mo
At 100 users infra share ≈ $0.24/user/mo.
- Starter : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
+ Starter : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
Professional: OCR $1.13 + AI $0.035 + infra $0.24 + Stripe $0.42 = $1.82 → 70 % gross margin
- Business : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin
+ Power : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin
-After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Business 43 %.
+After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Power 43 %.
At average usage (~40 % of quota) margins improve to 55-65 % after tax.
-⚠ If GPT-4o (not mini) is configured, Business AI cost at max rises to ~$1.92/user,
+⚠ If GPT-4o (not mini) is configured, Power AI cost at max rises to ~$1.92/user,
reducing after-tax margin to ~33 %. Recommend GPT-4o mini as default in production.
"""
@@ -46,7 +47,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"free": {
"id": "free",
"name": "Free",
- "tagline": "Explore DocuElevate at no cost",
+ "tagline": "Try DocuElevate free — no credit card needed",
"price_monthly": 0,
"price_yearly": 0,
"trial_days": 0,
@@ -75,7 +76,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"starter": {
"id": "starter",
"name": "Starter",
- "tagline": "Perfect for individuals getting started",
+ # Use case: freelancer sending ~50 invoices, contracts, or scanned receipts a month
+ "tagline": "Perfect for freelancers and side-project owners",
"price_monthly": 2.99,
"price_yearly": 28.99, # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free)
"trial_days": 30,
@@ -89,7 +91,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"max_mailboxes": 1,
"api_access": True,
"features": [
- "50 documents / month",
+ "50 documents / month — invoices, contracts, receipts",
"2 storage destinations",
"300 OCR pages / month",
"25 MB max file size",
@@ -104,7 +106,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"professional": {
"id": "professional",
"name": "Professional",
- "tagline": "For growing teams that need more power",
+ # Use case: consultant or knowledge worker handling ~150 docs/month across multiple platforms
+ "tagline": "For knowledge workers managing documents daily",
"price_monthly": 5.99,
"price_yearly": 57.99, # ≈ 80 % of monthly × 12 — save ~19 %
"trial_days": 30,
@@ -118,7 +121,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"max_mailboxes": 3,
"api_access": True,
"features": [
- "150 documents / month",
+ "150 documents / month — reports, contracts, invoices",
"5 storage destinations",
"750 OCR pages / month",
"100 MB max file size",
@@ -133,8 +136,9 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
},
"business": {
"id": "business",
- "name": "Business",
- "tagline": "High-volume processing for organisations",
+ "name": "Power",
+ # Use case: power user — real estate agent, bookkeeper, or researcher processing ~10 docs/day
+ "tagline": "For power users with high-volume document workflows",
"price_monthly": 7.99,
"price_yearly": 76.99, # ≈ 80 % of monthly × 12 — save ~20 %
"trial_days": 30,
@@ -148,7 +152,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"max_mailboxes": 0, # unlimited mailboxes
"api_access": True,
"features": [
- "300 documents / month",
+ "300 documents / month — ~10 documents per day",
"10 storage destinations",
"1,500 OCR pages / month",
"Unlimited file size",
@@ -156,7 +160,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"Unlimited email ingestion mailboxes",
"All ingestion methods",
"Webhooks & full API access",
- "Dedicated support",
+ "Priority support",
],
"cta": "Start free trial",
"badge": "Best Value",
diff --git a/docs/SubscriptionTiers.md b/docs/SubscriptionTiers.md
index d013fff7..f73b443d 100644
--- a/docs/SubscriptionTiers.md
+++ b/docs/SubscriptionTiers.md
@@ -2,6 +2,8 @@
DocuElevate uses database-backed subscription plans that are fully configurable by admins via the **Plan Designer** at `/admin/plans`. Four default tiers are seeded automatically on first startup.
+All plans are priced **per user, per month** (or per year with ~20 % discount). There are no team, business, or enterprise tiers — every plan is a single-user subscription.
+
## Default Plans
| Plan | Monthly | Yearly | Docs/Month | Lifetime Docs | OCR Pages/Mo | Max File | Mailboxes | Destinations |
@@ -9,12 +11,21 @@ DocuElevate uses database-backed subscription plans that are fully configurable
| **Free** | $0 | $0 | — | 50 total | 150 total | 5 MB | 0 | 1 |
| **Starter** | $2.99 | $28.99 | 50 | — | 300 | 25 MB | 1 | 2 |
| **Professional** | $5.99 | $57.99 | 150 | — | 750 | 100 MB | 3 | 5 |
-| **Business** | $7.99 | $76.99 | 300 | — | 1,500 | Unlimited | Unlimited | 10 |
+| **Power** | $7.99 | $76.99 | 300 | — | 1,500 | Unlimited | Unlimited | 10 |
> Prices ex-VAT. German customers add 19% MwSt.
All paid plans include a **30-day free trial**.
+### Intended Use Cases
+
+- **Free** — Try DocuElevate with no commitment. Good for one-off experiments or evaluating the service.
+- **Starter** — Freelancers and side-project owners sending ~50 invoices, contracts, or scanned receipts a month.
+- **Professional** — Knowledge workers (consultants, paralegals, accountants) handling ~150 multi-page documents a month across several cloud destinations.
+- **Power** — Power users with heavy daily workloads: real estate agents, bookkeepers, or researchers processing ~10 documents a day (≈ 300/month) with no file-size restrictions.
+
+> The **plan_id** in the database remains `"business"` for the Power tier to preserve backwards compatibility. The display name shown to users is "Power".
+
## How Plans Are Stored
Plans are stored in the `subscription_plans` database table. On application startup, `seed_default_plans()` is called automatically — if the table is empty, the four built-in defaults are inserted. If plans already exist, the seed is a no-op.
diff --git a/frontend/templates/pricing.html b/frontend/templates/pricing.html
index 17ff6e06..b6cae447 100644
--- a/frontend/templates/pricing.html
+++ b/frontend/templates/pricing.html
@@ -14,7 +14,7 @@
Choose the plan that's right for you
- From free exploration to unlimited enterprise processing — scale as your document workflows grow.
+ One price per person, per month — from casual exploration to power-user workflows. No team plans, no per-seat tiers.
diff --git a/tests/test_subscription.py b/tests/test_subscription.py
index 2f3a66c1..599137e8 100644
--- a/tests/test_subscription.py
+++ b/tests/test_subscription.py
@@ -107,7 +107,7 @@ def test_free_tier_has_no_mailboxes():
@pytest.mark.unit
def test_business_tier_has_highest_limits():
- """Business tier must have the highest limits of all paid tiers."""
+ """Power tier (plan_id 'business') must have the highest limits of all paid tiers."""
t = TIERS["business"]
# lifetime: no hard cap (0 = unlimited)
assert t["lifetime_file_limit"] == 0
@@ -121,9 +121,15 @@ def test_business_tier_has_highest_limits():
assert t["max_file_size_mb"] == 0
+@pytest.mark.unit
+def test_business_tier_display_name_is_power():
+ """The 'business' plan_id must display as 'Power'."""
+ assert TIERS["business"]["name"] == "Power"
+
+
@pytest.mark.unit
def test_mailbox_limits_increase_by_tier():
- """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(inf)."""
+ """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, power/business=0(inf)."""
assert TIERS["free"]["max_mailboxes"] == 0
assert TIERS["starter"]["max_mailboxes"] == 1
assert TIERS["professional"]["max_mailboxes"] == 3
@@ -144,7 +150,7 @@ def test_free_tier_has_no_trial():
@pytest.mark.unit
def test_pricing_order():
- """Paid tier prices must increase in order: starter < professional < business."""
+ """Paid tier prices must increase in order: starter < professional < power."""
assert TIERS["starter"]["price_monthly"] < TIERS["professional"]["price_monthly"]
assert TIERS["professional"]["price_monthly"] < TIERS["business"]["price_monthly"]
From 97f85ce74ed5d7970f330c923c7ae60ec299b7fd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 7 Mar 2026 20:14:34 +0000
Subject: [PATCH 09/16] feat(auth): auto-create admin user profiles with
highest tier and complimentary flag
- Add `is_complimentary` column to UserProfile model (migration 019)
- Update `_ensure_user_profile` to accept `is_admin` param; admins get
highest subscription tier, is_complimentary=True, onboarding skipped
- Call `_ensure_user_profile` from all login paths (OAuth, local user, admin creds)
- Add `is_complimentary` to UserProfileUpsert schema, response helpers,
list_users, get_user, upsert_user_profile in admin API
- Add complimentary toggle to admin users UI with gift badge in table
- Write 18 new tests covering complimentary plan and admin auto-creation
- Update SubscriptionTiers.md documentation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/admin_users.py | 11 ++
app/auth.py | 68 ++++++-
app/models.py | 4 +
docs/SubscriptionTiers.md | 29 +++
frontend/templates/admin_users.html | 32 ++-
.../versions/019_add_is_complimentary.py | 30 +++
tests/test_admin_users.py | 186 ++++++++++++++++++
tests/test_auth_module.py | 11 +-
8 files changed, 357 insertions(+), 14 deletions(-)
create mode 100644 migrations/versions/019_add_is_complimentary.py
diff --git a/app/api/admin_users.py b/app/api/admin_users.py
index f80d463f..47c9d042 100644
--- a/app/api/admin_users.py
+++ b/app/api/admin_users.py
@@ -59,6 +59,11 @@ class UserProfileUpsert(BaseModel):
subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$")
subscription_period_start: datetime | None = None
allow_overage: bool = False
+ is_complimentary: bool = Field(
+ default=False,
+ description="When True the user is on a complimentary (uncharged) plan — they keep all tier "
+ "quota benefits but are never billed via Stripe.",
+ )
class UserProfileResponse(BaseModel):
@@ -74,6 +79,7 @@ class UserProfileResponse(BaseModel):
subscription_billing_cycle: str
subscription_period_start: str | None
allow_overage: bool
+ is_complimentary: bool
created_at: str | None
updated_at: str | None
@@ -92,6 +98,7 @@ class UserSummary(BaseModel):
subscription_billing_cycle: str | None
subscription_period_start: str | None
allow_overage: bool
+ is_complimentary: bool
profile_id: int | None
document_count: int
last_upload: str | None
@@ -121,6 +128,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
if profile.subscription_period_start
else None,
"allow_overage": bool(profile.allow_overage),
+ "is_complimentary": bool(profile.is_complimentary),
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@@ -194,6 +202,7 @@ def list_users(
if (profile and profile.subscription_period_start)
else None,
"allow_overage": bool(profile.allow_overage) if profile else False,
+ "is_complimentary": bool(profile.is_complimentary) if profile else False,
"profile_id": profile.id if profile else None,
"document_count": doc_row.doc_count if doc_row else 0,
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
@@ -235,6 +244,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
if (profile and profile.subscription_period_start)
else None,
"allow_overage": bool(profile.allow_overage) if profile else False,
+ "is_complimentary": bool(profile.is_complimentary) if profile else False,
"profile_id": profile.id if profile else None,
"document_count": doc_count,
"last_upload": last_upload,
@@ -265,6 +275,7 @@ def upsert_user_profile(
profile.subscription_billing_cycle = body.subscription_billing_cycle
profile.subscription_period_start = body.subscription_period_start
profile.allow_overage = body.allow_overage
+ profile.is_complimentary = body.is_complimentary
if body.subscription_tier is not None:
from app.utils.subscription import TIERS
diff --git a/app/auth.py b/app/auth.py
index e0399915..99b67647 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -127,18 +127,35 @@ async def oauth_login(request: Request):
return await oauth.authentik.authorize_redirect(request, redirect_uri)
-def _ensure_user_profile(db: Session, user_data: dict) -> None:
- """Create a UserProfile row for *user_data* if one does not yet exist.
+def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None:
+ """Create or update a UserProfile row for *user_data*.
Uses the same identifier priority as ``get_current_owner_id`` (sub →
preferred_username → email → id) so that the profile's ``user_id`` matches
``FileRecord.owner_id`` for every document the user uploads.
- If a profile already exists it is left unchanged; only missing profiles
- are created so that admin-managed settings (tier, limits, etc.) are
- preserved across logins.
+ For regular users, an existing profile is left unchanged so that
+ admin-managed settings (tier, limits, etc.) are preserved across logins.
+
+ For admin users (*is_admin=True*) the following rules apply:
+ - If no profile exists: one is created with the highest subscription tier,
+ ``is_complimentary=True``, and ``onboarding_completed=True`` so that
+ admins skip the first-time setup wizard.
+ - If a profile already exists: ``is_complimentary`` is set to ``True``
+ and, when the current tier is ``"free"``, the tier is upgraded to the
+ highest available plan. Other admin-managed settings are left intact.
+
+ Args:
+ db: Active database session.
+ user_data: Mapping of user attributes as returned by the OAuth provider
+ or built by :func:`app.utils.local_auth.build_session_user`.
+ is_admin: When ``True``, apply admin-specific defaults on first login
+ and ensure the complimentary flag is always set.
"""
from app.models import UserProfile
+ from app.utils.subscription import TIER_ORDER
+
+ highest_tier = TIER_ORDER[-1]
user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
@@ -151,13 +168,41 @@ def _ensure_user_profile(db: Session, user_data: dict) -> None:
existing = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if existing is None:
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
- profile = UserProfile(user_id=user_id, display_name=display_name)
+ profile = UserProfile(
+ user_id=user_id,
+ display_name=display_name,
+ subscription_tier=highest_tier if is_admin else "free",
+ is_complimentary=is_admin,
+ onboarding_completed=is_admin,
+ )
db.add(profile)
db.commit()
- logger.info("Auto-created UserProfile for user_id=%s", user_id)
+ logger.info(
+ "Auto-created UserProfile for user_id=%s (admin=%s, tier=%s)",
+ user_id,
+ is_admin,
+ highest_tier if is_admin else "free",
+ )
+ elif is_admin:
+ # Ensure existing admin profiles always have complimentary flag set.
+ # Also upgrade from free tier to highest if still on default.
+ changed = False
+ if not existing.is_complimentary:
+ existing.is_complimentary = True
+ changed = True
+ if (existing.subscription_tier or "free") == "free":
+ existing.subscription_tier = highest_tier
+ changed = True
+ if changed:
+ db.commit()
+ logger.info(
+ "Updated admin UserProfile for user_id=%s (complimentary=True, tier=%s)",
+ user_id,
+ existing.subscription_tier,
+ )
except Exception:
db.rollback()
- logger.exception("Failed to auto-create UserProfile for user_id=%s", user_id)
+ logger.exception("Failed to auto-create/update UserProfile for user_id=%s", user_id)
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
@@ -193,7 +238,7 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
request.session["user"] = user_data
# Auto-create or update UserProfile so the user appears in admin user management
- _ensure_user_profile(db, user_data)
+ _ensure_user_profile(db, user_data, is_admin=is_admin)
# Log the successful authentication
logger.info("[SECURITY] OAUTH_LOGIN_SUCCESS user=%s admin=%s", user_data.get("email", "unknown"), is_admin)
@@ -251,6 +296,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
user_data = _build_session_user(local_user)
request.session["user"] = user_data
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
+ _ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
@@ -261,7 +307,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
# --- Admin credentials (always available as a fallback / single-user mode) ---
if username == settings.admin_username and password == settings.admin_password:
- request.session["user"] = {
+ admin_user_data = {
"id": "admin",
"name": "Administrator",
"email": f"{username}@local.docuelevate",
@@ -269,7 +315,9 @@ async def auth(request: Request, db: Session = Depends(get_db)):
"picture": "/static/images/default-avatar.svg",
"is_admin": True,
}
+ request.session["user"] = admin_user_data
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
+ _ensure_user_profile(db, admin_user_data, is_admin=True)
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302)
else:
diff --git a/app/models.py b/app/models.py
index 1f160041..5ea34eb9 100644
--- a/app/models.py
+++ b/app/models.py
@@ -238,6 +238,10 @@ class UserProfile(Base):
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
+ # When True, the user is on a complimentary (uncharged) plan — they keep all tier
+ # quota benefits but are never billed via Stripe. Automatically set for admin users.
+ is_complimentary = Column(Boolean, nullable=False, default=False, server_default="0")
+
# Onboarding tracking (added in migration 017)
onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
diff --git a/docs/SubscriptionTiers.md b/docs/SubscriptionTiers.md
index d013fff7..b895e762 100644
--- a/docs/SubscriptionTiers.md
+++ b/docs/SubscriptionTiers.md
@@ -55,6 +55,35 @@ When a user's `subscription_billing_cycle` is set to `yearly`:
Setting `UserProfile.allow_overage = True` bypasses monthly quota checks entirely for that user. Usage is still tracked so future billing integrations can charge retroactively. This field is not yet exposed in the admin UI.
+## is_complimentary Flag (Complimentary Plans)
+
+Setting `UserProfile.is_complimentary = True` marks a user as being on a **complimentary (uncharged) plan**. The user retains all quota benefits of their assigned subscription tier but is **never billed via Stripe**. This is useful for:
+
+- **Admin accounts** — automatically set on every admin user profile at login time.
+- **Gifted access** — granting full plan benefits to partners, testers, or sponsored users.
+
+### Admin Auto-Provisioning
+
+When an admin user logs in for the first time (via OAuth, local account, or the built-in admin credentials), DocuElevate automatically:
+
+1. Creates a `UserProfile` row if one does not already exist.
+2. Assigns the **highest available subscription tier** (currently `business`).
+3. Sets `is_complimentary = True` so the account is never billed.
+4. Sets `onboarding_completed = True` so admins skip the first-time setup wizard.
+
+On subsequent logins for existing admin profiles:
+- `is_complimentary` is ensured to be `True`.
+- If the profile was still on the `free` tier it is upgraded to the highest tier.
+- All other admin-managed settings (custom limits, notes, etc.) are preserved.
+
+### Managing via Admin UI
+
+The **User Management** page (`/admin/users`) shows a green gift icon (🎁) next to the plan badge for any user with `is_complimentary = True`. The toggle is available in the user edit modal under **Billing**.
+
+### API Field
+
+`is_complimentary` is exposed in the `PUT /api/admin/users/{user_id}` body and in all user detail responses.
+
## Plan Designer
Navigate to `/admin/plans` (admin only) to:
diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html
index 84ed2042..4b2b2d18 100644
--- a/frontend/templates/admin_users.html
+++ b/frontend/templates/admin_users.html
@@ -140,6 +140,14 @@
>
+
+
+ Complimentary plan
+
@@ -375,6 +383,25 @@
+
+
+
+
+
+
@@ -472,6 +499,7 @@ function adminUsersApp() {
subscription_tier: 'free',
subscription_billing_cycle: 'monthly',
subscription_period_start: null,
+ is_complimentary: false,
},
// Delete modal
@@ -524,7 +552,7 @@ function adminUsersApp() {
openCreateModal() {
this.isCreate = true;
this.modalTitle = 'Add User Profile';
- this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null };
+ this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null, is_complimentary: false };
this.modalOpen = true;
},
@@ -541,6 +569,7 @@ function adminUsersApp() {
subscription_tier: user.subscription_tier || 'free',
subscription_billing_cycle: user.subscription_billing_cycle || 'monthly',
subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null,
+ is_complimentary: !!user.is_complimentary,
};
this.modalOpen = true;
},
@@ -555,6 +584,7 @@ function adminUsersApp() {
notes: this.form.notes || null,
is_blocked: !!this.form.is_blocked,
subscription_tier: this.form.subscription_tier || 'free',
+ is_complimentary: !!this.form.is_complimentary,
};
const uid = encodeURIComponent(this.form.user_id);
const resp = await fetch(`/api/admin/users/${uid}`, {
diff --git a/migrations/versions/019_add_is_complimentary.py b/migrations/versions/019_add_is_complimentary.py
new file mode 100644
index 00000000..a66b355c
--- /dev/null
+++ b/migrations/versions/019_add_is_complimentary.py
@@ -0,0 +1,30 @@
+"""Add is_complimentary column to user_profiles
+
+Revision ID: 019_add_is_complimentary
+Revises: 018_add_local_users_and_billing
+Create Date: 2026-03-07
+
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "019_add_is_complimentary"
+down_revision: Union[str, None] = "018_add_local_users_and_billing"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Add is_complimentary column to user_profiles."""
+ op.add_column(
+ "user_profiles",
+ sa.Column("is_complimentary", sa.Boolean(), nullable=False, server_default="0"),
+ )
+
+
+def downgrade() -> None:
+ """Remove is_complimentary column from user_profiles."""
+ op.drop_column("user_profiles", "is_complimentary")
diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py
index e889a704..f961b065 100644
--- a/tests/test_admin_users.py
+++ b/tests/test_admin_users.py
@@ -477,3 +477,189 @@ class TestUserProfileModel:
with pytest.raises(IntegrityError):
au_session.commit()
au_session.rollback()
+
+
+# ---------------------------------------------------------------------------
+# Complimentary plan tests
+# ---------------------------------------------------------------------------
+
+
+class TestComplimentaryPlan:
+ """Tests for the is_complimentary field and admin auto-creation logic."""
+
+ @pytest.mark.unit
+ def test_create_profile_with_complimentary_flag(self, au_client, au_session):
+ """PUT can create a profile with is_complimentary=True."""
+ resp = au_client.put(
+ "/api/admin/users/comp@example.com",
+ json={"subscription_tier": "business", "is_complimentary": True, "is_blocked": False},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["is_complimentary"] is True
+ assert data["subscription_tier"] == "business"
+
+ profile = au_session.query(UserProfile).filter_by(user_id="comp@example.com").first()
+ assert profile is not None
+ assert profile.is_complimentary is True
+
+ @pytest.mark.unit
+ def test_update_profile_set_complimentary(self, au_client, au_session):
+ """PUT can toggle is_complimentary on an existing profile."""
+ _make_profile(au_session, "toggle@example.com", is_complimentary=False)
+
+ resp = au_client.put(
+ "/api/admin/users/toggle@example.com",
+ json={"is_blocked": False, "is_complimentary": True},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["is_complimentary"] is True
+
+ @pytest.mark.unit
+ def test_list_users_includes_complimentary_field(self, au_client, au_session):
+ """GET /api/admin/users/ returns is_complimentary per user."""
+ _make_profile(au_session, "complist@example.com", is_complimentary=True)
+
+ resp = au_client.get("/api/admin/users/")
+ assert resp.status_code == 200
+ users = {u["user_id"]: u for u in resp.json()["users"]}
+ assert "complist@example.com" in users
+ assert users["complist@example.com"]["is_complimentary"] is True
+
+ @pytest.mark.unit
+ def test_get_user_includes_complimentary_field(self, au_client, au_session):
+ """GET /api/admin/users/ returns is_complimentary in profile."""
+ _make_profile(au_session, "getcomp@example.com", is_complimentary=True, subscription_tier="business")
+
+ resp = au_client.get("/api/admin/users/getcomp%40example.com")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["is_complimentary"] is True
+ assert data["profile"]["is_complimentary"] is True
+
+ @pytest.mark.unit
+ def test_complimentary_defaults_to_false(self, au_client, au_session):
+ """Newly created profiles have is_complimentary=False by default."""
+ resp = au_client.put(
+ "/api/admin/users/nocomp@example.com",
+ json={"is_blocked": False},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["is_complimentary"] is False
+
+ @pytest.mark.unit
+ def test_profile_model_complimentary_column(self, au_session):
+ """UserProfile model stores is_complimentary correctly."""
+ profile = UserProfile(user_id="modelcomp@example.com", is_complimentary=True)
+ au_session.add(profile)
+ au_session.commit()
+ au_session.refresh(profile)
+ assert profile.is_complimentary is True
+
+
+# ---------------------------------------------------------------------------
+# _ensure_user_profile admin auto-creation tests
+# ---------------------------------------------------------------------------
+
+
+class TestEnsureUserProfileAdmin:
+ """Tests for _ensure_user_profile admin-specific behaviour."""
+
+ @pytest.mark.unit
+ def test_admin_login_creates_highest_tier_profile(self, au_session):
+ """Admin first login creates a profile with the highest subscription tier."""
+ from app.auth import _ensure_user_profile
+ from app.utils.subscription import TIER_ORDER
+
+ user_data = {
+ "preferred_username": "admin",
+ "email": "admin@local.docuelevate",
+ "name": "Administrator",
+ "is_admin": True,
+ }
+ _ensure_user_profile(au_session, user_data, is_admin=True)
+
+ # user_id uses preferred_username (sub not provided)
+ profile = au_session.query(UserProfile).filter_by(user_id="admin").first()
+ assert profile is not None
+ assert profile.subscription_tier == TIER_ORDER[-1]
+ assert profile.is_complimentary is True
+ assert profile.onboarding_completed is True
+
+ @pytest.mark.unit
+ def test_regular_user_login_creates_free_profile(self, au_session):
+ """Regular user login creates a profile with the free tier."""
+ from app.auth import _ensure_user_profile
+
+ user_data = {
+ "preferred_username": "regular",
+ "email": "user@example.com",
+ "name": "Regular User",
+ }
+ _ensure_user_profile(au_session, user_data, is_admin=False)
+
+ # user_id uses preferred_username (sub not provided)
+ profile = au_session.query(UserProfile).filter_by(user_id="regular").first()
+ assert profile is not None
+ assert profile.subscription_tier == "free"
+ assert profile.is_complimentary is False
+
+ @pytest.mark.unit
+ def test_admin_login_sets_complimentary_on_existing_profile(self, au_session):
+ """Existing admin profile gets is_complimentary=True on login."""
+ existing = UserProfile(user_id="existadmin", is_complimentary=False, subscription_tier="starter")
+ au_session.add(existing)
+ au_session.commit()
+
+ from app.auth import _ensure_user_profile
+
+ user_data = {"preferred_username": "existadmin", "email": "ea@example.com"}
+ _ensure_user_profile(au_session, user_data, is_admin=True)
+
+ au_session.refresh(existing)
+ assert existing.is_complimentary is True
+
+ @pytest.mark.unit
+ def test_admin_login_does_not_downgrade_existing_tier(self, au_session):
+ """Existing admin profile with a paid tier keeps that tier on re-login."""
+ from app.auth import _ensure_user_profile
+ from app.utils.subscription import TIER_ORDER
+
+ highest = TIER_ORDER[-1]
+ existing = UserProfile(user_id="toptieradmin", is_complimentary=False, subscription_tier=highest)
+ au_session.add(existing)
+ au_session.commit()
+
+ user_data = {"preferred_username": "toptieradmin", "email": "tt@example.com"}
+ _ensure_user_profile(au_session, user_data, is_admin=True)
+
+ au_session.refresh(existing)
+ assert existing.subscription_tier == highest
+ assert existing.is_complimentary is True
+
+ @pytest.mark.unit
+ def test_admin_login_upgrades_free_tier_on_existing_profile(self, au_session):
+ """Existing admin profile on free tier gets upgraded to highest tier."""
+ from app.auth import _ensure_user_profile
+ from app.utils.subscription import TIER_ORDER
+
+ existing = UserProfile(user_id="freeadmin", is_complimentary=False, subscription_tier="free")
+ au_session.add(existing)
+ au_session.commit()
+
+ user_data = {"preferred_username": "freeadmin", "email": "fa@example.com"}
+ _ensure_user_profile(au_session, user_data, is_admin=True)
+
+ au_session.refresh(existing)
+ assert existing.subscription_tier == TIER_ORDER[-1]
+ assert existing.is_complimentary is True
+
+ @pytest.mark.unit
+ def test_ensure_user_profile_no_identifier_logs_warning(self, au_session):
+ """_ensure_user_profile logs a warning when no stable user id is present."""
+ from app.auth import _ensure_user_profile
+
+ _ensure_user_profile(au_session, {}, is_admin=False)
+ # No profile should have been created
+ count = au_session.query(UserProfile).count()
+ assert count == 0
diff --git a/tests/test_auth_module.py b/tests/test_auth_module.py
index 7cf30187..d08830ff 100644
--- a/tests/test_auth_module.py
+++ b/tests/test_auth_module.py
@@ -278,8 +278,10 @@ class TestAuthEndpoint:
mock_form_data = {"username": "admin", "password": "secret123"}
mock_request.form = AsyncMock(return_value=mock_form_data)
mock_request.session = {}
+ mock_db = MagicMock()
- result = await auth(mock_request)
+ with patch("app.auth._ensure_user_profile"):
+ result = await auth(mock_request, db=mock_db)
# Verify redirect to upload page
assert isinstance(result, RedirectResponse)
@@ -305,8 +307,9 @@ class TestAuthEndpoint:
mock_form_data = {"username": "admin", "password": "wrong_password"}
mock_request.form = AsyncMock(return_value=mock_form_data)
mock_request.session = {}
+ mock_db = MagicMock()
- result = await auth(mock_request)
+ result = await auth(mock_request, db=mock_db)
# Verify redirect to login with error
assert isinstance(result, RedirectResponse)
@@ -327,8 +330,10 @@ class TestAuthEndpoint:
mock_form_data = {"username": "admin", "password": "secret123"}
mock_request.form = AsyncMock(return_value=mock_form_data)
mock_request.session = {"redirect_after_login": "/protected/page"}
+ mock_db = MagicMock()
- result = await auth(mock_request)
+ with patch("app.auth._ensure_user_profile"):
+ result = await auth(mock_request, db=mock_db)
# Verify redirect to saved URL
assert isinstance(result, RedirectResponse)
From 064ba72d361215c1370f0d3fd81ac04ff1624d2e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 7 Mar 2026 20:17:07 +0000
Subject: [PATCH 10/16] fix(ui): address code review feedback on complimentary
badge and aria attributes
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
frontend/templates/admin_users.html | 5 ++---
tests/test_admin_users.py | 2 +-
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html
index 4b2b2d18..7d0b86ee 100644
--- a/frontend/templates/admin_users.html
+++ b/frontend/templates/admin_users.html
@@ -143,10 +143,9 @@
- Complimentary plan
@@ -392,7 +391,7 @@
class="sr-only peer"
id="modal-complimentary"
role="switch"
- :aria-checked="form.is_complimentary"
+ :aria-checked="form.is_complimentary.toString()"
/>
diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py
index f961b065..7f215278 100644
--- a/tests/test_admin_users.py
+++ b/tests/test_admin_users.py
@@ -621,7 +621,7 @@ class TestEnsureUserProfileAdmin:
@pytest.mark.unit
def test_admin_login_does_not_downgrade_existing_tier(self, au_session):
- """Existing admin profile with a paid tier keeps that tier on re-login."""
+ """Existing admin profile with the highest tier keeps that tier on re-login."""
from app.auth import _ensure_user_profile
from app.utils.subscription import TIER_ORDER
From 3f67b80a42bdd6f5d35cf329079395d9b4a44181 Mon Sep 17 00:00:00 2001
From: semantic-release
Date: Sat, 7 Mar 2026 20:38:48 +0000
Subject: [PATCH 11/16] 0.84.0
Automatically generated by python-semantic-release
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93084a27..8e77726c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v0.84.0 (2026-03-07)
+
+### Features
+
+- **ui**: Show marketing landing page for unauthenticated multi-user visitors
+ ([`68e8af9`](https://github.com/christianlouis/DocuElevate/commit/68e8af95545b3cda94e1b84383fc42ae584705b7))
+
+
## v0.83.0 (2026-03-07)
### Bug Fixes
From 79e76522e3cc2da3843b7e479be55d1e5563265b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 7 Mar 2026 20:38:51 +0000
Subject: [PATCH 12/16] chore(release): update build metadata files [skip ci]
---
BUILD_DATE | 2 +-
GIT_SHA | 2 +-
RUNTIME_INFO | 12 ++++++------
VERSION | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/BUILD_DATE b/BUILD_DATE
index d1b9fd54..603a46f8 100644
--- a/BUILD_DATE
+++ b/BUILD_DATE
@@ -1 +1 @@
-2026-03-07T17:41:57Z
+2026-03-07T20:38:48Z
diff --git a/GIT_SHA b/GIT_SHA
index de410666..a9471308 100644
--- a/GIT_SHA
+++ b/GIT_SHA
@@ -1 +1 @@
-5b4c8cd
+dc1a127
diff --git a/RUNTIME_INFO b/RUNTIME_INFO
index a3a3517f..8e0b3ff4 100644
--- a/RUNTIME_INFO
+++ b/RUNTIME_INFO
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
-Version: 0.83.0
-Build Date: 2026-03-07T17:41:57Z
-Git Commit: 5b4c8cdb608c90769f5c26673d0ebff7c4b4b36c
-Git Short SHA: 5b4c8cd
+Version: 0.84.0
+Build Date: 2026-03-07T20:38:48Z
+Git Commit: dc1a12772a9910cbc19da73f8470d4bb5887c2cc
+Git Short SHA: dc1a127
Git Branch: main
-Commit Date: 2026-03-07T18:41:35+01:00
-Build Timestamp: 2026-03-07T17:41:57Z
+Commit Date: 2026-03-07T21:38:31+01:00
+Build Timestamp: 2026-03-07T20:38:48Z
==============================
diff --git a/VERSION b/VERSION
index 83dcd12c..09c49413 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.83.0
+0.84.0
From cfb1e2d62d1e6073ff0a7d39c4f8cfc9c44be1a5 Mon Sep 17 00:00:00 2001
From: semantic-release
Date: Sat, 7 Mar 2026 20:44:58 +0000
Subject: [PATCH 13/16] 0.85.0
Automatically generated by python-semantic-release
---
CHANGELOG.md | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e77726c..86a9bbee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v0.85.0 (2026-03-07)
+
+### Bug Fixes
+
+- **auth**: Restore get_user function body lost in refactor; fix button period placement
+ ([`19c1ccb`](https://github.com/christianlouis/DocuElevate/commit/19c1ccb11c10698259fa01b408979b4fac158cd5))
+
+- **ui**: Update plan descriptions to reflect per-user pricing
+ ([`9d11d74`](https://github.com/christianlouis/DocuElevate/commit/9d11d741f4e6c0f29529d7dafc93980eea955aeb))
+
+### Features
+
+- **auth**: Enable local user signup without SMTP, add admin user creation
+ ([`aa6e2fe`](https://github.com/christianlouis/DocuElevate/commit/aa6e2fe00157ed924d42ae305c932b04ad2c1a81))
+
+
## v0.84.0 (2026-03-07)
### Features
From 5a9d2e7ad6ae28871b3c81d623b9410636234b80 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 7 Mar 2026 20:45:01 +0000
Subject: [PATCH 14/16] chore(release): update build metadata files [skip ci]
---
BUILD_DATE | 2 +-
GIT_SHA | 2 +-
RUNTIME_INFO | 12 ++++++------
VERSION | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/BUILD_DATE b/BUILD_DATE
index 603a46f8..67c93b31 100644
--- a/BUILD_DATE
+++ b/BUILD_DATE
@@ -1 +1 @@
-2026-03-07T20:38:48Z
+2026-03-07T20:44:58Z
diff --git a/GIT_SHA b/GIT_SHA
index a9471308..0bd4384b 100644
--- a/GIT_SHA
+++ b/GIT_SHA
@@ -1 +1 @@
-dc1a127
+a27a0d6
diff --git a/RUNTIME_INFO b/RUNTIME_INFO
index 8e0b3ff4..4420e48c 100644
--- a/RUNTIME_INFO
+++ b/RUNTIME_INFO
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
-Version: 0.84.0
-Build Date: 2026-03-07T20:38:48Z
-Git Commit: dc1a12772a9910cbc19da73f8470d4bb5887c2cc
-Git Short SHA: dc1a127
+Version: 0.85.0
+Build Date: 2026-03-07T20:44:58Z
+Git Commit: a27a0d6f01db4b25cbbd40bbe3cebed70703de6a
+Git Short SHA: a27a0d6
Git Branch: main
-Commit Date: 2026-03-07T21:38:31+01:00
-Build Timestamp: 2026-03-07T20:38:48Z
+Commit Date: 2026-03-07T21:44:40+01:00
+Build Timestamp: 2026-03-07T20:44:58Z
==============================
diff --git a/VERSION b/VERSION
index 09c49413..137c1281 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.84.0
+0.85.0
From e8c285432513e87c73a0bd07e823e5d0b0564198 Mon Sep 17 00:00:00 2001
From: semantic-release
Date: Sat, 7 Mar 2026 20:45:41 +0000
Subject: [PATCH 15/16] 0.86.0
Automatically generated by python-semantic-release
---
CHANGELOG.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86a9bbee..6375d1a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v0.86.0 (2026-03-07)
+
+### Bug Fixes
+
+- **ui**: Address code review feedback on complimentary badge and aria attributes
+ ([`064ba72`](https://github.com/christianlouis/DocuElevate/commit/064ba72d361215c1370f0d3fd81ac04ff1624d2e))
+
+### Features
+
+- **auth**: Auto-create admin user profiles with highest tier and complimentary flag
+ ([`97f85ce`](https://github.com/christianlouis/DocuElevate/commit/97f85ce74ed5d7970f330c923c7ae60ec299b7fd))
+
+
## v0.85.0 (2026-03-07)
### Bug Fixes
From 3a1ecaf63a3bf100700f78ff27693ec76639ad9f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 7 Mar 2026 20:45:44 +0000
Subject: [PATCH 16/16] chore(release): update build metadata files [skip ci]
---
BUILD_DATE | 2 +-
GIT_SHA | 2 +-
RUNTIME_INFO | 12 ++++++------
VERSION | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/BUILD_DATE b/BUILD_DATE
index 67c93b31..11513cc4 100644
--- a/BUILD_DATE
+++ b/BUILD_DATE
@@ -1 +1 @@
-2026-03-07T20:44:58Z
+2026-03-07T20:45:41Z
diff --git a/GIT_SHA b/GIT_SHA
index 0bd4384b..f0a5e02c 100644
--- a/GIT_SHA
+++ b/GIT_SHA
@@ -1 +1 @@
-a27a0d6
+93b4dcf
diff --git a/RUNTIME_INFO b/RUNTIME_INFO
index 4420e48c..6b757bd1 100644
--- a/RUNTIME_INFO
+++ b/RUNTIME_INFO
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
-Version: 0.85.0
-Build Date: 2026-03-07T20:44:58Z
-Git Commit: a27a0d6f01db4b25cbbd40bbe3cebed70703de6a
-Git Short SHA: a27a0d6
+Version: 0.86.0
+Build Date: 2026-03-07T20:45:41Z
+Git Commit: 93b4dcf641ce830405396b955af929a352920f1d
+Git Short SHA: 93b4dcf
Git Branch: main
-Commit Date: 2026-03-07T21:44:40+01:00
-Build Timestamp: 2026-03-07T20:44:58Z
+Commit Date: 2026-03-07T21:45:21+01:00
+Build Timestamp: 2026-03-07T20:45:41Z
==============================
diff --git a/VERSION b/VERSION
index 137c1281..63cd8847 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.85.0
+0.86.0