291 lines
8.3 KiB
TypeScript
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
|
|
}
|