314 lines
8.4 KiB
TypeScript
314 lines
8.4 KiB
TypeScript
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import SplashPage from '../pages/SplashPage';
|
|
import type { User } from '@supabase/supabase-js';
|
|
import { supabase } from '../supabase';
|
|
import {
|
|
buildCacheKey,
|
|
readCache,
|
|
writeCache,
|
|
clearUserCache,
|
|
} from '../utils/localCache';
|
|
|
|
export interface Profile {
|
|
id: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
full_name: string;
|
|
phone: string;
|
|
country_of_residence: string;
|
|
avatar_path: string | null;
|
|
fcm_token: string | null;
|
|
notification_push_enabled: boolean;
|
|
notification_email_enabled: boolean;
|
|
notification_sms_enabled: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export type ProfileStatus = 'idle' | 'loading' | 'loaded' | 'missing' | 'error';
|
|
|
|
interface AuthContextType {
|
|
user: User | null;
|
|
profile: Profile | null;
|
|
profileStatus: ProfileStatus;
|
|
refreshProfile: () => Promise<Profile | null>;
|
|
setProfile: (profile: Profile | null) => void;
|
|
signOut: () => Promise<void>;
|
|
}
|
|
|
|
const BYPASS_AUTH = false;
|
|
|
|
const AuthContext = createContext<AuthContextType>({
|
|
user: null,
|
|
profile: null,
|
|
profileStatus: BYPASS_AUTH ? 'idle' : 'loading',
|
|
refreshProfile: async () => null,
|
|
setProfile: () => {},
|
|
signOut: async () => {},
|
|
});
|
|
|
|
const withTimeout = async <T,>(
|
|
request: PromiseLike<T>,
|
|
timeoutMs: number,
|
|
label: string
|
|
): Promise<T> => {
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
reject(
|
|
new Error(
|
|
`${label} timed out. Please check your connection and try again.`
|
|
)
|
|
);
|
|
}, timeoutMs);
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([Promise.resolve(request), timeout]);
|
|
} finally {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
export const useAuth = () => useContext(AuthContext);
|
|
|
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [profile, setProfileState] = useState<Profile | null>(null);
|
|
const [profileStatus, setProfileStatus] = useState<ProfileStatus>(
|
|
BYPASS_AUTH ? 'idle' : 'loading'
|
|
);
|
|
const [initializing, setInitializing] = useState(!BYPASS_AUTH);
|
|
const activeProfileFetchRef = useRef(0);
|
|
const mountedRef = useRef(false);
|
|
const currentUserIdRef = useRef<string | null>(null);
|
|
|
|
const applyProfile = useCallback((nextProfile: Profile | null) => {
|
|
if (!mountedRef.current) return;
|
|
setProfileState(nextProfile);
|
|
setProfileStatus(nextProfile ? 'loaded' : 'missing');
|
|
}, []);
|
|
|
|
const setProfile = useCallback(
|
|
(nextProfile: Profile | null) => {
|
|
activeProfileFetchRef.current += 1;
|
|
applyProfile(nextProfile);
|
|
},
|
|
[applyProfile]
|
|
);
|
|
|
|
const fetchProfile = useCallback(
|
|
async (
|
|
userId: string,
|
|
options?: { showLoading?: boolean }
|
|
): Promise<Profile | null> => {
|
|
const fetchId = activeProfileFetchRef.current + 1;
|
|
activeProfileFetchRef.current = fetchId;
|
|
|
|
if ((options?.showLoading ?? true) && mountedRef.current) {
|
|
setProfileStatus('loading');
|
|
}
|
|
|
|
const cacheKey = buildCacheKey(userId, 'profile');
|
|
try {
|
|
const cachedProfile = await readCache<Profile>(cacheKey);
|
|
if (
|
|
cachedProfile &&
|
|
mountedRef.current &&
|
|
fetchId === activeProfileFetchRef.current &&
|
|
currentUserIdRef.current === userId
|
|
) {
|
|
applyProfile(cachedProfile);
|
|
}
|
|
} catch (err) {
|
|
console.error('[AuthProvider] Failed to read cached profile', err);
|
|
}
|
|
|
|
try {
|
|
const { data, error } = await withTimeout(
|
|
supabase.from('profiles').select('*').eq('id', userId).maybeSingle(),
|
|
8000,
|
|
'Profile loading'
|
|
);
|
|
|
|
if (
|
|
!mountedRef.current ||
|
|
fetchId !== activeProfileFetchRef.current ||
|
|
currentUserIdRef.current !== userId
|
|
) {
|
|
return data ? (data as Profile) : null;
|
|
}
|
|
|
|
if (error) {
|
|
console.error('[AuthProvider] Failed to fetch profile', error);
|
|
setProfileState(null);
|
|
setProfileStatus('error');
|
|
return null;
|
|
}
|
|
|
|
const nextProfile = data ? (data as Profile) : null;
|
|
applyProfile(nextProfile);
|
|
if (nextProfile) {
|
|
void writeCache(cacheKey, nextProfile);
|
|
}
|
|
return nextProfile;
|
|
} catch (error) {
|
|
if (
|
|
mountedRef.current &&
|
|
fetchId === activeProfileFetchRef.current &&
|
|
currentUserIdRef.current === userId
|
|
) {
|
|
console.error('[AuthProvider] Profile fetch crashed', error);
|
|
setProfileState(null);
|
|
setProfileStatus('error');
|
|
}
|
|
return null;
|
|
}
|
|
},
|
|
[applyProfile]
|
|
);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
|
|
if (BYPASS_AUTH) {
|
|
setInitializing(false);
|
|
setProfileStatus('idle');
|
|
return () => {
|
|
mountedRef.current = false;
|
|
activeProfileFetchRef.current += 1;
|
|
};
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
supabase.auth
|
|
.getSession()
|
|
.then(async ({ data }) => {
|
|
if (cancelled || !mountedRef.current) return;
|
|
const currentUser = data.session?.user ?? null;
|
|
|
|
const keepSignedIn = localStorage.getItem('kumusha_keep_signed_in');
|
|
if (currentUser && keepSignedIn === 'false') {
|
|
currentUserIdRef.current = null;
|
|
setUser(null);
|
|
setProfileState(null);
|
|
setProfileStatus('idle');
|
|
await clearUserCache(currentUser.id);
|
|
await supabase.auth.signOut();
|
|
if (!cancelled && mountedRef.current) {
|
|
setInitializing(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
currentUserIdRef.current = currentUser?.id ?? null;
|
|
setUser(currentUser);
|
|
if (currentUser) {
|
|
await fetchProfile(currentUser.id, { showLoading: false });
|
|
} else {
|
|
setProfileState(null);
|
|
setProfileStatus('idle');
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error('[AuthProvider] Failed to initialise session', error);
|
|
if (!cancelled && mountedRef.current) {
|
|
currentUserIdRef.current = null;
|
|
setUser(null);
|
|
setProfileState(null);
|
|
setProfileStatus('missing');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled && mountedRef.current) {
|
|
setInitializing(false);
|
|
}
|
|
});
|
|
|
|
const {
|
|
data: { subscription },
|
|
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
|
const nextUser = session?.user ?? null;
|
|
if (!mountedRef.current) return;
|
|
const previousUserId = currentUserIdRef.current;
|
|
currentUserIdRef.current = nextUser?.id ?? null;
|
|
|
|
if (nextUser) {
|
|
if (nextUser.id !== previousUserId) {
|
|
setProfileState(null);
|
|
setProfileStatus('loading');
|
|
}
|
|
setUser(nextUser);
|
|
await fetchProfile(nextUser.id, { showLoading: false });
|
|
} else {
|
|
activeProfileFetchRef.current += 1;
|
|
setUser(null);
|
|
setProfileState(null);
|
|
setProfileStatus('idle');
|
|
if (previousUserId) {
|
|
await clearUserCache(previousUserId);
|
|
}
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
mountedRef.current = false;
|
|
activeProfileFetchRef.current += 1;
|
|
subscription.unsubscribe();
|
|
};
|
|
}, [fetchProfile]);
|
|
|
|
const signOut = useCallback(async () => {
|
|
activeProfileFetchRef.current += 1;
|
|
const currentUserId = currentUserIdRef.current;
|
|
currentUserIdRef.current = null;
|
|
setUser(null);
|
|
setProfileState(null);
|
|
setProfileStatus('idle');
|
|
if (currentUserId) {
|
|
await clearUserCache(currentUserId);
|
|
}
|
|
localStorage.removeItem('kumusha_keep_signed_in');
|
|
await supabase.auth.signOut();
|
|
}, []);
|
|
|
|
const refreshProfile = useCallback(() => {
|
|
return user
|
|
? fetchProfile(user.id, { showLoading: false })
|
|
: Promise.resolve(null);
|
|
}, [fetchProfile, user]);
|
|
|
|
const contextValue = useMemo(
|
|
() => ({
|
|
user,
|
|
profile,
|
|
profileStatus,
|
|
refreshProfile,
|
|
setProfile,
|
|
signOut,
|
|
}),
|
|
[profile, profileStatus, refreshProfile, setProfile, signOut, user]
|
|
);
|
|
|
|
if (initializing) {
|
|
return <SplashPage title="Send support to loved ones from anywhere" />;
|
|
}
|
|
|
|
return (
|
|
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
|
|
);
|
|
};
|