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")
|
@router.post("/complete", summary="Mark onboarding as completed")
|
||||||
def complete_onboarding(request: Request, db: DbSession) -> dict[str, bool]:
|
def complete_onboarding(request: Request, db: DbSession) -> dict[str, Any]:
|
||||||
"""Set onboarding_completed=True and record the completion timestamp."""
|
"""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)
|
user_id = _get_current_user_id(request)
|
||||||
profile = _get_or_create_profile(db, user_id)
|
profile = _get_or_create_profile(db, user_id)
|
||||||
profile.onboarding_completed = True
|
profile.onboarding_completed = True
|
||||||
@@ -221,5 +226,6 @@ def complete_onboarding(request: Request, db: DbSession) -> dict[str, bool]:
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info("Onboarding: completed for user %s", user_id)
|
redirect_url = request.session.pop("post_onboarding_redirect", "/upload")
|
||||||
return {"success": True}
|
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)
|
_ensure_user_profile(db, user_data)
|
||||||
|
|
||||||
# Log the successful authentication
|
# 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
|
# Redirect first-time users to onboarding
|
||||||
user_id = (
|
user_id = (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
from app.config import settings as _settings
|
from app.config import settings as _settings
|
||||||
from app.models import UserProfile
|
from app.models import UserProfile
|
||||||
from app.utils.subscription import get_all_tiers
|
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.
|
"""Return which storage providers are fully configured in the current settings.
|
||||||
|
|
||||||
Each entry is a dict with ``id``, ``name``, and ``icon`` keys.
|
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>
|
<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>
|
<p class="text-purple-100 text-sm">Start free, upgrade when you're ready</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6" x-data="{ annual: false }">
|
<div class="p-6">
|
||||||
|
|
||||||
<!-- Monthly / Annual toggle -->
|
<!-- 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>
|
<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="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'"
|
:class="annual ? 'bg-indigo-600' : 'bg-gray-300'"
|
||||||
role="switch"
|
role="switch"
|
||||||
@@ -185,7 +185,11 @@
|
|||||||
Free
|
Free
|
||||||
{% else %}
|
{% else %}
|
||||||
<span x-show="!annual">${{ tier.price_monthly }}<span class="text-xs font-normal text-gray-500">/mo</span></span>
|
<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 %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,6 +378,7 @@ function onboardingWizard() {
|
|||||||
contactEmail: '{{ user.email | default("") | e }}',
|
contactEmail: '{{ user.email | default("") | e }}',
|
||||||
selectedTier: 'free',
|
selectedTier: 'free',
|
||||||
billingCycle: 'monthly',
|
billingCycle: 'monthly',
|
||||||
|
annual: false,
|
||||||
selectedDestination: '',
|
selectedDestination: '',
|
||||||
|
|
||||||
get progressPercent() {
|
get progressPercent() {
|
||||||
@@ -459,8 +464,10 @@ function onboardingWizard() {
|
|||||||
this.loading = true; this.error = '';
|
this.loading = true; this.error = '';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/onboarding/complete', { method: 'POST' });
|
const r = await fetch('/api/onboarding/complete', { method: 'POST' });
|
||||||
if (r.ok) { window.location.href = '/upload'; }
|
if (r.ok) {
|
||||||
else { const d = await r.json(); this.error = d.detail || 'Failed to complete onboarding'; }
|
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.'; }
|
} catch (_) { this.error = 'Network error. Please try again.'; }
|
||||||
finally { this.loading = false; }
|
finally { this.loading = false; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,7 +278,9 @@ class TestOnboardingAPI:
|
|||||||
_make_profile(ob_session, _TEST_USER["sub"])
|
_make_profile(ob_session, _TEST_USER["sub"])
|
||||||
resp = ob_client_authed.post("/api/onboarding/complete")
|
resp = ob_client_authed.post("/api/onboarding/complete")
|
||||||
assert resp.status_code == 200
|
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
|
# Re-query to see persisted value
|
||||||
ob_session.expire_all()
|
ob_session.expire_all()
|
||||||
|
|||||||
Reference in New Issue
Block a user