Skip to content
File

Blob: src/client/auth/auth-provider.tsx

typescript111 lines
1import { type ReactNode, useEffect, useEffectEvent, useState } from "react";
2import type { UserSummary } from "@/contracts";
3import { ApiError } from "@/client/lib/api-contract";
4import { getApiClient } from "@/client/lib/api";
5import { SESSION_EXPIRED_EVENT } from "@/client/lib/live-api-request";
6import { clearStoredBookmark } from "@/client/lib/storage";
7import { useToast } from "@/client/toast";
8import { AuthContext, type AuthContextValue, type StartupErrorState } from "@/client/auth/auth-context";
9 
10const clearClientAuthState = (): void => {
11 clearStoredBookmark();
12};
13 
14export const AuthProvider = ({ children }: { children: ReactNode }) => {
15 const { pushToast } = useToast();
16 const [user, setUser] = useState<UserSummary | null>(null);
17 const [startupError, setStartupError] = useState<StartupErrorState | null>(null);
18 const [isInitializing, setIsInitializing] = useState(true);
19 const handleSessionExpired = useEffectEvent(() => {
20 if (user === null) return;
21 clearClientAuthState();
22 setUser(null);
23 pushToast({
24 tone: "error",
25 title: "Session expired",
26 message: "Your session was invalidated. Please sign in again.",
27 });
28 });
29 
30 useEffect(() => {
31 let canceled = false;
32 
33 const hydrateSession = async () => {
34 setStartupError(null);
35 
36 try {
37 await getApiClient().getAppConfig();
38 } catch (error) {
39 if (error instanceof ApiError && error.code === "encryption_not_configured") {
40 if (!canceled) {
41 setUser(null);
42 setStartupError({
43 code: error.code,
44 message: error.message,
45 });
46 setIsInitializing(false);
47 }
48 return;
49 }
50 }
51 
52 try {
53 const response = await getApiClient().getMe();
54 if (!canceled) {
55 setUser(response.user);
56 }
57 } catch (error) {
58 clearClientAuthState();
59 if (!canceled) {
60 setUser(null);
61 if (error instanceof ApiError && error.status !== 403 && error.code !== "invalid_session") {
62 setStartupError({
63 code: error.code,
64 message: error.message,
65 });
66 }
67 }
68 } finally {
69 if (!canceled) {
70 setIsInitializing(false);
71 }
72 }
73 };
74 
75 void hydrateSession();
76 
77 return () => {
78 canceled = true;
79 };
80 }, []);
81 
82 useEffect(() => {
83 window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
84 return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
85 }, []);
86 
87 const signOut = async (): Promise<void> => {
88 try {
89 await getApiClient().logout();
90 } finally {
91 clearClientAuthState();
92 setUser(null);
93 pushToast({
94 tone: "info",
95 title: "Signed out",
96 message: "Your browser session was cleared.",
97 });
98 }
99 };
100 
101 const value: AuthContextValue = {
102 user,
103 startupError,
104 isAuthenticated: user !== null,
105 isInitializing,
106 signOut,
107 };
108 
109 return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
110};