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.


Was this page helpful?