/** * Authentication context for the DocuElevate mobile app. * * Manages the lifecycle of the stored API token and user profile. The SSO * login flow uses expo-auth-session to open the server's OAuth page in the * system browser; on return the redirect URL carries a one-time code that is * exchanged for a session cookie, which is then traded for a permanent API * token via POST /api/mobile/generate-token. */ import * as Linking from "expo-linking"; import * as SecureStore from "expo-secure-store"; import * as WebBrowser from "expo-web-browser"; import React, { createContext, useCallback, useContext, useEffect, useState, } from "react"; import { SECURE_STORE_API_TOKEN_KEY, SECURE_STORE_BASE_URL_KEY, SECURE_STORE_OWNER_ID_KEY, api, type WhoAmIResponse, } from "../services/api"; WebBrowser.maybeCompleteAuthSession(); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface AuthState { isLoading: boolean; isAuthenticated: boolean; user: WhoAmIResponse | null; baseUrl: string; signIn: (serverUrl: string) => Promise; signInWithQR: (serverUrl: string, challengeToken: string) => Promise; signOut: () => Promise; setToken: (token: string) => Promise; } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const AuthContext = createContext({ isLoading: true, isAuthenticated: false, user: null, baseUrl: "", signIn: async () => {}, signInWithQR: async () => {}, signOut: async () => {}, setToken: async () => {}, }); // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- export function AuthProvider({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); const [user, setUser] = useState(null); const [baseUrl, setBaseUrl] = useState(""); // On mount: restore persisted session useEffect(() => { (async () => { try { const storedUrl = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY); const storedToken = await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY); if (storedUrl && storedToken) { await api.init(storedUrl); setBaseUrl(storedUrl); // Verify token is still valid const profile = await api.whoAmI(); setUser(profile); setIsAuthenticated(true); } } catch { // Token expired or server unavailable – clear stored credentials await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY); await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY); } finally { setIsLoading(false); } })(); }, []); const setToken = useCallback(async (token: string) => { await SecureStore.setItemAsync(SECURE_STORE_API_TOKEN_KEY, token); const profile = await api.whoAmI(); setUser(profile); await SecureStore.setItemAsync(SECURE_STORE_OWNER_ID_KEY, profile.owner_id); setIsAuthenticated(true); }, []); const signIn = useCallback( async (serverUrl: string) => { const cleanUrl = serverUrl.replace(/\/$/, ""); await api.init(cleanUrl); setBaseUrl(cleanUrl); // Compute the correct redirect URI for the current runtime: // • Expo Go (development) → exp://:/--/callback // • Standalone / EAS build → docuelevate://callback // Both schemes are accepted by the server; using Linking.createURL // ensures the browser deep-link resolves correctly in every environment. const redirectUri = Linking.createURL("callback"); // Open the web login page in the system browser. The user authenticates // via SSO or local credentials, then the app deep-link is triggered. // The WebBrowser.openAuthSessionAsync handles the redirect back to the app. const result = await WebBrowser.openAuthSessionAsync( `${cleanUrl}/login?mobile=1&redirect_uri=${encodeURIComponent(redirectUri)}`, redirectUri ); if (result.type !== "success") { throw new Error("Authentication was cancelled or failed"); } // Parse the token from the redirect URL if the server appended it, // otherwise hit the generate-token endpoint (session cookie is carried // by the WebBrowser). const url = new URL(result.url); const inlineToken = url.searchParams.get("token"); if (inlineToken) { await setToken(inlineToken); } else { // The server set a session cookie during the browser session; exchange // it for a persistent API token. const deviceInfo = await _getDeviceName(); const tokenResp = await api.generateMobileToken(deviceInfo); await setToken(tokenResp.token); } }, [setToken] ); const signInWithQR = useCallback( async (serverUrl: string, challengeToken: string) => { const cleanUrl = serverUrl.replace(/\/$/, ""); await api.init(cleanUrl); setBaseUrl(cleanUrl); const deviceInfo = await _getDeviceName(); const resp = await api.claimQRChallenge(challengeToken, deviceInfo); await setToken(resp.token); }, [setToken] ); const signOut = useCallback(async () => { await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY); await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY); setUser(null); setIsAuthenticated(false); }, []); return ( {children} ); } // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- export function useAuth(): AuthState { return useContext(AuthContext); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function _getDeviceName(): Promise { try { const Constants = await import("expo-constants"); return ( Constants.default.deviceName || Constants.default.expoConfig?.name || "Mobile App" ); } catch { return "Mobile App"; } }