Files
gh-christianlouis-docuelevate/mobile/src/context/AuthContext.tsx
T

194 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<void>;
signOut: () => Promise<void>;
setToken: (token: string) => Promise<void>;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const AuthContext = createContext<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
baseUrl: "",
signIn: 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<WhoAmIResponse | null>(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://<host>:<port>/--/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 signOut = useCallback(async () => {
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
setUser(null);
setIsAuthenticated(false);
}, []);
return (
<AuthContext.Provider
value={{
isLoading,
isAuthenticated,
user,
baseUrl,
signIn,
signOut,
setToken,
}}
>
{children}
</AuthContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAuth(): AuthState {
return useContext(AuthContext);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function _getDeviceName(): Promise<string> {
try {
const Constants = await import("expo-constants");
return (
Constants.default.deviceName ||
Constants.default.expoConfig?.name ||
"Mobile App"
);
} catch {
return "Mobile App";
}
}