feat(onboarding): add multi-step user onboarding wizard

Add a 5-step onboarding flow for new users:
- Migration 017: adds onboarding_completed, onboarding_completed_at,
  contact_email, preferred_destination to user_profiles
- app/api/onboarding.py: REST endpoints (status, profile, plan, storage,
  complete) with session-based auth using sub/preferred_username/email/id
  priority chain
- app/views/onboarding.py: GET /onboarding view with configured-destination
  detection helper for all 8 supported storage providers
- frontend/templates/onboarding.html: Alpine.js wizard with progress
  indicator, tier cards (server-rendered), storage destination cards,
  accessible markup (WCAG AA), and all fetch() API calls
- app/auth.py: redirect first-time OAuth users (onboarding_completed=False)
  to /onboarding after login
- 16 unit tests covering all endpoints, auth enforcement, and edge cases

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 11:04:53 +00:00
parent dd207eef9b
commit 99df0816b0
9 changed files with 1168 additions and 0 deletions
+2
View File
@@ -15,6 +15,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.plans import router as plans_router
@@ -60,3 +61,4 @@ 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)
+225
View File
@@ -0,0 +1,225 @@
"""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 for user %s", body.subscription_tier, body.billing_cycle, user_id)
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, bool]:
"""Set onboarding_completed=True and record the completion timestamp."""
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
logger.info("Onboarding: completed for user %s", user_id)
return {"success": True}
+13
View File
@@ -167,6 +167,19 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
# Log the successful authentication
logger.info(f"[SECURITY] OAUTH_LOGIN_SUCCESS user={user_data.get('email', 'unknown')} admin={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:
from app.models import UserProfile as _UserProfile
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")
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
+6
View File
@@ -209,6 +209,12 @@ 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)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+2
View File
@@ -13,6 +13,7 @@ from app.views.filemanager import router as filemanager_router
from app.views.general import router as general_router
from app.views.google_drive import router as google_drive_router
from app.views.license_routes import router as license_router # Add the license router
from app.views.onboarding import router as onboarding_router
from app.views.onedrive import router as onedrive_router
from app.views.plans import router as plans_router # Admin Plan Designer
from app.views.queue import router as queue_router
@@ -39,3 +40,4 @@ 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
+90
View File
@@ -0,0 +1,90 @@
"""View route for the user onboarding wizard."""
import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
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) -> 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,
},
)
+470
View File
@@ -0,0 +1,470 @@
{% 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" x-data="{ annual: false }">
<!-- Monthly / Annual toggle -->
<div class="flex items-center justify-center gap-3 mb-6">
<span class="text-sm font-medium" :class="!annual ? 'text-indigo-600' : 'text-gray-500'">Monthly</span>
<button @click="annual = !annual; billingCycle = annual ? 'yearly' : 'monthly'"
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">${{ "%.2f" | format(tier.price_monthly * 10) }}<span class="text-xs font-normal text-gray-500">/yr</span></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',
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) { window.location.href = '/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,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")
+315
View File
@@ -0,0 +1,315 @@
"""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
assert resp.json() == {"success": True}
# 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