Merge pull request #492 from christianlouis/copilot/fix-pricing-page-issues

fix: merge main, resolve test failures, and patch CodeQL CWE-312 sensitive data logging
This commit is contained in:
Christian Krakau-Louis
2026-03-07 18:34:30 +01:00
committed by GitHub
28 changed files with 4374 additions and 29 deletions
+4
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router
from app.api.azure import router as azure_router
from app.api.billing import router as billing_router
from app.api.database import router as database_router
from app.api.diagnostic import router as diagnostic_router
from app.api.dropbox import router as dropbox_router
@@ -15,6 +16,7 @@ from app.api.duplicates import router as duplicates_router
from app.api.files import router as files_router
from app.api.google_drive import router as google_drive_router
from app.api.logs import router as logs_router
from app.api.onboarding import router as onboarding_router
from app.api.onedrive import router as onedrive_router
from app.api.openai import router as openai_router
from app.api.pipelines import router as pipelines_router
@@ -61,4 +63,6 @@ router.include_router(webhooks_router)
router.include_router(database_router)
router.include_router(subscriptions_router)
router.include_router(plans_router)
router.include_router(onboarding_router)
router.include_router(billing_router)
router.include_router(pipelines_router)
+418
View File
@@ -0,0 +1,418 @@
"""Stripe billing integration for DocuElevate.
Provides three endpoints:
- POST /api/billing/create-checkout-session — starts Stripe Checkout for a plan upgrade
- POST /api/billing/create-portal-session — opens Stripe Customer Portal (manage/cancel)
- POST /api/billing/webhook — handles Stripe webhook events
- GET /api/billing/success — success landing page after checkout
Stripe Python SDK license: MIT (compatible with this project's Apache 2.0 license).
GDPR: Stripe acts as a data processor under a Data Processing Agreement (DPA).
Stripe is SOC 2 Type II certified and supports EU data residency.
SOC2: Stripe is SOC 2 Type II certified.
EU VAT: Configure Stripe Tax in the Stripe Dashboard for automatic VAT collection.
"""
import json
import logging
import pathlib
from datetime import datetime, timezone
from typing import Any
import stripe
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.models import SubscriptionPlan, UserProfile
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/billing", tags=["billing"])
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
_templates = Jinja2Templates(directory=str(_templates_dir))
def _get_stripe() -> stripe.StripeClient | None:
"""Return a configured Stripe client, or None when not configured."""
if not settings.stripe_secret_key:
return None
return stripe.StripeClient(settings.stripe_secret_key)
def _get_or_create_stripe_customer(
client: stripe.StripeClient,
db: Session,
owner_id: str,
email: str | None,
name: str | None,
) -> str:
"""Return the Stripe customer_id for *owner_id*, creating one if needed.
Args:
client: Configured Stripe client.
db: Database session.
owner_id: Stable user identifier.
email: User's email for the Stripe customer record.
name: User's display name for the Stripe customer record.
Returns:
The Stripe customer ID string.
"""
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if profile and profile.stripe_customer_id:
return profile.stripe_customer_id
customer = client.customers.create(
params={
"email": email or "",
"name": name or "",
"metadata": {"docuelevate_user_id": owner_id},
}
)
if profile:
profile.stripe_customer_id = customer.id
db.commit()
return customer.id
class CheckoutSessionBody(BaseModel):
"""Request body for creating a Stripe Checkout session."""
plan_id: str
billing_cycle: str = "monthly" # "monthly" | "yearly"
class PortalSessionBody(BaseModel):
"""Request body for creating a Stripe Customer Portal session."""
return_url: str | None = None
@router.post("/create-checkout-session", summary="Create a Stripe Checkout session for a plan upgrade")
@require_login
async def create_checkout_session(
request: Request,
body: CheckoutSessionBody,
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Create a Stripe Checkout session.
The client should redirect the user to the returned ``checkout_url``.
Raises:
503: Stripe is not configured.
404: Plan not found or has no Stripe price configured.
"""
client = _get_stripe()
if not client:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing is not configured.")
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == body.plan_id).first()
if plan is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan {body.plan_id!r} not found.")
price_id = plan.stripe_price_id_yearly if body.billing_cycle == "yearly" else plan.stripe_price_id_monthly
if not price_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=(
f"Stripe price ID not configured for plan {body.plan_id!r} ({body.billing_cycle}). "
"Please set it in the Admin Plan Designer."
),
)
user = request.session.get("user") or {}
owner_id = get_current_owner_id(request) or user.get("email") or ""
email = user.get("email")
name = user.get("name")
customer_id = _get_or_create_stripe_customer(client, db, owner_id, email, name)
base = str(request.base_url).rstrip("/")
success_url = settings.stripe_success_url or f"{base}/api/billing/success"
cancel_url = settings.stripe_cancel_url or f"{base}/pricing"
trial_days = plan.trial_days if plan.trial_days > 0 else None
session_params: dict[str, Any] = {
"customer": customer_id,
"mode": "subscription",
"line_items": [{"price": price_id, "quantity": 1}],
"success_url": success_url + "?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": cancel_url,
"subscription_data": {
"metadata": {
"docuelevate_user_id": owner_id,
"plan_id": body.plan_id,
"billing_cycle": body.billing_cycle,
},
},
"metadata": {"docuelevate_user_id": owner_id, "plan_id": body.plan_id},
"allow_promotion_codes": True,
"billing_address_collection": "auto",
"tax_id_collection": {"enabled": True},
"automatic_tax": {"enabled": True},
}
if trial_days:
session_params["subscription_data"]["trial_period_days"] = trial_days
checkout_session = client.checkout.sessions.create(params=session_params)
logger.info(
"Created Stripe checkout session %s for user %s plan %s",
checkout_session.id,
owner_id,
body.plan_id,
)
return {"checkout_url": checkout_session.url, "session_id": checkout_session.id}
@router.post("/create-portal-session", summary="Create a Stripe Customer Portal session")
@require_login
async def create_portal_session(
request: Request,
body: PortalSessionBody,
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Create a Stripe Customer Portal session for subscription self-management.
Raises:
503: Stripe not configured.
404: No Stripe customer found for this user.
"""
client = _get_stripe()
if not client:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing is not configured.")
user = request.session.get("user") or {}
owner_id = get_current_owner_id(request) or user.get("email") or ""
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if not profile or not profile.stripe_customer_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No billing account found. Please subscribe to a plan first.",
)
base = str(request.base_url).rstrip("/")
return_url = body.return_url or f"{base}/subscription"
portal = client.billing_portal.sessions.create(
params={
"customer": profile.stripe_customer_id,
"return_url": return_url,
}
)
logger.info("Created Stripe portal session for user %s", owner_id)
return {"portal_url": portal.url}
@router.post("/webhook", include_in_schema=False)
async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, str]:
"""Handle Stripe webhook events.
Syncs subscription status to UserProfile.subscription_tier.
Events handled:
- ``checkout.session.completed`` — activate subscription after payment
- ``customer.subscription.updated`` — sync tier change
- ``customer.subscription.deleted`` — downgrade to free on cancellation
- ``invoice.payment_failed`` — log failed payment
"""
if not settings.stripe_secret_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing not configured.")
payload = await request.body()
sig_header = request.headers.get("stripe-signature", "")
try:
if settings.stripe_webhook_secret:
event = stripe.Webhook.construct_event(payload, sig_header, settings.stripe_webhook_secret)
else:
logger.warning(
"[SECURITY] STRIPE_WEBHOOK_SECRET is not configured. "
"Webhook events are accepted without signature verification. "
"Set STRIPE_WEBHOOK_SECRET in production to prevent spoofed events."
)
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
except stripe.SignatureVerificationError:
logger.warning("[SECURITY] Stripe webhook signature verification failed")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid webhook signature.")
except Exception as exc:
logger.warning("Failed to parse Stripe webhook: %s", exc)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid webhook payload.")
_handle_stripe_event(db, event)
return {"status": "ok"}
@router.get("/success", include_in_schema=False)
@require_login
async def billing_success(request: Request) -> Any:
"""Show a success page after a completed Stripe Checkout."""
return _templates.TemplateResponse("billing_success.html", {"request": request})
def _handle_stripe_event(db: Session, event: Any) -> None:
"""Dispatch Stripe event to the appropriate handler.
Args:
db: Database session.
event: Parsed Stripe event object.
"""
etype = event.get("type", "") if isinstance(event, dict) else getattr(event, "type", "")
data_obj = (
event.get("data", {}).get("object", {})
if isinstance(event, dict)
else getattr(getattr(event, "data", None), "object", {})
)
if etype == "checkout.session.completed":
_on_checkout_completed(db, data_obj)
elif etype == "customer.subscription.updated":
_on_subscription_updated(db, data_obj)
elif etype == "customer.subscription.deleted":
_on_subscription_deleted(db, data_obj)
elif etype == "invoice.payment_failed":
customer_id = data_obj.get("customer", "") if isinstance(data_obj, dict) else getattr(data_obj, "customer", "")
logger.warning("Stripe invoice payment failed for customer %s", customer_id)
else:
logger.debug("Unhandled Stripe event type: %s", etype)
def _resolve_user_id_from_customer(db: Session, customer_id: str) -> str | None:
"""Look up the DocuElevate user_id for a Stripe customer_id.
Args:
db: Database session.
customer_id: Stripe customer ID.
Returns:
The matching ``UserProfile.user_id``, or ``None`` if not found.
"""
profile = db.query(UserProfile).filter(UserProfile.stripe_customer_id == customer_id).first()
return profile.user_id if profile else None
def _resolve_plan_id_from_price(db: Session, price_id: str) -> str | None:
"""Map a Stripe price_id to a DocuElevate plan_id via SubscriptionPlan.
Args:
db: Database session.
price_id: Stripe price ID.
Returns:
The matching ``SubscriptionPlan.plan_id``, or ``None`` if not found.
"""
plan = (
db.query(SubscriptionPlan)
.filter(
(SubscriptionPlan.stripe_price_id_monthly == price_id)
| (SubscriptionPlan.stripe_price_id_yearly == price_id)
)
.first()
)
return plan.plan_id if plan else None
def _on_checkout_completed(db: Session, data: Any) -> None:
"""Activate a subscription after a successful checkout.
Args:
db: Database session.
data: Stripe ``checkout.session`` object.
"""
meta = data.get("metadata") or {} if isinstance(data, dict) else getattr(data, "metadata", {}) or {}
user_id = meta.get("docuelevate_user_id") if isinstance(meta, dict) else getattr(meta, "docuelevate_user_id", None)
plan_id = meta.get("plan_id") if isinstance(meta, dict) else getattr(meta, "plan_id", None)
billing_cycle = (
meta.get("billing_cycle", "monthly") if isinstance(meta, dict) else getattr(meta, "billing_cycle", "monthly")
)
if not user_id:
return
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile and plan_id:
profile.subscription_tier = plan_id
profile.subscription_billing_cycle = billing_cycle
profile.subscription_period_start = datetime.now(tz=timezone.utc)
customer_id = data.get("customer", "") if isinstance(data, dict) else getattr(data, "customer", "")
if customer_id:
profile.stripe_customer_id = customer_id
db.commit()
logger.info("Activated plan %s/%s after checkout", plan_id, billing_cycle)
def _on_subscription_updated(db: Session, data: Any) -> None:
"""Sync tier change when a subscription is updated.
Args:
db: Database session.
data: Stripe ``customer.subscription`` object.
"""
customer_id = data.get("customer", "") if isinstance(data, dict) else getattr(data, "customer", "")
user_id = _resolve_user_id_from_customer(db, customer_id)
if not user_id:
return
items_data = data.get("items") or {} if isinstance(data, dict) else getattr(data, "items", None) or {}
items = items_data.get("data") or [] if isinstance(items_data, dict) else getattr(items_data, "data", []) or []
if not items:
return
first_item = items[0]
price_obj = (
first_item.get("price") or {} if isinstance(first_item, dict) else getattr(first_item, "price", {}) or {}
)
price_id = price_obj.get("id") if isinstance(price_obj, dict) else getattr(price_obj, "id", None)
if not price_id:
return
plan_id = _resolve_plan_id_from_price(db, price_id)
if not plan_id:
logger.warning("Unknown Stripe price_id %s on subscription.updated", price_id)
return
recurring = (
price_obj.get("recurring", {}) if isinstance(price_obj, dict) else getattr(price_obj, "recurring", {}) or {}
)
interval = (
recurring.get("interval", "month") if isinstance(recurring, dict) else getattr(recurring, "interval", "month")
)
billing_cycle = "yearly" if interval == "year" else "monthly"
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile:
profile.subscription_tier = plan_id
profile.subscription_billing_cycle = billing_cycle
db.commit()
logger.info("Updated subscription to %s/%s", plan_id, billing_cycle)
def _on_subscription_deleted(db: Session, data: Any) -> None:
"""Downgrade user to free tier after subscription cancellation.
Args:
db: Database session.
data: Stripe ``customer.subscription`` object.
"""
customer_id = data.get("customer", "") if isinstance(data, dict) else getattr(data, "customer", "")
user_id = _resolve_user_id_from_customer(db, customer_id)
if not user_id:
return
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile:
profile.subscription_tier = "free"
profile.subscription_billing_cycle = "monthly"
db.commit()
logger.info("Downgraded user %s to free tier after subscription cancellation", user_id)
+321
View File
@@ -0,0 +1,321 @@
"""Local user authentication API — signup, email verification, password reset.
Provides the REST endpoints and page routes for the self-registration flow:
- GET /signup — signup page (HTML)
- POST /api/auth/signup — create account + send verification email
- GET /verify-email — activate account from email link (redirect)
- GET /verify-email-sent — confirmation landing page (HTML)
- POST /api/auth/resend-verification — re-send verification email
- POST /api/auth/request-password-reset — start password reset
- POST /api/auth/reset-password — set new password using token
- GET /reset-password — password reset form page (HTML)
"""
import logging
import pathlib
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from starlette.responses import RedirectResponse
from app.config import settings
from app.database import get_db
from app.models import LocalUser, UserProfile
from app.utils.local_auth import (
build_session_user,
generate_token,
hash_password,
is_token_expired,
send_password_reset_email,
send_verification_email,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["local-auth"])
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
templates = Jinja2Templates(directory=str(_templates_dir))
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class SignupBody(BaseModel):
"""Body for the signup endpoint."""
email: str = Field(..., max_length=255)
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)
password_confirm: str
class ResendVerificationBody(BaseModel):
"""Body for the resend-verification endpoint."""
email: str
class PasswordResetRequestBody(BaseModel):
"""Body for the request-password-reset endpoint."""
email: str
class PasswordResetBody(BaseModel):
"""Body for the reset-password endpoint."""
token: str
new_password: str = Field(..., min_length=8, max_length=128)
new_password_confirm: str
# ---------------------------------------------------------------------------
# Page routes (return HTML)
# ---------------------------------------------------------------------------
@router.get("/signup", include_in_schema=False)
async def signup_page(request: Request) -> Any:
"""Render the signup page, or redirect to login when multi-user / signup is disabled."""
if not settings.multi_user_enabled:
return RedirectResponse(url="/login?error=Multi-user+mode+is+not+enabled", status_code=302)
if not settings.allow_local_signup:
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
return templates.TemplateResponse(
"signup.html",
{
"request": request,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
@router.get("/verify-email-sent", include_in_schema=False)
async def verify_email_sent_page(request: Request) -> Any:
"""Render the verify-email-sent confirmation page."""
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
@router.get("/reset-password", include_in_schema=False)
async def reset_password_page(request: Request) -> Any:
"""Render the password reset form page."""
token = request.query_params.get("token", "")
return templates.TemplateResponse(
"password_reset_form.html",
{
"request": request,
"token": token,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
# ---------------------------------------------------------------------------
# API endpoints (return JSON or redirect)
# ---------------------------------------------------------------------------
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]:
"""Create a new local user account and send a verification email.
The account is inactive until the user clicks the email link.
Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``.
Raises:
403: Multi-user mode or local signup is disabled.
503: SMTP is not configured.
422: Passwords do not match.
409: Email or username already registered.
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.")
if not settings.allow_local_signup:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
if not settings.email_host:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email (SMTP) must be configured before local signup can be enabled.",
)
if body.password != body.password_confirm:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
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.")
token = generate_token()
user = LocalUser(
email=body.email,
username=body.username,
display_name=body.display_name,
hashed_password=hash_password(body.password),
is_active=False,
email_verification_token=token,
email_verification_sent_at=datetime.now(tz=timezone.utc),
)
db.add(user)
profile = UserProfile(
user_id=body.email,
display_name=body.display_name or body.username,
)
db.add(profile)
# Flush to the DB so constraint violations (duplicate key etc.) surface NOW,
# before we attempt to send the email. We do NOT commit yet — the commit only
# happens after the email is sent successfully so that a failed email leaves
# no orphan records in the database.
try:
db.flush()
except Exception:
db.rollback()
raise
base_url = str(request.base_url).rstrip("/")
try:
send_verification_email(body.email, body.username, token, base_url)
except Exception as exc:
# Email failed — roll back so no unverifiable user row persists.
# The user can simply try registering again once SMTP is fixed.
db.rollback()
logger.warning("Signup email failed for %s: %s", body.email, exc)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."),
) from exc
db.commit()
logger.info("New local user registered: %s", body.email)
return {"message": "Verification email sent. Please check your inbox."}
@router.get("/verify-email", include_in_schema=False)
async def verify_email(request: Request, db: DbSession) -> Any:
"""Activate a local user account from the email verification link.
Redirects to the login page on failure, or to onboarding/upload on success.
"""
token = request.query_params.get("token", "")
user = db.query(LocalUser).filter(LocalUser.email_verification_token == token).first()
if not user:
return RedirectResponse(
url="/login?error=Invalid+or+expired+verification+link",
status_code=302,
)
if is_token_expired(user.email_verification_sent_at):
return RedirectResponse(
url="/login?error=Verification+link+has+expired.+Please+request+a+new+one",
status_code=302,
)
user.is_active = True
user.email_verification_token = None
user.email_verification_sent_at = None
# Ensure profile exists
if not db.query(UserProfile).filter(UserProfile.user_id == user.email).first():
db.add(UserProfile(user_id=user.email, display_name=user.display_name or user.username))
db.commit()
request.session["user"] = build_session_user(user)
logger.info("[SECURITY] EMAIL_VERIFIED user=%s", user.email)
profile = db.query(UserProfile).filter(UserProfile.user_id == user.email).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=302)
return RedirectResponse(url="/upload", status_code=302)
@router.post("/api/auth/resend-verification")
async def resend_verification(request: Request, body: ResendVerificationBody, db: DbSession) -> dict[str, str]:
"""Re-send the verification email for a pending account.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if not user or user.is_active:
return {"message": "Verification email resent if account exists."}
token = generate_token()
user.email_verification_token = token
user.email_verification_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_verification_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Failed to resend verification email to %s: %s", user.email, exc)
return {"message": "Verification email resent if account exists."}
@router.post("/api/auth/request-password-reset")
async def request_password_reset(request: Request, body: PasswordResetRequestBody, db: DbSession) -> dict[str, str]:
"""Send a password reset email.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if not user:
return {"message": "Password reset email sent if account exists."}
token = generate_token()
user.password_reset_token = token
user.password_reset_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_password_reset_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Failed to send password reset email to %s: %s", user.email, exc)
return {"message": "Password reset email sent if account exists."}
@router.post("/api/auth/reset-password")
async def reset_password(body: PasswordResetBody, db: DbSession) -> dict[str, str]:
"""Set a new password using a valid reset token.
Raises:
400: Token is invalid or expired.
422: Passwords do not match.
"""
user = db.query(LocalUser).filter(LocalUser.password_reset_token == body.token).first()
if not user or is_token_expired(user.password_reset_sent_at):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token.",
)
if body.new_password != body.new_password_confirm:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Passwords do not match.",
)
user.hashed_password = hash_password(body.new_password)
user.password_reset_token = None
user.password_reset_sent_at = None
db.commit()
logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email)
return {"message": "Password updated successfully."}
+231
View File
@@ -0,0 +1,231 @@
"""API endpoints for the user onboarding wizard.
Provides a REST interface for the multi-step onboarding flow, allowing
authenticated users to set their profile, choose a subscription plan,
select a storage destination, and mark onboarding as complete.
"""
import logging
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import UserProfile
from app.utils.subscription import TIERS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/onboarding", tags=["onboarding"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_current_user_id(request: Request) -> str:
"""Extract the stable user_id from the session using the same priority as _ensure_user_profile.
Priority: sub → preferred_username → email → id.
Raises:
HTTPException: 401 if the user is not authenticated.
"""
user = request.session.get("user")
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return user_id
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ProfileBody(BaseModel):
"""Body for the profile step of the onboarding wizard."""
display_name: str | None = Field(default=None, max_length=255)
contact_email: str | None = Field(default=None, max_length=255)
class PlanBody(BaseModel):
"""Body for the plan step of the onboarding wizard."""
subscription_tier: str
billing_cycle: str = Field(pattern="^(monthly|yearly)$")
class StorageBody(BaseModel):
"""Body for the storage step of the onboarding wizard."""
preferred_destination: str | None = Field(default=None, max_length=50)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
"""Serialize a UserProfile to a plain dict for API responses."""
return {
"user_id": profile.user_id,
"display_name": profile.display_name,
"contact_email": profile.contact_email,
"subscription_tier": profile.subscription_tier or "free",
"subscription_billing_cycle": profile.subscription_billing_cycle or "monthly",
"preferred_destination": profile.preferred_destination,
"onboarding_completed": bool(profile.onboarding_completed),
"onboarding_completed_at": profile.onboarding_completed_at.isoformat()
if profile.onboarding_completed_at
else None,
}
def _get_or_create_profile(db: Session, user_id: str) -> UserProfile:
"""Return the UserProfile for *user_id*, creating one if it does not exist."""
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile is None:
profile = UserProfile(user_id=user_id)
db.add(profile)
db.flush()
return profile
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/status", summary="Get onboarding status for the current user")
def get_onboarding_status(request: Request, db: DbSession) -> dict[str, Any]:
"""Return whether onboarding has been completed and the current step.
The ``step`` field is a best-effort estimate: 1 for brand-new profiles,
further along when partial data has already been saved.
"""
user_id = _get_current_user_id(request)
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile is None:
return {"completed": False, "step": 1, "profile": None}
# Derive a sensible current step from saved data so the wizard can resume.
step = 1
if profile.display_name or profile.contact_email:
step = 2
if profile.subscription_tier and profile.subscription_tier != "free":
step = 3
if profile.preferred_destination:
step = 4
if profile.onboarding_completed:
step = 5
return {
"completed": bool(profile.onboarding_completed),
"step": step,
"profile": _profile_to_dict(profile),
}
@router.post("/profile", summary="Save profile step during onboarding")
def save_profile(request: Request, body: ProfileBody, db: DbSession) -> dict[str, Any]:
"""Persist the user's display name and contact email from the profile step."""
user_id = _get_current_user_id(request)
profile = _get_or_create_profile(db, user_id)
if body.display_name is not None:
profile.display_name = body.display_name
if body.contact_email is not None:
profile.contact_email = body.contact_email
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
logger.info("Onboarding: saved profile for user %s", user_id)
return _profile_to_dict(profile)
@router.post("/plan", summary="Save plan selection during onboarding")
def save_plan(request: Request, body: PlanBody, db: DbSession) -> dict[str, Any]:
"""Persist the chosen subscription tier and billing cycle from the plan step.
Raises:
HTTPException: 422 if the tier is not a recognised value.
"""
user_id = _get_current_user_id(request)
if body.subscription_tier not in TIERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
)
profile = _get_or_create_profile(db, user_id)
profile.subscription_tier = body.subscription_tier
profile.subscription_billing_cycle = body.billing_cycle
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
logger.info("Onboarding: saved plan %s/%s", body.subscription_tier, body.billing_cycle)
return _profile_to_dict(profile)
@router.post("/storage", summary="Save storage preference during onboarding")
def save_storage(request: Request, body: StorageBody, db: DbSession) -> dict[str, Any]:
"""Persist the user's preferred storage destination from the storage step."""
user_id = _get_current_user_id(request)
profile = _get_or_create_profile(db, user_id)
profile.preferred_destination = body.preferred_destination
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
logger.info("Onboarding: saved storage preference '%s' for user %s", body.preferred_destination, user_id)
return _profile_to_dict(profile)
@router.post("/complete", summary="Mark onboarding as completed")
def complete_onboarding(request: Request, db: DbSession) -> dict[str, Any]:
"""Set onboarding_completed=True, record the completion timestamp, and return the post-onboarding redirect URL.
The redirect URL is read from ``request.session["post_onboarding_redirect"]`` (stored by
``oauth_callback`` when it reroutes a first-time user to the wizard) and defaults to
``/upload`` when the session key is absent.
"""
user_id = _get_current_user_id(request)
profile = _get_or_create_profile(db, user_id)
profile.onboarding_completed = True
profile.onboarding_completed_at = datetime.now(tz=timezone.utc)
try:
db.commit()
except Exception:
db.rollback()
raise
redirect_url = request.session.pop("post_onboarding_redirect", "/upload")
logger.info("Onboarding: completed for user %s, redirecting to %s", user_id, redirect_url)
return {"success": True, "redirect_url": redirect_url}
+106 -12
View File
@@ -5,16 +5,26 @@ import pathlib
from functools import wraps
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Request, status
from fastapi import APIRouter, Depends, Request, status
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from starlette.responses import RedirectResponse
from app.config import settings
from app.database import get_db
oauth = OAuth()
# Conditional imports: only used when multi_user_enabled=True. Imported here at
# module level (not inside auth()) so they don't incur repeated import overhead.
# Guards at call-sites ensure they are never *called* in single-user mode.
from app.models import LocalUser as _LocalUser
from app.models import UserProfile as _UserProfile
from app.utils.local_auth import build_session_user as _build_session_user
from app.utils.local_auth import verify_password as _verify_password
logger = logging.getLogger(__name__)
oauth = OAuth()
AUTH_ENABLED = settings.auth_enabled
# Set up templates for authentication
@@ -91,7 +101,7 @@ def get_gravatar_url(email):
async def login(request: Request):
"""Show login page with appropriate authentication options"""
"""Show login page with appropriate authentication options."""
return templates.TemplateResponse(
"login.html",
{
@@ -100,8 +110,10 @@ async def login(request: Request):
"message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED,
"oauth_provider_name": OAUTH_PROVIDER_NAME,
"app_version": settings.version, # Changed from app_version to version
"app_version": settings.version,
"csrf_token": getattr(request.state, "csrf_token", ""),
# "Create account" link is only shown when multi-user mode AND local signup are both enabled
"allow_signup": settings.multi_user_enabled and settings.allow_local_signup,
},
)
@@ -115,7 +127,40 @@ async def oauth_login(request: Request):
return await oauth.authentik.authorize_redirect(request, redirect_uri)
async def oauth_callback(request: Request):
def _ensure_user_profile(db: Session, user_data: dict) -> None:
"""Create a UserProfile row for *user_data* if one does not yet exist.
Uses the same identifier priority as ``get_current_owner_id`` (sub →
preferred_username → email → id) so that the profile's ``user_id`` matches
``FileRecord.owner_id`` for every document the user uploads.
If a profile already exists it is left unchanged; only missing profiles
are created so that admin-managed settings (tier, limits, etc.) are
preserved across logins.
"""
from app.models import UserProfile
user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
)
if not user_id:
logger.warning("Cannot create UserProfile: no stable user identifier in OAuth userinfo")
return
try:
existing = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if existing is None:
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
profile = UserProfile(user_id=user_id, display_name=display_name)
db.add(profile)
db.commit()
logger.info("Auto-created UserProfile for user_id=%s", user_id)
except Exception:
db.rollback()
logger.exception("Failed to auto-create UserProfile for user_id=%s", user_id)
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
"""Handle OAuth callback from provider"""
try:
token = await oauth.authentik.authorize_access_token(request)
@@ -147,8 +192,22 @@ async def oauth_callback(request: Request):
request.session["user"] = user_data
# Auto-create or update UserProfile so the user appears in admin user management
_ensure_user_profile(db, user_data)
# Log the successful authentication
logger.info(f"[SECURITY] OAUTH_LOGIN_SUCCESS user={user_data.get('email', 'unknown')} admin={is_admin}")
logger.info("[SECURITY] OAUTH_LOGIN_SUCCESS user=%s admin=%s", user_data.get("email", "unknown"), is_admin)
# Redirect first-time users to onboarding
user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
)
if user_id:
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/upload")
@@ -158,14 +217,50 @@ async def oauth_callback(request: Request):
return RedirectResponse(url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND)
async def auth(request: Request):
"""Handle local username/password authentication"""
async def auth(request: Request, db: Session = Depends(get_db)):
"""Handle local username/password authentication.
In multi-user mode (``MULTI_USER_ENABLED=True``) local registered users are
checked first; if no matching LocalUser is found the request falls through to
the single admin-credential check so that single-user deployments continue to
work without any database involvement.
In single-user mode (``MULTI_USER_ENABLED=False``, the default) the LocalUser
table is never queried — only the configured ADMIN_USERNAME / ADMIN_PASSWORD
are accepted, preserving full backward compatibility.
"""
form_data = await request.form()
username = form_data.get("username")
password = form_data.get("password")
# --- LocalUser check (multi-user mode only) ---
if settings.multi_user_enabled:
local_user = (
db.query(_LocalUser).filter((_LocalUser.username == username) | (_LocalUser.email == username)).first()
)
if local_user is not None:
if not _verify_password(password or "", local_user.hashed_password):
logger.warning("[SECURITY] LOCAL_LOGIN_FAILURE user=%s", username)
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
if not local_user.is_active:
logger.warning("[SECURITY] LOCAL_LOGIN_UNVERIFIED user=%s", username)
return RedirectResponse(
url="/login?error=Please+verify+your+email+address+before+logging+in",
status_code=302,
)
user_data = _build_session_user(local_user)
request.session["user"] = user_data
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=302)
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302)
# --- Admin credentials (always available as a fallback / single-user mode) ---
if username == settings.admin_username and password == settings.admin_password:
# Create user session
request.session["user"] = {
"id": "admin",
"name": "Administrator",
@@ -174,12 +269,11 @@ async def auth(request: Request):
"picture": "/static/images/default-avatar.svg",
"is_admin": True,
}
logger.info(f"[SECURITY] LOCAL_LOGIN_SUCCESS user={username}")
# Redirect to original destination or default
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302)
else:
logger.warning(f"[SECURITY] LOCAL_LOGIN_FAILURE user={username}")
logger.warning("[SECURITY] LOCAL_LOGIN_FAILURE user=%s", username)
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
+18
View File
@@ -172,6 +172,24 @@ class Settings(BaseSettings):
authentik_config_url: Optional[str] = None
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
# Local user signup
allow_local_signup: bool = Field(
default=False,
description=(
"Allow users to self-register with email and password. "
"Has no effect unless MULTI_USER_ENABLED is also True. "
"Requires SMTP to be configured so verification emails can be sent. "
"Default: False (registration disabled — admin creates users manually)."
),
)
# Stripe billing
stripe_secret_key: Optional[str] = None
stripe_publishable_key: Optional[str] = None
stripe_webhook_secret: Optional[str] = None
stripe_success_url: Optional[str] = None # e.g. https://app.example.com/billing/success
stripe_cancel_url: Optional[str] = None # e.g. https://app.example.com/pricing
# IMAP 1
imap1_host: Optional[str] = None
imap1_port: Optional[int] = 993
+2
View File
@@ -16,6 +16,7 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from app.api import router as api_router
from app.api.local_auth import router as local_auth_router
from app.auth import router as auth_router
from app.config import settings
from app.database import init_db
@@ -260,4 +261,5 @@ def test_500():
app.include_router(frontend_router)
app.include_router(files_router) # Explicitly include the files router
app.include_router(auth_router)
app.include_router(local_auth_router)
app.include_router(api_router, prefix="/api")
+34
View File
@@ -176,6 +176,31 @@ class WebhookConfig(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class LocalUser(Base):
"""A locally-registered user authenticated by email and bcrypt password.
Created during the self-registration flow when ``allow_local_signup`` is
enabled. The account is inactive (``is_active=False``) until the user
clicks the verification link sent to their email address.
"""
__tablename__ = "local_users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
username = Column(String(64), unique=True, nullable=False, index=True)
display_name = Column(String(255), nullable=True)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, nullable=False, default=False, server_default="0")
is_admin = Column(Boolean, nullable=False, default=False, server_default="0")
email_verification_token = Column(String(128), nullable=True)
email_verification_sent_at = Column(DateTime(timezone=True), nullable=True)
password_reset_token = Column(String(128), nullable=True)
password_reset_sent_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class UserProfile(Base):
"""Per-user profile for admin-managed settings in multi-user mode.
@@ -213,6 +238,13 @@ class UserProfile(Base):
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
# Onboarding tracking (added in migration 017)
onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
contact_email = Column(String(255), nullable=True)
preferred_destination = Column(String(50), nullable=True)
stripe_customer_id = Column(String(64), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -260,6 +292,8 @@ class SubscriptionPlan(Base):
sort_order = Column(Integer, nullable=False, default=0)
features = Column(Text, nullable=True) # JSON-encoded list[str]
api_access = Column(Boolean, nullable=False, default=False)
stripe_price_id_monthly = Column(String(128), nullable=True)
stripe_price_id_yearly = Column(String(128), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+187
View File
@@ -0,0 +1,187 @@
"""Utilities for local (email/password) user authentication.
Provides password hashing (bcrypt), secure token generation, and
synchronous SMTP email helpers for account verification and password
reset flows. No external dependencies beyond bcrypt (already in
requirements.txt) and Python stdlib.
"""
import logging
import secrets
import smtplib
import socket
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import bcrypt
from app.config import settings
logger = logging.getLogger(__name__)
TOKEN_BYTES = 32 # 256 bits of entropy
TOKEN_EXPIRY_HOURS = 24 # verification + reset tokens expire after 24 h
def hash_password(plain: str) -> str:
"""Return a bcrypt hash of *plain*. Stores result as a UTF-8 string."""
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Return True when *plain* matches the stored bcrypt *hashed* string."""
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except Exception:
return False
def generate_token() -> str:
"""Return a 256-bit URL-safe random token string."""
return secrets.token_urlsafe(TOKEN_BYTES)
def is_token_expired(sent_at: datetime | None) -> bool:
"""Return True when *sent_at* is None or older than TOKEN_EXPIRY_HOURS."""
if sent_at is None:
return True
return datetime.now(tz=timezone.utc) > sent_at.astimezone(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS)
def _smtp_send(subject: str, html_body: str, plain_body: str, recipient: str) -> None:
"""Send an HTML email via the configured SMTP server.
Args:
subject: Email subject line.
html_body: HTML version of the email body.
plain_body: Plain-text version of the email body.
recipient: Recipient email address.
Raises:
RuntimeError: When SMTP is not configured or sending fails.
"""
if not settings.email_host:
raise RuntimeError("SMTP is not configured (EMAIL_HOST missing). Cannot send email.")
sender = settings.email_sender or settings.email_username or "noreply@docuelevate.local"
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = recipient
msg.attach(MIMEText(plain_body, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))
try:
socket.gethostbyname(settings.email_host)
except socket.gaierror as exc:
raise RuntimeError(f"Cannot resolve SMTP host {settings.email_host!r}: {exc}") from exc
with smtplib.SMTP(settings.email_host, settings.email_port or 587, timeout=30) as server:
if settings.email_use_tls:
server.starttls()
if settings.email_username and settings.email_password:
server.login(settings.email_username, settings.email_password)
server.send_message(msg)
logger.info("Sent %r to %s", subject, recipient)
def send_verification_email(email: str, username: str, token: str, base_url: str) -> None:
"""Send a double opt-in verification email to *email*.
Args:
email: Recipient email address.
username: The user's chosen username (used in greeting).
token: The verification token to embed in the link.
base_url: The base URL of the application (e.g. https://app.example.com).
"""
verify_url = f"{base_url}/verify-email?token={token}"
subject = "Verify your DocuElevate account"
html_body = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f5;margin:0;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
<h1 style="color:#4f46e5;font-size:24px;margin-bottom:8px;">Welcome to DocuElevate, {username}!</h1>
<p style="color:#374151;">Thanks for signing up. Please confirm your email address to activate your account.</p>
<div style="text-align:center;margin:32px 0;">
<a href="{verify_url}"
style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 32px;border-radius:8px;font-weight:600;font-size:16px;">
Confirm my email address
</a>
</div>
<p style="color:#6b7280;font-size:13px;">This link expires in 24 hours. If you did not create an account, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">DocuElevate &middot; Intelligent Document Processing</p>
</div>
</body>
</html>"""
plain_body = (
f"Welcome to DocuElevate, {username}!\n\n"
f"Please verify your email address by visiting:\n{verify_url}\n\n"
"This link expires in 24 hours."
)
_smtp_send(subject, html_body, plain_body, email)
def send_password_reset_email(email: str, username: str, token: str, base_url: str) -> None:
"""Send a password reset email to *email*.
Args:
email: Recipient email address.
username: The user's username (used in greeting).
token: The password reset token to embed in the link.
base_url: The base URL of the application.
"""
reset_url = f"{base_url}/reset-password?token={token}"
subject = "Reset your DocuElevate password"
html_body = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f5;margin:0;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
<h1 style="color:#4f46e5;font-size:24px;margin-bottom:8px;">Password Reset</h1>
<p style="color:#374151;">Hi {username}, you requested a password reset for your DocuElevate account.</p>
<div style="text-align:center;margin:32px 0;">
<a href="{reset_url}"
style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 32px;border-radius:8px;font-weight:600;font-size:16px;">
Reset my password
</a>
</div>
<p style="color:#6b7280;font-size:13px;">This link expires in 24 hours. If you did not request a password reset, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">DocuElevate &middot; Intelligent Document Processing</p>
</div>
</body>
</html>"""
plain_body = (
f"Hi {username},\n\n"
f"You requested a password reset. Visit the link below:\n{reset_url}\n\n"
"This link expires in 24 hours. If you did not request this, ignore this email."
)
_smtp_send(subject, html_body, plain_body, email)
def build_session_user(user: object) -> dict:
"""Build the session user dict for a LocalUser, matching the OAuth session format.
Args:
user: A ``LocalUser`` ORM instance.
Returns:
Dict suitable for storing in ``request.session["user"]``.
"""
from app.auth import get_gravatar_url
return {
"sub": user.email, # type: ignore[attr-defined]
"id": user.email, # type: ignore[attr-defined]
"email": user.email, # type: ignore[attr-defined]
"preferred_username": user.username, # type: ignore[attr-defined]
"name": user.display_name or user.username, # type: ignore[attr-defined]
"picture": get_gravatar_url(user.email), # type: ignore[attr-defined]
"is_admin": bool(user.is_admin), # type: ignore[attr-defined]
"auth_method": "local",
}
+54
View File
@@ -1784,6 +1784,60 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
# Local User Signup
"allow_local_signup": {
"category": "Authentication",
"description": (
"Allow users to self-register with email and password. "
"Has no effect unless MULTI_USER_ENABLED is also True. "
"Requires SMTP (EMAIL_HOST) to be configured so verification emails can be sent."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Stripe Billing
"stripe_secret_key": {
"category": "Billing",
"description": "Stripe secret API key (starts with sk_). Required for payment processing.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": False,
},
"stripe_publishable_key": {
"category": "Billing",
"description": "Stripe publishable key (starts with pk_). Exposed to the browser for Checkout.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"stripe_webhook_secret": {
"category": "Billing",
"description": "Stripe webhook signing secret (starts with whsec_). Used to verify incoming webhook payloads.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": False,
},
"stripe_success_url": {
"category": "Billing",
"description": "Absolute URL Stripe redirects to after a successful checkout (e.g. https://app.example.com/billing/success).",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"stripe_cancel_url": {
"category": "Billing",
"description": "Absolute URL Stripe redirects to when a user cancels the checkout flow (e.g. https://app.example.com/pricing).",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
}
+2
View File
@@ -13,6 +13,7 @@ from app.views.filemanager import router as filemanager_router
from app.views.general import router as general_router
from app.views.google_drive import router as google_drive_router
from app.views.license_routes import router as license_router # Add the license router
from app.views.onboarding import router as onboarding_router
from app.views.onedrive import router as onedrive_router
from app.views.pipelines import router as pipelines_router # Processing pipelines
from app.views.plans import router as plans_router # Admin Plan Designer
@@ -40,4 +41,5 @@ router.include_router(search_router)
router.include_router(queue_router)
router.include_router(subscriptions_router) # Pricing + subscription pages
router.include_router(plans_router) # Admin Plan Designer
router.include_router(onboarding_router) # User onboarding wizard
router.include_router(pipelines_router) # Processing pipelines
+91
View File
@@ -0,0 +1,91 @@
"""View route for the user onboarding wizard."""
import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.config import Settings
from app.config import settings as _settings
from app.models import UserProfile
from app.utils.subscription import get_all_tiers
from app.views.base import APIRouter, get_db, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Destination helper
# ---------------------------------------------------------------------------
_DESTINATION_META: list[dict] = [
{"id": "dropbox", "name": "Dropbox", "icon": "fab fa-dropbox"},
{"id": "gdrive", "name": "Google Drive", "icon": "fab fa-google-drive"},
{"id": "onedrive", "name": "OneDrive", "icon": "fab fa-microsoft"},
{"id": "s3", "name": "Amazon S3", "icon": "fab fa-aws"},
{"id": "nextcloud", "name": "Nextcloud", "icon": "fas fa-cloud"},
{"id": "webdav", "name": "WebDAV", "icon": "fas fa-server"},
{"id": "sftp", "name": "SFTP", "icon": "fas fa-terminal"},
{"id": "ftp", "name": "FTP", "icon": "fas fa-server"},
]
def _get_configured_destinations(cfg: Settings) -> list[dict]:
"""Return which storage providers are fully configured in the current settings.
Each entry is a dict with ``id``, ``name``, and ``icon`` keys.
Args:
cfg: The application settings object (``app.config.settings``).
Returns:
A list of destination dicts for providers that have the required
credentials set.
"""
checks: dict[str, bool] = {
"dropbox": bool(cfg.dropbox_refresh_token and cfg.dropbox_app_key),
"gdrive": bool(cfg.google_drive_credentials_json or cfg.google_drive_refresh_token),
"onedrive": bool(cfg.onedrive_refresh_token and cfg.onedrive_client_id),
"s3": bool(cfg.aws_access_key_id and cfg.s3_bucket_name),
"nextcloud": bool(cfg.nextcloud_upload_url and cfg.nextcloud_username),
"webdav": bool(cfg.webdav_url and cfg.webdav_username),
"sftp": bool(cfg.sftp_host and cfg.sftp_username),
"ftp": bool(cfg.ftp_host and cfg.ftp_username),
}
return [meta for meta in _DESTINATION_META if checks.get(meta["id"], False)]
# ---------------------------------------------------------------------------
# Route
# ---------------------------------------------------------------------------
@router.get("/onboarding", include_in_schema=False)
@require_login
async def onboarding_page(request: Request, db: Session = Depends(get_db)):
"""Render the multi-step onboarding wizard.
Redirects to ``/upload`` when the user has already completed onboarding.
"""
user = request.session.get("user") or {}
user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
if user_id:
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile and profile.onboarding_completed:
from starlette.responses import RedirectResponse
return RedirectResponse(url="/upload", status_code=302)
configured_destinations = _get_configured_destinations(_settings)
tiers = get_all_tiers(db)
return templates.TemplateResponse(
"onboarding.html",
{
"request": request,
"user": user,
"configured_destinations": configured_destinations,
"tiers": tiers,
},
)
+172
View File
@@ -0,0 +1,172 @@
# Billing Setup Guide
This guide covers how to configure Stripe billing and local user sign-up in DocuElevate.
## Table of Contents
- [Local User Sign-up](#local-user-sign-up)
- [Stripe Billing Integration](#stripe-billing-integration)
- [Prerequisites](#prerequisites)
- [Configuration](#configuration)
- [Setting Up Plans](#setting-up-plans)
- [Webhook Configuration](#webhook-configuration)
- [Billing Flows](#billing-flows)
- [Compliance Notes](#compliance-notes)
---
## Local User Sign-up
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.
### Configuration
```bash
ALLOW_LOCAL_SIGNUP=true
# SMTP (required for verification emails)
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USERNAME=noreply@example.com
EMAIL_PASSWORD=yourpassword
EMAIL_USE_TLS=true
EMAIL_SENDER=DocuElevate <noreply@example.com>
```
### Sign-up Flow
1. User visits `/signup` and fills out the registration form.
2. DocuElevate sends a verification email with a 24-hour token link.
3. User clicks the link — their account is activated and they are signed in.
4. First-time users are redirected to the onboarding wizard.
### Password Reset Flow
1. User clicks "Forgot password?" on the login page.
2. User enters their email address.
3. DocuElevate sends a password reset email with a 24-hour token link.
4. User clicks the link, enters a new password, and is redirected to sign in.
### Security
- Passwords are hashed with bcrypt (12 rounds).
- Verification and reset tokens are 256-bit URL-safe random strings.
- All tokens expire after 24 hours.
- Sign-up and login endpoints return generic error messages to prevent user enumeration.
---
## Stripe Billing Integration
DocuElevate integrates with [Stripe](https://stripe.com) to handle subscription payments. Stripe acts as a data processor under a Data Processing Agreement (DPA) and is SOC 2 Type II certified.
### Prerequisites
- A Stripe account (sign up at [stripe.com](https://stripe.com))
- Products and prices created in the Stripe Dashboard for each paid plan
- A publicly reachable webhook endpoint (or use [Stripe CLI](https://stripe.com/docs/stripe-cli) for local testing)
### Configuration
```bash
STRIPE_SECRET_KEY=sk_live_... # Your Stripe secret key
STRIPE_PUBLISHABLE_KEY=pk_live_... # Your Stripe publishable key (for frontend)
STRIPE_WEBHOOK_SECRET=whsec_... # Webhook signing secret
STRIPE_SUCCESS_URL=https://app.example.com/api/billing/success # Optional override
STRIPE_CANCEL_URL=https://app.example.com/pricing # Optional override
```
> **Security:** Never commit your Stripe secret key. Store it in your environment or secrets manager.
### Setting Up Plans
After starting DocuElevate, go to **Admin → Plans** to configure each plan:
1. Open the **Plan Designer** for a paid tier (e.g. Starter, Professional).
2. Enter the **Stripe Price ID (monthly)** from your Stripe Dashboard (e.g. `price_1OtAbc...`).
3. Optionally enter the **Stripe Price ID (yearly)** for annual billing.
4. Save the plan.
Stripe Price IDs look like `price_1OtAbcDefGhIjKlMnOpQrSt`. Find them in **Products** in your Stripe Dashboard.
### Webhook Configuration
Stripe webhooks allow DocuElevate to sync subscription status in real time.
#### Stripe Dashboard setup
1. Go to **Developers → Webhooks** in the Stripe Dashboard.
2. Click **Add endpoint**.
3. Set the endpoint URL to: `https://your-app-domain.com/api/billing/webhook`
4. Select the following events:
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
5. Copy the **Signing secret** and set `STRIPE_WEBHOOK_SECRET` in your environment.
#### Local testing with Stripe CLI
```bash
# Install Stripe CLI and log in
stripe login
# Forward webhooks to your local server
stripe listen --forward-to http://localhost:8000/api/billing/webhook
# Trigger a test event
stripe trigger checkout.session.completed
```
### Billing Flows
#### Subscribe to a plan
1. User visits `/pricing`.
2. User clicks the **CTA button** on a paid plan.
3. DocuElevate calls `POST /api/billing/create-checkout-session`.
4. User is redirected to Stripe Checkout.
5. After payment, Stripe fires `checkout.session.completed`.
6. DocuElevate webhook handler activates the subscription tier.
7. User is redirected to `/api/billing/success`.
#### Manage or cancel subscription
1. User visits their account settings.
2. DocuElevate calls `POST /api/billing/create-portal-session`.
3. User is redirected to the Stripe Customer Portal.
4. User can update payment method, upgrade, downgrade, or cancel.
5. Stripe fires `customer.subscription.updated` or `customer.subscription.deleted`.
6. DocuElevate webhook handler syncs the change.
#### Cancellation
When a subscription is cancelled, Stripe fires `customer.subscription.deleted` and DocuElevate automatically downgrades the user to the free tier.
---
## Compliance Notes
| Topic | Details |
|-------|---------|
| **GDPR** | Stripe acts as a data processor. A Data Processing Agreement (DPA) is available in the Stripe Dashboard. Stripe supports EU data residency. |
| **SOC 2** | Stripe is SOC 2 Type II certified. |
| **EU VAT** | Configure [Stripe Tax](https://stripe.com/tax) in the Stripe Dashboard for automatic VAT collection. |
| **PCI DSS** | Card data is handled entirely by Stripe. DocuElevate never sees or stores card details. |
---
## Environment Variable Reference
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `ALLOW_LOCAL_SIGNUP` | bool | `false` | Allow users to self-register with email/password |
| `STRIPE_SECRET_KEY` | string | — | Stripe API secret key |
| `STRIPE_PUBLISHABLE_KEY` | string | — | Stripe API publishable key |
| `STRIPE_WEBHOOK_SECRET` | string | — | Webhook signing secret from Stripe Dashboard |
| `STRIPE_SUCCESS_URL` | string | — | Override redirect URL after successful checkout |
| `STRIPE_CANCEL_URL` | string | — | Override redirect URL when checkout is cancelled |
See [ConfigurationGuide.md](./ConfigurationGuide.md) for the full environment variable reference.
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Subscription Activated</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-circle-check text-green-600 text-5xl" aria-hidden="true"></i>
</div>
</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">You're all set!</h1>
<p class="text-gray-600 mb-8">
Your subscription has been activated. Thank you for choosing DocuElevate!
</p>
<div class="space-y-3">
<a href="/subscription"
class="block w-full py-3 px-4 rounded-lg border-2 border-indigo-600 text-indigo-600 font-semibold hover:bg-indigo-50 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>
<i class="fas fa-credit-card mr-2" aria-hidden="true"></i>Manage subscription
</a>
<a href="/upload"
class="block w-full py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>
<i class="fas fa-arrow-right mr-2" aria-hidden="true"></i>Go to dashboard
</a>
</div>
</main>
</body>
</html>
+9
View File
@@ -78,6 +78,15 @@
Return to Home
</a>
</div>
{% if allow_signup %}
<div class="mt-4 text-center">
<span class="text-sm text-gray-600">Don't have an account?</span>
<a href="/signup" class="ml-1 text-sm font-medium text-indigo-600 hover:text-indigo-500">
Create account
</a>
</div>
{% endif %}
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
+477
View File
@@ -0,0 +1,477 @@
{% extends "base.html" %}
{% block title %}Welcome to DocuElevate{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 py-8 px-4"
x-data="onboardingWizard()" x-init="init()">
<div class="max-w-2xl mx-auto">
<!-- Brand header -->
<div class="text-center mb-8">
<div class="inline-flex items-center gap-2 text-indigo-600 font-bold text-2xl mb-2">
<i class="fas fa-file-alt" aria-hidden="true"></i>
DocuElevate
</div>
<p class="text-gray-500 text-sm">Let's get you set up</p>
</div>
<!-- Progress indicator -->
<div class="flex items-center justify-between mb-8 relative" role="list" aria-label="Onboarding steps">
<!-- Background track -->
<div class="absolute left-0 right-0 top-4 h-0.5 bg-gray-200" aria-hidden="true"></div>
<!-- Progress fill -->
<div class="absolute left-0 top-4 h-0.5 bg-indigo-500 transition-all duration-500"
:style="`width: ${progressPercent}%`" aria-hidden="true"></div>
<template x-for="(label, i) in stepLabels" :key="i">
<div class="flex flex-col items-center z-10" role="listitem">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-all duration-300 bg-white"
:class="i + 1 < step ? 'bg-indigo-600 border-indigo-600 text-white' : i + 1 === step ? 'border-indigo-600 text-indigo-600' : 'border-gray-300 text-gray-400'"
:aria-current="i + 1 === step ? 'step' : undefined">
<span x-show="i + 1 < step" aria-label="Completed">
<i class="fas fa-check text-xs" aria-hidden="true"></i>
</span>
<span x-show="i + 1 >= step" x-text="i + 1" aria-hidden="true"></span>
</div>
<span class="text-xs mt-1 hidden sm:block transition-all duration-300"
:class="i + 1 === step ? 'text-indigo-600 font-semibold' : 'text-gray-400'"
x-text="label"></span>
</div>
</template>
</div>
<!-- Wizard card -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- ================================================================
Step 1: Welcome
================================================================ -->
<div x-show="step === 1"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-white text-center">
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-hand-wave text-3xl" aria-hidden="true"></i>
</div>
<h1 class="text-3xl font-extrabold mb-2">
Welcome, {{ user.given_name | default(user.name) | default("there") }}! 👋
</h1>
<p class="text-indigo-100 text-lg">You're just a few steps away from transforming how you handle documents.</p>
</div>
<div class="p-8">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<div class="text-center p-4 rounded-xl bg-blue-50">
<i class="fas fa-magic text-blue-500 text-2xl mb-2" aria-hidden="true"></i>
<h3 class="font-semibold text-gray-800 text-sm">Smart OCR</h3>
<p class="text-gray-500 text-xs mt-1">AI extracts text from any document</p>
</div>
<div class="text-center p-4 rounded-xl bg-indigo-50">
<i class="fas fa-tags text-indigo-500 text-2xl mb-2" aria-hidden="true"></i>
<h3 class="font-semibold text-gray-800 text-sm">Auto-tagging</h3>
<p class="text-gray-500 text-xs mt-1">Documents organised automatically</p>
</div>
<div class="text-center p-4 rounded-xl bg-purple-50">
<i class="fas fa-cloud-upload-alt text-purple-500 text-2xl mb-2" aria-hidden="true"></i>
<h3 class="font-semibold text-gray-800 text-sm">Cloud Sync</h3>
<p class="text-gray-500 text-xs mt-1">Instantly backed up to your storage</p>
</div>
</div>
<p class="text-gray-500 text-sm text-center mb-6">
This quick setup takes about 2 minutes. You can change everything later in your settings.
</p>
<button @click="step++"
class="w-full py-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition min-h-[44px]">
Let's get started <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- ================================================================
Step 2: Your Profile
================================================================ -->
<div x-show="step === 2"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 px-8 py-6 text-white">
<i class="fas fa-user-circle text-4xl mb-2" aria-hidden="true"></i>
<h2 class="text-2xl font-bold">Your Profile</h2>
<p class="text-blue-100 text-sm">Tell us a little about yourself</p>
</div>
<div class="p-8">
<div class="mb-5">
<label for="displayName" class="block text-sm font-medium text-gray-700 mb-1">
Display Name
</label>
<input id="displayName"
type="text"
x-model="displayName"
placeholder="Your name"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
aria-describedby="displayNameHint" />
<p id="displayNameHint" class="text-gray-400 text-xs mt-1">This is how you'll appear in DocuElevate.</p>
</div>
<div class="mb-6">
<label for="contactEmail" class="block text-sm font-medium text-gray-700 mb-1">
Contact Email
</label>
<input id="contactEmail"
type="email"
x-model="contactEmail"
placeholder="you@example.com"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
aria-describedby="contactEmailHint" />
<p id="contactEmailHint" class="text-gray-400 text-xs mt-1">Used for notifications. Can be different from your login email.</p>
</div>
<div class="flex gap-3">
<button @click="goBack()"
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button @click="goNext()"
:disabled="loading"
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">Continue <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i></span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</div>
</div>
<!-- ================================================================
Step 3: Choose Your Plan
================================================================ -->
<div x-show="step === 3"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-purple-500 to-pink-600 px-8 py-6 text-white">
<i class="fas fa-layer-group text-4xl mb-2" aria-hidden="true"></i>
<h2 class="text-2xl font-bold">Choose Your Plan</h2>
<p class="text-purple-100 text-sm">Start free, upgrade when you're ready</p>
</div>
<div class="p-6">
<!-- Monthly / Annual toggle -->
<div class="flex items-center justify-center gap-3 mb-6" x-effect="billingCycle = annual ? 'yearly' : 'monthly'">
<span class="text-sm font-medium" :class="!annual ? 'text-indigo-600' : 'text-gray-500'">Monthly</span>
<button @click="annual = !annual"
class="relative w-12 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
:class="annual ? 'bg-indigo-600' : 'bg-gray-300'"
role="switch"
:aria-checked="annual.toString()"
aria-label="Toggle billing cycle">
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-300"
:class="annual ? 'translate-x-6' : 'translate-x-0'"></span>
</button>
<span class="text-sm font-medium" :class="annual ? 'text-indigo-600' : 'text-gray-500'">
Annual <span class="bg-green-100 text-green-700 text-xs px-1.5 py-0.5 rounded-full ml-1">2 months free</span>
</span>
</div>
<!-- Tier cards (server-rendered by Jinja2) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
{% for tier in tiers %}
<button type="button"
@click="selectedTier = '{{ tier.id }}'"
class="text-left p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
:class="selectedTier === '{{ tier.id }}' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-white'"
:aria-pressed="(selectedTier === '{{ tier.id }}').toString()">
<div class="flex items-center justify-between mb-1">
<span class="font-bold text-gray-800">{{ tier.name }}</span>
<span class="text-lg font-extrabold text-indigo-600">
{% if tier.price_monthly == 0 %}
Free
{% else %}
<span x-show="!annual">${{ tier.price_monthly }}<span class="text-xs font-normal text-gray-500">/mo</span></span>
<span x-show="annual">
{% if tier.price_yearly == 0 %}Free
{% else %}${{ "%.2f"|format(tier.price_yearly) }}<span class="text-xs font-normal text-gray-500">/yr</span>
{% endif %}
</span>
{% endif %}
</span>
</div>
<p class="text-gray-500 text-xs">{{ tier.tagline }}</p>
{% if tier.id != "free" and tier.get("trial_days") %}
<span class="inline-block mt-1.5 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
{{ tier.trial_days }}-day free trial
</span>
{% endif %}
</button>
{% endfor %}
</div>
<!-- Beta notice -->
<div class="bg-amber-50 border border-amber-200 rounded-xl p-3 mb-5 flex gap-2 text-sm text-amber-700">
<i class="fas fa-info-circle mt-0.5 shrink-0" aria-hidden="true"></i>
<span>Payment processing is coming soon. You can select a plan now and billing will activate at launch.</span>
</div>
<div class="flex gap-3">
<button @click="goBack()"
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button @click="goNext()"
:disabled="loading"
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">Continue <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i></span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</div>
</div>
<!-- ================================================================
Step 4: Storage Destination
================================================================ -->
<div x-show="step === 4"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-green-500 to-teal-600 px-8 py-6 text-white">
<i class="fas fa-hdd text-4xl mb-2" aria-hidden="true"></i>
<h2 class="text-2xl font-bold">Storage Destination</h2>
<p class="text-green-100 text-sm">Where should your processed documents go?</p>
</div>
<div class="p-8">
{% if configured_destinations %}
<p class="text-gray-600 text-sm mb-4">Select where you'd like your documents stored. You can change this later in settings.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6" role="listbox" aria-label="Storage destinations">
{% for dest in configured_destinations %}
<button type="button"
@click="selectedDestination = '{{ dest.id }}'"
role="option"
:aria-selected="(selectedDestination === '{{ dest.id }}').toString()"
class="flex items-center gap-3 p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 min-h-[44px]"
:class="selectedDestination === '{{ dest.id }}' ? 'border-green-500 bg-green-50' : 'border-gray-200 hover:border-gray-300 bg-white'">
<i class="{{ dest.icon }} text-xl text-gray-600" aria-hidden="true"></i>
<span class="font-medium text-gray-800">{{ dest.name }}</span>
<span x-show="selectedDestination === '{{ dest.id }}'" class="ml-auto text-green-600">
<i class="fas fa-check-circle" aria-hidden="true"></i>
</span>
</button>
{% endfor %}
</div>
<p class="text-gray-400 text-xs mb-4">
Not seeing your provider?
{% if user.is_admin %}
<a href="/settings" class="text-indigo-500 hover:underline">Go to Settings</a> to configure more destinations.
{% else %}
Ask your administrator to configure additional storage providers.
{% endif %}
</p>
{% else %}
<div class="text-center py-8">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-hdd text-2xl text-gray-400" aria-hidden="true"></i>
</div>
<h3 class="font-semibold text-gray-700 mb-2">No Storage Configured Yet</h3>
<p class="text-gray-500 text-sm mb-4">
No storage destinations have been configured for this instance yet.
You can skip this step and set one up later.
</p>
{% if user.is_admin %}
<a href="/settings"
class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition text-sm font-semibold min-h-[44px]">
<i class="fas fa-cog" aria-hidden="true"></i> Configure Storage
</a>
{% else %}
<p class="text-gray-400 text-xs">Contact your administrator to set up a storage destination.</p>
{% endif %}
</div>
{% endif %}
<div class="flex gap-3">
<button @click="goBack()"
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button @click="goNext()"
:disabled="loading"
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">
{% if configured_destinations %}Continue{% else %}Skip{% endif %}
<i class="fas fa-arrow-right ml-1" aria-hidden="true"></i>
</span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</div>
</div>
<!-- ================================================================
Step 5: All Set!
================================================================ -->
<div x-show="step === 5"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-white text-center">
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check-circle text-4xl" aria-hidden="true"></i>
</div>
<h2 class="text-3xl font-extrabold mb-2">You're all set! 🎉</h2>
<p class="text-indigo-100">Your account is configured and ready to go.</p>
</div>
<div class="p-8">
<ul class="space-y-3 mb-8" aria-label="Setup summary">
<li class="flex items-center gap-3 text-gray-700">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center shrink-0">
<i class="fas fa-check text-green-600 text-sm" aria-hidden="true"></i>
</div>
<span>Profile saved</span>
</li>
<li class="flex items-center gap-3 text-gray-700">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center shrink-0">
<i class="fas fa-check text-green-600 text-sm" aria-hidden="true"></i>
</div>
<span>Plan selected: <strong x-text="selectedTier"></strong></span>
</li>
<li class="flex items-center gap-3 text-gray-700">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="selectedDestination ? 'bg-green-100' : 'bg-gray-100'">
<i class="fas text-sm"
:class="selectedDestination ? 'fa-check text-green-600' : 'fa-minus text-gray-400'"
aria-hidden="true"></i>
</div>
<span x-show="selectedDestination">Storage: <strong x-text="selectedDestination"></strong></span>
<span x-show="!selectedDestination" class="text-gray-400">No storage destination selected (can be set later)</span>
</li>
</ul>
<button @click="goNext()"
:disabled="loading"
class="w-full py-3 px-6 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">
<i class="fas fa-rocket mr-2" aria-hidden="true"></i> Start uploading documents
</span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Setting up…</span>
</button>
</div>
</div>
</div><!-- /card -->
<!-- Error display -->
<div x-show="error"
x-text="error"
role="alert"
aria-live="polite"
class="mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm text-center">
</div>
</div><!-- /max-w-2xl -->
</div><!-- /min-h-screen -->
<script>
function onboardingWizard() {
return {
step: 1,
totalSteps: 5,
loading: false,
error: '',
// Form data pre-populated from Jinja2 context
displayName: '{{ user.name | default("") | e }}',
contactEmail: '{{ user.email | default("") | e }}',
selectedTier: 'free',
billingCycle: 'monthly',
annual: false,
selectedDestination: '',
get progressPercent() {
return ((this.step - 1) / (this.totalSteps - 1)) * 100;
},
stepLabels: ['Welcome', 'Your Profile', 'Your Plan', 'Storage', 'All Done!'],
async init() {
try {
const r = await fetch('/api/onboarding/status');
if (r.ok) {
const data = await r.json();
if (data.completed) {
window.location.href = '/upload';
return;
}
if (data.profile) {
if (data.profile.display_name) this.displayName = data.profile.display_name;
if (data.profile.contact_email) this.contactEmail = data.profile.contact_email;
if (data.profile.subscription_tier) this.selectedTier = data.profile.subscription_tier;
if (data.profile.subscription_billing_cycle) this.billingCycle = data.profile.subscription_billing_cycle;
if (data.profile.preferred_destination) this.selectedDestination = data.profile.preferred_destination;
}
}
} catch (_) {
// Non-fatal: proceed with defaults
}
},
async goNext() {
if (this.step === 2) await this.saveProfile();
else if (this.step === 3) await this.savePlan();
else if (this.step === 4) await this.saveStorage();
else if (this.step === 5) { await this.completeOnboarding(); return; }
if (!this.error) this.step++;
},
goBack() {
if (this.step > 1) this.step--;
},
async saveProfile() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: this.displayName, contact_email: this.contactEmail })
});
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save profile'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
},
async savePlan() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription_tier: this.selectedTier, billing_cycle: this.billingCycle })
});
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save plan'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
},
async saveStorage() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/storage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferred_destination: this.selectedDestination || null })
});
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save storage preference'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
},
async completeOnboarding() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/complete', { method: 'POST' });
if (r.ok) {
const data = await r.json();
window.location.href = data.redirect_url || '/upload';
} else { const d = await r.json(); this.error = d.detail || 'Failed to complete onboarding'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
}
};
}
</script>
{% endblock %}
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Reset Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Set a new password</h1>
<p class="text-center text-gray-500 text-sm mb-6">Enter your new password below.</p>
<div
x-data="{
token: '{{ token | default('', true) }}',
new_password: '',
new_password_confirm: '',
loading: false,
error: '',
success: false,
async submit() {
this.error = '';
if (this.new_password !== this.new_password_confirm) {
this.error = 'Passwords do not match.';
return;
}
this.loading = true;
try {
const resp = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
body: JSON.stringify({
token: this.token,
new_password: this.new_password,
new_password_confirm: this.new_password_confirm
})
});
if (resp.ok) {
this.success = true;
} else {
const data = await resp.json();
this.error = data.detail || 'Password reset failed. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="success" x-cloak class="text-center py-4">
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-check-circle text-green-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<p class="text-green-700 font-semibold mb-4">Password updated successfully!</p>
<a href="/login"
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>Sign in</a>
</div>
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700">New password <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="password" id="new_password" name="new_password" required
x-model="new_password"
autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
aria-required="true"
aria-describedby="pw-hint"
>
<p id="pw-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div>
<label for="new_password_confirm" class="block text-sm font-medium text-gray-700">Confirm new password <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="password" id="new_password_confirm" name="new_password_confirm" required
x-model="new_password_confirm"
autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
aria-required="true"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Update password</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Updating&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Back to sign in
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+59 -4
View File
@@ -2,7 +2,7 @@
{% block title %}Pricing & Plans DocuElevate{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<div class="bg-gray-50 min-h-screen" x-data="{ annual: false }">
<!-- ── Hero ──────────────────────────────────────────────────────────────── -->
<div class="bg-gradient-to-br from-blue-700 via-indigo-700 to-purple-700 text-white py-20 px-4">
@@ -18,7 +18,7 @@
</p>
<!-- Annual / Monthly toggle (cosmetic — actual billing handled separately) -->
<div class="mt-8 inline-flex items-center bg-white/10 rounded-full p-1 gap-1" x-data="{ annual: false }">
<div class="mt-8 inline-flex items-center bg-white/10 rounded-full p-1 gap-1">
<button
@click="annual = false"
:class="!annual ? 'bg-white text-indigo-700 shadow' : 'text-white'"
@@ -36,8 +36,7 @@
</div>
<!-- ── Tier cards ────────────────────────────────────────────────────────── -->
<div class="max-w-7xl mx-auto px-4 -mt-10 pb-20" x-data="{ annual: false }">
<!-- Recreate the toggle state here so cards react to the hero toggle too -->
<div class="max-w-7xl mx-auto px-4 -mt-10 pb-20">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{% for tier in tiers %}
@@ -118,6 +117,13 @@
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border-2 border-blue-600 text-blue-600 font-semibold hover:bg-blue-50 transition"
>{{ tier.cta }}</a>
{% elif tier.stripe_price_id_monthly or tier.stripe_price_id_yearly %}
<button
type="button"
onclick="startCheckout('{{ tier.id }}')"
class="block w-full text-center py-3 px-4 rounded-lg {% if tier.highlight %}bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md{% else %}bg-blue-600 text-white font-semibold hover:bg-blue-700 transition{% endif %}"
style="min-height:44px;"
>{{ tier.cta }}</button>
{% elif tier.highlight %}
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md"
@@ -378,3 +384,52 @@
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
/**
* Initiate a Stripe Checkout session for the given plan.
* Uses the currently-selected billing cycle toggle (monthly/yearly).
*
* @param {string} planId - The plan identifier (e.g. 'starter', 'professional').
*/
async function startCheckout(planId) {
const cycleEl = document.querySelector('[data-billing-cycle]');
const billingCycle = (cycleEl && cycleEl.dataset.billingCycle) ? cycleEl.dataset.billingCycle : 'monthly';
// Clear previous error
const errEl = document.getElementById('checkout-error');
if (errEl) { errEl.textContent = ''; errEl.hidden = true; }
try {
const resp = await fetch('/api/billing/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_id: planId, billing_cycle: billingCycle })
});
if (resp.status === 401) {
window.location.href = '/login?message=Please+sign+in+to+subscribe';
return;
}
if (resp.ok) {
const data = await resp.json();
if (data.checkout_url) {
window.location.href = data.checkout_url;
return;
}
}
const data = await resp.json().catch(() => ({}));
const msg = data.detail || 'Unable to start checkout. Please try again.';
if (errEl) { errEl.textContent = msg; errEl.hidden = false; }
} catch (e) {
if (errEl) { errEl.textContent = 'Network error. Please try again.'; errEl.hidden = false; }
}
}
</script>
<div id="checkout-error"
role="alert"
aria-live="assertive"
hidden
class="fixed bottom-6 left-1/2 -translate-x-1/2 bg-red-100 border border-red-400 text-red-700 px-6 py-3 rounded-lg shadow-lg text-sm z-50">
</div>
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Create Account</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Create your account</h1>
<p class="text-center text-gray-500 text-sm mb-6">Already have an account?
<a href="/login" class="text-blue-600 hover:text-blue-500 font-medium">Sign in</a>
</p>
<div
x-data="{
email: '',
username: '',
display_name: '',
password: '',
password_confirm: '',
loading: false,
error: '',
async submit() {
this.error = '';
if (this.password !== this.password_confirm) {
this.error = 'Passwords do not match.';
return;
}
this.loading = true;
try {
const resp = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
body: JSON.stringify({
email: this.email,
username: this.username,
display_name: this.display_name || null,
password: this.password,
password_confirm: this.password_confirm
})
});
if (resp.ok) {
window.location.href = '/verify-email-sent';
} else {
const data = await resp.json();
this.error = data.detail || 'Registration failed. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded"
role="alert"
aria-live="polite">
<p x-text="error"></p>
</div>
<form @submit.prevent="submit" class="space-y-4" novalidate>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email address <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="email" id="email" name="email" required autocomplete="email"
x-model="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
style="min-height:44px;"
>
</div>
<div>
<label for="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="username" name="username" required autocomplete="username"
x-model="username"
pattern="^[a-zA-Z0-9_-]+$"
minlength="3" maxlength="64"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
aria-describedby="username-hint"
style="min-height:44px;"
>
<p id="username-hint" class="mt-1 text-xs text-gray-500">364 characters. Letters, numbers, hyphens and underscores only.</p>
</div>
<div>
<label for="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="display_name" name="display_name" autocomplete="name"
x-model="display_name"
maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
style="min-height:44px;"
>
</div>
<div>
<label for="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="password" name="password" required autocomplete="new-password"
x-model="password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
aria-describedby="password-hint"
style="min-height:44px;"
>
<p id="password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700">Confirm password <span aria-hidden="true" class="text-red-500">*</span></label>
<input
type="password" id="password_confirm" name="password_confirm" required autocomplete="new-password"
x-model="password_confirm"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
aria-required="true"
style="min-height:44px;"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Create account</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Creating account&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Return to Home
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Verify Your Email</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<div class="flex justify-center mb-4">
<div class="bg-indigo-100 rounded-full p-4">
<i class="fas fa-envelope-circle-check text-indigo-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Check your inbox</h1>
<p class="text-gray-600 mb-6">
We've sent you a verification email. Please click the link in the email to activate your account.
</p>
<p class="text-sm text-gray-500 mb-8">
The link expires in 24 hours. If you don't see the email, check your spam folder.
</p>
<div
x-data="{ email: '', loading: false, message: '', error: '' }"
class="border-t border-gray-100 pt-6"
>
<p class="text-sm text-gray-600 mb-3">Didn't receive it?</p>
<div x-show="message" x-cloak
class="bg-green-100 border-l-4 border-green-500 text-green-700 p-3 mb-4 rounded text-sm"
role="status" aria-live="polite">
<p x-text="message"></p>
</div>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4 rounded text-sm"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<form
@submit.prevent="async () => {
error = ''; message = '';
if (!email) { error = 'Please enter your email address.'; return; }
loading = true;
try {
const resp = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (resp.ok) {
message = 'Verification email resent. Please check your inbox.';
} else {
error = 'Something went wrong. Please try again.';
}
} catch(e) {
error = 'Network error. Please try again.';
} finally {
loading = false;
}
}"
class="space-y-3"
novalidate
>
<div>
<label for="resend-email" class="sr-only">Email address</label>
<input
type="email" id="resend-email" name="email"
x-model="email"
placeholder="Enter your email address"
autocomplete="email"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50 text-sm"
aria-label="Email address for resend"
style="min-height:44px;"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-indigo-600 rounded-md text-sm font-medium text-indigo-600 hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Resend verification email</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Back to sign in
</a>
</div>
</main>
</body>
</html>
@@ -0,0 +1,45 @@
"""Add onboarding fields to user_profiles
Revision ID: 017_add_onboarding_fields
Revises: 016_add_userprofile_billing
Create Date: 2026-03-08
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "017_add_onboarding_fields"
down_revision: Union[str, None] = "016_add_userprofile_billing"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Add onboarding_completed, onboarding_completed_at, contact_email, preferred_destination to user_profiles."""
op.add_column(
"user_profiles",
sa.Column("onboarding_completed", sa.Boolean(), nullable=False, server_default="0"),
)
op.add_column(
"user_profiles",
sa.Column("onboarding_completed_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("contact_email", sa.String(255), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("preferred_destination", sa.String(50), nullable=True),
)
def downgrade() -> None:
"""Remove onboarding columns from user_profiles."""
op.drop_column("user_profiles", "preferred_destination")
op.drop_column("user_profiles", "contact_email")
op.drop_column("user_profiles", "onboarding_completed_at")
op.drop_column("user_profiles", "onboarding_completed")
@@ -0,0 +1,49 @@
"""Add local_users table and billing columns
Revision ID: 018_add_local_users_and_billing
Revises: 017_add_onboarding_fields, 017_add_pipelines
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "018_add_local_users_and_billing"
down_revision: Union[str, tuple] = ("017_add_onboarding_fields", "017_add_pipelines")
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create local_users table and add billing columns."""
op.create_table(
"local_users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("username", sa.String(64), nullable=False),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("email_verification_token", sa.String(128), nullable=True),
sa.Column("email_verification_sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("password_reset_token", sa.String(128), nullable=True),
sa.Column("password_reset_sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
sa.UniqueConstraint("username"),
)
op.add_column("user_profiles", sa.Column("stripe_customer_id", sa.String(64), nullable=True))
op.add_column("subscription_plans", sa.Column("stripe_price_id_monthly", sa.String(128), nullable=True))
op.add_column("subscription_plans", sa.Column("stripe_price_id_yearly", sa.String(128), nullable=True))
def downgrade() -> None:
"""Reverse the migration."""
op.drop_column("subscription_plans", "stripe_price_id_yearly")
op.drop_column("subscription_plans", "stripe_price_id_monthly")
op.drop_column("user_profiles", "stripe_customer_id")
op.drop_table("local_users")
+1
View File
@@ -44,3 +44,4 @@ pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
meilisearch>=0.31.0 # Full-text search engine client
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
+166 -13
View File
@@ -375,6 +375,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
userinfo = {
"email": "test@example.com",
@@ -388,11 +389,12 @@ class TestOAuthCallback:
with (
patch("app.auth.oauth") as mock_oauth,
patch("app.auth.settings") as mock_settings,
patch("app.auth._ensure_user_profile"),
):
mock_oauth.authentik = mock_authentik
mock_settings.admin_group_name = "admin"
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
assert isinstance(result, RedirectResponse)
assert result.status_code == status.HTTP_302_FOUND
@@ -407,6 +409,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
userinfo = {"email": "test@example.com", "name": "Test User"}
@@ -416,11 +419,12 @@ class TestOAuthCallback:
with (
patch("app.auth.oauth") as mock_oauth,
patch("app.auth.settings") as mock_settings,
patch("app.auth._ensure_user_profile"),
):
mock_oauth.authentik = mock_authentik
mock_settings.admin_group_name = "admin"
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
# Gravatar should be added
assert "picture" in mock_request.session["user"]
@@ -433,6 +437,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
userinfo = {
"email": "test@example.com",
@@ -446,11 +451,12 @@ class TestOAuthCallback:
with (
patch("app.auth.oauth") as mock_oauth,
patch("app.auth.settings") as mock_settings,
patch("app.auth._ensure_user_profile"),
):
mock_oauth.authentik = mock_authentik
mock_settings.admin_group_name = "admin"
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
# Custom picture should be preserved, not replaced with Gravatar
assert mock_request.session["user"]["picture"] == "https://example.com/custom-pic.jpg"
@@ -462,6 +468,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
userinfo = {
"email": "admin@example.com",
@@ -475,11 +482,12 @@ class TestOAuthCallback:
with (
patch("app.auth.oauth") as mock_oauth,
patch("app.auth.settings") as mock_settings,
patch("app.auth._ensure_user_profile"),
):
mock_oauth.authentik = mock_authentik
mock_settings.admin_group_name = "admin"
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
# User should be marked as admin
assert mock_request.session["user"]["is_admin"] is True
@@ -491,6 +499,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
userinfo = {
"email": "user@example.com",
@@ -504,11 +513,12 @@ class TestOAuthCallback:
with (
patch("app.auth.oauth") as mock_oauth,
patch("app.auth.settings") as mock_settings,
patch("app.auth._ensure_user_profile"),
):
mock_oauth.authentik = mock_authentik
mock_settings.admin_group_name = "admin"
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
# User should not be marked as admin
assert mock_request.session["user"]["is_admin"] is False
@@ -520,6 +530,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
mock_authentik = MagicMock()
mock_authentik.authorize_access_token = AsyncMock(return_value={})
@@ -527,7 +538,7 @@ class TestOAuthCallback:
with patch("app.auth.oauth") as mock_oauth:
mock_oauth.authentik = mock_authentik
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
assert isinstance(result, RedirectResponse)
assert "/login?error=Failed+to+retrieve+user+information" in result.headers["location"]
@@ -539,6 +550,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {"redirect_after_login": "/protected/page"}
mock_db = MagicMock()
userinfo = {"email": "test@example.com", "name": "Test User"}
@@ -548,11 +560,12 @@ class TestOAuthCallback:
with (
patch("app.auth.oauth") as mock_oauth,
patch("app.auth.settings") as mock_settings,
patch("app.auth._ensure_user_profile"),
):
mock_oauth.authentik = mock_authentik
mock_settings.admin_group_name = "admin"
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
assert isinstance(result, RedirectResponse)
assert result.headers["location"] == "/protected/page"
@@ -566,6 +579,7 @@ class TestOAuthCallback:
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
mock_authentik = MagicMock()
mock_authentik.authorize_access_token = AsyncMock(side_effect=Exception("OAuth error"))
@@ -573,19 +587,158 @@ class TestOAuthCallback:
with patch("app.auth.oauth") as mock_oauth:
mock_oauth.authentik = mock_authentik
result = await oauth_callback(mock_request)
result = await oauth_callback(mock_request, db=mock_db)
assert isinstance(result, RedirectResponse)
assert "/login?error=Authentication+failed" in result.headers["location"]
@pytest.mark.asyncio
async def test_oauth_callback_creates_user_profile(self):
"""Test OAuth callback auto-creates a UserProfile for the authenticated user."""
from app.auth import oauth_callback
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
userinfo = {
"sub": "oauth-sub-abc123",
"email": "new@example.com",
"name": "New User",
"preferred_username": "newuser",
}
mock_authentik = MagicMock()
mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo})
with (
patch("app.auth.oauth") as mock_oauth,
patch("app.auth.settings") as mock_settings,
patch("app.auth._ensure_user_profile") as mock_ensure,
):
mock_oauth.authentik = mock_authentik
mock_settings.admin_group_name = "admin"
await oauth_callback(mock_request, db=mock_db)
# _ensure_user_profile should be called with the db and user_data
mock_ensure.assert_called_once()
call_args = mock_ensure.call_args
assert call_args[0][0] is mock_db
assert call_args[0][1]["sub"] == "oauth-sub-abc123"
@pytest.mark.unit
class TestEnsureUserProfile:
"""Tests for _ensure_user_profile() helper."""
def test_creates_profile_for_new_user(self):
"""New user_id should insert a UserProfile row."""
from app.auth import _ensure_user_profile
from app.models import UserProfile
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
user_data = {
"sub": "sub-xyz",
"email": "alice@example.com",
"name": "Alice",
"preferred_username": "alice",
}
_ensure_user_profile(mock_db, user_data)
mock_db.add.assert_called_once()
added_profile = mock_db.add.call_args[0][0]
assert isinstance(added_profile, UserProfile)
assert added_profile.user_id == "sub-xyz"
assert added_profile.display_name == "Alice"
mock_db.commit.assert_called_once()
def test_skips_existing_profile(self):
"""Existing profile should not be overwritten."""
from app.auth import _ensure_user_profile
from app.models import UserProfile
mock_db = MagicMock()
existing = UserProfile(user_id="sub-xyz", display_name="Old Name")
mock_db.query.return_value.filter.return_value.first.return_value = existing
user_data = {"sub": "sub-xyz", "name": "New Name"}
_ensure_user_profile(mock_db, user_data)
mock_db.add.assert_not_called()
mock_db.commit.assert_not_called()
def test_uses_preferred_username_fallback(self):
"""Falls back to preferred_username when sub is absent."""
from app.auth import _ensure_user_profile
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
user_data = {"preferred_username": "bob", "name": "Bob"}
_ensure_user_profile(mock_db, user_data)
added_profile = mock_db.add.call_args[0][0]
assert added_profile.user_id == "bob"
def test_uses_email_fallback(self):
"""Falls back to email when sub and preferred_username are absent."""
from app.auth import _ensure_user_profile
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
user_data = {"email": "carol@example.com", "name": "Carol"}
_ensure_user_profile(mock_db, user_data)
added_profile = mock_db.add.call_args[0][0]
assert added_profile.user_id == "carol@example.com"
def test_no_op_when_no_identifier(self):
"""Does nothing and logs a warning when no identifier is found."""
from app.auth import _ensure_user_profile
mock_db = MagicMock()
_ensure_user_profile(mock_db, {})
mock_db.add.assert_not_called()
mock_db.commit.assert_not_called()
def test_handles_db_exception_gracefully(self):
"""DB errors are caught; a rollback is issued and no exception propagates."""
from app.auth import _ensure_user_profile
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_db.commit.side_effect = Exception("DB error")
# Should not raise
_ensure_user_profile(mock_db, {"sub": "sub-error-test"})
mock_db.rollback.assert_called_once()
@pytest.mark.unit
class TestAuthFunction:
"""Tests for auth() function (local authentication)."""
def _make_mock_db(self):
"""Create a mock DB that returns None for LocalUser queries (no local users)."""
mock_db = MagicMock()
# query().filter().first() returns None → no LocalUser found
mock_db.query.return_value.filter.return_value.first.return_value = None
return mock_db
@pytest.mark.asyncio
async def test_auth_success(self):
"""Test successful local authentication."""
"""Test successful local authentication (admin fallback)."""
from app.auth import auth
mock_request = MagicMock(spec=Request)
@@ -597,7 +750,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass"
result = await auth(mock_request)
result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
@@ -620,7 +773,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass"
result = await auth(mock_request)
result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse)
assert "/login?error=Invalid+username+or+password" in result.headers["location"]
@@ -640,7 +793,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass"
result = await auth(mock_request)
result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse)
assert "/login?error=Invalid+username+or+password" in result.headers["location"]
@@ -659,7 +812,7 @@ class TestAuthFunction:
mock_settings.admin_username = "testadmin"
mock_settings.admin_password = "testpass"
result = await auth(mock_request)
result = await auth(mock_request, db=self._make_mock_db())
assert isinstance(result, RedirectResponse)
assert result.headers["location"] == "/settings"
+3
View File
@@ -270,6 +270,7 @@ class TestAuthEndpoint:
with patch("app.auth.settings") as mock_settings:
mock_settings.admin_username = "admin"
mock_settings.admin_password = "secret123"
mock_settings.multi_user_enabled = False
from app.auth import auth
@@ -296,6 +297,7 @@ class TestAuthEndpoint:
with patch("app.auth.settings") as mock_settings:
mock_settings.admin_username = "admin"
mock_settings.admin_password = "secret123"
mock_settings.multi_user_enabled = False
from app.auth import auth
@@ -317,6 +319,7 @@ class TestAuthEndpoint:
with patch("app.auth.settings") as mock_settings:
mock_settings.admin_username = "admin"
mock_settings.admin_password = "secret123"
mock_settings.multi_user_enabled = False
from app.auth import auth
+486
View File
@@ -0,0 +1,486 @@
"""Tests for the Stripe billing API endpoints.
Covers:
- POST /api/billing/create-checkout-session
- POST /api/billing/create-portal-session
- POST /api/billing/webhook (all event types)
- GET /api/billing/success
- Internal helpers: _handle_stripe_event, _on_checkout_completed,
_on_subscription_updated, _on_subscription_deleted
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.billing import (
_handle_stripe_event,
_on_checkout_completed,
_on_subscription_deleted,
_on_subscription_updated,
_resolve_plan_id_from_price,
_resolve_user_id_from_customer,
)
from app.database import Base, get_db
from app.models import SubscriptionPlan, UserProfile
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def bill_engine():
"""In-memory SQLite engine for billing tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def bill_session(bill_engine):
"""DB session for one test."""
Session = sessionmaker(bind=bill_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def bill_client(bill_engine):
"""TestClient with DB dependency overridden and a logged-in session."""
from app.main import app
Session = sessionmaker(bind=bill_engine)
def override_get_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.pop(get_db, None)
@pytest.fixture()
def starter_plan(bill_session):
"""A SubscriptionPlan with Stripe price IDs in the DB."""
plan = SubscriptionPlan(
plan_id="starter",
name="Starter",
price_monthly=9.0,
price_yearly=90.0,
trial_days=0,
stripe_price_id_monthly="price_monthly_starter",
stripe_price_id_yearly="price_yearly_starter",
)
bill_session.add(plan)
bill_session.commit()
return plan
@pytest.fixture()
def user_profile(bill_session):
"""A UserProfile for user1@example.com."""
profile = UserProfile(
user_id="user1@example.com",
display_name="Test User",
stripe_customer_id=None,
)
bill_session.add(profile)
bill_session.commit()
return profile
# ---------------------------------------------------------------------------
# Tests: _get_stripe returns None when not configured
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_get_stripe_returns_none_when_not_configured():
"""_get_stripe returns None when stripe_secret_key is not set."""
from app.api.billing import _get_stripe
with patch("app.api.billing.settings") as mock_settings:
mock_settings.stripe_secret_key = None
result = _get_stripe()
assert result is None
@pytest.mark.unit
def test_get_stripe_returns_client_when_configured():
"""_get_stripe returns a StripeClient when key is configured."""
from app.api.billing import _get_stripe
with patch("app.api.billing.settings") as mock_settings:
mock_settings.stripe_secret_key = "sk_test_fake"
result = _get_stripe()
assert result is not None
# ---------------------------------------------------------------------------
# Tests: create-checkout-session
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_create_checkout_session_stripe_not_configured(bill_client):
"""POST /api/billing/create-checkout-session returns 503 when Stripe not set."""
with patch("app.api.billing._get_stripe", return_value=None):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "starter", "billing_cycle": "monthly"},
)
assert resp.status_code == 503
@pytest.mark.integration
def test_create_checkout_session_plan_not_found(bill_client):
"""POST /api/billing/create-checkout-session returns 404 for unknown plan."""
mock_client = MagicMock()
with patch("app.api.billing._get_stripe", return_value=mock_client):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "nonexistent", "billing_cycle": "monthly"},
)
assert resp.status_code == 404
@pytest.mark.integration
def test_create_checkout_session_no_price_id(bill_client, bill_session):
"""POST /api/billing/create-checkout-session returns 404 when price ID not set."""
plan = SubscriptionPlan(
plan_id="noprice",
name="No Price",
price_monthly=5.0,
price_yearly=50.0,
trial_days=0,
stripe_price_id_monthly=None,
stripe_price_id_yearly=None,
)
bill_session.add(plan)
bill_session.commit()
mock_client = MagicMock()
with patch("app.api.billing._get_stripe", return_value=mock_client):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "noprice", "billing_cycle": "monthly"},
)
assert resp.status_code == 404
@pytest.mark.integration
def test_create_checkout_session_success(bill_client, starter_plan, user_profile):
"""POST /api/billing/create-checkout-session returns checkout_url on success."""
mock_client = MagicMock()
mock_customer = MagicMock()
mock_customer.id = "cus_test123"
mock_session = MagicMock()
mock_session.id = "cs_test456"
mock_session.url = "https://checkout.stripe.com/test"
mock_client.customers.create.return_value = mock_customer
mock_client.checkout.sessions.create.return_value = mock_session
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "starter", "billing_cycle": "monthly"},
)
assert resp.status_code == 200
data = resp.json()
assert "checkout_url" in data
assert data["checkout_url"] == "https://checkout.stripe.com/test"
@pytest.mark.integration
def test_create_checkout_session_yearly(bill_client, starter_plan, user_profile):
"""POST /api/billing/create-checkout-session uses yearly price ID for yearly cycle."""
mock_client = MagicMock()
mock_customer = MagicMock()
mock_customer.id = "cus_test123"
mock_session = MagicMock()
mock_session.id = "cs_test456"
mock_session.url = "https://checkout.stripe.com/yearly"
mock_client.customers.create.return_value = mock_customer
mock_client.checkout.sessions.create.return_value = mock_session
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post(
"/api/billing/create-checkout-session",
json={"plan_id": "starter", "billing_cycle": "yearly"},
)
assert resp.status_code == 200
# Verify yearly price ID was used
call_params = mock_client.checkout.sessions.create.call_args[1]["params"]
assert call_params["line_items"][0]["price"] == "price_yearly_starter"
# ---------------------------------------------------------------------------
# Tests: create-portal-session
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_create_portal_session_stripe_not_configured(bill_client):
"""POST /api/billing/create-portal-session returns 503 when not configured."""
with patch("app.api.billing._get_stripe", return_value=None):
resp = bill_client.post("/api/billing/create-portal-session", json={})
assert resp.status_code == 503
@pytest.mark.integration
def test_create_portal_session_no_customer(bill_client, user_profile):
"""POST /api/billing/create-portal-session returns 404 when no Stripe customer."""
mock_client = MagicMock()
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post("/api/billing/create-portal-session", json={})
assert resp.status_code == 404
@pytest.mark.integration
def test_create_portal_session_success(bill_client, bill_session, user_profile):
"""POST /api/billing/create-portal-session returns portal_url on success."""
user_profile.stripe_customer_id = "cus_existing"
bill_session.commit()
mock_client = MagicMock()
mock_portal = MagicMock()
mock_portal.url = "https://billing.stripe.com/portal/test"
mock_client.billing_portal.sessions.create.return_value = mock_portal
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"),
):
resp = bill_client.post("/api/billing/create-portal-session", json={})
assert resp.status_code == 200
assert resp.json()["portal_url"] == "https://billing.stripe.com/portal/test"
# ---------------------------------------------------------------------------
# Tests: webhook
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_webhook_not_configured(bill_client):
"""POST /api/billing/webhook returns 503 when billing not configured."""
with patch("app.api.billing.settings") as mock_settings:
mock_settings.stripe_secret_key = None
mock_settings.stripe_webhook_secret = None
resp = bill_client.post(
"/api/billing/webhook",
content=b"{}",
headers={"content-type": "application/json"},
)
assert resp.status_code == 503
@pytest.mark.integration
def test_webhook_invalid_signature(bill_client):
"""POST /api/billing/webhook returns 400 on invalid Stripe signature."""
import stripe
with (
patch("app.api.billing.settings") as mock_settings,
patch("stripe.Webhook.construct_event", side_effect=stripe.SignatureVerificationError("bad", "sig")),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = "whsec_test"
resp = bill_client.post(
"/api/billing/webhook",
content=b'{"type":"test"}',
headers={"stripe-signature": "bad_sig", "content-type": "application/json"},
)
assert resp.status_code == 400
@pytest.mark.integration
def test_webhook_checkout_completed(bill_client, bill_session, starter_plan, user_profile):
"""POST /api/billing/webhook activates plan on checkout.session.completed."""
payload = json.dumps(
{
"type": "checkout.session.completed",
"data": {
"object": {
"customer": "cus_new",
"metadata": {
"docuelevate_user_id": "user1@example.com",
"plan_id": "starter",
"billing_cycle": "monthly",
},
}
},
}
).encode()
with (
patch("app.api.billing.settings") as mock_settings,
patch("stripe.Event.construct_from", return_value=json.loads(payload)),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = None
resp = bill_client.post(
"/api/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp.status_code == 200
bill_session.expire_all()
profile = bill_session.query(UserProfile).filter(UserProfile.user_id == "user1@example.com").first()
assert profile.subscription_tier == "starter"
@pytest.mark.integration
def test_webhook_subscription_deleted(bill_client, bill_session, user_profile):
"""POST /api/billing/webhook downgrades to free on subscription deleted."""
user_profile.stripe_customer_id = "cus_del"
user_profile.subscription_tier = "starter"
bill_session.commit()
payload = json.dumps(
{
"type": "customer.subscription.deleted",
"data": {"object": {"customer": "cus_del"}},
}
).encode()
with (
patch("app.api.billing.settings") as mock_settings,
patch("stripe.Event.construct_from", return_value=json.loads(payload)),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = None
resp = bill_client.post(
"/api/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp.status_code == 200
bill_session.expire_all()
profile = bill_session.query(UserProfile).filter(UserProfile.user_id == "user1@example.com").first()
assert profile.subscription_tier == "free"
# ---------------------------------------------------------------------------
# Unit tests: internal helpers
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_resolve_user_id_from_customer(bill_session, user_profile):
"""_resolve_user_id_from_customer returns user_id for known Stripe customer."""
user_profile.stripe_customer_id = "cus_known"
bill_session.commit()
result = _resolve_user_id_from_customer(bill_session, "cus_known")
assert result == "user1@example.com"
@pytest.mark.unit
def test_resolve_user_id_from_customer_unknown(bill_session):
"""_resolve_user_id_from_customer returns None for unknown customer."""
result = _resolve_user_id_from_customer(bill_session, "cus_unknown")
assert result is None
@pytest.mark.unit
def test_resolve_plan_id_from_price(bill_session, starter_plan):
"""_resolve_plan_id_from_price finds plan by monthly price ID."""
result = _resolve_plan_id_from_price(bill_session, "price_monthly_starter")
assert result == "starter"
@pytest.mark.unit
def test_resolve_plan_id_from_price_yearly(bill_session, starter_plan):
"""_resolve_plan_id_from_price finds plan by yearly price ID."""
result = _resolve_plan_id_from_price(bill_session, "price_yearly_starter")
assert result == "starter"
@pytest.mark.unit
def test_resolve_plan_id_from_price_unknown(bill_session):
"""_resolve_plan_id_from_price returns None for unknown price."""
result = _resolve_plan_id_from_price(bill_session, "price_unknown")
assert result is None
@pytest.mark.unit
def test_on_checkout_completed_missing_user_id(bill_session):
"""_on_checkout_completed does nothing when user_id is absent."""
data = {"metadata": {}, "customer": "cus_test"}
_on_checkout_completed(bill_session, data) # Should not raise
@pytest.mark.unit
def test_on_subscription_updated_no_items(bill_session, user_profile):
"""_on_subscription_updated does nothing when items list is empty."""
user_profile.stripe_customer_id = "cus_upd"
bill_session.commit()
data = {"customer": "cus_upd", "items": {"data": []}}
_on_subscription_updated(bill_session, data) # Should not raise
@pytest.mark.unit
def test_on_subscription_deleted_unknown_customer(bill_session):
"""_on_subscription_deleted does nothing for unknown customer."""
data = {"customer": "cus_nobody"}
_on_subscription_deleted(bill_session, data) # Should not raise
@pytest.mark.unit
def test_handle_stripe_event_unhandled_type(bill_session):
"""_handle_stripe_event logs but does not raise for unknown event types."""
event = {"type": "unknown.event.type", "data": {"object": {}}}
_handle_stripe_event(bill_session, event) # Should not raise
@pytest.mark.unit
def test_handle_stripe_event_payment_failed(bill_session):
"""_handle_stripe_event handles invoice.payment_failed without raising."""
event = {
"type": "invoice.payment_failed",
"data": {"object": {"customer": "cus_fail"}},
}
_handle_stripe_event(bill_session, event) # Should not raise
# ---------------------------------------------------------------------------
# Tests: billing success page
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_billing_success_page(bill_client):
"""GET /api/billing/success returns 200 for logged-in user."""
resp = bill_client.get("/api/billing/success")
assert resp.status_code == 200
assert b"subscription" in resp.content.lower() or b"success" in resp.content.lower()
+677
View File
@@ -0,0 +1,677 @@
"""Tests for local user authentication: signup, email verification, and password reset.
Covers:
- POST /api/auth/signup (success, disabled, SMTP missing, password mismatch, conflicts)
- GET /verify-email (valid token, invalid token, expired token)
- POST /api/auth/resend-verification
- POST /api/auth/request-password-reset
- POST /api/auth/reset-password
- GET /signup (page route)
- GET /verify-email-sent (page route)
- GET /reset-password (page route)
- app/utils/local_auth utility functions
- auth() login flow with LocalUser
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.config import settings
from app.database import Base, get_db
from app.models import LocalUser, UserProfile
from app.utils.local_auth import (
build_session_user,
generate_token,
hash_password,
is_token_expired,
verify_password,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_TEST_DB_URL = "sqlite:///:memory:"
@pytest.fixture()
def la_engine():
"""In-memory SQLite engine for local auth tests."""
engine = create_engine(
_TEST_DB_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def la_session(la_engine):
"""DB session for one test."""
Session = sessionmaker(bind=la_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def la_client(la_engine):
"""TestClient with DB dependency overridden."""
from app.main import app
Session = sessionmaker(bind=la_engine)
def override_get_db():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=True) as client:
yield client
app.dependency_overrides.pop(get_db, None)
@pytest.fixture()
def active_user(la_session):
"""A fully active LocalUser in the DB."""
user = LocalUser(
email="active@example.com",
username="activeuser",
display_name="Active User",
hashed_password=hash_password("password123"),
is_active=True,
)
la_session.add(user)
la_session.add(UserProfile(user_id="active@example.com", display_name="Active User", onboarding_completed=True))
la_session.commit()
return user
@pytest.fixture()
def pending_user(la_session):
"""A LocalUser with a pending email verification token."""
token = "validtoken123"
user = LocalUser(
email="pending@example.com",
username="pendinguser",
hashed_password=hash_password("password123"),
is_active=False,
email_verification_token=token,
email_verification_sent_at=datetime.now(tz=timezone.utc),
)
la_session.add(user)
la_session.commit()
return user
# ---------------------------------------------------------------------------
# Unit tests: local_auth utilities
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_hash_and_verify_password():
"""hash_password produces a bcrypt hash that verify_password validates."""
plain = "super$ecret99"
hashed = hash_password(plain)
assert hashed != plain
assert verify_password(plain, hashed) is True
assert verify_password("wrong", hashed) is False
@pytest.mark.unit
def test_verify_password_bad_hash_returns_false():
"""verify_password returns False for a non-bcrypt string."""
assert verify_password("any", "notahash") is False
@pytest.mark.unit
def test_generate_token_unique():
"""generate_token returns distinct non-empty strings."""
tokens = {generate_token() for _ in range(10)}
assert len(tokens) == 10
for t in tokens:
assert len(t) > 20
@pytest.mark.unit
def test_is_token_expired_none():
"""None sent_at is treated as expired."""
assert is_token_expired(None) is True
@pytest.mark.unit
def test_is_token_expired_old():
"""Token sent more than 24 h ago is expired."""
old = datetime.now(tz=timezone.utc) - timedelta(hours=25)
assert is_token_expired(old) is True
@pytest.mark.unit
def test_is_token_expired_fresh():
"""Token sent recently is not expired."""
fresh = datetime.now(tz=timezone.utc) - timedelta(hours=1)
assert is_token_expired(fresh) is False
@pytest.mark.unit
def test_build_session_user():
"""build_session_user returns the expected dict structure."""
user = MagicMock()
user.email = "u@example.com"
user.username = "uname"
user.display_name = "Display Name"
user.is_admin = False
with patch("app.auth.get_gravatar_url", return_value="https://gravatar.com/test"):
result = build_session_user(user)
assert result["email"] == "u@example.com"
assert result["preferred_username"] == "uname"
assert result["name"] == "Display Name"
assert result["is_admin"] is False
assert result["auth_method"] == "local"
assert "picture" in result
# ---------------------------------------------------------------------------
# Integration tests: signup
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_signup_disabled(la_client):
"""POST /api/auth/signup returns 403 when allow_local_signup is False."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = False
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "auser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 403
@pytest.mark.integration
def test_signup_smtp_not_configured(la_client):
"""POST /api/auth/signup returns 503 when SMTP is not configured."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = None
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "auser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 503
@pytest.mark.integration
def test_signup_password_mismatch(la_client):
"""POST /api/auth/signup returns 422 when passwords do not match."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "auser",
"password": "password1",
"password_confirm": "different1",
},
)
assert resp.status_code == 422
@pytest.mark.integration
def test_signup_success(la_client):
"""POST /api/auth/signup creates user and returns 201."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email") as mock_send,
):
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
mock_settings.version = "test"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "new@example.com",
"username": "newuser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 201
assert "Verification email sent" in resp.json()["message"]
mock_send.assert_called_once()
@pytest.mark.integration
def test_signup_duplicate_email(la_client, active_user):
"""POST /api/auth/signup returns 409 when email already registered."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email"),
):
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "active@example.com",
"username": "otheruser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 409
assert "Email" in resp.json()["detail"]
@pytest.mark.integration
def test_signup_duplicate_username(la_client, active_user):
"""POST /api/auth/signup returns 409 when username already taken."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email"),
):
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "different@example.com",
"username": "activeuser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 409
assert "Username" in resp.json()["detail"]
@pytest.mark.integration
def test_signup_smtp_failure_cleans_up(la_client, la_session):
"""POST /api/auth/signup cleans up user records if email send fails."""
with (
patch("app.api.local_auth.settings") as mock_settings,
patch("app.api.local_auth.send_verification_email", side_effect=RuntimeError("SMTP down")),
):
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "fail@example.com",
"username": "failuser",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 503
# User should NOT exist in the DB
user = la_session.query(LocalUser).filter(LocalUser.email == "fail@example.com").first()
assert user is None
# ---------------------------------------------------------------------------
# Integration tests: email verification
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_verify_email_valid_token(la_client, pending_user):
"""GET /verify-email with valid token activates account and redirects."""
resp = la_client.get(
f"/verify-email?token={pending_user.email_verification_token}",
follow_redirects=False,
)
assert resp.status_code == 302
@pytest.mark.integration
def test_verify_email_invalid_token(la_client):
"""GET /verify-email with unknown token redirects to login with error."""
resp = la_client.get("/verify-email?token=doesnotexist", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["location"]
@pytest.mark.integration
def test_verify_email_expired_token(la_client, la_session):
"""GET /verify-email with expired token redirects to login with error."""
old_time = datetime.now(tz=timezone.utc) - timedelta(hours=25)
user = LocalUser(
email="expired@example.com",
username="expireduser",
hashed_password=hash_password("password123"),
is_active=False,
email_verification_token="expiredtoken",
email_verification_sent_at=old_time,
)
la_session.add(user)
la_session.commit()
resp = la_client.get("/verify-email?token=expiredtoken", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["location"]
# ---------------------------------------------------------------------------
# Integration tests: resend verification
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_resend_verification_always_200(la_client):
"""POST /api/auth/resend-verification returns 200 for unknown email."""
with patch("app.api.local_auth.send_verification_email"):
resp = la_client.post(
"/api/auth/resend-verification",
json={"email": "nobody@example.com"},
)
assert resp.status_code == 200
@pytest.mark.integration
def test_resend_verification_sends_email(la_client, pending_user):
"""POST /api/auth/resend-verification sends email for pending user."""
with patch("app.api.local_auth.send_verification_email") as mock_send:
resp = la_client.post(
"/api/auth/resend-verification",
json={"email": pending_user.email},
)
assert resp.status_code == 200
mock_send.assert_called_once()
# ---------------------------------------------------------------------------
# Integration tests: password reset
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_request_password_reset_always_200(la_client):
"""POST /api/auth/request-password-reset returns 200 for unknown email."""
with patch("app.api.local_auth.send_password_reset_email"):
resp = la_client.post(
"/api/auth/request-password-reset",
json={"email": "nobody@example.com"},
)
assert resp.status_code == 200
@pytest.mark.integration
def test_request_password_reset_sends_email(la_client, active_user):
"""POST /api/auth/request-password-reset sends email for known user."""
with patch("app.api.local_auth.send_password_reset_email") as mock_send:
resp = la_client.post(
"/api/auth/request-password-reset",
json={"email": active_user.email},
)
assert resp.status_code == 200
mock_send.assert_called_once()
@pytest.mark.integration
def test_reset_password_success(la_client, la_session):
"""POST /api/auth/reset-password updates password with valid token."""
token = "resettoken123"
user = LocalUser(
email="reset@example.com",
username="resetuser",
hashed_password=hash_password("oldpassword"),
is_active=True,
password_reset_token=token,
password_reset_sent_at=datetime.now(tz=timezone.utc),
)
la_session.add(user)
la_session.commit()
resp = la_client.post(
"/api/auth/reset-password",
json={
"token": token,
"new_password": "newpassword1",
"new_password_confirm": "newpassword1",
},
)
assert resp.status_code == 200
la_session.refresh(user)
assert verify_password("newpassword1", user.hashed_password)
assert user.password_reset_token is None
@pytest.mark.integration
def test_reset_password_invalid_token(la_client):
"""POST /api/auth/reset-password returns 400 for invalid token."""
resp = la_client.post(
"/api/auth/reset-password",
json={
"token": "badtoken",
"new_password": "newpassword1",
"new_password_confirm": "newpassword1",
},
)
assert resp.status_code == 400
@pytest.mark.integration
def test_reset_password_mismatch(la_client, la_session):
"""POST /api/auth/reset-password returns 422 when passwords do not match."""
token = "mismatchtoken"
user = LocalUser(
email="mismatch@example.com",
username="mismatchuser",
hashed_password=hash_password("old"),
is_active=True,
password_reset_token=token,
password_reset_sent_at=datetime.now(tz=timezone.utc),
)
la_session.add(user)
la_session.commit()
resp = la_client.post(
"/api/auth/reset-password",
json={
"token": token,
"new_password": "newpassword1",
"new_password_confirm": "different_pw",
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Integration tests: page routes
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_signup_page_disabled_redirects(la_client):
"""GET /signup redirects when allow_local_signup is False."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = False
resp = la_client.get("/signup", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["location"]
@pytest.mark.integration
def test_signup_page_enabled(la_client):
"""GET /signup returns 200 when allow_local_signup is True."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.version = "test"
resp = la_client.get("/signup")
assert resp.status_code == 200
assert b"Create" in resp.content
@pytest.mark.integration
def test_verify_email_sent_page(la_client):
"""GET /verify-email-sent returns 200."""
resp = la_client.get("/verify-email-sent")
assert resp.status_code == 200
@pytest.mark.integration
def test_reset_password_page(la_client):
"""GET /reset-password returns 200."""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.version = "test"
resp = la_client.get("/reset-password?token=abc123")
assert resp.status_code == 200
assert b"password" in resp.content.lower()
# ---------------------------------------------------------------------------
# Integration tests: auth() login flow with LocalUser
# ---------------------------------------------------------------------------
@pytest.mark.unit
@pytest.mark.asyncio
@patch.object(settings, "multi_user_enabled", True)
async def test_local_login_success(la_session, active_user):
"""auth() with valid LocalUser credentials sets session and redirects."""
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "activeuser", "password": "password123"})
mock_request.session = {}
result = await auth(mock_request, db=la_session)
assert result.status_code == 302
assert "user" in mock_request.session
assert mock_request.session["user"]["email"] == "active@example.com"
@pytest.mark.unit
@pytest.mark.asyncio
@patch.object(settings, "multi_user_enabled", True)
async def test_local_login_by_email(la_session, active_user):
"""auth() accepts email as username for LocalUser lookup."""
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "active@example.com", "password": "password123"})
mock_request.session = {}
result = await auth(mock_request, db=la_session)
assert result.status_code == 302
assert "user" in mock_request.session
@pytest.mark.unit
@pytest.mark.asyncio
@patch.object(settings, "multi_user_enabled", True)
async def test_local_login_wrong_password(la_session, active_user):
"""auth() with wrong password redirects to login with error."""
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "activeuser", "password": "wrongpassword"})
mock_request.session = {}
result = await auth(mock_request, db=la_session)
assert result.status_code == 302
assert "/login" in result.headers["location"]
assert "user" not in mock_request.session
@pytest.mark.unit
@pytest.mark.asyncio
@patch.object(settings, "multi_user_enabled", True)
async def test_local_login_unverified(la_session, pending_user):
"""auth() for unverified user redirects with verification message."""
from unittest.mock import AsyncMock, MagicMock
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "pendinguser", "password": "password123"})
mock_request.session = {}
result = await auth(mock_request, db=la_session)
assert result.status_code == 302
assert "verify" in result.headers["location"].lower()
# ---------------------------------------------------------------------------
# Single-user backward-compatibility: LocalUser table must NOT be queried
# ---------------------------------------------------------------------------
@pytest.mark.unit
@pytest.mark.asyncio
@patch.object(settings, "multi_user_enabled", False)
async def test_single_user_mode_skips_local_user_table(la_session, active_user):
"""In single-user mode auth() must not query LocalUser even when a matching
row exists. It should fall through to the admin-credential check."""
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import patch as _patch
from fastapi import Request
from app.auth import auth
mock_request = MagicMock(spec=Request)
# Use the active LocalUser's credentials — they must NOT work in single-user mode
# because the whole LocalUser block is skipped.
mock_request.form = AsyncMock(return_value={"username": "activeuser", "password": "password123"})
mock_request.session = {}
with (
_patch.object(settings, "admin_username", "activeuser"),
_patch.object(settings, "admin_password", "password123"),
):
result = await auth(mock_request, db=la_session)
# Should succeed via admin-credentials path (is_admin=True), not LocalUser path
assert result.status_code == 302
assert "user" in mock_request.session
# Admin path sets is_admin=True and id="admin"
assert mock_request.session["user"]["is_admin"] is True
assert mock_request.session["user"]["id"] == "admin"
+317
View File
@@ -0,0 +1,317 @@
"""Unit tests for the onboarding wizard API (/api/onboarding).
Covers:
- GET /api/onboarding/status (auth required, new user, returning user)
- POST /api/onboarding/profile (saves display_name / contact_email)
- POST /api/onboarding/plan (saves tier + billing cycle, rejects invalid tiers)
- POST /api/onboarding/storage (saves preferred_destination)
- POST /api/onboarding/complete (marks onboarding_completed=True)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import UserProfile
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_TEST_USER = {
"sub": "user-onb-123",
"name": "Test User",
"email": "test@example.com",
"is_admin": False,
}
@pytest.fixture()
def ob_engine():
"""In-memory SQLite engine for onboarding tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def ob_session(ob_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=ob_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def ob_client_authed(ob_engine):
"""TestClient that bypasses auth by monkey-patching _get_current_user_id."""
from app.api import onboarding as ob_module
from app.main import app
original = ob_module._get_current_user_id
def override_db():
Session = sessionmaker(bind=ob_engine)
session = Session()
try:
yield session
finally:
session.close()
def fake_user_id(_request):
return _TEST_USER["sub"]
ob_module._get_current_user_id = fake_user_id
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
ob_module._get_current_user_id = original
app.dependency_overrides.clear()
def _make_profile(session, user_id: str, **kwargs) -> UserProfile:
"""Insert a UserProfile row with sensible defaults."""
kwargs.setdefault("is_blocked", False)
kwargs.setdefault("onboarding_completed", False)
profile = UserProfile(user_id=user_id, **kwargs)
session.add(profile)
session.commit()
session.refresh(profile)
return profile
# ---------------------------------------------------------------------------
# TestOnboardingAPI
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOnboardingAPI:
"""Unit tests for the /api/onboarding endpoints."""
# ------------------------------------------------------------------
# GET /api/onboarding/status
# ------------------------------------------------------------------
def test_status_requires_auth(self, ob_engine):
"""Unauthenticated requests to /status must return 401."""
from app.main import app
def override_db():
Session = sessionmaker(bind=ob_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
# No session user injected → _get_current_user_id raises 401
resp = client.get("/api/onboarding/status")
app.dependency_overrides.clear()
assert resp.status_code == 401
def test_status_returns_not_completed_for_new_user(self, ob_client_authed, ob_session):
"""A user with no profile should get completed=False and step=1."""
resp = ob_client_authed.get("/api/onboarding/status")
assert resp.status_code == 200
data = resp.json()
assert data["completed"] is False
assert data["step"] == 1
assert data["profile"] is None
def test_status_returns_not_completed_for_existing_incomplete_profile(self, ob_client_authed, ob_session):
"""A user with an existing profile but onboarding_completed=False → completed=False."""
_make_profile(ob_session, _TEST_USER["sub"], display_name="Alice", onboarding_completed=False)
resp = ob_client_authed.get("/api/onboarding/status")
assert resp.status_code == 200
data = resp.json()
assert data["completed"] is False
assert data["profile"]["display_name"] == "Alice"
def test_status_returns_completed_when_done(self, ob_client_authed, ob_session):
"""A user with onboarding_completed=True → completed=True and step=5."""
_make_profile(ob_session, _TEST_USER["sub"], onboarding_completed=True)
resp = ob_client_authed.get("/api/onboarding/status")
assert resp.status_code == 200
data = resp.json()
assert data["completed"] is True
assert data["step"] == 5
# ------------------------------------------------------------------
# POST /api/onboarding/profile
# ------------------------------------------------------------------
def test_save_profile_updates_display_name(self, ob_client_authed, ob_session):
"""POST /profile should persist display_name to UserProfile."""
resp = ob_client_authed.post(
"/api/onboarding/profile",
json={"display_name": "Jane Doe", "contact_email": "jane@example.com"},
)
assert resp.status_code == 200
data = resp.json()
assert data["display_name"] == "Jane Doe"
assert data["contact_email"] == "jane@example.com"
# Verify DB was actually updated
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
assert profile is not None
assert profile.display_name == "Jane Doe"
assert profile.contact_email == "jane@example.com"
def test_save_profile_requires_auth(self, ob_engine):
"""POST /profile without auth should return 401."""
from app.main import app
def override_db():
Session = sessionmaker(bind=ob_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
resp = client.post("/api/onboarding/profile", json={"display_name": "x"})
app.dependency_overrides.clear()
assert resp.status_code == 401
def test_save_profile_null_fields_allowed(self, ob_client_authed, ob_session):
"""Sending null for display_name and contact_email should succeed."""
resp = ob_client_authed.post(
"/api/onboarding/profile",
json={"display_name": None, "contact_email": None},
)
assert resp.status_code == 200
# ------------------------------------------------------------------
# POST /api/onboarding/plan
# ------------------------------------------------------------------
def test_save_plan_updates_subscription_tier(self, ob_client_authed, ob_session):
"""POST /plan should persist the chosen tier and billing cycle."""
resp = ob_client_authed.post(
"/api/onboarding/plan",
json={"subscription_tier": "starter", "billing_cycle": "monthly"},
)
assert resp.status_code == 200
data = resp.json()
assert data["subscription_tier"] == "starter"
assert data["subscription_billing_cycle"] == "monthly"
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
assert profile.subscription_tier == "starter"
def test_save_plan_yearly_billing(self, ob_client_authed, ob_session):
"""POST /plan with billing_cycle=yearly should persist correctly."""
resp = ob_client_authed.post(
"/api/onboarding/plan",
json={"subscription_tier": "professional", "billing_cycle": "yearly"},
)
assert resp.status_code == 200
assert resp.json()["subscription_billing_cycle"] == "yearly"
def test_save_plan_rejects_invalid_tier(self, ob_client_authed):
"""POST /plan with an unknown tier should return 422."""
resp = ob_client_authed.post(
"/api/onboarding/plan",
json={"subscription_tier": "unicorn", "billing_cycle": "monthly"},
)
assert resp.status_code == 422
assert "Invalid subscription_tier" in resp.json()["detail"]
def test_save_plan_rejects_invalid_billing_cycle(self, ob_client_authed):
"""POST /plan with an invalid billing_cycle value should return 422."""
resp = ob_client_authed.post(
"/api/onboarding/plan",
json={"subscription_tier": "free", "billing_cycle": "weekly"},
)
assert resp.status_code == 422
# ------------------------------------------------------------------
# POST /api/onboarding/storage
# ------------------------------------------------------------------
def test_save_storage_updates_preferred_destination(self, ob_client_authed, ob_session):
"""POST /storage should persist the preferred_destination."""
resp = ob_client_authed.post(
"/api/onboarding/storage",
json={"preferred_destination": "dropbox"},
)
assert resp.status_code == 200
data = resp.json()
assert data["preferred_destination"] == "dropbox"
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
assert profile.preferred_destination == "dropbox"
def test_save_storage_accepts_null_destination(self, ob_client_authed, ob_session):
"""POST /storage with null preferred_destination should succeed (skip storage)."""
resp = ob_client_authed.post(
"/api/onboarding/storage",
json={"preferred_destination": None},
)
assert resp.status_code == 200
assert resp.json()["preferred_destination"] is None
# ------------------------------------------------------------------
# POST /api/onboarding/complete
# ------------------------------------------------------------------
def test_complete_sets_onboarding_completed(self, ob_client_authed, ob_session):
"""POST /complete should set onboarding_completed=True on the profile."""
_make_profile(ob_session, _TEST_USER["sub"])
resp = ob_client_authed.post("/api/onboarding/complete")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert "redirect_url" in data
# Re-query to see persisted value
ob_session.expire_all()
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
assert profile.onboarding_completed is True
assert profile.onboarding_completed_at is not None
def test_complete_creates_profile_if_missing(self, ob_client_authed, ob_session):
"""POST /complete should create a profile when none exists and mark it done."""
resp = ob_client_authed.post("/api/onboarding/complete")
assert resp.status_code == 200
profile = ob_session.query(UserProfile).filter(UserProfile.user_id == _TEST_USER["sub"]).first()
assert profile is not None
assert profile.onboarding_completed is True
def test_complete_requires_auth(self, ob_engine):
"""POST /complete without auth should return 401."""
from app.main import app
def override_db():
Session = sessionmaker(bind=ob_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
resp = client.post("/api/onboarding/complete")
app.dependency_overrides.clear()
assert resp.status_code == 401