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/BUILD_DATE b/BUILD_DATE index d1b9fd54..11513cc4 100644 --- a/BUILD_DATE +++ b/BUILD_DATE @@ -1 +1 @@ -2026-03-07T17:41:57Z +2026-03-07T20:45:41Z diff --git a/CHANGELOG.md b/CHANGELOG.md index 93084a27..6375d1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,43 @@ 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 + +- **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 + +- **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 diff --git a/GIT_SHA b/GIT_SHA index de410666..f0a5e02c 100644 --- a/GIT_SHA +++ b/GIT_SHA @@ -1 +1 @@ -5b4c8cd +93b4dcf diff --git a/RUNTIME_INFO b/RUNTIME_INFO index a3a3517f..6b757bd1 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.86.0 +Build Date: 2026-03-07T20:45:41Z +Git Commit: 93b4dcf641ce830405396b955af929a352920f1d +Git Short SHA: 93b4dcf Git Branch: main -Commit Date: 2026-03-07T18:41:35+01:00 -Build Timestamp: 2026-03-07T17:41:57Z +Commit Date: 2026-03-07T21:45:21+01:00 +Build Timestamp: 2026-03-07T20:45:41Z ============================== diff --git a/VERSION b/VERSION index 83dcd12c..63cd8847 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.83.0 +0.86.0 diff --git a/app/api/admin_users.py b/app/api/admin_users.py index f80d463f..5f0a4672 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"]) @@ -59,6 +62,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 +82,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,11 +101,36 @@ 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 +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 # --------------------------------------------------------------------------- @@ -121,6 +155,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 +229,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, @@ -209,6 +245,110 @@ def list_users( } +# --------------------------------------------------------------------------- +# Local user management (admin-only) +# --------------------------------------------------------------------------- +# NOTE: These routes MUST be defined before /{user_id:path} to avoid being +# swallowed by the catch-all path parameter. +# --------------------------------------------------------------------------- + + +@router.get("/local", summary="List all local (email/password) user accounts") +def list_local_users(db: DbSession, _admin: AdminUser) -> list[dict[str, Any]]: + """Return every local user account with basic metadata.""" + users = db.query(LocalUser).order_by(LocalUser.created_at.desc()).all() + return [ + { + "id": u.id, + "email": u.email, + "username": u.username, + "display_name": u.display_name, + "is_active": u.is_active, + "is_admin": u.is_admin, + "created_at": u.created_at.isoformat() if u.created_at else None, + } + for u in users + ] + + +@router.post("/local", status_code=status.HTTP_201_CREATED, summary="Create a local user account") +def create_local_user(body: LocalUserCreate, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Create a new local (email/password) user account. + + The account is immediately active — no email verification is required when + created by an administrator. A matching UserProfile row is also created. + + Raises: + 409: Email or username already registered. + """ + if db.query(LocalUser).filter(LocalUser.email == body.email).first(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.") + if db.query(LocalUser).filter(LocalUser.username == body.username).first(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.") + + user = LocalUser( + email=body.email, + username=body.username, + display_name=body.display_name, + hashed_password=hash_password(body.password), + is_active=True, + is_admin=body.is_admin, + ) + db.add(user) + + # Ensure a UserProfile exists for the new user + if not db.query(UserProfile).filter(UserProfile.user_id == body.email).first(): + db.add(UserProfile(user_id=body.email, display_name=body.display_name or body.username)) + + try: + db.commit() + db.refresh(user) + except Exception: + db.rollback() + raise + + logger.info("Admin created local user account: %s", body.email) + return { + "id": user.id, + "email": user.email, + "username": user.username, + "display_name": user.display_name, + "is_active": user.is_active, + "is_admin": user.is_admin, + "created_at": user.created_at.isoformat() if user.created_at else None, + } + + +@router.delete( + "/local/{local_user_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a local user account", +) +def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> None: + """Delete a local user account by its numeric ID. + + The associated UserProfile is also removed. Documents owned by this user + are **not** deleted. + """ + user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.") + + # Remove associated profile if present + profile = db.query(UserProfile).filter(UserProfile.user_id == user.email).first() + if profile: + db.delete(profile) + + try: + db.delete(user) + db.commit() + except Exception: + db.rollback() + raise + + logger.info("Admin deleted local user account: %s", user.email) + + @router.get("/{user_id:path}", summary="Get details for a single user") def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]: """Return profile and document statistics for a specific user.""" @@ -235,6 +375,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 +406,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/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/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 3f111091..560b2b8b 100644 --- a/app/models.py +++ b/app/models.py @@ -238,13 +238,17 @@ class UserProfile(Base): subscription_period_start = Column(DateTime(timezone=True), nullable=True) allow_overage = Column(Boolean, nullable=False, default=False, server_default="0") - # Pending subscription change (added in migration 019) + # Pending subscription change (added in migration 020_add_subscription_change_pending) # When a user requests a downgrade, the new tier is stored here and the # change is applied on `subscription_change_pending_date`. Upgrades are # applied immediately and these fields are left NULL. subscription_change_pending_tier = Column(String(50), nullable=True) subscription_change_pending_date = Column(DateTime(timezone=True), nullable=True) + # 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/app/utils/subscription.py b/app/utils/subscription.py index b121c9c5..f0d1bd3e 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/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/app/views/general.py b/app/views/general.py index 8a295f55..6efb2a7f 100644 --- a/app/views/general.py +++ b/app/views/general.py @@ -123,6 +123,7 @@ async def serve_index(request: Request, db: Session = Depends(get_db)): "user_tier": user_tier, "multi_user_enabled": settings.multi_user_enabled, "is_admin": is_admin, + "allow_signup": settings.multi_user_enabled and settings.allow_local_signup, }, ) 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/docs/SubscriptionTiers.md b/docs/SubscriptionTiers.md index d013fff7..147a3cb6 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. @@ -55,6 +66,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/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..2b6e83df 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -23,6 +23,13 @@ > Add User Profile + @@ -140,6 +147,13 @@ > + + + @@ -375,6 +389,25 @@

