From d8be57c43c8995674a95b16d0ff401429f2a9223 Mon Sep 17 00:00:00 2001 From: adescoteaux1 Date: Mon, 15 Dec 2025 16:18:48 -0500 Subject: [PATCH 1/2] env templates --- backend/env.sample | 11 ----------- backend/env.template | 22 ++++++++++++++++++++++ env.template | 10 ++++++++++ frontend/Dockerfile | 3 --- frontend/env.template | 4 ++++ 5 files changed, 36 insertions(+), 14 deletions(-) delete mode 100644 backend/env.sample create mode 100644 backend/env.template create mode 100644 env.template create mode 100644 frontend/env.template diff --git a/backend/env.sample b/backend/env.sample deleted file mode 100644 index a921ddcb..00000000 --- a/backend/env.sample +++ /dev/null @@ -1,11 +0,0 @@ -DB_USER="" -DB_PASSWORD="" -DB_HOST="" -DB_PORT="" -DB_NAME="" - -PORT="8080" - -SUPABASE_URL="" -SUPABASE_ANON_KEY="" -SUPABASE_SERVICE_ROLE_KEY="" \ No newline at end of file diff --git a/backend/env.template b/backend/env.template new file mode 100644 index 00000000..11f6eb86 --- /dev/null +++ b/backend/env.template @@ -0,0 +1,22 @@ +DB_USER= +DB_PASSWORD= +DB_HOST= +DB_PORT= +DB_NAME= + +PORT= + +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +AWS_S3_BUCKET= + +RESEND_API_KEY= + +DB_MAX_OPEN_CONNS=2 +DB_MAX_IDLE_CONNS=0 +DB_CONN_MAX_LIFETIME=300 \ No newline at end of file diff --git a/env.template b/env.template new file mode 100644 index 00000000..b5c1180a --- /dev/null +++ b/env.template @@ -0,0 +1,10 @@ +NEXT_PUBLIC_API_BASE_URL= + +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +AWS_S3_BUCKET= +AWS_REGION= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +RESEND_API_KEY= diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 85dc3da3..9634f852 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -8,9 +8,6 @@ RUN npm install COPY . . -ARG NEXT_PUBLIC_API_URL -ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL - ARG NEXT_PUBLIC_API_BASE_URL ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL diff --git a/frontend/env.template b/frontend/env.template new file mode 100644 index 00000000..d23dc6d7 --- /dev/null +++ b/frontend/env.template @@ -0,0 +1,4 @@ +NEXT_PUBLIC_API_BASE_URL= + +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= From eac78d0af24798767aa2f0ddcbc8958c2f2df1a9 Mon Sep 17 00:00:00 2001 From: adescoteaux1 Date: Tue, 16 Dec 2025 14:09:59 -0500 Subject: [PATCH 2/2] fix for toggle of email verfication --- backend/api/openapi.yaml | 10 +++ backend/env.template | 2 + backend/internal/auth/login.go | 74 ++++++++----------- backend/internal/auth/signup.go | 39 ++++------ backend/internal/models/auth.go | 34 +++++++++ .../internal/service/handler/auth/handler.go | 8 +- .../service/handler/auth/handler_test.go | 4 +- .../internal/service/handler/auth/login.go | 2 +- .../internal/service/handler/auth/signup.go | 2 +- backend/internal/service/server.go | 12 ++- backend/internal/service/server_test.go | 4 +- frontend/src/app/signup/complete/page.tsx | 62 ++++++++++------ frontend/src/contexts/authContext.tsx | 74 ++++++++++--------- frontend/src/hooks/useAuth.ts | 3 +- .../lib/api/theSpecialStandardAPI.schemas.ts | 4 + 15 files changed, 199 insertions(+), 135 deletions(-) create mode 100644 backend/internal/models/auth.go diff --git a/backend/api/openapi.yaml b/backend/api/openapi.yaml index bafeb886..e0dbe052 100644 --- a/backend/api/openapi.yaml +++ b/backend/api/openapi.yaml @@ -91,6 +91,7 @@ paths: required: - access_token - user + - needs_mfa properties: access_token: type: string @@ -104,6 +105,10 @@ paths: format: uuid description: The unique identifier of the newly created therapist example: "f20e5948-01ba-4113-b453-db05d8bde3bc" + needs_mfa: + type: boolean + description: Indicates if the user needs multi-factor authentication + example: false "400": description: Bad Request (e.g., validation errors) content: @@ -165,6 +170,7 @@ paths: - expires_in - refresh_token - user + - needs_mfa properties: access_token: type: string @@ -190,6 +196,10 @@ paths: format: uuid description: The unique identifier of the newly created therapist example: "f20e5948-01ba-4113-b453-db05d8bde3bc" + needs_mfa: + type: boolean + description: Indicates if the user needs multi-factor authentication + example: false error: content: application/json: diff --git a/backend/env.template b/backend/env.template index 11f6eb86..3c6e1383 100644 --- a/backend/env.template +++ b/backend/env.template @@ -16,6 +16,8 @@ AWS_REGION= AWS_S3_BUCKET= RESEND_API_KEY= +EMAIL_VERIFICATION_ENABLED=true +RESEND_FROM_EMAIL= DB_MAX_OPEN_CONNS=2 DB_MAX_IDLE_CONNS=0 diff --git a/backend/internal/auth/login.go b/backend/internal/auth/login.go index 3b1a20bb..eae1950d 100644 --- a/backend/internal/auth/login.go +++ b/backend/internal/auth/login.go @@ -10,38 +10,26 @@ import ( "specialstandard/internal/errs" "github.com/goccy/go-json" - "github.com/google/uuid" -) - -type userResponse struct { - ID uuid.UUID `json:"id"` -} -type SignInResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - User userResponse `json:"user"` - Error interface{} `json:"error"` -} + "specialstandard/internal/models" +) -func SupabaseLogin(cfg *config.Supabase, email string, password string) (SignInResponse, error) { +func SupabaseLogin(cfg *config.Supabase, email string, password string, needsEmailVerification bool) (models.SignInResponse, error) { supabaseURL := cfg.URL serviceroleKey := cfg.ServiceRoleKey - payload := Payload{ + payload := models.Payload{ Email: email, Password: password, } payloadBytes, err := json.Marshal(payload) if err != nil { - return SignInResponse{}, err + return models.SignInResponse{}, err } req, err := http.NewRequest("POST", fmt.Sprintf("%s/auth/v1/token?grant_type=password", supabaseURL), bytes.NewBuffer(payloadBytes)) if err != nil { - return SignInResponse{}, err + return models.SignInResponse{}, err } req.Header.Set("Content-Type", "application/json") @@ -51,7 +39,7 @@ func SupabaseLogin(cfg *config.Supabase, email string, password string) (SignInR res, err := Client.Do(req) if err != nil { slog.Error("Failed to execute Request", "err", err) - return SignInResponse{}, errs.BadRequest("Failed to execute Request") + return models.SignInResponse{}, errs.BadRequest("Failed to execute Request") } defer func() { _ = res.Body.Close() @@ -60,40 +48,42 @@ func SupabaseLogin(cfg *config.Supabase, email string, password string) (SignInR body, err := io.ReadAll(res.Body) if err != nil { slog.Error("Failed to read response body", "err", err) - return SignInResponse{}, errs.BadRequest("Failed to read response body") + return models.SignInResponse{}, errs.BadRequest("Failed to read response body") } if res.StatusCode != http.StatusOK { - // Try to parse the error response - var errorResp struct { - Message string `json:"msg"` - Error string `json:"error"` - } - - if err := json.Unmarshal(body, &errorResp); err == nil { - // Use the parsed message if available - if errorResp.Message != "" { - return SignInResponse{}, errs.BadRequest(errorResp.Message) - } - if errorResp.Error != "" { - return SignInResponse{}, errs.BadRequest(errorResp.Error) - } - } - - // Fallback to generic message if parsing fails - return SignInResponse{}, errs.BadRequest("Invalid credentials") -} + // Try to parse the error response + var errorResp struct { + Message string `json:"msg"` + Error string `json:"error"` + } + + if err := json.Unmarshal(body, &errorResp); err == nil { + // Use the parsed message if available + if errorResp.Message != "" { + return models.SignInResponse{}, errs.BadRequest(errorResp.Message) + } + if errorResp.Error != "" { + return models.SignInResponse{}, errs.BadRequest(errorResp.Error) + } + } - var signInResponse SignInResponse + // Fallback to generic message if parsing fails + return models.SignInResponse{}, errs.BadRequest("Invalid credentials") + } + + var signInResponse models.SignInResponse err = json.Unmarshal(body, &signInResponse) if err != nil { slog.Error("Failed to parse response body", "body", err) - return SignInResponse{}, errs.BadRequest("Failed to parse response body") + return models.SignInResponse{}, errs.BadRequest("Failed to parse response body") } if signInResponse.Error != nil { - return SignInResponse{}, errs.BadRequest(fmt.Sprintf("Sign In Response Error %v", signInResponse.Error)) + return models.SignInResponse{}, errs.BadRequest(fmt.Sprintf("Sign In Response Error %v", signInResponse.Error)) } + signInResponse.RequiresMFA = needsEmailVerification + return signInResponse, nil } diff --git a/backend/internal/auth/signup.go b/backend/internal/auth/signup.go index 92e2c833..007699a2 100644 --- a/backend/internal/auth/signup.go +++ b/backend/internal/auth/signup.go @@ -12,22 +12,9 @@ import ( "specialstandard/internal/errs" "github.com/goccy/go-json" - "github.com/google/uuid" -) - -type Payload struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type UserSignupResponse struct { - ID uuid.UUID `json:"id"` -} -type SignupResponse struct { - AccessToken string `json:"access_token"` - User UserSignupResponse `json:"user"` -} + "specialstandard/internal/models" +) func validatePasswordStrength(password string) error { if len(password) < 8 { @@ -45,27 +32,27 @@ func validatePasswordStrength(password string) error { return nil } -func SupabaseSignup(cfg *config.Supabase, email, password string) (SignupResponse, error) { +func SupabaseSignup(cfg *config.Supabase, email, password string, needsEmailVerification bool) (models.SignupResponse, error) { if err := validatePasswordStrength(password); err != nil { - return SignupResponse{}, errs.BadRequest(fmt.Sprintf("Weak Password: %v", err)) + return models.SignupResponse{}, errs.BadRequest(fmt.Sprintf("Weak Password: %v", err)) } supabaseURL := cfg.URL apiKey := cfg.ServiceRoleKey - payload := Payload{ + payload := models.Payload{ Email: email, Password: password, } payloadBytes, err := json.Marshal(payload) if err != nil { - return SignupResponse{}, err + return models.SignupResponse{}, err } req, err := http.NewRequest("POST", fmt.Sprintf("%s/auth/v1/signup", supabaseURL), bytes.NewBuffer(payloadBytes)) if err != nil { slog.Error("Error in Request Creation: ", "err", err) - return SignupResponse{}, errs.BadRequest(fmt.Sprintf("Failed to create request: %v", err)) + return models.SignupResponse{}, errs.BadRequest(fmt.Sprintf("Failed to create request: %v", err)) } req.Header.Set("Content-Type", "application/json") @@ -75,7 +62,7 @@ func SupabaseSignup(cfg *config.Supabase, email, password string) (SignupRespons res, err := Client.Do(req) if err != nil { slog.Error("Error executing request: ", "err", err) - return SignupResponse{}, errs.BadRequest(fmt.Sprintf("Failed to execute request: %v, %s", err, supabaseURL)) + return models.SignupResponse{}, errs.BadRequest(fmt.Sprintf("Failed to execute request: %v, %s", err, supabaseURL)) } defer func() { _ = res.Body.Close() @@ -84,19 +71,21 @@ func SupabaseSignup(cfg *config.Supabase, email, password string) (SignupRespons body, err := io.ReadAll(res.Body) if err != nil { slog.Error("Error reading response body: ", "body", body) - return SignupResponse{}, errs.BadRequest("Failed to read response body", string(body)) + return models.SignupResponse{}, errs.BadRequest("Failed to read response body", string(body)) } if res.StatusCode != http.StatusOK { slog.Error("Error Response: ", "res.StatusCode", res.StatusCode, "body", string(body)) - return SignupResponse{}, errs.BadRequest(fmt.Sprintf("Failed to login %d, %s", res.StatusCode, body)) + return models.SignupResponse{}, errs.BadRequest(fmt.Sprintf("Failed to login %d, %s", res.StatusCode, body)) } - var response SignupResponse + var response models.SignupResponse if err := json.Unmarshal(body, &response); err != nil { slog.Error("Error parsing response: ", "err", err) - return SignupResponse{}, errs.BadRequest("Failed to parse request") + return models.SignupResponse{}, errs.BadRequest("Failed to parse request") } + response.RequiresMFA = needsEmailVerification + return response, nil } diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go new file mode 100644 index 00000000..c29ff6ab --- /dev/null +++ b/backend/internal/models/auth.go @@ -0,0 +1,34 @@ +package models + +import ( + "github.com/google/uuid" +) + +type userResponse struct { + ID uuid.UUID `json:"id"` +} + +type SignInResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + User userResponse `json:"user"` + Error interface{} `json:"error"` + RequiresMFA bool `json:"needs_mfa"` +} + +type Payload struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type UserSignupResponse struct { + ID uuid.UUID `json:"id"` +} + +type SignupResponse struct { + AccessToken string `json:"access_token"` + User UserSignupResponse `json:"user"` + RequiresMFA bool `json:"needs_mfa"` +} diff --git a/backend/internal/service/handler/auth/handler.go b/backend/internal/service/handler/auth/handler.go index d387942e..444b0d70 100644 --- a/backend/internal/service/handler/auth/handler.go +++ b/backend/internal/service/handler/auth/handler.go @@ -6,8 +6,9 @@ import ( ) type Handler struct { - config config.Supabase - therapistRepository storage.TherapistRepository + config config.Supabase + therapistRepository storage.TherapistRepository + emailVerificationEnabled bool } type Credentials struct { @@ -18,9 +19,10 @@ type Credentials struct { RememberMe bool `json:"remember_me"` } -func NewHandler(config config.Supabase, therapistRepository storage.TherapistRepository) *Handler { +func NewHandler(config config.Supabase, therapistRepository storage.TherapistRepository, emailVerificationEnabled bool) *Handler { return &Handler{ config, therapistRepository, + emailVerificationEnabled, } } diff --git a/backend/internal/service/handler/auth/handler_test.go b/backend/internal/service/handler/auth/handler_test.go index d816f447..843306c7 100644 --- a/backend/internal/service/handler/auth/handler_test.go +++ b/backend/internal/service/handler/auth/handler_test.go @@ -85,7 +85,7 @@ func TestHandler_SignUp(t *testing.T) { ServiceRoleKey: "SRK", } - handler := NewHandler(mockConfig, mockRepo) + handler := NewHandler(mockConfig, mockRepo, true) app.Post("/signup", handler.SignUp) req := httptest.NewRequest("POST", "/signup", strings.NewReader(tt.payload)) @@ -152,7 +152,7 @@ func TestHandler_Login(t *testing.T) { ServiceRoleKey: "SRK", } - handler := NewHandler(mockConfig, mockRepo) + handler := NewHandler(mockConfig, mockRepo, true) app.Post("/login", handler.Login) req := httptest.NewRequest("POST", "/login", strings.NewReader(tt.payload)) diff --git a/backend/internal/service/handler/auth/login.go b/backend/internal/service/handler/auth/login.go index 7ac26d9b..122c7341 100644 --- a/backend/internal/service/handler/auth/login.go +++ b/backend/internal/service/handler/auth/login.go @@ -17,7 +17,7 @@ func (h *Handler) Login(c *fiber.Ctx) error { return errs.BadRequest(fmt.Sprintf("Invalid Request Body: %v", cred)) } - signInResponse, err := auth.SupabaseLogin(&h.config, cred.Email, cred.Password) + signInResponse, err := auth.SupabaseLogin(&h.config, cred.Email, cred.Password, h.emailVerificationEnabled) if err != nil { slog.Error("Supabase Login Error: ", "err", err.Error()) diff --git a/backend/internal/service/handler/auth/signup.go b/backend/internal/service/handler/auth/signup.go index a2461cf8..723d2756 100644 --- a/backend/internal/service/handler/auth/signup.go +++ b/backend/internal/service/handler/auth/signup.go @@ -22,7 +22,7 @@ func (h *Handler) SignUp(c *fiber.Ctx) error { return errs.BadRequest(fmt.Sprintf("Invalid Request Body: %v", err)) } - res, err := auth.SupabaseSignup(&h.config, cred.Email, cred.Password) + res, err := auth.SupabaseSignup(&h.config, cred.Email, cred.Password, h.emailVerificationEnabled) if err != nil { slog.Error(fmt.Sprintf("Signup Request Failed: %v", err)) return errs.InternalServerError(fmt.Sprintf("Signup Request Failed: %v", err)) diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 13ffd9f6..0338f2c9 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -2,6 +2,7 @@ package service import ( "log/slog" + "os" "specialstandard/internal/config" "specialstandard/internal/errs" "specialstandard/internal/s3_client" @@ -21,6 +22,7 @@ import ( "specialstandard/internal/service/verification" "specialstandard/internal/storage" "specialstandard/internal/storage/postgres" + "strconv" "context" "net/http" @@ -76,6 +78,14 @@ func SetupApp(config config.Config, repo *storage.Repository, bucket *s3_client. Level: compress.LevelBestSpeed, })) + // Parse email verification enabled flag (defaults to true if not set or invalid) + emailVerificationEnabled := true + if envValue := os.Getenv("EMAIL_VERIFICATION_ENABLED"); envValue != "" { + if parsed, err := strconv.ParseBool(envValue); err == nil { + emailVerificationEnabled = parsed + } + } + // Use logging middleware app.Use(logger.New()) @@ -109,7 +119,7 @@ func SetupApp(config config.Config, repo *storage.Repository, bucket *s3_client. return c.SendStatus(http.StatusOK) }) - SupabaseAuthHandler := auth.NewHandler(config.Supabase, repo.Therapist) + SupabaseAuthHandler := auth.NewHandler(config.Supabase, repo.Therapist, emailVerificationEnabled) authGroup := apiV1.Group("/auth") authGroup.Post("/login", SupabaseAuthHandler.Login) diff --git a/backend/internal/service/server_test.go b/backend/internal/service/server_test.go index 605a52b6..9777a555 100644 --- a/backend/internal/service/server_test.go +++ b/backend/internal/service/server_test.go @@ -2207,7 +2207,7 @@ func TestHandler_Signup(t *testing.T) { ServiceRoleKey: "SRK", } - handler := auth.NewHandler(mockConfig, mockRepo) + handler := auth.NewHandler(mockConfig, mockRepo, true) app.Post("/signup", handler.SignUp) req := httptest.NewRequest("POST", "/signup", strings.NewReader(tt.payload)) @@ -2274,7 +2274,7 @@ func TestHandler_Login(t *testing.T) { ServiceRoleKey: "SRK", } - handler := auth.NewHandler(mockConfig, mockRepo) + handler := auth.NewHandler(mockConfig, mockRepo, true) app.Post("/login", handler.Login) req := httptest.NewRequest("POST", "/login", strings.NewReader(tt.payload)) diff --git a/frontend/src/app/signup/complete/page.tsx b/frontend/src/app/signup/complete/page.tsx index d576439c..2b8cd38b 100644 --- a/frontend/src/app/signup/complete/page.tsx +++ b/frontend/src/app/signup/complete/page.tsx @@ -10,9 +10,8 @@ import { useEffect, useState } from "react"; export default function CompletePage() { const router = useRouter(); const [userName, setUserName] = useState(""); - const [needsMFA, setNeedsMFA] = useState(true); + const [needsMFA, setNeedsMFA] = useState(null); // null = loading const [tempUserId, setTempUserId] = useState(null); - const { completeMFALogin } = useAuthContext(); useEffect(() => { @@ -23,33 +22,57 @@ export default function CompletePage() { setUserName(data.firstName || ""); } - // Get temp userId from localStorage (stored during signup) + // Check if MFA is required (stored during signup) + const requiresMFA = localStorage.getItem("signup_requires_mfa"); const storedTempUserId = localStorage.getItem("temp_userId"); - if (storedTempUserId) { - setTempUserId(storedTempUserId); - } else { - // If no temp userId, something went wrong - redirect to signup + + if (!storedTempUserId) { + // No temp userId - something went wrong, redirect to signup console.error("No temp userId found"); router.push("/signup"); + return; + } + + setTempUserId(storedTempUserId); + + // Check if MFA is required + if (requiresMFA === "false") { + // MFA not required - complete login immediately + setNeedsMFA(false); + completeMFALogin(); + + // Clean up + localStorage.removeItem("signup_requires_mfa"); + localStorage.removeItem("onboardingData"); + localStorage.removeItem("therapistProfile"); + localStorage.removeItem("onboardingSessions"); + + // Redirect immediately + setTimeout(() => { + router.push("/"); + }, 500); + } else { + // MFA required - show verification screen + setNeedsMFA(true); } - }, [router]); + }, [router, completeMFALogin]); const handleMFAVerified = () => { completeMFALogin(); setNeedsMFA(false); - - // Clean up onboarding data + + // Clean up + localStorage.removeItem("signup_requires_mfa"); localStorage.removeItem("onboardingData"); localStorage.removeItem("therapistProfile"); - localStorage.removeItem("onboardingStudents"); localStorage.removeItem("onboardingSessions"); - - // Redirect immediately + + // Redirect router.push("/"); }; - // Show loading if we don't have userId yet - if (!tempUserId) { + // Show loading while checking MFA requirement + if (needsMFA === null || !tempUserId) { return (
@@ -57,7 +80,7 @@ export default function CompletePage() { ); } - // Show MFA verification first + // Show MFA verification if needed if (needsMFA) { return (
@@ -69,19 +92,17 @@ export default function CompletePage() { ); } - // Success screen (brief loading state while redirecting) + // Success screen (brief loading state while redirecting when MFA disabled) return (

{userName ? `${userName}, you're all set!` : "You're all set!"}

-

Your account is ready to go. You can head straight to your dashboard and view your sessions on your schedule!

-
-
); -} +} \ No newline at end of file diff --git a/frontend/src/contexts/authContext.tsx b/frontend/src/contexts/authContext.tsx index fc290424..9ba3a95f 100644 --- a/frontend/src/contexts/authContext.tsx +++ b/frontend/src/contexts/authContext.tsx @@ -1,18 +1,17 @@ "use client"; import { useAuth } from "@/hooks/useAuth"; -import { useTherapist } from "@/hooks/useTherapists"; // Import the hook to fetch profile -import type { PostAuthLoginBody, PostAuthSignupBody, Therapist } from "@/lib/api/theSpecialStandardAPI.schemas"; // Combined type import +import { useTherapist } from "@/hooks/useTherapists"; +import type { PostAuthLoginBody, PostAuthSignupBody, Therapist } from "@/lib/api/theSpecialStandardAPI.schemas"; import { useRouter } from "next/navigation"; import { createContext, useContext, useEffect, useState } from "react"; -// 1. Update the AuthContextType to include the profile and its loading state interface AuthContextType { userId: string | null; isAuthenticated: boolean; - isLoading: boolean; // Initial check loading - therapistProfile: Therapist | null; // ADDED: Therapist profile data - isProfileLoading: boolean; // ADDED: Status of profile data fetching + isLoading: boolean; + therapistProfile: Therapist | null; + isProfileLoading: boolean; login: ( credentials: PostAuthLoginBody ) => Promise<{ requiresMFA: boolean; userId?: string | null }>; @@ -33,20 +32,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const { userLogin, userLogout, userSignup } = useAuth(); - // 2. Use the dedicated hook to fetch the profile based on userId const { therapist: therapistProfile, isLoading: isProfileLoading, } = useTherapist(userId); - // Check if user is authenticated on mount useEffect(() => { const checkAuth = () => { const storedUserId = localStorage.getItem("userId"); const storedJwt = localStorage.getItem("jwt"); - // Only authenticate if we have REAL jwt and userId (not temp ones) if (storedJwt && storedUserId) { setUserId(storedUserId); } else { @@ -62,8 +58,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const response = await userLogin(credentials); console.warn("Login response:", response); - // Store credentials temporarily, DON'T set as authenticated yet - if (response.access_token && response.user?.id) { + if (!response.access_token || !response.user?.id) { + throw new Error("Invalid login response"); + } + + // Check if backend indicates MFA is required for this login + // When EMAIL_VERIFICATION_ENABLED=false, backend returns requires_mfa: false + const requiresMFA = response.needs_mfa ?? true; // Default to true for safety + + if (requiresMFA) { + // Store temporarily and require MFA localStorage.setItem("temp_jwt", response.access_token); localStorage.setItem("temp_userId", response.user.id); @@ -72,21 +76,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { userId: response.user.id, }); - // Return that MFA is required AND return the userId return { requiresMFA: true, userId: response.user.id }; - } - - // If no MFA required (shouldn't happen in your case), authenticate immediately - if (response.access_token) { + } else { + // MFA not required (verification disabled) - authenticate immediately localStorage.setItem("jwt", response.access_token); - } - if (response.user?.id) { localStorage.setItem("userId", response.user.id); setUserId(response.user.id); - // userId change triggers useTherapist + + return { requiresMFA: false, userId: response.user.id }; } - - return { requiresMFA: false, userId: response.user?.id || null }; } catch (error) { console.error("Login failed:", error); throw error; @@ -126,7 +124,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (finalUserId) { setUserId(finalUserId); - // userId change triggers useTherapist } }; @@ -134,18 +131,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { const response = await userSignup(credentials); - // Store as temp credentials (similar to login) - DON'T authenticate yet - if (response.access_token && response.user?.id) { - localStorage.setItem("temp_jwt", response.access_token); - localStorage.setItem("temp_userId", response.user.id); - - setPendingMFAAuth({ - jwt: response.access_token, - userId: response.user.id, - }); + if (!response.access_token || !response.user?.id) { + throw new Error("Invalid signup response"); } - // DON'T set userId or authenticate - wait for MFA + // Check if MFA is required + const requiresMFA = response.needs_mfa ?? true; // Default to true for safety + + // Store temp credentials + localStorage.setItem("temp_jwt", response.access_token); + localStorage.setItem("temp_userId", response.user.id); + localStorage.setItem("signup_requires_mfa", String(requiresMFA)); // Store MFA requirement + + setPendingMFAAuth({ + jwt: response.access_token, + userId: response.user.id, + }); + router.push("/signup/link"); } catch (error) { console.error("Signup failed:", error); @@ -160,6 +162,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.removeItem("temp_jwt"); localStorage.removeItem("temp_userId"); localStorage.removeItem("recentlyViewedStudents"); + localStorage.removeItem("signup_requires_mfa"); // Clean up signup MFA flag setPendingMFAAuth(null); @@ -172,19 +175,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); userLogout(); - setUserId(null); // Setting userId to null automatically resets the profile via useTherapist + setUserId(null); router.push("/login"); }; - // 3. Update the Context.Provider value return ( { localStorage.removeItem('jwt') localStorage.removeItem('userId') - localStorage.removeItem('recentlyViewedStudents') + localStorage.removeItem('recentlyViewedStudents') + localStorage.removeItem("signup_requires_mfa"); queryClient.setQueryData(['user'], null) queryClient.invalidateQueries({ queryKey: ['user'] }) diff --git a/frontend/src/lib/api/theSpecialStandardAPI.schemas.ts b/frontend/src/lib/api/theSpecialStandardAPI.schemas.ts index ba6b45da..eb94582d 100644 --- a/frontend/src/lib/api/theSpecialStandardAPI.schemas.ts +++ b/frontend/src/lib/api/theSpecialStandardAPI.schemas.ts @@ -826,6 +826,8 @@ export type PostAuthSignup201 = { /** The JWT Access Token issued for this User */ access_token: string; user: PostAuthSignup201User; + /** Indicates if the user needs multi-factor authentication */ + needs_mfa: boolean; }; export type PostAuthLoginBody = { @@ -852,6 +854,8 @@ export type PostAuthLogin200 = { /** Used to get a new access token after the current one expires... */ refresh_token: string; user: PostAuthLogin200User; + /** Indicates if the user needs multi-factor authentication */ + needs_mfa: boolean; error?: unknown; };