feat: add multi-step user onboarding wizard

- 5-step wizard: Welcome → Profile → Plan → Storage → All Set!
- New migration 017: onboarding_completed, contact_email, preferred_destination fields
- REST API at /api/onboarding/{status,profile,plan,storage,complete}
- GET /onboarding view with configured-destinations helper
- OAuth callback redirects first-time users to onboarding
- 16 unit tests for all endpoints; 65 total tests pass

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 11:12:21 +00:00
parent 99df0816b0
commit e0de0fd6fb
5 changed files with 29 additions and 13 deletions
+10 -4
View File
@@ -208,8 +208,13 @@ def save_storage(request: Request, body: StorageBody, db: DbSession) -> dict[str
@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."""
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
@@ -221,5 +226,6 @@ def complete_onboarding(request: Request, db: DbSession) -> dict[str, bool]:
db.rollback()
raise
logger.info("Onboarding: completed for user %s", user_id)
return {"success": True}
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}
+1 -1
View File
@@ -165,7 +165,7 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
_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 = (
+2 -1
View File
@@ -5,6 +5,7 @@ import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.config import Settings
from app.config import settings as _settings
from app.models import UserProfile
from app.utils.subscription import get_all_tiers
@@ -29,7 +30,7 @@ _DESTINATION_META: list[dict] = [
]
def _get_configured_destinations(cfg) -> list[dict]:
def _get_configured_destinations(cfg: Settings) -> list[dict]:
"""Return which storage providers are fully configured in the current settings.
Each entry is a dict with ``id``, ``name``, and ``icon`` keys.
+13 -6
View File
@@ -151,12 +151,12 @@
<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 }">
<div class="p-6">
<!-- Monthly / Annual toggle -->
<div class="flex items-center justify-center gap-3 mb-6">
<div class="flex items-center justify-center gap-3 mb-6" x-effect="billingCycle = annual ? 'yearly' : 'monthly'">
<span class="text-sm font-medium" :class="!annual ? 'text-indigo-600' : 'text-gray-500'">Monthly</span>
<button @click="annual = !annual; billingCycle = annual ? 'yearly' : 'monthly'"
<button @click="annual = !annual"
class="relative w-12 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
:class="annual ? 'bg-indigo-600' : 'bg-gray-300'"
role="switch"
@@ -185,7 +185,11 @@
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>
<span x-show="annual">
{% if tier.price_yearly == 0 %}Free
{% else %}${{ "%.2f"|format(tier.price_yearly) }}<span class="text-xs font-normal text-gray-500">/yr</span>
{% endif %}
</span>
{% endif %}
</span>
</div>
@@ -374,6 +378,7 @@ function onboardingWizard() {
contactEmail: '{{ user.email | default("") | e }}',
selectedTier: 'free',
billingCycle: 'monthly',
annual: false,
selectedDestination: '',
get progressPercent() {
@@ -459,8 +464,10 @@ function onboardingWizard() {
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'; }
if (r.ok) {
const data = await r.json();
window.location.href = data.redirect_url || '/upload';
} else { const d = await r.json(); this.error = d.detail || 'Failed to complete onboarding'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
}
+3 -1
View File
@@ -278,7 +278,9 @@ class TestOnboardingAPI:
_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}
data = resp.json()
assert data["success"] is True
assert "redirect_url" in data
# Re-query to see persisted value
ob_session.expire_all()