DocDoeAI / src /lib /auth /AuthProvider.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
6.62 kB
"use client";
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
ApiUser,
AuthLoginRequest,
AuthSignupResponse,
AuthSignupRequest,
UserUpdateRequest,
} from "@/lib/api/types";
import {
fetchCurrentUser,
AUTH_SESSION_UPDATED_EVENT,
getAuthToken,
getStoredUser,
loginWithEmailPassword,
signupWithEmailPassword,
logoutCurrentSession,
updateCurrentUser,
verifyLoginOtp,
verifySignupOtp,
} from "@/lib/auth/auth-client";
import { getPublicApiBaseUrl, getPublicDemoMode } from "@/lib/env";
type AuthLoginOptions = {
staySignedIn?: boolean;
rememberEmail?: boolean;
};
type AuthContextValue = {
user: ApiUser | null;
isLoading: boolean;
isDevMode: boolean;
error: string | null;
refreshUser: () => Promise<void>;
login: (payload: AuthLoginRequest, options?: AuthLoginOptions) => Promise<void>;
loginWithOtp: (
payload: { email: string; code: string },
options?: AuthLoginOptions,
) => Promise<void>;
signup: (payload: AuthSignupRequest) => Promise<AuthSignupResponse>;
verifySignup: (payload: { email: string; code: string }) => Promise<void>;
logout: () => void;
updateProfile: (payload: UserUpdateRequest) => Promise<void>;
};
export const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ApiUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasToken, setHasToken] = useState(false);
const refreshSequence = useRef(0);
const refreshUser = useCallback(async () => {
const sequence = ++refreshSequence.current;
setError(null);
try {
const nextUser = await fetchCurrentUser();
if (sequence !== refreshSequence.current) return;
setUser(nextUser);
setHasToken(Boolean(getAuthToken()));
} catch (caughtError) {
if (sequence !== refreshSequence.current) return;
const message = caughtError instanceof Error ? caughtError.message : "Could not load user session.";
// Distinguish auth errors (401/403) from network/server errors
const isAuthError =
message.includes("401") ||
message.includes("403") ||
message.toLowerCase().includes("unauthorized") ||
message.toLowerCase().includes("session expired");
if (isAuthError) {
// Real auth failure β€” clear session, show guest state
setUser(null);
setHasToken(false);
setError("Your session expired. Please sign in again.");
} else {
// Network or server error β€” preserve existing user to avoid data loss
const stored = getStoredUser();
setUser(stored);
setHasToken(Boolean(getAuthToken()));
if (!stored) {
setError("Could not connect to server. Please check your connection.");
}
// Don't set error if we have a stored user β€” silent retry is better UX
}
} finally {
if (sequence === refreshSequence.current) setIsLoading(false);
}
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => void refreshUser(), 0);
return () => window.clearTimeout(timeoutId);
}, [refreshUser]);
// Background wake-up ping: HF Space sleeps on inactivity and takes ~60s to
// restart. Fire a silent /health request immediately so the Space is awake
// by the time the user tries to send a message.
useEffect(() => {
if (typeof window === "undefined") return;
const base = getPublicApiBaseUrl();
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 90_000);
void fetch(`${base}/health`, { signal: controller.signal }).catch(() => {/* silent */}).finally(() => window.clearTimeout(timer));
return () => { controller.abort(); window.clearTimeout(timer); };
}, []);
useEffect(() => {
function handleSessionUpdated() {
const storedUser = getStoredUser();
const token = getAuthToken();
setUser(storedUser);
setHasToken(Boolean(token));
setError(null);
setIsLoading(false);
// Confirm the just-persisted session with the backend without delaying
// navigation to onboarding or the workspace.
void refreshUser();
}
function handleSessionCleared() {
// Session was explicitly cleared (401 received) β€” show sign-in prompt, not demo data
refreshSequence.current += 1;
setUser(null);
setHasToken(false);
setIsLoading(false);
setError("Your session expired. Please sign in again.");
}
window.addEventListener(AUTH_SESSION_UPDATED_EVENT, handleSessionUpdated);
window.addEventListener("docdoe-auth-session-cleared", handleSessionCleared);
return () => {
window.removeEventListener(AUTH_SESSION_UPDATED_EVENT, handleSessionUpdated);
window.removeEventListener("docdoe-auth-session-cleared", handleSessionCleared);
};
}, [refreshUser]);
const value = useMemo<AuthContextValue>(
() => ({
user,
isLoading,
isDevMode: getPublicDemoMode() !== "live" && Boolean(user) && !hasToken,
error,
refreshUser,
login: async (payload, options) => {
setError(null);
const response = await loginWithEmailPassword(payload, options);
setUser(response.user);
setHasToken(true);
},
loginWithOtp: async (payload, options) => {
setError(null);
const response = await verifyLoginOtp(payload, options);
setUser(response.user);
setHasToken(true);
},
signup: async (payload) => {
setError(null);
const response = await signupWithEmailPassword(payload);
if ("access_token" in response) {
setUser(response.user);
setHasToken(true);
}
return response;
},
verifySignup: async (payload) => {
setError(null);
const response = await verifySignupOtp(payload);
setUser(response.user);
setHasToken(true);
},
logout: () => {
refreshSequence.current += 1;
logoutCurrentSession();
setUser(null);
setHasToken(false);
setIsLoading(false);
},
updateProfile: async (payload) => {
setError(null);
const nextUser = await updateCurrentUser(payload);
setUser(nextUser);
},
}),
[error, hasToken, isLoading, refreshUser, user],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}