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:
+10
-4
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user