You're all set!
++ Your subscription has been activated. Thank you for choosing DocuElevate! +
+ + +diff --git a/app/api/__init__.py b/app/api/__init__.py index f266a266..9387fe88 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/billing.py b/app/api/billing.py new file mode 100644 index 00000000..75090d4c --- /dev/null +++ b/app/api/billing.py @@ -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) diff --git a/app/api/local_auth.py b/app/api/local_auth.py new file mode 100644 index 00000000..8316f7d6 --- /dev/null +++ b/app/api/local_auth.py @@ -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."} diff --git a/app/api/onboarding.py b/app/api/onboarding.py new file mode 100644 index 00000000..396f645d --- /dev/null +++ b/app/api/onboarding.py @@ -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} diff --git a/app/auth.py b/app/auth.py index c9c7099c..e0399915 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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) diff --git a/app/config.py b/app/config.py index d0322e82..848846a6 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/main.py b/app/main.py index 7c46593f..7aba642b 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py index 1287ff40..1f160041 100644 --- a/app/models.py +++ b/app/models.py @@ -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()) diff --git a/app/utils/local_auth.py b/app/utils/local_auth.py new file mode 100644 index 00000000..669d6819 --- /dev/null +++ b/app/utils/local_auth.py @@ -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""" + +
+ +Thanks for signing up. Please confirm your email address to activate your account.
+ +This link expires in 24 hours. If you did not create an account, you can safely ignore this email.
+DocuElevate · Intelligent Document Processing
+Hi {username}, you requested a password reset for your DocuElevate account.
+ +This link expires in 24 hours. If you did not request a password reset, you can safely ignore this email.
+DocuElevate · Intelligent Document Processing
++ Your subscription has been activated. Thank you for choosing DocuElevate! +
+ + +Let's get you set up
+You're just a few steps away from transforming how you handle documents.
+AI extracts text from any document
+Documents organised automatically
+Instantly backed up to your storage
++ This quick setup takes about 2 minutes. You can change everything later in your settings. +
+ +Tell us a little about yourself
+This is how you'll appear in DocuElevate.
+Used for notifications. Can be different from your login email.
+Start free, upgrade when you're ready
+Where should your processed documents go?
+Select where you'd like your documents stored. You can change this later in settings.
++ Not seeing your provider? + {% if user.is_admin %} + Go to Settings to configure more destinations. + {% else %} + Ask your administrator to configure additional storage providers. + {% endif %} +
+ {% else %} ++ No storage destinations have been configured for this instance yet. + You can skip this step and set one up later. +
+ {% if user.is_admin %} + + Configure Storage + + {% else %} +Contact your administrator to set up a storage destination.
+ {% endif %} +Your account is configured and ready to go.
+Enter your new password below.
+ +