Web Auth Views
File: api/nextango/apps/web_auth/views.py
Last Updated: 2026-07-09
Overview
web_auth covers the full account lifecycle: email-first login routing, password and OTP authentication, customer registration, password recovery, session status, logout, and profile editing. Every view in this module declares permission_classes = [AllowAny] explicitly and authentication_classes = []. These endpoints are the authentication entry points themselves, so no view here relies on the project-wide DEFAULT_PERMISSION_CLASSES fallback (IsActiveUser in production, AllowAny under DEBUG). All paths are mounted under /auth/ at the application root.
CheckEmailView
POST /auth/check-email/
Step 1 of the auth flow. Determines whether the frontend should show a password field or send an OTP for a given email. Demo sessions always route to OTP.
Request:
{ "email": "[email protected]", "session_type": "dashboard" }
Response (200, has password): { "action": "password_login", "has_password": true, "user_exists": true, "message": "..." }
Response (200, no password or new user): { "action": "send_otp", "has_password": false, "user_exists": true|false, "message": "..." }
Response (400): { "error": "Email is required" }
Non-existent and deactivated users receive send_otp rather than a 404, to avoid revealing whether an email is registered.
LoginView
POST /auth/login/
Step 2a of the auth flow. Handles password login for dashboard sessions, initiates OTP for demo sessions or passwordless dashboard users, and supports a dev-mode bypass.
Request (password login): { "email": "...", "password": "...", "session_type": "dashboard" }
Request (OTP initiation): { "email": "...", "session_type": "demo" }
Request (dev bypass, DEBUG only): { "email": "...", "session_type": "dashboard", "dev_bypass": true }
Response (200, password login): { "success": true, "session_type": "...", "user": {...}, "session": {...} }
Response (200, OTP sent): { "success": true, "message": "Verification code sent to ...", "email": "..." }
Response (401): { "error": "Invalid password" } or { "error": "Invalid credentials" }
Response (429): { "error": "..." } (account lockout)
Response (500): { "error": "Failed to send verification code. Please try again." }
On a successful password login, the view resolves the caller's guest WebSession (via web_commerce's resolve_web_session) before creating the new dashboard session, then merges the guest cart into the customer's cart if one exists, or creates a cart otherwise. dev_bypass is honored only when settings.DEBUG is True; the flag is ignored in production.
VerifyOTPView
POST /auth/verify-otp/
Step 2b of the auth flow. Verifies the OTP code and either creates a session or, for dashboard users without a password, prompts for password creation.
Request: { "email": "...", "otp_code": "123456", "session_type": "dashboard" }
Response (200, dashboard with password): { "success": true, "session_type": "...", "user": {...}, "session": {...} }
Response (200, dashboard without password): { "success": true, "action": "create_password", "message": "...", "email": "..." }
Response (200, demo, existing user): { "success": true, "session_type": "demo", "user": {...}, "session": {...} }
Response (200, demo, guest): { "success": true, "session_type": "demo", "is_guest": true, "email": "...", "session": {...} }
Response (400): { "error": "Email and OTP code are required" } or { "error": "Invalid or expired verification code" }
Response (404): { "error": "User account not found. Dashboard access requires an account." }
Response (429): { "error": "..." } (account lockout)
Successful dashboard verification performs the same guest-cart merge as LoginView.
SetPasswordView
POST /auth/set-password/
Step 3 of the auth flow, called after VerifyOTPView returns action: "create_password". Sets a password and creates a session immediately, without a separate login round trip.
Request: { "email": "...", "password": "...", "session_type": "dashboard" }
Response (200): { "success": true, "message": "Password created successfully", "session_type": "...", "user": {...}, "session": {...} }
Response (400): { "error": "Email and password are required" } or { "error": "Password validation failed", "details": [...] }
Response (404): { "error": "User account not found" }
Password is validated with Django's validate_password() (AUTH_PASSWORD_VALIDATORS). This view does not re-verify the OTP; it trusts that VerifyOTPView already ran.
ForgotPasswordView
POST /auth/forgot-password/
Starts password recovery by sending an OTP. Only applies to users who already have a password; OTP-only users are redirected to the regular login flow.
Request: { "email": "[email protected]" }
Response (200): { "success": true, "message": "Recovery code sent to ..." }
Response (200, non-existent user): { "success": true, "message": "If account exists, recovery code was sent" }
Response (400): { "error": "Email is required" } or { "error": "No password set. Use regular login flow." }
Response (429): { "error": "..." } (rate limited)
Response (500): { "error": "Failed to send recovery code. Please try again." }
Non-existent users get the same success response as existing ones, to prevent user enumeration. The OTP is stored with purpose='password_recovery', a separate namespace from login OTPs.
VerifyRecoveryOTPView
POST /auth/verify-recovery-otp/
Validates a recovery OTP without consuming it, so the frontend can show the new-password form before the code is spent. Consumption happens in ResetPasswordView.
Request: { "email": "...", "otp_code": "123456" }
Response (200): { "success": true, "message": "Recovery code is valid" }
Response (400): { "error": "Email and OTP code are required" }, { "error": "Invalid or expired recovery code" }, or { "error": "Too many attempts. Please request a new code." }
Response (429): { "error": "..." } (account lockout)
Response (500): { "error": "Verification failed" }
Each call increments the OTP's attempt counter without marking it verified, up to a maximum of 5 attempts.
ResetPasswordView
POST /auth/reset-password/
Completes password recovery. Validates the new password before verifying (and consuming) the OTP, so an invalid password does not waste the code.
Request: { "email": "...", "otp_code": "...", "new_password": "..." }
Response (200): { "success": true, "message": "Password reset successfully. You can now log in with your new password." }
Response (400): { "error": "Email, OTP code, and new password are required" }, { "error": "Password validation failed", "details": [...] }, or { "error": "Invalid or expired recovery code" }
Response (404): { "error": "User account not found" }
No session is created here; the user logs in separately after the reset.
SessionStatusView
GET /auth/session/
Checks the current session and returns authentication state, session data, user data, and applicable permissions. Called on page load to restore auth state.
Response (200, authenticated):
{
"authenticated": true,
"session_type": "dashboard",
"is_guest": false,
"session": { "...WebSession fields..." },
"permissions": [ { "...SessionPermission fields..." } ],
"user": { "...UserBasicSerializer fields..." }
}
user is present only when the session has a linked user.
Response (200, not authenticated): { "authenticated": false }
LogoutView
POST /auth/logout/
Terminates the current session: deletes the WebSession row and flushes the Django session.
Request: no body required.
Response (200): { "success": true, "message": "Logged out successfully" }
InitiateRegistrationView
POST /auth/register/initiate/
Step 1 of customer registration. Sends a verification OTP to the email and stores a pending registration in Redis with a 1-hour TTL. No User or Customer record is created at this step. Existing users are redirected to login instead of registering again.
Request: { "email": "...", "name": "...", "first_name": "...", "last_name": "..." } (name, first_name, last_name optional)
Response (200): { "success": true, "registration_token": "...", "message": "..." }
Response (400, existing user): { "success": false, "action": "redirect_to_login", "message": "..." }
Response (400, validation error): serializer error dict
Response (429): { "success": false, "retry_after": N, "error": "..." }
registration_token is required by VerifyRegistrationView and ResendOTPView.
VerifyRegistrationView
POST /auth/register/verify/
Step 2 of customer registration. Verifies the OTP, creates the User and Customer role, creates a 7-day dashboard session, and merges a guest cart into the new customer's cart if one exists.
Request: { "email": "...", "otp_code": "...", "registration_token": "..." }
Response (200):
{
"success": true,
"user": { "...UserBasicSerializer fields..." },
"session": { "...WebSessionSerializer fields..." },
"next_action": "...",
"profile_complete": true,
"missing_fields": []
}
Response (400): { "success": false, "action": "redirect_to_login" | "restart_registration", "remaining_attempts": N }
Response (500): { "success": false, "error": "Registration successful but session creation failed. Please login." }
Guest-cart merge is scoped to carts owned by the resolved guest WebSession only. A cart matching the session-stored cart_id is preferred; otherwise the first active cart owned by that WebSession is used. A cart that matches cart_id but carries no ownership tie to the guest session is not claimed.
ResendOTPView
POST /auth/register/resend-otp/
Resends the registration OTP, invalidating the previous one. Requires the registration_token from InitiateRegistrationView.
Request: { "email": "...", "registration_token": "..." }
Response (200): { "success": true, "message": "..." }
Response (400, expired registration): { "success": false, "action": "restart_registration", "message": "..." }
Response (429): { "success": false, "retry_after": N }
InitiateEmailChangeView
POST /auth/update-email/initiate/
Sends an OTP to a new email address to verify ownership before an email change. Requires an active session with a linked user; session validation happens manually inside the view rather than through authentication_classes.
Request: { "email": "[email protected]" }
Response (200): { "success": true, "message": "Verification code sent to ..." }
Response (400): { "error": "Email is required" } or { "error": "Email address already in use" }
Response (401): { "error": "Authentication required" }
Response (500): { "error": "Failed to send verification code. Please try again." }
The OTP is stored with purpose='email_change', sent to the new email, not the current one.
UpdateUserView
PATCH /auth/users/me/
Updates the current user's name, first/last name, or email. Requires an active session with a linked user.
Request: { "name": "...", "first_name": "...", "last_name": "...", "email": "...", "otp_code": "..." } (all fields optional except otp_code when email changes)
Response (200): { "success": true, "user": { "...UserBasicSerializer fields..." } }
Response (400): { "error": "Name must be at least 2 characters" }, { "error": "OTP code required for email change" }, { "error": "Invalid or expired verification code" }, or { "error": "Email address already in use" }
Response (401): { "error": "Authentication required" }
Name changes need no OTP. Email changes require otp_code from InitiateEmailChangeView. The save is wrapped in sync_context('skip_sync'), since users sync to Sanity on first purchase, not on profile edits.