import React, { createContext, useContext, useEffect, useRef, useState } from "react"; import { demoUsers } from "../data/mockAppData"; import { supabase, hasSupabaseConfig } from "../supabaseClient"; import { logError } from "../utils/errorLogger"; const AuthContext = createContext(null); const STORAGE_KEY = "construction-auth-local-user"; const SIGNED_OUT_FLAG = "supersam-signed-out"; const encodeLocalAuth = (data) => { try { return btoa(encodeURIComponent(JSON.stringify(data))); } catch { return null; } }; const decodeLocalAuth = (raw) => { try { return JSON.parse(decodeURIComponent(atob(raw))); } catch { return null; } }; export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя."; export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору."; const UNKNOWN_EMAIL_ERROR_PATTERNS = [ /user not found/i, /email not found/i, /user does not exist/i, /invalid login credentials/i, /signup is disabled/i, /sign up is disabled/i, /signups not allowed/i, /email not registered/i, /email address is not verified/i, ]; const STALE_REFRESH_TOKEN_PATTERNS = [ /invalid refresh token/i, /refresh token not found/i, ]; export const normalizeOtpError = (error) => { const message = error instanceof Error ? error.message : String(error || ""); if (UNKNOWN_EMAIL_ERROR_PATTERNS.some((pattern) => pattern.test(message))) { return new Error(UNKNOWN_EMAIL_ERROR); } return error instanceof Error ? error : new Error(message || PROFILE_LOAD_ERROR); }; export const isStaleRefreshTokenError = (error) => { const message = error instanceof Error ? error.message : String(error || ""); return STALE_REFRESH_TOKEN_PATTERNS.some((pattern) => pattern.test(message)); }; export const buildOtpRequestPayload = (email) => ({ email, options: { shouldCreateUser: false, }, }); export const resolveDemoUser = (email, roleHint) => { return ( demoUsers.find((user) => user.role === roleHint) || demoUsers.find((user) => user.email === email) || demoUsers[0] ); }; export const mapProfileToAuthUser = (profile) => { if (!profile) { return null; } const roleInfo = Array.isArray(profile.role_info) ? profile.role_info[0] : profile.role_info; return { id: profile.id, email: profile.email, name: profile.name, role: roleInfo?.name || "manager", lastLogin: profile.last_login, }; }; export const mapSessionUserToAuthUser = (sessionUser) => { if (!sessionUser) { return null; } const userMetadata = sessionUser.user_metadata || {}; const appMetadata = sessionUser.app_metadata || {}; return { id: sessionUser.id, email: sessionUser.email, name: userMetadata.name || sessionUser.email || "Пользователь", role: userMetadata.role || appMetadata.role || null, lastLogin: sessionUser.last_sign_in_at || sessionUser.updated_at || null, }; }; export const fetchUserProfile = async (userId) => { if (!supabase || !userId) return null; const { data, error } = await supabase .from("users") .select("id, email, name, role_id, last_login, roles(name)") .eq("id", userId) .maybeSingle(); if (error || !data) return null; return { id: data.id, email: data.email, name: data.name, role_info: data.roles, last_login: data.last_login, }; }; /** Check if user explicitly signed out (flag survives page refresh via sessionStorage) */ const isSignedOut = () => sessionStorage.getItem(SIGNED_OUT_FLAG) === "1"; /** Clear ALL auth state from storage — called on explicit signOut */ const clearAllAuthStorage = () => { // Clear Supabase secureStorage keys from localStorage localStorage.removeItem("supersam-auth"); localStorage.removeItem("supersam-ak"); // Clear local auth cache from localStorage localStorage.removeItem(STORAGE_KEY); localStorage.removeItem("construction-auth-role-hint"); // Set signed-out flag so page refresh doesn't auto-restore session sessionStorage.setItem(SIGNED_OUT_FLAG, "1"); }; export const AuthProvider = ({ children }) => { // Supabase mode: always start null, session restore via onAuthStateChange // Demo mode: restore from localStorage const [user, setUser] = useState(() => { if (hasSupabaseConfig) return null; const stored = localStorage.getItem(STORAGE_KEY); return stored ? decodeLocalAuth(stored) : null; }); const [pendingEmail, setPendingEmail] = useState(""); const [isOtpSent, setIsOtpSent] = useState(false); const [isLoading, setIsLoading] = useState(false); const [authError, setAuthError] = useState(""); // Track whether the initial session restore from Supabase has completed const [isSessionLoading, setIsSessionLoading] = useState(() => !!(hasSupabaseConfig && supabase)); // Ref to prevent getSession from restoring session after explicit signOut const signedOutRef = useRef(false); useEffect(() => { if (!hasSupabaseConfig || !supabase) { return undefined; } // Track whether getSession() has resolved — onAuthStateChange's INITIAL_SESSION // can fire with null before storage has been read, causing premature redirect. // Only onAuthStateChange should update user AFTER initial load is complete. let getSessionResolved = false; const { data: { subscription }, } = supabase.auth.onAuthStateChange((event, session) => { // During initial load, ignore null sessions from onAuthStateChange — // getSession() is the authoritative source. SIGNED_OUT events are always valid. if (!session?.user) { if (!getSessionResolved && event === "INITIAL_SESSION") { // Don't set user=null or isSessionLoading=false yet — let getSession() decide. return; } setUser(null); setAuthError(""); window.__supersam_user_id__ = null; setIsSessionLoading(false); return; } // Block session restore if user explicitly signed out (ref or sessionStorage flag) if (signedOutRef.current || isSignedOut()) { setIsSessionLoading(false); return; } const baseUser = mapSessionUserToAuthUser(session.user); // Expose userId for error logger window.__supersam_user_id__ = session.user?.id || null; if (baseUser) { fetchUserProfile(session.user.id).then((profile) => { if (profile) { setUser(mapProfileToAuthUser(profile)); } else { setUser({ ...baseUser, role: baseUser.role || "manager" }); } setIsSessionLoading(false); }); } else { setUser(null); setIsSessionLoading(false); } setAuthError(""); }); supabase.auth.getSession().then(({ data, error }) => { getSessionResolved = true; if (error && isStaleRefreshTokenError(error)) { setUser(null); setAuthError("Сессия истекла. Войдите заново."); clearAllAuthStorage(); void supabase.auth.signOut({ scope: "local" }); setIsSessionLoading(false); return; } // Block session restore if user explicitly signed out (ref or sessionStorage flag) if (signedOutRef.current || isSignedOut()) { setIsSessionLoading(false); return; } if (data.session?.user) { const baseUser = mapSessionUserToAuthUser(data.session.user); if (baseUser) { fetchUserProfile(data.session.user.id).then((profile) => { if (profile) { setUser(mapProfileToAuthUser(profile)); } else { setUser({ ...baseUser, role: baseUser.role || "manager" }); } setIsSessionLoading(false); }); } else { setIsSessionLoading(false); } } else { setIsSessionLoading(false); } }).catch(() => { // getSession rejected — ensure we don't hang forever setIsSessionLoading(false); }); return () => subscription.unsubscribe(); }, []); useEffect(() => { if (user && isDemoMode) { const encoded = encodeLocalAuth(user); if (encoded) localStorage.setItem(STORAGE_KEY, encoded); } if (!user && isDemoMode) { localStorage.removeItem(STORAGE_KEY); } }, [user]); const requestOtp = async ({ email, roleHint = "manager" }) => { setIsLoading(true); setPendingEmail(email); setAuthError(""); try { if (hasSupabaseConfig && supabase) { const { data, error } = await supabase.functions.invoke("request-otp", { body: buildOtpRequestPayload(email), }); if (error || data?.ok === false) { let edgeErrorMessage = data?.error; if (!edgeErrorMessage && typeof Response !== "undefined" && error?.context instanceof Response) { try { const cloned = error.context.clone(); const body = await cloned.json(); edgeErrorMessage = body?.error || body?.message; } catch (e) { // ignore parse failure } } throw normalizeOtpError(new Error(edgeErrorMessage || (error instanceof Error ? error.message : String(error)) || PROFILE_LOAD_ERROR)); } } else { localStorage.setItem("construction-auth-role-hint", roleHint || "manager"); } setIsOtpSent(true); return { success: true }; } catch (error) { const normalizedError = normalizeOtpError(error); setAuthError(normalizedError.message); logError(error, { component: "AuthContext.sendOtp" }); return { success: false, error: normalizedError }; } finally { setIsLoading(false); } }; const verifyOtp = async ({ email, otp }) => { setIsLoading(true); try { if (hasSupabaseConfig && supabase) { const { data, error } = await supabase.functions.invoke("verify-otp", { body: { email, otp }, }); if (error || data?.ok === false) { let edgeErrorMessage = data?.error; if (!edgeErrorMessage && typeof Response !== "undefined" && error?.context instanceof Response) { try { const cloned = error.context.clone(); const body = await cloned.json(); edgeErrorMessage = body?.error || body?.message; } catch (e) { // ignore parse failure } } throw normalizeOtpError(new Error(edgeErrorMessage || (error instanceof Error ? error.message : String(error)) || PROFILE_LOAD_ERROR)); } // Clear signedOut flag — user is logging in signedOutRef.current = false; sessionStorage.removeItem(SIGNED_OUT_FLAG); if (data?.session?.access_token && data?.session?.refresh_token) { const { data: sessionData, error: sessionError } = await supabase.auth.setSession({ access_token: data.session.access_token, refresh_token: data.session.refresh_token, }); if (sessionError) { throw normalizeOtpError(sessionError); } const baseUser = mapSessionUserToAuthUser(sessionData.session?.user || data.session.user); if (baseUser) { const profile = await fetchUserProfile(baseUser.id); if (profile) { setUser(mapProfileToAuthUser(profile)); } else { setUser({ ...baseUser, role: baseUser.role || "manager" }); } } } else { setUser(mapSessionUserToAuthUser(data?.user || null)); } setAuthError(""); return { success: Boolean(data?.session || data?.user) }; } if (otp !== "000000") { throw new Error("Неверный код подтверждения"); } const roleHint = localStorage.getItem("construction-auth-role-hint"); const demoUser = resolveDemoUser(email, roleHint); setUser(demoUser); return { success: true }; } catch (error) { const normalizedError = normalizeOtpError(error); setAuthError(normalizedError.message); logError(error, { component: "AuthContext.verifyOtp" }); return { success: false, error: normalizedError }; } finally { setIsLoading(false); } }; const signOut = async () => { // Set flag BEFORE signOut to prevent onAuthStateChange/getSession from restoring session signedOutRef.current = true; if (hasSupabaseConfig && supabase) { try { await supabase.auth.signOut({ scope: "local" }); } catch (e) { // Ignore — session may already be invalid } } // Hard clear all auth storage so auto-login is impossible clearAllAuthStorage(); setUser(null); setPendingEmail(""); setIsOtpSent(false); setAuthError(""); }; const isDemoMode = !hasSupabaseConfig && import.meta.env.VITE_ENABLE_DEMO === "true"; const value = { user, pendingEmail, isOtpSent, isLoading, isSessionLoading, authError, isDemoMode, requestOtp, verifyOtp, signOut, loginAsDemoUser: (demoUser) => { setUser(demoUser); setAuthError(""); }, }; return {children}; }; export const useAuth = () => { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used within AuthProvider"); } return context; };