A social knowledge tool for researchers built on ATProto
1import {
2 useState,
3 useEffect,
4 createContext,
5 useContext,
6 ReactNode,
7 useCallback,
8} from 'react';
9import { ApiClient } from '@/api-client/ApiClient';
10
11interface ExtensionAuthContextType {
12 isAuthenticated: boolean;
13 isLoading: boolean;
14 accessToken: string | null;
15 user: any | null;
16 loginWithAppPassword: (handle: string, password: string) => Promise<void>;
17 logout: () => Promise<void>;
18 error: string | null;
19}
20
21const ExtensionAuthContext = createContext<
22 ExtensionAuthContextType | undefined
23>(undefined);
24
25export const ExtensionAuthProvider = ({
26 children,
27}: {
28 children: ReactNode;
29}) => {
30 const [isAuthenticated, setIsAuthenticated] = useState(false);
31 const [isLoading, setIsLoading] = useState(true);
32 const [accessToken, setAccessToken] = useState<string | null>(null);
33 const [user, setUser] = useState<any | null>(null);
34 const [error, setError] = useState<string | null>(null);
35
36 const createApiClient = useCallback((token: string | null) => {
37 return new ApiClient(
38 process.env.PLASMO_PUBLIC_API_URL || 'http://127.0.0.1:3000',
39 );
40 }, []);
41
42 // Use chrome.storage instead of localStorage
43 const getStoredToken = useCallback(async (): Promise<string | null> => {
44 return new Promise((resolve) => {
45 if (typeof chrome !== 'undefined' && chrome.storage) {
46 chrome.storage.local.get(['accessToken'], (result) => {
47 resolve(result.accessToken || null);
48 });
49 } else {
50 // Fallback to localStorage for development
51 resolve(localStorage.getItem('accessToken'));
52 }
53 });
54 }, []);
55
56 const getStoredRefreshToken = useCallback(async (): Promise<
57 string | null
58 > => {
59 return new Promise((resolve) => {
60 if (typeof chrome !== 'undefined' && chrome.storage) {
61 chrome.storage.local.get(['refreshToken'], (result) => {
62 resolve(result.refreshToken || null);
63 });
64 } else {
65 // Fallback to localStorage for development
66 resolve(localStorage.getItem('refreshToken'));
67 }
68 });
69 }, []);
70
71 const setStoredToken = useCallback(async (token: string | null) => {
72 if (typeof chrome !== 'undefined' && chrome.storage) {
73 if (token) {
74 chrome.storage.local.set({ accessToken: token });
75 } else {
76 chrome.storage.local.remove(['accessToken']);
77 }
78 } else {
79 // Fallback to localStorage for development
80 if (token) {
81 localStorage.setItem('accessToken', token);
82 } else {
83 localStorage.removeItem('accessToken');
84 }
85 }
86 }, []);
87
88 // Helper function to initialize auth state (extracted for reuse)
89 const initAuth = useCallback(async () => {
90 try {
91 const token = await getStoredToken();
92 if (token) {
93 setAccessToken(token);
94 const apiClient = createApiClient(token);
95 const userData = await apiClient.getMyProfile();
96 setUser(userData);
97 setIsAuthenticated(true);
98 } else {
99 // No token found, ensure we're in unauthenticated state
100 setAccessToken(null);
101 setUser(null);
102 setIsAuthenticated(false);
103 }
104 setError(null);
105 } catch (error) {
106 console.error('Auth initialization failed:', error);
107 // Token invalid, clear it
108 await setStoredToken(null);
109 setAccessToken(null);
110 setUser(null);
111 setIsAuthenticated(false);
112 setError('Session expired. Please sign in again.');
113 } finally {
114 setIsLoading(false);
115 }
116 }, [getStoredToken, setStoredToken, createApiClient]);
117
118 // Initialize auth state on mount
119 useEffect(() => {
120 initAuth();
121 }, [initAuth]);
122
123 // Listen for auth state changes from background script
124 useEffect(() => {
125 const handleAuthStateChange = (message: any) => {
126 if (message.type === 'AUTH_STATE_CHANGED') {
127 // Reload auth state from storage when background script notifies us
128 initAuth();
129 }
130 };
131
132 if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
133 chrome.runtime.onMessage.addListener(handleAuthStateChange);
134
135 return () => {
136 chrome.runtime.onMessage.removeListener(handleAuthStateChange);
137 };
138 }
139 }, [initAuth]);
140
141 const loginWithAppPassword = useCallback(
142 async (identifier: string, appPassword: string) => {
143 try {
144 setError(null);
145 setIsLoading(true);
146
147 // Use unauthenticated client for login
148 const unauthenticatedClient = createApiClient(null);
149 const response = await unauthenticatedClient.loginWithAppPassword({
150 identifier,
151 appPassword,
152 });
153 const { accessToken: newToken } = response;
154
155 setAccessToken(newToken);
156 await setStoredToken(newToken);
157
158 // Create new authenticated client for profile fetch
159 const authenticatedClient = createApiClient(newToken);
160 const userData = await authenticatedClient.getMyProfile();
161 setUser(userData);
162 setIsAuthenticated(true);
163 } catch (error: any) {
164 console.error('App password login failed:', error);
165 setError(
166 error.message || 'Login failed. Please check your credentials.',
167 );
168 throw error;
169 } finally {
170 setIsLoading(false);
171 }
172 },
173 [createApiClient, setStoredToken],
174 );
175
176 const logout = useCallback(async () => {
177 try {
178 // Notify background script to handle logout
179 if (typeof chrome !== 'undefined' && chrome.runtime) {
180 chrome.runtime.sendMessage({ type: 'LOGOUT' });
181 } else {
182 // Fallback for development
183 setAccessToken(null);
184 setUser(null);
185 setIsAuthenticated(false);
186 setError(null);
187 await setStoredToken(null);
188 localStorage.removeItem('refreshToken');
189 }
190 } catch (error) {
191 console.error('Logout failed:', error);
192 }
193 }, [setStoredToken]);
194
195 return (
196 <ExtensionAuthContext.Provider
197 value={{
198 isAuthenticated,
199 isLoading,
200 accessToken,
201 user,
202 loginWithAppPassword,
203 logout,
204 error,
205 }}
206 >
207 {children}
208 </ExtensionAuthContext.Provider>
209 );
210};
211
212export const useExtensionAuth = () => {
213 const context = useContext(ExtensionAuthContext);
214 if (!context) {
215 throw new Error(
216 'useExtensionAuth must be used within ExtensionAuthProvider',
217 );
218 }
219 return context;
220};