supersam/src/context/AuthContext.jsx

389 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 sessionStorage
sessionStorage.removeItem("supersam-auth");
sessionStorage.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("");
// Ref to prevent getSession from restoring session after explicit signOut
const signedOutRef = useRef(false);
useEffect(() => {
if (!hasSupabaseConfig || !supabase) {
return undefined;
}
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!session?.user) {
setUser(null);
setAuthError("");
window.__supersam_user_id__ = null;
return;
}
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
if (signedOutRef.current || isSignedOut()) {
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" });
}
});
} else {
setUser(null);
}
setAuthError("");
});
supabase.auth.getSession().then(({ data, error }) => {
if (error && isStaleRefreshTokenError(error)) {
setUser(null);
setAuthError("Сессия истекла. Войдите заново.");
clearAllAuthStorage();
void supabase.auth.signOut({ scope: "local" });
return;
}
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
if (signedOutRef.current || isSignedOut()) {
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" });
}
});
}
}
});
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,
authError,
isDemoMode,
requestOtp,
verifyOtp,
signOut,
loginAsDemoUser: (demoUser) => {
setUser(demoUser);
setAuthError("");
},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
};