// src/context/AuthContext.jsx
//
// Auth state + sign-in / sign-up methods for Library Loot.
//
// Wraps Firebase Auth. Exposes the current user, the parsed ID token claims
// (`admin` and `tenant` — populated by Cloud Functions in ITEM 2b), and
// helpers for Google and Email/Password sign-in. All components that need
// auth state read it via the `useAuth()` hook below.
//
// Note on claims: in ITEM 2a no Cloud Function has set claims yet, so
// `claims` will only contain Firebase's built-in claims (uid, email, etc.).
// In ITEM 2b, `claimSetupToken` sets `{ admin: true, tenant: 'luckey-logic' }`
// on the calling user. Consumers should treat missing claims as
// "regular signed-in parent user."
//
// Created by Miguel Brown on 5/12/26.
// Copyright (c) 2026 Luckey Logic LLC. All rights reserved.
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState
} from 'react'
import {
GoogleAuthProvider,
createUserWithEmailAndPassword,
onAuthStateChanged,
signInWithEmailAndPassword,
signInWithPopup,
signOut as fbSignOut,
updateProfile
} from 'firebase/auth'
import {
collection,
doc,
getDoc,
serverTimestamp,
writeBatch
} from 'firebase/firestore'
import { httpsCallable } from 'firebase/functions'
import LastModified from '../model/LastModified.js'
import { auth, db, functions } from '../firebase'
// How stale `lastSeenAt` can get before we re-write it. Without this
// floor, every token refresh (every hour, give or take) would write a
// fresh timestamp even when nothing else changed. With it, we cap user-
// doc writes to roughly once every 5 minutes per active session.
const LAST_SEEN_REFRESH_MS = 5 * 60 * 1000
const AuthContext = createContext(null)
/**
* Normalizes Firebase Auth error codes into short, human-friendly strings.
*
* @param {Error} err - Firebase Auth error (or any thrown value).
* @returns {string} - One-line message for display next to a form.
*/
function readableAuthError(err) {
const code = err && err.code ? err.code : ''
switch (code) {
case 'auth/email-already-in-use':
return 'An account with that email already exists. Try signing in instead.'
case 'auth/invalid-email':
return 'That email address doesn\'t look right.'
case 'auth/weak-password':
return 'Password is too short — use at least 6 characters.'
case 'auth/user-not-found':
case 'auth/wrong-password':
case 'auth/invalid-credential':
return 'Email and password don\'t match.'
case 'auth/popup-closed-by-user':
return 'Sign-in window was closed before completing.'
case 'auth/popup-blocked':
return 'Your browser blocked the sign-in window. Please allow popups for this site.'
case 'auth/network-request-failed':
return 'Network problem. Check your connection and try again.'
case 'auth/too-many-requests':
return 'Too many attempts. Wait a minute and try again.'
default:
return err && err.message ? err.message : 'Sign-in failed. Please try again.'
}
}
/**
* AuthProvider — wraps the app and provides auth state via React Context.
* Mount this once near the top of the tree, inside the Router.
*
* @param {Object} props
* @param {JSX.Element} props.children
* @returns {JSX.Element}
*/
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [claims, setClaims] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// ── AUTH STATE SUBSCRIPTION ──
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (fbUser) => {
if (fbUser) {
try {
// forceRefresh = true so we always pick up the latest custom
// claims (set by Cloud Functions in ITEM 2b / 2c).
const tokenResult = await fbUser.getIdTokenResult(true)
setUser(fbUser)
setClaims(tokenResult.claims)
} catch (e) {
setUser(fbUser)
setClaims(null)
}
} else {
setUser(null)
setClaims(null)
}
setLoading(false)
})
return () => unsubscribe()
}, [])
// ── TENANT CLAIM AUTO-BOOTSTRAP ──
// If a signed-in user is missing the tenant claim, call the
// `bootstrapTenantClaim` Cloud Function to set it + create their
// parent user doc. Idempotent server-side, so multiple-callers /
// tab-races are safe. Runs at most once per user per browser session.
useEffect(() => {
if (loading) return
if (!user) return
if (claims && claims.tenant) return // Already bootstrapped.
let cancelled = false
;(async () => {
try {
const bootstrap = httpsCallable(functions, 'bootstrapTenantClaim')
await bootstrap({})
if (cancelled) return
// Force-refresh the ID token to pick up the newly-set claim.
if (auth.currentUser) {
const refreshed = await auth.currentUser.getIdTokenResult(true)
if (cancelled) return
setUser({ ...auth.currentUser })
setClaims(refreshed.claims)
}
} catch (e) {
// Non-fatal: the user is still signed in, just without a tenant
// claim. Rules will deny them until the next sign-in retries.
// Log to the browser console so the operator can spot the issue
// — common cause is Cloud Functions still propagating after deploy.
// eslint-disable-next-line no-console
console.warn('bootstrapTenantClaim failed; will retry next sign-in.', e)
}
})()
return () => { cancelled = true }
}, [user, claims, loading])
// ── AUTH-PROFILE MIRROR ──
// Firebase Auth holds displayName / photoURL / email; admin views,
// future LOOT conversation logs, and Cloud Functions all need access
// to those values via the user's Firestore doc rather than going
// through the server-side Admin SDK. This effect mirrors them into
// `/{tenant}/_main/users/{uid}` whenever they drift out of sync.
//
// Two timestamp concepts on the user doc (different semantics):
// - lastSeenAt: bumped on every sign-in or 5+ min of active session,
// regardless of whether anything changed. Activity
// signal. Useful for "purge inactive users" etc.
// - lastModified: a LastModified object (byName/byUUID/date/state).
// Bumped ONLY when fields actually change. Each prior
// value is archived to /lastModifieds/{id} subcollection
// in the same batched write — permanent audit trail.
//
// Gated on bootstrap success (`claims.tenant` set) so we never try to
// write into a doc that bootstrapTenantClaim hasn't created yet.
// Idempotent: reads the current doc, compares mirrored fields, writes
// only if something changed (or if lastSeenAt is stale by more than
// LAST_SEEN_REFRESH_MS). Excess re-renders cost one Firestore read.
useEffect(() => {
if (loading) return
if (!user) return
if (!claims || !claims.tenant) return
let cancelled = false
;(async () => {
try {
const userRef = doc(
db, claims.tenant, '_main', 'users', user.uid
)
const snap = await getDoc(userRef)
if (cancelled) return
if (!snap.exists()) {
// Bootstrap should have created the doc — if it's missing,
// something upstream failed. Leave a breadcrumb; do not try
// to create the doc from the client (rules forbid that).
// eslint-disable-next-line no-console
console.warn('[mirror] user doc missing — bootstrap may have failed.')
return
}
const current = snap.data() || {}
const wanted = {
displayName: user.displayName || null,
photoURL : user.photoURL || null,
email : user.email || null
}
const profileChanged = (
current.displayName !== wanted.displayName ||
current.photoURL !== wanted.photoURL ||
current.email !== wanted.email
)
// Pre-9c.2 docs (and any doc the bootstrap function created
// before the mirror started writing lastModified) won't have
// a `lastModified` field yet. Seed one on first encounter so
// every doc has at least the audit shape, even when no actual
// profile fields have drifted.
const needsInitialLastModified = !current.lastModified
const lastSeenMs = current.lastSeenAt && current.lastSeenAt.toMillis
? current.lastSeenAt.toMillis()
: 0
const lastSeenStale = (Date.now() - lastSeenMs) >= LAST_SEEN_REFRESH_MS
const shouldUpdateAudit = profileChanged || needsInitialLastModified
if (!shouldUpdateAudit && !lastSeenStale) return
// Build the doc update. Always bump lastSeenAt (activity).
// Bump lastModified when fields changed OR when this is the
// first time we're seeding the audit field. Archive the
// previous lastModified value (if any) to the /lastModifieds
// subcollection in the same batch so accountability history
// survives a partial-failure scenario.
const batch = writeBatch(db)
if (shouldUpdateAudit) {
// Build the per-field change diff. 'created' state captures the
// genesis baseline so `changes` stays empty; 'updated' state
// records each field that drifted with previous + current
// values for the admin audit viewer.
const changes = []
if (!needsInitialLastModified) {
if (current.displayName !== wanted.displayName) {
changes.push({
field : 'displayName',
previous: current.displayName ?? null,
current : wanted.displayName ?? null
})
}
if (current.photoURL !== wanted.photoURL) {
changes.push({
field : 'photoURL',
previous: current.photoURL ?? null,
current : wanted.photoURL ?? null
})
}
if (current.email !== wanted.email) {
changes.push({
field : 'email',
previous: current.email ?? null,
current : wanted.email ?? null
})
}
}
const newLastModified = new LastModified({
byName : user.displayName || user.email || '(unknown)',
byUUID : user.uid,
date : serverTimestamp(),
// 'created' on the very first mirror encounter — we're
// initializing the audit field for an existing doc, not
// describing a data change. 'updated' otherwise.
state : needsInitialLastModified ? 'created' : 'updated',
changes
}).toDict()
// Archive the previous lastModified BEFORE we overwrite it.
// First-ever mirror has nothing to archive — skip the write.
if (current.lastModified) {
const archiveRef = doc(collection(userRef, 'lastModifieds'))
batch.set(archiveRef, current.lastModified)
}
batch.set(userRef, {
...wanted,
lastSeenAt : serverTimestamp(),
lastModified: newLastModified
}, { merge: true })
} else {
// Activity-only refresh — don't touch lastModified.
batch.set(userRef, {
lastSeenAt: serverTimestamp()
}, { merge: true })
}
await batch.commit()
} catch (e) {
// Non-fatal — auth still works without the mirror. Common
// cause during dev: Firestore rules haven't been redeployed
// since this code shipped (the new self-write + audit-archive
// rules live in firestore.rules). Run
// `firebase deploy --only firestore:rules` first.
// eslint-disable-next-line no-console
console.warn('[mirror] profile mirror failed (non-fatal)', e)
}
})()
return () => { cancelled = true }
}, [user, claims, loading])
// ── SIGN-IN METHODS ──
const signInWithGoogle = useCallback(async () => {
setError(null)
try {
const provider = new GoogleAuthProvider()
provider.setCustomParameters({ prompt: 'select_account' })
await signInWithPopup(auth, provider)
} catch (e) {
const msg = readableAuthError(e)
setError(msg)
throw new Error(msg)
}
}, [])
const signInWithEmail = useCallback(async (email, password) => {
setError(null)
try {
await signInWithEmailAndPassword(auth, email, password)
} catch (e) {
const msg = readableAuthError(e)
setError(msg)
throw new Error(msg)
}
}, [])
const signUpWithEmail = useCallback(async (email, password, displayName) => {
setError(null)
try {
const cred = await createUserWithEmailAndPassword(auth, email, password)
if (displayName) {
await updateProfile(cred.user, { displayName })
await cred.user.reload()
// onAuthStateChanged already fired with displayName=null. updateProfile
// does NOT re-fire the listener, so manually sync the local user state
// to the post-update auth.currentUser. Shallow-clone so React sees a
// new reference and rerenders consumers.
if (auth.currentUser) {
setUser({ ...auth.currentUser })
}
}
} catch (e) {
const msg = readableAuthError(e)
setError(msg)
throw new Error(msg)
}
}, [])
/**
* Update the signed-in user's displayName in Firebase Auth.
*
* Trims the input, validates non-empty + reasonable length, calls
* `updateProfile`, reloads the auth user, and bumps local state so
* the profile-mirror useEffect picks up the change and writes a new
* lastModified to Firestore (with the previous one archived to the
* lastModifieds subcollection).
*
* Surfaces the same readable errors the sign-in methods do via
* setError, and re-throws so the caller's catch can also react.
*
* @param {string} newName
* @returns {Promise<void>}
*/
const updateDisplayName = useCallback(async (newName) => {
setError(null)
if (!auth.currentUser) {
const msg = 'You need to be signed in to update your profile.'
setError(msg)
throw new Error(msg)
}
const trimmed = (newName || '').trim()
if (!trimmed) {
const msg = 'Name can\'t be empty.'
setError(msg)
throw new Error(msg)
}
if (trimmed.length > 80) {
const msg = 'Name is too long (80 character max).'
setError(msg)
throw new Error(msg)
}
try {
await updateProfile(auth.currentUser, { displayName: trimmed })
await auth.currentUser.reload()
// onAuthStateChanged doesn't re-fire for updateProfile, so we
// manually shallow-clone the auth user so the mirror useEffect
// sees a new reference and detects the displayName drift.
setUser({ ...auth.currentUser })
} catch (e) {
const msg = readableAuthError(e)
setError(msg)
throw new Error(msg)
}
}, [])
const signOut = useCallback(async () => {
setError(null)
try {
await fbSignOut(auth)
} catch (e) {
const msg = readableAuthError(e)
setError(msg)
throw new Error(msg)
}
}, [])
const clearError = useCallback(() => setError(null), [])
// ── DERIVED FLAGS ──
// `isAdmin` is true only when the Cloud Function in ITEM 2b has set the
// admin custom claim. Until then, this is always false.
const isAdmin = Boolean(claims && claims.admin === true)
const tenantClaim = claims && claims.tenant ? String(claims.tenant) : null
const value = useMemo(() => ({
user,
claims,
loading,
error,
isAdmin,
tenantClaim,
signInWithGoogle,
signInWithEmail,
signUpWithEmail,
updateDisplayName,
signOut,
clearError
}), [user, claims, loading, error, isAdmin, tenantClaim, signInWithGoogle, signInWithEmail, signUpWithEmail, updateDisplayName, signOut, clearError])
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
/**
* useAuth — read auth state and call sign-in / sign-up / sign-out helpers.
* Must be used inside an `<AuthProvider>`.
*
* @returns {Object} { user, claims, loading, error, isAdmin, tenantClaim,
* signInWithGoogle, signInWithEmail, signUpWithEmail,
* updateDisplayName, signOut, clearError }
*/
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useAuth() must be used inside an <AuthProvider>')
}
return ctx
}