chore: merge main into notifications branch

Resolve conflicts in app/auth.py and app/api/admin_users.py:

- auth.py: combine admin-aware profile creation (from main, adding
  is_complimentary/highest-tier defaults for admins) with signup
  notification/webhook (from our branch). Admin users skip the
  signup notification since they are the ones being notified.

- admin_users.py: combine is_complimentary assignment (from main)
  with tier_changed/new_tier tracking variables (from our branch)
  to fire plan-change notifications when an admin updates a user.
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 20:52:44 +00:00
28 changed files with 1267 additions and 108 deletions
+5
View File
@@ -133,6 +133,11 @@ ADMIN_GROUP_NAME=admin
# When enabled, each user has their own document space with isolated uploads, # When enabled, each user has their own document space with isolated uploads,
# search, and file management. Requires AUTH_ENABLED=true. # search, and file management. Requires AUTH_ENABLED=true.
MULTI_USER_ENABLED=false MULTI_USER_ENABLED=false
# Allow users to self-register with an email address and password.
# Set to true to enable the /signup page. Requires MULTI_USER_ENABLED=true.
# When SMTP is configured, a verification email is sent before the account is activated.
# Without SMTP, accounts are activated immediately upon registration.
# ALLOW_LOCAL_SIGNUP=false
# Default upload limit per user per day (0 = unlimited) # Default upload limit per user per day (0 = unlimited)
DEFAULT_DAILY_UPLOAD_LIMIT=0 DEFAULT_DAILY_UPLOAD_LIMIT=0
# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false) # Show unowned documents (owner_id=NULL) to all users (true) or only admins (false)
+1 -1
View File
@@ -1 +1 @@
2026-03-07T17:41:57Z 2026-03-07T20:45:41Z
+37
View File
@@ -10,6 +10,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list --> <!-- version list -->
## 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) ## v0.83.0 (2026-03-07)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -1 +1 @@
5b4c8cd 93b4dcf
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information DocuElevate Build Information
============================== ==============================
Version: 0.83.0 Version: 0.86.0
Build Date: 2026-03-07T17:41:57Z Build Date: 2026-03-07T20:45:41Z
Git Commit: 5b4c8cdb608c90769f5c26673d0ebff7c4b4b36c Git Commit: 93b4dcf641ce830405396b955af929a352920f1d
Git Short SHA: 5b4c8cd Git Short SHA: 93b4dcf
Git Branch: main Git Branch: main
Commit Date: 2026-03-07T18:41:35+01:00 Commit Date: 2026-03-07T21:45:21+01:00
Build Timestamp: 2026-03-07T17:41:57Z Build Timestamp: 2026-03-07T20:45:41Z
============================== ==============================
+1 -1
View File
@@ -1 +1 @@
0.83.0 0.86.0
+143 -1
View File
@@ -2,6 +2,8 @@
Provides CRUD operations for user profiles and aggregate statistics so that Provides CRUD operations for user profiles and aggregate statistics so that
administrators can inspect, configure, and manage users in multi-user mode. administrators can inspect, configure, and manage users in multi-user mode.
Also provides endpoints for admins to create and manage local (email/password)
user accounts directly, without requiring email verification.
""" """
import logging import logging
@@ -14,7 +16,8 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import FileRecord, UserProfile from app.models import FileRecord, LocalUser, UserProfile
from app.utils.local_auth import hash_password
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/users", tags=["admin-users"]) router = APIRouter(prefix="/admin/users", tags=["admin-users"])
@@ -59,6 +62,11 @@ class UserProfileUpsert(BaseModel):
subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$") subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$")
subscription_period_start: datetime | None = None subscription_period_start: datetime | None = None
allow_overage: bool = False 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 PaymentIssueBody(BaseModel): class PaymentIssueBody(BaseModel):
@@ -80,6 +88,7 @@ class UserProfileResponse(BaseModel):
subscription_billing_cycle: str subscription_billing_cycle: str
subscription_period_start: str | None subscription_period_start: str | None
allow_overage: bool allow_overage: bool
is_complimentary: bool
created_at: str | None created_at: str | None
updated_at: str | None updated_at: str | None
@@ -98,11 +107,36 @@ class UserSummary(BaseModel):
subscription_billing_cycle: str | None subscription_billing_cycle: str | None
subscription_period_start: str | None subscription_period_start: str | None
allow_overage: bool allow_overage: bool
is_complimentary: bool
profile_id: int | None profile_id: int | None
document_count: int document_count: int
last_upload: str | None last_upload: str | None
class LocalUserCreate(BaseModel):
"""Body for admin-creating a local (email/password) user account."""
email: str = Field(..., max_length=255, description="Email address for the new user")
username: str = Field(..., min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
display_name: str | None = Field(default=None, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
is_admin: bool = Field(default=False, description="Grant admin privileges")
class LocalUserResponse(BaseModel):
"""Summary of a local user account."""
id: int
email: str
username: str
display_name: str | None
is_active: bool
is_admin: bool
created_at: str | None
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -127,6 +161,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
if profile.subscription_period_start if profile.subscription_period_start
else None, else None,
"allow_overage": bool(profile.allow_overage), "allow_overage": bool(profile.allow_overage),
"is_complimentary": bool(profile.is_complimentary),
"created_at": profile.created_at.isoformat() if profile.created_at else None, "created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None, "updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
} }
@@ -200,6 +235,7 @@ def list_users(
if (profile and profile.subscription_period_start) if (profile and profile.subscription_period_start)
else None, else None,
"allow_overage": bool(profile.allow_overage) if profile else False, "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, "profile_id": profile.id if profile else None,
"document_count": doc_row.doc_count if doc_row else 0, "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, "last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
@@ -215,6 +251,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") @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]: def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Return profile and document statistics for a specific user.""" """Return profile and document statistics for a specific user."""
@@ -241,6 +381,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
if (profile and profile.subscription_period_start) if (profile and profile.subscription_period_start)
else None, else None,
"allow_overage": bool(profile.allow_overage) if profile else False, "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, "profile_id": profile.id if profile else None,
"document_count": doc_count, "document_count": doc_count,
"last_upload": last_upload, "last_upload": last_upload,
@@ -272,6 +413,7 @@ def upsert_user_profile(
profile.subscription_billing_cycle = body.subscription_billing_cycle profile.subscription_billing_cycle = body.subscription_billing_cycle
profile.subscription_period_start = body.subscription_period_start profile.subscription_period_start = body.subscription_period_start
profile.allow_overage = body.allow_overage profile.allow_overage = body.allow_overage
profile.is_complimentary = body.is_complimentary
tier_changed = False tier_changed = False
new_tier: str | None = None new_tier: str | None = None
if body.subscription_tier is not None: if body.subscription_tier is not None:
+50 -32
View File
@@ -128,15 +128,18 @@ async def reset_password_page(request: Request) -> Any:
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED) @router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]: async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str | bool]:
"""Create a new local user account and send a verification email. """Create a new local user account.
When SMTP is configured the account is inactive until the user clicks the
verification link sent to their email. When SMTP is **not** configured the
account is activated immediately so that deployments without email can still
use the self-registration flow.
The account is inactive until the user clicks the email link.
Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``. Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``.
Raises: Raises:
403: Multi-user mode or local signup is disabled. 403: Multi-user mode or local signup is disabled.
503: SMTP is not configured.
422: Passwords do not match. 422: Passwords do not match.
409: Email or username already registered. 409: Email or username already registered.
""" """
@@ -144,11 +147,6 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.")
if not settings.allow_local_signup: if not settings.allow_local_signup:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
if not settings.email_host:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email (SMTP) must be configured before local signup can be enabled.",
)
if body.password != body.password_confirm: if body.password != body.password_confirm:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
@@ -157,16 +155,30 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
if db.query(LocalUser).filter(LocalUser.username == body.username).first(): if db.query(LocalUser).filter(LocalUser.username == body.username).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
token = generate_token() smtp_configured = bool(settings.email_host)
user = LocalUser(
email=body.email, if smtp_configured:
username=body.username, token = generate_token()
display_name=body.display_name, user = LocalUser(
hashed_password=hash_password(body.password), email=body.email,
is_active=False, username=body.username,
email_verification_token=token, display_name=body.display_name,
email_verification_sent_at=datetime.now(tz=timezone.utc), 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) db.add(user)
profile = UserProfile( profile = UserProfile(
@@ -185,22 +197,28 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
db.rollback() db.rollback()
raise raise
base_url = str(request.base_url).rstrip("/") if smtp_configured and token:
try: base_url = str(request.base_url).rstrip("/")
send_verification_email(body.email, body.username, token, base_url) try:
except Exception as exc: send_verification_email(body.email, body.username, token, base_url)
# Email failed — roll back so no unverifiable user row persists. except Exception as exc:
# The user can simply try registering again once SMTP is fixed. # Email failed — roll back so no unverifiable user row persists.
db.rollback() # The user can simply try registering again once SMTP is fixed.
logger.warning("Signup email failed for %s: %s", body.email, exc) db.rollback()
raise HTTPException( logger.warning("Signup email failed for %s: %s", body.email, exc)
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, raise HTTPException(
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."), status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
) from exc detail=(
"Failed to send verification email. Please check that SMTP is correctly configured and try again."
),
) from exc
db.commit() db.commit()
logger.info("New local user registered: %s", body.email) logger.info("New local user registered: %s", body.email)
return {"message": "Verification email sent. Please check your inbox."}
if smtp_configured:
return {"message": "Verification email sent. Please check your inbox.", "email_verification_required": True}
return {"message": "Account created successfully. You can now log in.", "email_verification_required": False}
@router.get("/verify-email", include_in_schema=False) @router.get("/verify-email", include_in_schema=False)
+73 -24
View File
@@ -127,18 +127,35 @@ async def oauth_login(request: Request):
return await oauth.authentik.authorize_redirect(request, redirect_uri) return await oauth.authentik.authorize_redirect(request, redirect_uri)
def _ensure_user_profile(db: Session, user_data: dict) -> None: def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None:
"""Create a UserProfile row for *user_data* if one does not yet exist. """Create or update a UserProfile row for *user_data*.
Uses the same identifier priority as ``get_current_owner_id`` (sub → Uses the same identifier priority as ``get_current_owner_id`` (sub →
preferred_username → email → id) so that the profile's ``user_id`` matches preferred_username → email → id) so that the profile's ``user_id`` matches
``FileRecord.owner_id`` for every document the user uploads. ``FileRecord.owner_id`` for every document the user uploads.
If a profile already exists it is left unchanged; only missing profiles For regular users, an existing profile is left unchanged so that
are created so that admin-managed settings (tier, limits, etc.) are admin-managed settings (tier, limits, etc.) are preserved across logins.
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.models import UserProfile
from app.utils.subscription import TIER_ORDER
highest_tier = TIER_ORDER[-1]
user_id = ( user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id") user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
@@ -152,29 +169,58 @@ def _ensure_user_profile(db: Session, user_data: dict) -> None:
if existing is None: if existing is None:
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email") display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
email = user_data.get("email") email = 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.add(profile)
db.commit() db.commit()
logger.info("Auto-created UserProfile for user_id=%s", user_id) logger.info(
# Notify admins and fire webhook for new signup "Auto-created UserProfile for user_id=%s (admin=%s, tier=%s)",
try: user_id,
from app.utils.notification import notify_user_signup is_admin,
from app.utils.webhook import dispatch_webhook_event highest_tier if is_admin else "free",
)
# Notify admins and fire webhook for new (non-admin) user signup
if not is_admin:
try:
from app.utils.notification import notify_user_signup
from app.utils.webhook import dispatch_webhook_event
notify_user_signup(user_id, display_name=display_name, email=email) notify_user_signup(user_id, display_name=display_name, email=email)
dispatch_webhook_event( dispatch_webhook_event(
"user.signup", "user.signup",
{ {
"user_id": user_id, "user_id": user_id,
"display_name": display_name, "display_name": display_name,
"email": email, "email": email,
}, },
)
except Exception:
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
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:
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
except Exception: except Exception:
db.rollback() 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)): async def oauth_callback(request: Request, db: Session = Depends(get_db)):
@@ -210,7 +256,7 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
request.session["user"] = user_data request.session["user"] = user_data
# Auto-create or update UserProfile so the user appears in admin user management # 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 # Log the successful authentication
logger.info("[SECURITY] OAUTH_LOGIN_SUCCESS user=%s admin=%s", user_data.get("email", "unknown"), is_admin) logger.info("[SECURITY] OAUTH_LOGIN_SUCCESS user=%s admin=%s", user_data.get("email", "unknown"), is_admin)
@@ -268,6 +314,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
user_data = _build_session_user(local_user) user_data = _build_session_user(local_user)
request.session["user"] = user_data request.session["user"] = user_data
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email) 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() profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
if profile and not profile.onboarding_completed: if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload") post_onboarding = request.session.pop("redirect_after_login", "/upload")
@@ -278,7 +325,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
# --- Admin credentials (always available as a fallback / single-user mode) --- # --- Admin credentials (always available as a fallback / single-user mode) ---
if username == settings.admin_username and password == settings.admin_password: if username == settings.admin_username and password == settings.admin_password:
request.session["user"] = { admin_user_data = {
"id": "admin", "id": "admin",
"name": "Administrator", "name": "Administrator",
"email": f"{username}@local.docuelevate", "email": f"{username}@local.docuelevate",
@@ -286,7 +333,9 @@ async def auth(request: Request, db: Session = Depends(get_db)):
"picture": "/static/images/default-avatar.svg", "picture": "/static/images/default-avatar.svg",
"is_admin": True, "is_admin": True,
} }
request.session["user"] = admin_user_data
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username) 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") redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302) return RedirectResponse(url=redirect_url, status_code=302)
else: else:
+4
View File
@@ -238,6 +238,10 @@ class UserProfile(Base):
subscription_period_start = Column(DateTime(timezone=True), nullable=True) subscription_period_start = Column(DateTime(timezone=True), nullable=True)
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0") 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 tracking (added in migration 017)
onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0") onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True) onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
+18 -14
View File
@@ -1,11 +1,12 @@
""" """
Subscription tier definitions and enforcement utilities for DocuElevate SaaS. 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): Four tiers (prices ex-VAT; German customers +19 % MwSt):
- free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest - 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 - 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 - 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". Limits use 0 to represent "unlimited".
All paid tiers include a 30-day free trial (trial_days field). 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 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. 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 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. 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. 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": { "free": {
"id": "free", "id": "free",
"name": "Free", "name": "Free",
"tagline": "Explore DocuElevate at no cost", "tagline": "Try DocuElevate free — no credit card needed",
"price_monthly": 0, "price_monthly": 0,
"price_yearly": 0, "price_yearly": 0,
"trial_days": 0, "trial_days": 0,
@@ -75,7 +76,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"starter": { "starter": {
"id": "starter", "id": "starter",
"name": "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_monthly": 2.99,
"price_yearly": 28.99, # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free) "price_yearly": 28.99, # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free)
"trial_days": 30, "trial_days": 30,
@@ -89,7 +91,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"max_mailboxes": 1, "max_mailboxes": 1,
"api_access": True, "api_access": True,
"features": [ "features": [
"50 documents / month", "50 documents / month — invoices, contracts, receipts",
"2 storage destinations", "2 storage destinations",
"300 OCR pages / month", "300 OCR pages / month",
"25 MB max file size", "25 MB max file size",
@@ -104,7 +106,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"professional": { "professional": {
"id": "professional", "id": "professional",
"name": "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_monthly": 5.99,
"price_yearly": 57.99, # ≈ 80 % of monthly × 12 — save ~19 % "price_yearly": 57.99, # ≈ 80 % of monthly × 12 — save ~19 %
"trial_days": 30, "trial_days": 30,
@@ -118,7 +121,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"max_mailboxes": 3, "max_mailboxes": 3,
"api_access": True, "api_access": True,
"features": [ "features": [
"150 documents / month", "150 documents / month — reports, contracts, invoices",
"5 storage destinations", "5 storage destinations",
"750 OCR pages / month", "750 OCR pages / month",
"100 MB max file size", "100 MB max file size",
@@ -133,8 +136,9 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
}, },
"business": { "business": {
"id": "business", "id": "business",
"name": "Business", "name": "Power",
"tagline": "High-volume processing for organisations", # 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_monthly": 7.99,
"price_yearly": 76.99, # ≈ 80 % of monthly × 12 — save ~20 % "price_yearly": 76.99, # ≈ 80 % of monthly × 12 — save ~20 %
"trial_days": 30, "trial_days": 30,
@@ -148,7 +152,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"max_mailboxes": 0, # unlimited mailboxes "max_mailboxes": 0, # unlimited mailboxes
"api_access": True, "api_access": True,
"features": [ "features": [
"300 documents / month", "300 documents / month — ~10 documents per day",
"10 storage destinations", "10 storage destinations",
"1,500 OCR pages / month", "1,500 OCR pages / month",
"Unlimited file size", "Unlimited file size",
@@ -156,7 +160,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"Unlimited email ingestion mailboxes", "Unlimited email ingestion mailboxes",
"All ingestion methods", "All ingestion methods",
"Webhooks & full API access", "Webhooks & full API access",
"Dedicated support", "Priority support",
], ],
"cta": "Start free trial", "cta": "Start free trial",
"badge": "Best Value", "badge": "Best Value",
+4
View File
@@ -32,6 +32,10 @@ def _inject_global_context(ctx: dict) -> None:
ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False)) ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True)) ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True))
ctx.setdefault(
"allow_signup",
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
)
req = ctx.get("request") req = ctx.get("request")
if req is not None: if req is not None:
+1
View File
@@ -123,6 +123,7 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
"user_tier": user_tier, "user_tier": user_tier,
"multi_user_enabled": settings.multi_user_enabled, "multi_user_enabled": settings.multi_user_enabled,
"is_admin": is_admin, "is_admin": is_admin,
"allow_signup": settings.multi_user_enabled and settings.allow_local_signup,
}, },
) )
+12 -2
View File
@@ -19,14 +19,14 @@ This guide covers how to configure Stripe billing and local user sign-up in Docu
By default, user accounts are created by an administrator. To allow users to self-register with an email address and password, set `ALLOW_LOCAL_SIGNUP=true`. By default, user accounts are created by an administrator. To allow users to self-register with an email address and password, set `ALLOW_LOCAL_SIGNUP=true`.
> **Note:** SMTP must be configured before enabling local sign-up. New accounts require email verification before they can log in. > **Note:** SMTP is **optional** for local sign-up. When SMTP is configured, new accounts require email verification before they can log in. Without SMTP, accounts are activated immediately upon registration — useful for self-hosted deployments without email infrastructure.
### Configuration ### Configuration
```bash ```bash
ALLOW_LOCAL_SIGNUP=true ALLOW_LOCAL_SIGNUP=true
# SMTP (required for verification emails) # SMTP (optional — enables email verification and password reset)
EMAIL_HOST=smtp.example.com EMAIL_HOST=smtp.example.com
EMAIL_PORT=587 EMAIL_PORT=587
EMAIL_USERNAME=noreply@example.com EMAIL_USERNAME=noreply@example.com
@@ -37,11 +37,21 @@ EMAIL_SENDER=DocuElevate <noreply@example.com>
### Sign-up Flow ### Sign-up Flow
**With SMTP configured (recommended):**
1. User visits `/signup` and fills out the registration form. 1. User visits `/signup` and fills out the registration form.
2. DocuElevate sends a verification email with a 24-hour token link. 2. DocuElevate sends a verification email with a 24-hour token link.
3. User clicks the link — their account is activated and they are signed in. 3. User clicks the link — their account is activated and they are signed in.
4. First-time users are redirected to the onboarding wizard. 4. First-time users are redirected to the onboarding wizard.
**Without SMTP:**
1. User visits `/signup` and fills out the registration form.
2. Account is activated immediately — no email verification required.
3. User is redirected to the login page to sign in straight away.
### Admin-Created Accounts
Administrators can create local user accounts directly from the **Admin → User Management** page without requiring self-registration. Admin-created accounts are immediately active regardless of SMTP configuration.
### Password Reset Flow ### Password Reset Flow
1. User clicks "Forgot password?" on the login page. 1. User clicks "Forgot password?" on the login page.
+41 -1
View File
@@ -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. 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 ## Default Plans
| Plan | Monthly | Yearly | Docs/Month | Lifetime Docs | OCR Pages/Mo | Max File | Mailboxes | Destinations | | 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 | | **Free** | $0 | $0 | — | 50 total | 150 total | 5 MB | 0 | 1 |
| **Starter** | $2.99 | $28.99 | 50 | — | 300 | 25 MB | 1 | 2 | | **Starter** | $2.99 | $28.99 | 50 | — | 300 | 25 MB | 1 | 2 |
| **Professional** | $5.99 | $57.99 | 150 | — | 750 | 100 MB | 3 | 5 | | **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. > Prices ex-VAT. German customers add 19% MwSt.
All paid plans include a **30-day free trial**. 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 ## 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. 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. 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 ## Plan Designer
Navigate to `/admin/plans` (admin only) to: Navigate to `/admin/plans` (admin only) to:
+9 -6
View File
@@ -304,11 +304,14 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
/** /**
* Render the login / get-started buttons for unauthenticated visitors. * Render the login / get-started buttons for unauthenticated visitors.
* Reads the data-multi-user attribute that the server injects on <body> to * Reads the data-multi-user and data-allow-signup attributes that the server
* decide whether to show a prominent "Get Started" CTA alongside the login link. * injects on <body> to decide whether to show a prominent "Get Started" CTA
* alongside the login link, and whether it should link to /signup or /pricing.
*/ */
function _renderLoggedOutAuth(authSection, mobileAuthSection) { function _renderLoggedOutAuth(authSection, mobileAuthSection) {
const multiUser = document.body.getAttribute('data-multi-user') === 'true'; const multiUser = document.body.getAttribute('data-multi-user') === 'true';
const allowSignup = document.body.getAttribute('data-allow-signup') === 'true';
const startHref = allowSignup ? '/signup' : '/pricing';
if (authSection) { if (authSection) {
authSection.textContent = ''; authSection.textContent = '';
@@ -324,10 +327,10 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
if (multiUser) { if (multiUser) {
const startLink = document.createElement('a'); const startLink = document.createElement('a');
startLink.href = '/pricing'; startLink.href = startHref;
startLink.className = startLink.className =
'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500'; 'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500';
startLink.textContent = 'Get Started'; startLink.textContent = allowSignup ? 'Sign Up' : 'Get Started';
row.appendChild(startLink); row.appendChild(startLink);
} }
@@ -350,14 +353,14 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
if (multiUser) { if (multiUser) {
const startLink = document.createElement('a'); const startLink = document.createElement('a');
startLink.href = '/pricing'; startLink.href = startHref;
startLink.className = startLink.className =
'block px-3 py-3 rounded-md text-base font-medium text-white bg-blue-600 hover:text-white hover:bg-blue-700 mt-1'; 'block px-3 py-3 rounded-md text-base font-medium text-white bg-blue-600 hover:text-white hover:bg-blue-700 mt-1';
const startIcon = document.createElement('i'); const startIcon = document.createElement('i');
startIcon.className = 'fas fa-arrow-right mr-2'; startIcon.className = 'fas fa-arrow-right mr-2';
startIcon.setAttribute('aria-hidden', 'true'); startIcon.setAttribute('aria-hidden', 'true');
startLink.appendChild(startIcon); startLink.appendChild(startIcon);
startLink.appendChild(document.createTextNode('Get Started')); startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
mobileAuthSection.appendChild(startLink); mobileAuthSection.appendChild(startLink);
} }
} }
+333 -2
View File
@@ -23,6 +23,13 @@
> >
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile <i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
</button> </button>
<button
type="button"
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> Create Local Account
</button>
</div> </div>
<!-- ── Alert banner ───────────────────────────────────────────────────────── --> <!-- ── Alert banner ───────────────────────────────────────────────────────── -->
@@ -140,6 +147,13 @@
></i> ></i>
<span x-text="user.subscription_tier || 'free'"></span> <span x-text="user.subscription_tier || 'free'"></span>
</span> </span>
<span
x-show="user.is_complimentary"
class="ml-1 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700"
aria-label="Complimentary plan — not billed"
>
<i class="fas fa-gift" aria-hidden="true"></i>
</span>
</td> </td>
<!-- Upload limit --> <!-- Upload limit -->
<td class="px-4 py-3 text-sm text-center text-gray-700"> <td class="px-4 py-3 text-sm text-center text-gray-700">
@@ -375,6 +389,25 @@
</p> </p>
</div> </div>
<!-- Complimentary plan toggle -->
<div class="flex items-center gap-3">
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.is_complimentary"
class="sr-only peer"
id="modal-complimentary"
role="switch"
:aria-checked="form.is_complimentary.toString()"
/>
<div class="w-10 h-6 bg-gray-200 peer-focus:ring-2 peer-focus:ring-blue-400 rounded-full peer peer-checked:bg-green-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-4"></div>
</label>
<label for="modal-complimentary" class="text-sm font-medium text-gray-700">
Complimentary plan
<span class="text-xs text-gray-400 font-normal">(user keeps tier benefits but is never billed — set automatically for admin accounts)</span>
</label>
</div>
</div> </div>
<!-- Footer --> <!-- Footer -->
@@ -399,6 +432,200 @@
</div> </div>
</div> </div>
<!-- ── Local Accounts section ────────────────────────────────────────────── -->
<div class="bg-white shadow rounded-lg mt-8">
<div class="px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<i class="fas fa-user-lock text-green-600" aria-hidden="true"></i>
Local User Accounts
</h2>
<p class="text-sm text-gray-500 mt-0.5">
Email/password accounts created directly on this server.
</p>
</div>
<button
type="button"
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-plus mr-1" aria-hidden="true"></i> New Account
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="Local user accounts">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-if="localUsersLoading">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading…
</td>
</tr>
</template>
<template x-if="!localUsersLoading && localUsers.length === 0">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
No local accounts yet.
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">Create one.</button>
</td>
</tr>
</template>
<template x-for="lu in localUsers" :key="lu.id">
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-mono text-gray-800" x-text="lu.username"></td>
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.email"></td>
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.display_name || '—'"></td>
<td class="px-4 py-3 text-sm text-center">
<span
:class="lu.is_active ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_active ? 'Active' : 'Unverified'"
></span>
</td>
<td class="px-4 py-3 text-sm text-center">
<span
:class="lu.is_admin ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_admin ? 'Admin' : 'User'"
></span>
</td>
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap" x-text="lu.created_at ? formatDate(lu.created_at) : '—'"></td>
<td class="px-4 py-3 text-sm text-right">
<button
type="button"
@click="confirmDeleteLocalUser(lu)"
class="text-red-600 hover:text-red-800 focus:outline-none"
:aria-label="`Delete account for ${lu.username}`"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ── Create local user modal ────────────────────────────────────────────── -->
<div
x-show="localUserModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="create-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="localUserModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">Create Local Account</h2>
<button type="button" @click="localUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitCreateLocalUser" class="px-6 py-5 space-y-4">
<div>
<label for="lu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="email" id="lu-email" x-model="localUserModal.form.email" required autocomplete="off"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true">
</div>
<div>
<label for="lu-username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="text" id="lu-username" x-model="localUserModal.form.username" required autocomplete="off"
pattern="^[a-zA-Z0-9_-]+$" minlength="3" maxlength="64"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-username-hint">
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">364 characters. Letters, numbers, hyphens and underscores only.</p>
</div>
<div>
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
<input type="text" id="lu-display-name" x-model="localUserModal.form.display_name" autocomplete="off" maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;">
</div>
<div>
<label for="lu-password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="password" id="lu-password" x-model="localUserModal.form.password" required autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-password-hint">
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="lu-is-admin" x-model="localUserModal.form.is_admin"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
<label for="lu-is-admin" class="text-sm text-gray-700">Grant admin privileges</label>
</div>
<div x-show="localUserModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
role="alert" aria-live="assertive" x-text="localUserModal.error">
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="localUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit" :disabled="localUserModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500 disabled:opacity-50">
<span x-show="!localUserModal.saving">Create Account</span>
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Creating…</span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Delete local user confirmation modal ───────────────────────────────── -->
<div
x-show="deleteLocalUserModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteLocalUserModal.open = false">
<div class="px-6 py-4 border-b">
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">Delete Local Account</h2>
</div>
<div class="px-6 py-5">
<p class="text-sm text-gray-700">
Are you sure you want to delete the account for
<strong class="font-mono" x-text="deleteLocalUserModal.username"></strong>
(<span class="font-mono" x-text="deleteLocalUserModal.email"></span>)?
</p>
<p class="text-sm text-gray-500 mt-2">This cannot be undone. Documents owned by this user are <strong>not</strong> deleted.</p>
</div>
<div class="px-6 py-4 border-t flex justify-end gap-3">
<button type="button" @click="deleteLocalUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="button" @click="executeDeleteLocalUser()" :disabled="deleteLocalUserModal.deleting"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">
<span x-show="!deleteLocalUserModal.deleting">Delete Account</span>
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting…</span>
</button>
</div>
</div>
</div>
<!-- ── Delete confirmation modal ──────────────────────────────────────────── --> <!-- ── Delete confirmation modal ──────────────────────────────────────────── -->
<div <div
x-show="deleteModal.open" x-show="deleteModal.open"
@@ -472,6 +699,7 @@ function adminUsersApp() {
subscription_tier: 'free', subscription_tier: 'free',
subscription_billing_cycle: 'monthly', subscription_billing_cycle: 'monthly',
subscription_period_start: null, subscription_period_start: null,
is_complimentary: false,
}, },
// Delete modal // Delete modal
@@ -481,6 +709,23 @@ function adminUsersApp() {
deleting: false, deleting: false,
}, },
// Local users
localUsers: [],
localUsersLoading: true,
localUserModal: {
open: false,
saving: false,
error: '',
form: { email: '', username: '', display_name: '', password: '', is_admin: false },
},
deleteLocalUserModal: {
open: false,
id: null,
username: '',
email: '',
deleting: false,
},
// Alert // Alert
alert: { show: false, type: 'success', title: '', message: '' }, alert: { show: false, type: 'success', title: '', message: '' },
@@ -491,7 +736,7 @@ function adminUsersApp() {
}, },
async init() { async init() {
await this.fetchUsers(1); await Promise.all([this.fetchUsers(1), this.fetchLocalUsers()]);
}, },
async fetchUsers(page) { async fetchUsers(page) {
@@ -524,7 +769,7 @@ function adminUsersApp() {
openCreateModal() { openCreateModal() {
this.isCreate = true; this.isCreate = true;
this.modalTitle = 'Add User Profile'; 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; this.modalOpen = true;
}, },
@@ -541,6 +786,7 @@ function adminUsersApp() {
subscription_tier: user.subscription_tier || 'free', subscription_tier: user.subscription_tier || 'free',
subscription_billing_cycle: user.subscription_billing_cycle || 'monthly', subscription_billing_cycle: user.subscription_billing_cycle || 'monthly',
subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null, subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null,
is_complimentary: !!user.is_complimentary,
}; };
this.modalOpen = true; this.modalOpen = true;
}, },
@@ -555,6 +801,7 @@ function adminUsersApp() {
notes: this.form.notes || null, notes: this.form.notes || null,
is_blocked: !!this.form.is_blocked, is_blocked: !!this.form.is_blocked,
subscription_tier: this.form.subscription_tier || 'free', subscription_tier: this.form.subscription_tier || 'free',
is_complimentary: !!this.form.is_complimentary,
}; };
const uid = encodeURIComponent(this.form.user_id); const uid = encodeURIComponent(this.form.user_id);
const resp = await fetch(`/api/admin/users/${uid}`, { const resp = await fetch(`/api/admin/users/${uid}`, {
@@ -633,6 +880,90 @@ function adminUsersApp() {
this.alert = { show: true, type, title, message }; this.alert = { show: true, type, title, message };
setTimeout(() => { this.alert.show = false; }, type === 'success' ? 5000 : 10000); setTimeout(() => { this.alert.show = false; }, type === 'success' ? 5000 : 10000);
}, },
async fetchLocalUsers() {
this.localUsersLoading = true;
try {
const resp = await fetch('/api/admin/users/local', {
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) {
this.showAlert('error', 'Failed to load local users', resp.statusText);
return;
}
this.localUsers = await resp.json();
} catch (e) {
this.showAlert('error', 'Network error', e.message);
} finally {
this.localUsersLoading = false;
}
},
openCreateLocalUserModal() {
this.localUserModal.form = { email: '', username: '', display_name: '', password: '', is_admin: false };
this.localUserModal.error = '';
this.localUserModal.saving = false;
this.localUserModal.open = true;
},
async submitCreateLocalUser() {
this.localUserModal.error = '';
this.localUserModal.saving = true;
try {
const resp = await fetch('/api/admin/users/local', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify(this.localUserModal.form),
});
if (resp.ok) {
this.localUserModal.open = false;
this.showAlert('success', 'Account created', `Local account for "${this.localUserModal.form.username}" was created successfully.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.localUserModal.error = err.detail || 'Failed to create account.';
}
} catch (e) {
this.localUserModal.error = 'Network error: ' + e.message;
} finally {
this.localUserModal.saving = false;
}
},
confirmDeleteLocalUser(lu) {
this.deleteLocalUserModal.id = lu.id;
this.deleteLocalUserModal.username = lu.username;
this.deleteLocalUserModal.email = lu.email;
this.deleteLocalUserModal.deleting = false;
this.deleteLocalUserModal.open = true;
},
async executeDeleteLocalUser() {
this.deleteLocalUserModal.deleting = true;
try {
const resp = await fetch(`/api/admin/users/local/${this.deleteLocalUserModal.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (resp.status === 204) {
this.deleteLocalUserModal.open = false;
this.showAlert('success', 'Deleted', `Account for "${this.deleteLocalUserModal.username}" has been removed.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
this.deleteLocalUserModal.open = false;
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.deleteLocalUserModal.open = false;
} finally {
this.deleteLocalUserModal.deleting = false;
}
},
}; };
} }
</script> </script>
+2 -1
View File
@@ -34,7 +34,8 @@
</head> </head>
<body class="bg-gray-50 min-h-screen flex flex-col" <body class="bg-gray-50 min-h-screen flex flex-col"
data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}"> data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}"
data-allow-signup="{{ 'true' if allow_signup else 'false' }}">
<!-- Skip to main content link for keyboard/screen reader users --> <!-- Skip to main content link for keyboard/screen reader users -->
<a href="#main-content" class="skip-link">Skip to main content</a> <a href="#main-content" class="skip-link">Skip to main content</a>
+135 -4
View File
@@ -1,12 +1,143 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<div class="container mx-auto px-4 py-8"> <div class="{% if multi_user_enabled and not is_logged_in %}bg-gray-50 min-h-screen{% else %}container mx-auto px-4 py-8{% endif %}">
{% 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) #}
{# ══════════════════════════════════════════════════════════════════════════ #}
<!-- Hero -->
<div class="bg-gradient-to-br from-blue-700 via-indigo-700 to-purple-700 text-white py-20 px-4">
<div class="max-w-4xl mx-auto text-center">
<span class="inline-block bg-white/20 text-white text-xs font-semibold uppercase tracking-widest px-3 py-1 rounded-full mb-4">
Intelligent Document Processing
</span>
<h1 class="text-4xl sm:text-5xl font-extrabold mb-4 leading-tight">
From upload to insight — automatically.
</h1>
<p class="text-indigo-100 text-lg max-w-2xl mx-auto mb-8">
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.
</p>
<div class="flex flex-wrap justify-center gap-4">
{% if allow_signup %}
<a href="/signup"
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Get Started — it's free
</a>
{% else %}
<a href="/login"
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i> Log In
</a>
{% endif %}
<a href="/pricing"
class="bg-transparent border-2 border-white text-white hover:bg-white hover:text-indigo-700 font-bold py-3 px-8 rounded-xl transition duration-200 flex items-center min-h-[44px]">
<i class="fas fa-tags mr-2" aria-hidden="true"></i> View Plans &amp; Pricing
</a>
</div>
</div>
</div>
<!-- Feature grid -->
<div class="max-w-6xl mx-auto px-4 py-16">
<h2 class="text-2xl font-bold text-center text-gray-800 mb-10">Everything you need for smart document workflows</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
<div class="h-12 w-12 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-file-alt text-blue-600 text-xl" aria-hidden="true"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 mb-1">OCR &amp; Text Extraction</h3>
<p class="text-gray-500 text-sm">Azure Document Intelligence converts scanned PDFs and images into fully searchable text automatically.</p>
</div>
</div>
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
<div class="h-12 w-12 rounded-xl bg-indigo-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-brain text-indigo-600 text-xl" aria-hidden="true"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 mb-1">AI Metadata Extraction</h3>
<p class="text-gray-500 text-sm">OpenAI, Claude, Gemini, and other pluggable AI providers classify documents and pull out key fields like dates, amounts, and subjects.</p>
</div>
</div>
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
<div class="h-12 w-12 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-cloud-upload-alt text-green-600 text-xl" aria-hidden="true"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 mb-1">Multi-Cloud Storage</h3>
<p class="text-gray-500 text-sm">Route processed files to Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, Paperless NGX, WebDAV, FTP/SFTP, and more.</p>
</div>
</div>
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
<div class="h-12 w-12 rounded-xl bg-yellow-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-mail-bulk text-yellow-600 text-xl" aria-hidden="true"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 mb-1">Email &amp; IMAP Ingestion</h3>
<p class="text-gray-500 text-sm">Automatically pull documents from Gmail or any IMAP mailbox — no manual uploads needed.</p>
</div>
</div>
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
<div class="h-12 w-12 rounded-xl bg-purple-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-search text-purple-600 text-xl" aria-hidden="true"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 mb-1">Full-Text Search</h3>
<p class="text-gray-500 text-sm">Instantly find any document by content, metadata, or tags across your entire archive.</p>
</div>
</div>
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
<div class="h-12 w-12 rounded-xl bg-pink-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-project-diagram text-pink-600 text-xl" aria-hidden="true"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 mb-1">Custom Pipelines</h3>
<p class="text-gray-500 text-sm">Build processing pipelines with configurable steps — OCR, AI extraction, format conversion, and storage routing in any order.</p>
</div>
</div>
</div>
</div>
<!-- CTA banner -->
<div class="bg-indigo-700 text-white py-14 px-4">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-3xl font-extrabold mb-4">Ready to elevate your document workflow?</h2>
<p class="text-indigo-200 mb-8">Join teams already automating their document processing with DocuElevate.</p>
<div class="flex flex-wrap justify-center gap-4">
{% if allow_signup %}
<a href="/signup"
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
<i class="fas fa-rocket mr-2" aria-hidden="true"></i> Create a free account
</a>
{% else %}
<a href="/login"
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i> Log In
</a>
{% endif %}
<a href="/pricing"
class="bg-transparent border-2 border-white text-white hover:bg-white hover:text-indigo-700 font-bold py-3 px-8 rounded-xl transition duration-200 flex items-center min-h-[44px]">
<i class="fas fa-tags mr-2" aria-hidden="true"></i> See pricing
</a>
</div>
</div>
</div>
{% elif multi_user_enabled %}
{# ══════════════════════════════════════════════════════════════════════════ #}
{# MULTI-USER / SAAS DASHBOARD (logged-in user) #}
{# ══════════════════════════════════════════════════════════════════════════ #} {# ══════════════════════════════════════════════════════════════════════════ #}
<!-- Hero --> <!-- Hero -->
+2 -2
View File
@@ -14,7 +14,7 @@
Choose the plan that's right for you Choose the plan that's right for you
</h1> </h1>
<p class="text-indigo-100 text-lg max-w-2xl mx-auto"> <p class="text-indigo-100 text-lg max-w-2xl mx-auto">
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.
</p> </p>
<!-- Annual / Monthly toggle (cosmetic — actual billing handled separately) --> <!-- Annual / Monthly toggle (cosmetic — actual billing handled separately) -->
@@ -318,7 +318,7 @@
<td class="px-4 py-3 text-center text-xs text-gray-500">Community</td> <td class="px-4 py-3 text-center text-xs text-gray-500">Community</td>
<td class="px-4 py-3 text-center text-xs text-gray-700">Email</td> <td class="px-4 py-3 text-center text-xs text-gray-700">Email</td>
<td class="px-4 py-3 text-center text-xs text-gray-700 font-medium">Priority email</td> <td class="px-4 py-3 text-center text-xs text-gray-700 font-medium">Priority email</td>
<td class="px-4 py-3 text-center text-xs text-indigo-700 font-bold">Dedicated</td> <td class="px-4 py-3 text-center text-xs text-indigo-700 font-bold">Priority</td>
</tr> </tr>
</tbody> </tbody>
+6 -1
View File
@@ -50,7 +50,12 @@
}) })
}); });
if (resp.ok) { 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 { } else {
const data = await resp.json(); const data = await resp.json();
this.error = data.detail || 'Registration failed. Please try again.'; this.error = data.detail || 'Registration failed. Please try again.';
+1 -1
View File
@@ -42,7 +42,7 @@
{% if tier_id == 'free' %}fa-seedling text-gray-500 {% if tier_id == 'free' %}fa-seedling text-gray-500
{% elif tier_id == 'starter' %}fa-rocket text-blue-600 {% elif tier_id == 'starter' %}fa-rocket text-blue-600
{% elif tier_id == 'professional' %}fa-star text-indigo-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"></i> text-2xl" aria-hidden="true"></i>
</div> </div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Current Plan</p> <p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Current Plan</p>
@@ -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")
+186
View File
@@ -477,3 +477,189 @@ class TestUserProfileModel:
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
au_session.commit() au_session.commit()
au_session.rollback() 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/<id> 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
+8 -3
View File
@@ -278,8 +278,10 @@ class TestAuthEndpoint:
mock_form_data = {"username": "admin", "password": "secret123"} mock_form_data = {"username": "admin", "password": "secret123"}
mock_request.form = AsyncMock(return_value=mock_form_data) mock_request.form = AsyncMock(return_value=mock_form_data)
mock_request.session = {} 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 # Verify redirect to upload page
assert isinstance(result, RedirectResponse) assert isinstance(result, RedirectResponse)
@@ -305,8 +307,9 @@ class TestAuthEndpoint:
mock_form_data = {"username": "admin", "password": "wrong_password"} mock_form_data = {"username": "admin", "password": "wrong_password"}
mock_request.form = AsyncMock(return_value=mock_form_data) mock_request.form = AsyncMock(return_value=mock_form_data)
mock_request.session = {} 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 # Verify redirect to login with error
assert isinstance(result, RedirectResponse) assert isinstance(result, RedirectResponse)
@@ -327,8 +330,10 @@ class TestAuthEndpoint:
mock_form_data = {"username": "admin", "password": "secret123"} mock_form_data = {"username": "admin", "password": "secret123"}
mock_request.form = AsyncMock(return_value=mock_form_data) mock_request.form = AsyncMock(return_value=mock_form_data)
mock_request.session = {"redirect_after_login": "/protected/page"} 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 # Verify redirect to saved URL
assert isinstance(result, RedirectResponse) assert isinstance(result, RedirectResponse)
+128 -2
View File
@@ -208,7 +208,7 @@ def test_signup_disabled(la_client):
@pytest.mark.integration @pytest.mark.integration
def test_signup_smtp_not_configured(la_client): def test_signup_smtp_not_configured(la_client):
"""POST /api/auth/signup returns 503 when SMTP is not configured.""" """POST /api/auth/signup succeeds without SMTP and activates the account immediately."""
with patch("app.api.local_auth.settings") as mock_settings: with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True mock_settings.multi_user_enabled = True
@@ -222,7 +222,10 @@ def test_signup_smtp_not_configured(la_client):
"password_confirm": "password1", "password_confirm": "password1",
}, },
) )
assert resp.status_code == 503 assert resp.status_code == 201
data = resp.json()
assert data["email_verification_required"] is False
assert "now log in" in data["message"]
@pytest.mark.integration @pytest.mark.integration
@@ -266,6 +269,7 @@ def test_signup_success(la_client):
) )
assert resp.status_code == 201 assert resp.status_code == 201
assert "Verification email sent" in resp.json()["message"] assert "Verification email sent" in resp.json()["message"]
assert resp.json()["email_verification_required"] is True
mock_send.assert_called_once() mock_send.assert_called_once()
@@ -675,3 +679,125 @@ async def test_single_user_mode_skips_local_user_table(la_session, active_user):
# Admin path sets is_admin=True and id="admin" # Admin path sets is_admin=True and id="admin"
assert mock_request.session["user"]["is_admin"] is True assert mock_request.session["user"]["is_admin"] is True
assert mock_request.session["user"]["id"] == "admin" assert mock_request.session["user"]["id"] == "admin"
# ---------------------------------------------------------------------------
# Integration tests: admin local user management
# ---------------------------------------------------------------------------
@pytest.fixture()
def admin_session_client(la_engine):
"""TestClient with admin access via dependency override."""
from app.api.admin_users import _require_admin
from app.main import app
Session = sessionmaker(bind=la_engine)
def override_get_db():
db = Session()
try:
yield db
finally:
db.close()
def override_require_admin():
return {"id": "admin@example.com", "is_admin": True, "display_name": "Admin"}
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[_require_admin] = override_require_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=True) as client:
yield client
app.dependency_overrides.pop(get_db, None)
app.dependency_overrides.pop(_require_admin, None)
@pytest.mark.integration
def test_admin_list_local_users_empty(admin_session_client):
"""GET /api/admin/users/local returns an empty list when no local users exist."""
resp = admin_session_client.get("/api/admin/users/local")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.integration
def test_admin_create_local_user(admin_session_client, la_session):
"""POST /api/admin/users/local creates a new active local user."""
resp = admin_session_client.post(
"/api/admin/users/local",
json={
"email": "newuser@example.com",
"username": "newuser",
"password": "password1",
"is_admin": False,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "newuser@example.com"
assert data["username"] == "newuser"
assert data["is_active"] is True
assert data["is_admin"] is False
user = la_session.query(LocalUser).filter(LocalUser.email == "newuser@example.com").first()
assert user is not None
assert user.is_active is True
@pytest.mark.integration
def test_admin_create_local_user_duplicate_email(admin_session_client, active_user):
"""POST /api/admin/users/local returns 409 when email already exists."""
resp = admin_session_client.post(
"/api/admin/users/local",
json={
"email": "active@example.com",
"username": "differentuser",
"password": "password1",
},
)
assert resp.status_code == 409
@pytest.mark.integration
def test_admin_create_local_user_duplicate_username(admin_session_client, active_user):
"""POST /api/admin/users/local returns 409 when username already taken."""
resp = admin_session_client.post(
"/api/admin/users/local",
json={
"email": "different@example.com",
"username": "activeuser",
"password": "password1",
},
)
assert resp.status_code == 409
@pytest.mark.integration
def test_admin_delete_local_user(admin_session_client, la_session, active_user):
"""DELETE /api/admin/users/local/{id} removes the account."""
user_id = active_user.id
resp = admin_session_client.delete(f"/api/admin/users/local/{user_id}")
assert resp.status_code == 204
user = la_session.query(LocalUser).filter(LocalUser.id == user_id).first()
assert user is None
@pytest.mark.integration
def test_admin_delete_local_user_not_found(admin_session_client):
"""DELETE /api/admin/users/local/{id} returns 404 for unknown ID."""
resp = admin_session_client.delete("/api/admin/users/local/99999")
assert resp.status_code == 404
@pytest.mark.integration
def test_admin_local_user_list_after_create(admin_session_client):
"""GET /api/admin/users/local returns the created user."""
admin_session_client.post(
"/api/admin/users/local",
json={"email": "listed@example.com", "username": "listeduser", "password": "password1"},
)
resp = admin_session_client.get("/api/admin/users/local")
assert resp.status_code == 200
users = resp.json()
assert any(u["email"] == "listed@example.com" for u in users)
+9 -3
View File
@@ -107,7 +107,7 @@ def test_free_tier_has_no_mailboxes():
@pytest.mark.unit @pytest.mark.unit
def test_business_tier_has_highest_limits(): 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"] t = TIERS["business"]
# lifetime: no hard cap (0 = unlimited) # lifetime: no hard cap (0 = unlimited)
assert t["lifetime_file_limit"] == 0 assert t["lifetime_file_limit"] == 0
@@ -121,9 +121,15 @@ def test_business_tier_has_highest_limits():
assert t["max_file_size_mb"] == 0 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 @pytest.mark.unit
def test_mailbox_limits_increase_by_tier(): 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["free"]["max_mailboxes"] == 0
assert TIERS["starter"]["max_mailboxes"] == 1 assert TIERS["starter"]["max_mailboxes"] == 1
assert TIERS["professional"]["max_mailboxes"] == 3 assert TIERS["professional"]["max_mailboxes"] == 3
@@ -144,7 +150,7 @@ def test_free_tier_has_no_trial():
@pytest.mark.unit @pytest.mark.unit
def test_pricing_order(): 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["starter"]["price_monthly"] < TIERS["professional"]["price_monthly"]
assert TIERS["professional"]["price_monthly"] < TIERS["business"]["price_monthly"] assert TIERS["professional"]["price_monthly"] < TIERS["business"]["price_monthly"]
+21
View File
@@ -610,6 +610,27 @@ class TestSignupNotificationFromAuth:
mock_notify.assert_not_called() mock_notify.assert_not_called()
def test_no_signup_notification_for_new_admin_user(self, mocker):
"""Admin users never receive a signup notification even on first login."""
mock_notify = mocker.patch("app.utils.notification.notify_user_signup")
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
from app.auth import _ensure_user_profile
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None # no existing profile
user_data = {
"sub": "admin-user-sub",
"name": "Admin User",
"email": "admin@example.com",
}
_ensure_user_profile(mock_db, user_data, is_admin=True)
mock_notify.assert_not_called()
mock_dispatch.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests: webhook events listed via the API include new user events # Tests: webhook events listed via the API include new user events