Private
Public Access
Phase 3: PWA offline caching (frontend only)
Adds a real Workbox runtime-caching strategy on top of the Phase 1 app-shell
precache: StaleWhileRevalidate (cache-and-refresh) for the five read
endpoints (rooms/mine, open rooms, room messages, room members, invites/mine)
with a bounded/expiring cache per endpoint, while /api/auth/* and all
mutations stay network-only. An OfflineBanner (navigator.onLine-driven) and
a clearer Composer status line ("Connecting..." vs "You're offline") surface
what's actually happening; api/client.ts gains a NetworkError distinct from
ApiError so a genuine cache-miss-while-offline shows a quiet empty state
instead of a red error.
Manual offline testing (backend stopped, `vite preview` against the real
production service worker) surfaced a real gap the plan hadn't accounted
for: GET /api/auth/me is intentionally NetworkOnly, but that meant
ProtectedRoute could never confirm a session while offline and always
bounced to /login -- none of the newly-cached room/message data was ever
reachable. Fixed by caching a minimal, non-sensitive "last known user" in
localStorage (lib/lastUser.ts) and having AuthContext fall back to it for
any *unconfirmed* auth check (network failure, or a down backend answering
through a live reverse proxy with its own 502/503/504 -- both happen in
real deployments, not just literal airplane-mode). Only a server-confirmed
401 still clears it and signs the user out; every real action still
re-checks the actual session cookie server-side, so this can't grant
anything -- it only keeps cached UI reachable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import * as authApi from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import { ApiError, NetworkError } from '../api/client'
|
||||
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
offline: boolean
|
||||
login: (usernameOrEmail: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
@@ -15,30 +17,58 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [offline, setOffline] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi
|
||||
.me()
|
||||
.then(setUser)
|
||||
.then((u) => {
|
||||
setUser(u)
|
||||
setOffline(false)
|
||||
saveLastUser(u)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!(err instanceof ApiError && err.status === 401)) {
|
||||
// A confirmed 401 means the server is reachable and says "not
|
||||
// logged in" -- that's the only case that should actually clear the
|
||||
// cached identity. Everything else (a real network failure, or the
|
||||
// backend being down behind a reverse proxy that answers with its
|
||||
// own 502/503/504 -- both happen in production, not just literal
|
||||
// offline) means we simply couldn't get a confirmed answer.
|
||||
// /api/auth/me is NetworkOnly by design (never trust a stale
|
||||
// "who am I" as the *source of truth*), but treating "couldn't
|
||||
// check" the same as "confirmed logged out" would lock users out of
|
||||
// the cached rooms/messages entirely whenever the backend is
|
||||
// unreachable. Every real action still re-checks the actual session
|
||||
// cookie server-side, so falling back here can't grant anything.
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearLastUser()
|
||||
return
|
||||
}
|
||||
if (!(err instanceof NetworkError)) {
|
||||
console.error('Failed to load current user', err)
|
||||
}
|
||||
const cached = loadLastUser()
|
||||
setUser(cached)
|
||||
setOffline(cached !== null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function login(usernameOrEmail: string, password: string) {
|
||||
setUser(await authApi.login(usernameOrEmail, password))
|
||||
const u = await authApi.login(usernameOrEmail, password)
|
||||
setUser(u)
|
||||
setOffline(false)
|
||||
saveLastUser(u)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authApi.logout()
|
||||
setUser(null)
|
||||
clearLastUser()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, loading, offline, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user