Files
grateful-journal/src/contexts/AuthContext.tsx
2026-04-21 11:49:48 +05:30

291 lines
8.3 KiB
TypeScript

import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react'
import {
browserLocalPersistence,
onAuthStateChanged,
setPersistence,
signInWithPopup,
signInWithRedirect,
getRedirectResult,
signOut as firebaseSignOut,
type User,
} from 'firebase/auth'
import { auth, googleProvider } from '../lib/firebase'
import { registerUser, getUserByEmail } from '../lib/api'
import {
deriveSecretKey,
generateDeviceKey,
generateSalt,
getSalt,
saveSalt,
getDeviceKey,
saveDeviceKey,
encryptSecretKey,
decryptSecretKey,
saveEncryptedSecretKey,
getEncryptedSecretKey,
} from '../lib/crypto'
import { REMINDER_TIME_KEY, REMINDER_ENABLED_KEY } from '../hooks/useReminder'
type MongoUser = {
id: string
email: string
displayName?: string
photoURL?: string
theme?: string
tutorial?: boolean
backgroundImage?: string | null
backgroundImages?: string[]
reminder?: {
enabled: boolean
time?: string
timezone?: string
}
}
type AuthContextValue = {
user: User | null
userId: string | null
mongoUser: MongoUser | null
loading: boolean
secretKey: Uint8Array | null
authError: string | null
signInWithGoogle: () => Promise<void>
signOut: () => Promise<void>
refreshMongoUser: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [userId, setUserId] = useState<string | null>(null)
const [mongoUser, setMongoUser] = useState<MongoUser | null>(null)
const [secretKey, setSecretKey] = useState<Uint8Array | null>(null)
const [loading, setLoading] = useState(true)
const [authError, setAuthError] = useState<string | null>(null)
// Apply custom background image whenever mongoUser changes
useEffect(() => {
const bg = mongoUser?.backgroundImage
if (bg) {
document.body.style.backgroundImage = `url(${bg})`
document.body.style.backgroundSize = 'cover'
document.body.style.backgroundPosition = 'center'
document.body.style.backgroundAttachment = 'fixed'
document.body.classList.add('gj-has-bg')
} else {
document.body.style.backgroundImage = ''
document.body.classList.remove('gj-has-bg')
}
}, [mongoUser?.backgroundImage])
// Initialize encryption keys on login
async function initializeEncryption(authUser: User) {
try {
const firebaseUID = authUser.uid
// Get or create salt
let salt = getSalt()
if (!salt) {
salt = generateSalt()
saveSalt(salt)
}
// Derive master key from Firebase UID (stable across sessions)
const derivedKey = await deriveSecretKey(firebaseUID, salt)
// Check if device key exists
let deviceKey = await getDeviceKey()
if (!deviceKey) {
// First login on this device: generate device key
deviceKey = await generateDeviceKey()
await saveDeviceKey(deviceKey)
}
// Check if encrypted key exists in IndexedDB
const cachedEncrypted = await getEncryptedSecretKey()
if (!cachedEncrypted) {
// First login (or IndexedDB cleared): encrypt and cache the key
const encrypted = await encryptSecretKey(derivedKey, deviceKey)
await saveEncryptedSecretKey(encrypted.ciphertext, encrypted.nonce)
} else {
// Subsequent login on same device: verify we can decrypt
// (This ensures device key is correct)
try {
await decryptSecretKey(
cachedEncrypted.ciphertext,
cachedEncrypted.nonce,
deviceKey
)
} catch (error) {
console.warn('Device key mismatch, regenerating...', error)
// Device key doesn't match - regenerate
deviceKey = await generateDeviceKey()
await saveDeviceKey(deviceKey)
const encrypted = await encryptSecretKey(derivedKey, deviceKey)
await saveEncryptedSecretKey(encrypted.ciphertext, encrypted.nonce)
}
}
// Keep secret key in memory for session
setSecretKey(derivedKey)
} catch (error) {
console.error('Error initializing encryption:', error)
throw error
}
}
function syncReminderFromDb(mongoUser: MongoUser) {
const r = mongoUser.reminder
if (r) {
localStorage.setItem(REMINDER_ENABLED_KEY, r.enabled ? 'true' : 'false')
if (r.time) localStorage.setItem(REMINDER_TIME_KEY, r.time)
else localStorage.removeItem(REMINDER_TIME_KEY)
} else {
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
localStorage.removeItem(REMINDER_TIME_KEY)
}
}
// Register or fetch user from MongoDB
async function syncUserWithDatabase(authUser: User) {
try {
const token = await authUser.getIdToken()
const email = authUser.email!
// Initialize encryption before syncing user
await initializeEncryption(authUser)
// Try to get existing user
try {
// console.log('[Auth] Fetching user by email:', email)
const existingUser = await getUserByEmail(email, token) as MongoUser
setUserId(existingUser.id)
setMongoUser(existingUser)
syncReminderFromDb(existingUser)
} catch (error) {
console.warn('[Auth] User not found, registering...', error)
const newUser = await registerUser(
{
email,
displayName: authUser.displayName || undefined,
photoURL: authUser.photoURL || undefined,
},
token
) as MongoUser
// console.log('[Auth] Registered new user:', newUser.id)
setUserId(newUser.id)
setMongoUser(newUser)
syncReminderFromDb(newUser)
}
} catch (error) {
console.error('[Auth] Error syncing user with database:', error)
throw error
}
}
useEffect(() => {
// Handle returning from a redirect sign-in (Safari / iOS / Android WebViews)
getRedirectResult(auth).catch((error) => {
console.error('[Auth] Redirect sign-in error:', error)
setAuthError(error instanceof Error ? error.message : 'Sign-in failed')
})
// onAuthStateChanged below handles the successful redirect result automatically
const unsubscribe = onAuthStateChanged(auth, async (u) => {
setUser(u)
if (u) {
try {
await syncUserWithDatabase(u)
} catch (error) {
console.error('Auth sync failed:', error)
}
} else {
setUserId(null)
setMongoUser(null)
setSecretKey(null)
}
setLoading(false)
})
return () => unsubscribe()
}, [])
async function signInWithGoogle() {
setAuthError(null)
await setPersistence(auth, browserLocalPersistence)
// Safari blocks cross-origin storage in popups (ITP), so use redirect flow
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
if (isSafari) {
await signInWithRedirect(auth, googleProvider)
return
}
try {
await signInWithPopup(auth, googleProvider)
} catch (err: unknown) {
const code = (err as { code?: string })?.code
if (code === 'auth/popup-blocked') {
// Popup was blocked (common on iOS Safari / Android WebViews) — fall back to redirect
await signInWithRedirect(auth, googleProvider)
} else {
throw err
}
}
}
async function refreshMongoUser() {
if (!user) return
try {
const token = await user.getIdToken()
const email = user.email!
const updated = await getUserByEmail(email, token) as MongoUser
setMongoUser(updated)
} catch (error) {
console.error('[Auth] Error refreshing mongo user:', error)
}
}
async function signOut() {
setSecretKey(null)
setMongoUser(null)
localStorage.removeItem('gj-tour-pending-step')
localStorage.removeItem(REMINDER_TIME_KEY)
localStorage.removeItem(REMINDER_ENABLED_KEY)
await firebaseSignOut(auth)
setUserId(null)
}
const value: AuthContextValue = {
user,
userId,
mongoUser,
secretKey,
loading,
authError,
signInWithGoogle,
signOut,
refreshMongoUser,
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (ctx == null) {
throw new Error('useAuth must be used within AuthProvider')
}
return ctx
}