dorfteich/apps/web/src/auth/auth-context.tsx
Claude Opus 4.8 af81b50fa6
Some checks failed
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Failing after 2m18s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m32s
CD / Promote to Int (push) Successful in 12s
Add offline editing: local persistence, PWA shell, offline resolution (#38)
Editing continues without a connection and merges conflict-free on
reconnect (ADR 0003, realtime-collaboration.md §Offline).

- y-indexeddb mirrors every opened page's Y.Doc to IndexedDB, sharing the
  document with the collab provider. The local copy is discarded when the
  page is left after a successful server sync (bounding IndexedDB growth)
  and kept otherwise so offline edits survive to the next visit.
- vite-plugin-pwa service worker precaches the app shell (build assets only)
  with a navigation fallback; `/api` and `/collab` are denylisted and there
  is no runtime caching, so API responses are never cached or poisoned.
- Offline page resolution WITHOUT caching API responses: the app itself
  persists the small metadata it needs to reopen a visited page (page/pond
  ids + slugs, bounded LRU in localStorage) and the last signed-in user, so
  after an offline tab reload the app stays signed in, resolves the page, and
  restores its content from IndexedDB. Both are revalidated when the network
  returns (a 401 clears the cached user).
- Local-only UI: a banner when there are edits held only on this device
  (provider `onUnsyncedChanges`), de + en.

Tests: `page-cache` unit test (remember/recall + bounded eviction); a new
`offline` e2e pack (validated locally against the full stack and wired into
CI): edit, reload while offline (shell from the SW, content from IndexedDB),
assert an API call fails offline (no SW API caching), then reconnect and a
second client converges. The e2e static server serves `.webmanifest`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 22:08:55 +02:00

93 lines
3.1 KiB
TypeScript

import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { CurrentUser } from '@dorfteich/shared';
import { createContext, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError, apiGet, apiPost } from '../lib/api';
interface AuthContextValue {
user: CurrentUser | null;
isLoading: boolean;
/** Re-reads /auth/me — call after login or profile changes. */
refresh: () => Promise<unknown>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
// The last known signed-in user, cached so the app stays usable offline after a
// reload (issue #38). The session cookie is still the real authority — this is
// only a fallback while `/auth/me` is unreachable, and is revalidated (or
// cleared on a 401) as soon as the network returns.
const CURRENT_USER_KEY = 'dorfteich:current-user';
function readCachedUser(): CurrentUser | null {
try {
const raw = localStorage.getItem(CURRENT_USER_KEY);
return raw ? (JSON.parse(raw) as CurrentUser) : null;
} catch {
return null;
}
}
function writeCachedUser(user: CurrentUser | null): void {
try {
if (user) localStorage.setItem(CURRENT_USER_KEY, JSON.stringify(user));
else localStorage.removeItem(CURRENT_USER_KEY);
} catch {
// Best-effort: ignore quota / disabled storage.
}
}
async function fetchCurrentUser(): Promise<CurrentUser | null> {
try {
const user = await apiGet<CurrentUser>('/auth/me');
writeCachedUser(user);
return user;
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
writeCachedUser(null);
return null;
}
throw error;
}
}
export function AuthProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const queryClient = useQueryClient();
const { i18n } = useTranslation();
const query = useQuery({ queryKey: ['auth', 'me'], queryFn: fetchCurrentUser, retry: false });
// Offline (e.g. after a reload), `/auth/me` can't resolve; fall back to the
// cached user so the app stays signed in until the network returns (#38).
const cachedUser = query.data === undefined && !navigator.onLine ? readCachedUser() : null;
const user = query.data ?? cachedUser;
// The profile locale wins over browser detection (issue #17).
const userLocale = user?.locale;
useEffect(() => {
if (userLocale && i18n.language !== userLocale) {
void i18n.changeLanguage(userLocale);
}
}, [userLocale, i18n]);
const value: AuthContextValue = {
user,
isLoading: query.isPending && !cachedUser,
refresh: () => queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }),
logout: async () => {
await apiPost('/auth/logout');
writeCachedUser(null);
queryClient.setQueryData(['auth', 'me'], null);
queryClient.clear();
},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth requires an <AuthProvider> ancestor');
return context;
}