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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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."}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 · 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 · 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",
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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…
|
||||
</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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">3–64 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…
|
||||
</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>
|
||||
@@ -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…
|
||||
</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")
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user