+ +
+ + +
+ @@ -399,6 +432,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/index.html b/frontend/templates/index.html index eaf520a1..d5503540 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -1,12 +1,143 @@ {% extends "base.html" %} -{% block title %}Dashboard – DocuElevate{% endblock %} +{% block title %}{% if multi_user_enabled and not is_logged_in %}DocuElevate – Intelligent Document Processing{% else %}Dashboard – DocuElevate{% endif %}{% endblock %} {% block content %} -
+
-{% 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. +

+
+ {% if allow_signup %} + + Get Started — it's free + + {% else %} + + Log In + + {% endif %} + + View Plans & Pricing + +
+
+
+ + +
+

Everything you need for smart document workflows

+
+ +
+
+ +
+
+

OCR & Text Extraction

+

Azure Document Intelligence converts scanned PDFs and images into fully searchable text automatically.

+
+
+ +
+
+ +
+
+

AI Metadata Extraction

+

OpenAI, Claude, Gemini, and other pluggable AI providers classify documents and pull out key fields like dates, amounts, and subjects.

+
+
+ +
+
+ +
+
+

Multi-Cloud Storage

+

Route processed files to Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, Paperless NGX, WebDAV, FTP/SFTP, and more.

+
+
+ +
+
+ +
+
+

Email & IMAP Ingestion

+

Automatically pull documents from Gmail or any IMAP mailbox — no manual uploads needed.

+
+
+ +
+
+ +
+
+

Full-Text Search

+

Instantly find any document by content, metadata, or tags across your entire archive.

+
+
+ +
+
+ +
+
+

Custom Pipelines

+

Build processing pipelines with configurable steps — OCR, AI extraction, format conversion, and storage routing in any order.

+
+
+ +
+
+ + +
+
+

Ready to elevate your document workflow?

+

Join teams already automating their document processing with DocuElevate.

+
+ {% if allow_signup %} + + Create a free account + + {% else %} + + Log In + + {% endif %} + + See pricing + +
+
+
+ +{% elif multi_user_enabled %} +{# ══════════════════════════════════════════════════════════════════════════ #} +{# MULTI-USER / SAAS DASHBOARD (logged-in user) #} {# ══════════════════════════════════════════════════════════════════════════ #} 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.

@@ -318,7 +318,7 @@ Community Email Priority email - Dedicated + Priority 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/frontend/templates/subscription.html b/frontend/templates/subscription.html index 65fbe296..09143224 100644 --- a/frontend/templates/subscription.html +++ b/frontend/templates/subscription.html @@ -65,7 +65,7 @@ {% if tier_id == 'free' %}fa-seedling text-gray-500 {% elif tier_id == 'starter' %}fa-rocket text-blue-600 {% elif tier_id == 'professional' %}fa-star text-indigo-600 - {% else %}fa-building text-purple-600{% endif %} + {% else %}fa-bolt text-purple-600{% endif %} text-2xl" aria-hidden="true">

Current Plan

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/migrations/versions/019_add_subscription_change_pending.py b/migrations/versions/020_add_subscription_change_pending.py similarity index 79% rename from migrations/versions/019_add_subscription_change_pending.py rename to migrations/versions/020_add_subscription_change_pending.py index f9ee935f..c826ab08 100644 --- a/migrations/versions/019_add_subscription_change_pending.py +++ b/migrations/versions/020_add_subscription_change_pending.py @@ -1,7 +1,7 @@ """Add pending subscription-change columns to user_profiles -Revision ID: 019_add_subscription_change_pending -Revises: 018_add_local_users_and_billing +Revision ID: 020_add_subscription_change_pending +Revises: 019_add_is_complimentary Create Date: 2026-03-07 """ @@ -10,8 +10,8 @@ from typing import Union import sqlalchemy as sa from alembic import op -revision: str = "019_add_subscription_change_pending" -down_revision: Union[str, None] = "018_add_local_users_and_billing" +revision: str = "020_add_subscription_change_pending" +down_revision: Union[str, None] = "019_add_is_complimentary" depends_on: Union[str, None] = None diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py index e889a704..7f215278 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 the highest 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) 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) diff --git a/tests/test_subscription.py b/tests/test_subscription.py index f8f71af5..21a98f68 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"]