389 lines
12 KiB
JavaScript
389 lines
12 KiB
JavaScript
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;
|
||
}; |