Files
calendar/companion/contexts/AuthContext.tsx
T
Dhairyashil ShindeandGitHub 9203cb262c feat(companion): component improvements, migrate to path aliases, expo-haptics, expo-image (#26226)
* react compiler

* remove compilation mode 'all', it will use infer by default

* feat(companion): add theme tokens, path aliases, and component improvements

- Extend tailwind.config.js with Cal.com brand color tokens (cal.text, cal.bg, cal.border, cal.accent, cal.brand)
- Create theme/colors.ts for JS usage where Tailwind classes can't be used
- Configure path aliases in tsconfig.json (@/components, @/hooks, @/utils, etc.)
- Add expo-image and expo-haptics dependencies
- Enhance AppPressable with Reanimated animations (opacity + scale) and haptic feedback
- Extract shared logic from BookingListItem into useBookingListItemData hook and BookingListItemParts
- Extract shared logic from EventTypeListItem into useEventTypeListItemData hook and EventTypeListItemParts
- Extract shared logic from AvailabilityListItem into AvailabilityListItemParts
- Update all list item components to use new theme tokens instead of hardcoded hex values

* fix react compiler non memo issue

* feat(companion): migrate to path aliases and expo-image

- Remove theme/colors.ts and theme/index.ts (defer dark mode to future PR)
- Replace colors import with hardcoded hex values in components
- Migrate all files to use path aliases (@/components/*, @/hooks/*, etc.)
- Migrate SvgImage to use expo-image for better performance
- Update tsconfig.json to remove @/theme/* alias

* fix(companion): add path aliases to extension tsconfig

Add baseUrl and paths configuration to tsconfig.extension.json to support
path aliases in shared types used by the extension build.
2025-12-28 08:08:33 -03:00

367 lines
11 KiB
TypeScript

import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { CalComAPIService } from "@/services/calcom";
import {
type CalComOAuthService,
createCalComOAuthService,
type OAuthTokens,
} from "@/services/oauthService";
import type { UserProfile } from "@/services/types/users.types";
import { WebAuthService } from "@/services/webAuth";
import { secureStorage } from "@/utils/storage";
/**
* Simplified user info stored in auth context
* Contains only the essential fields needed for the app
*/
interface AuthUserInfo {
id: number;
email: string;
name: string;
username: string;
}
interface AuthContextType {
isAuthenticated: boolean;
accessToken: string | null;
refreshToken: string | null;
userInfo: AuthUserInfo | null;
isWebSession: boolean;
loginFromWebSession: (userInfo: UserProfile) => Promise<void>;
loginWithOAuth: () => Promise<void>;
logout: () => Promise<void>;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const ACCESS_TOKEN_KEY = "cal_access_token";
const REFRESH_TOKEN_KEY = "cal_refresh_token";
const OAUTH_TOKENS_KEY = "cal_oauth_tokens";
const AUTH_TYPE_KEY = "cal_auth_type";
type AuthType = "oauth" | "web_session";
interface AuthProviderProps {
children: ReactNode;
}
// Use the shared secure storage adapter
const storage = secureStorage;
const getErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
const getErrorStack = (error: unknown) => (error instanceof Error ? error.stack : undefined);
export function AuthProvider({ children }: AuthProviderProps) {
"use no memo";
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<AuthUserInfo | null>(null);
const [isWebSession, setIsWebSession] = useState(false);
const [loading, setLoading] = useState(true);
const [oauthService] = useState(() => {
try {
return createCalComOAuthService();
} catch (error) {
console.warn("Failed to initialize OAuth service:", error);
return null;
}
});
// Setup refresh token function for OAuth
const setupRefreshTokenFunction = useCallback((service: CalComOAuthService) => {
CalComAPIService.setRefreshTokenFunction(async (refreshToken: string) => {
const newTokens = await service.refreshAccessToken(refreshToken);
return {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
};
});
}, []);
// Common post-login setup: configure API service and fetch user profile
const setupAfterLogin = useCallback(async (token: string, refreshToken?: string) => {
CalComAPIService.setAccessToken(token, refreshToken);
try {
const profile = await CalComAPIService.getUserProfile();
// Store user info for use in the app (e.g., to display "You" in bookings)
if (profile) {
setUserInfo({
email: profile.email,
name: profile.name,
id: profile.id,
username: profile.username,
});
}
} catch (profileError) {
console.error("Failed to fetch user profile:", profileError);
// Don't fail login if profile fetch fails
}
}, []);
const saveOAuthTokens = useCallback(
async (tokens: OAuthTokens) => {
await storage.set(OAUTH_TOKENS_KEY, JSON.stringify(tokens));
await storage.set(AUTH_TYPE_KEY, "oauth");
if (oauthService) {
try {
await oauthService.syncTokensToExtension(tokens);
} catch (error) {
console.warn("Failed to sync tokens to extension:", error);
}
}
},
[oauthService]
);
const clearAuth = useCallback(async () => {
await storage.removeAll([ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, OAUTH_TOKENS_KEY, AUTH_TYPE_KEY]);
if (oauthService) {
try {
await oauthService.clearTokensFromExtension();
} catch (error) {
console.warn("Failed to clear tokens from extension:", error);
}
}
}, [oauthService]);
// Reset all auth state
const resetAuthState = useCallback(() => {
setAccessToken(null);
setRefreshToken(null);
setUserInfo(null);
setIsAuthenticated(false);
setIsWebSession(false);
CalComAPIService.clearAuth();
CalComAPIService.clearUserProfile();
}, []);
const logout = useCallback(async () => {
try {
await clearAuth();
resetAuthState();
} catch (error) {
const message = getErrorMessage(error);
console.error("Failed to logout", message);
if (__DEV__) {
console.debug("[AuthContext] logout failed", { message, stack: getErrorStack(error) });
}
}
}, [clearAuth, resetAuthState]);
// Handle OAuth authentication
const handleOAuthAuth = useCallback(
async (storedTokens: OAuthTokens) => {
if (!oauthService) return;
// Refresh token if expired
let tokens = storedTokens;
let tokensWereRefreshed = false;
if (oauthService.isTokenExpired(storedTokens) && storedTokens.refreshToken) {
try {
console.log("Access token expired, refreshing...");
tokens = await oauthService.refreshAccessToken(storedTokens.refreshToken);
await saveOAuthTokens(tokens);
tokensWereRefreshed = true;
} catch (refreshError) {
console.error("Failed to refresh token:", refreshError);
await clearAuth();
return;
}
}
// Set state
setAccessToken(tokens.accessToken);
setRefreshToken(tokens.refreshToken || null);
setIsAuthenticated(true);
setIsWebSession(false);
// Setup API service and refresh function
await setupAfterLogin(tokens.accessToken, tokens.refreshToken);
if (tokens.refreshToken) {
setupRefreshTokenFunction(oauthService);
}
if (!tokensWereRefreshed) {
try {
await oauthService.syncTokensToExtension(tokens);
} catch (error) {
console.warn("Failed to sync tokens to extension on init:", error);
}
}
},
[oauthService, saveOAuthTokens, clearAuth, setupAfterLogin, setupRefreshTokenFunction]
);
// Handle web session authentication
const handleWebSessionAuth = useCallback(() => {
setIsWebSession(true);
}, []);
const checkAuthState = useCallback(async () => {
try {
const authType = (await storage.get(AUTH_TYPE_KEY)) as AuthType | null;
const storedOAuthTokens = await storage.get(OAUTH_TOKENS_KEY);
const storedTokens = storedOAuthTokens ? JSON.parse(storedOAuthTokens) : null;
if (authType === "oauth" && storedTokens && oauthService) {
await handleOAuthAuth(storedTokens);
} else if (authType === "web_session") {
handleWebSessionAuth();
}
setLoading(false);
} catch (error) {
const message = getErrorMessage(error);
console.error("Failed to check auth state", message);
if (__DEV__) {
console.debug("[AuthContext] checkAuthState failed", {
message,
stack: getErrorStack(error),
});
}
setLoading(false);
}
}, [oauthService, handleOAuthAuth, handleWebSessionAuth]);
useEffect(() => {
checkAuthState();
// Set up token refresh callback
const handleTokenRefresh = async (newAccessToken: string, newRefreshToken?: string) => {
try {
const tokens: OAuthTokens = {
accessToken: newAccessToken,
refreshToken: newRefreshToken || refreshToken || undefined,
tokenType: "Bearer",
};
await saveOAuthTokens(tokens);
setAccessToken(newAccessToken);
if (newRefreshToken) {
setRefreshToken(newRefreshToken);
}
CalComAPIService.setAccessToken(
newAccessToken,
newRefreshToken || refreshToken || undefined
);
} catch (error) {
const message = getErrorMessage(error);
console.error("Failed to handle token refresh", message);
if (__DEV__) {
console.debug("[AuthContext] token refresh handler failed", {
message,
stack: getErrorStack(error),
});
}
await logout();
}
};
CalComAPIService.setTokenRefreshCallback(handleTokenRefresh);
return () => {
CalComAPIService.setTokenRefreshCallback(() => Promise.resolve());
};
}, [refreshToken, checkAuthState, logout, saveOAuthTokens]);
const loginFromWebSession = async (sessionUserInfo: UserProfile) => {
try {
setUserInfo({
id: sessionUserInfo.id,
email: sessionUserInfo.email,
name: sessionUserInfo.name,
username: sessionUserInfo.username,
});
setIsAuthenticated(true);
setIsWebSession(true);
await storage.set(AUTH_TYPE_KEY, "web_session");
// Try to get tokens from web session
const tokens = await WebAuthService.getTokensFromWebSession();
if (tokens.accessToken) {
setAccessToken(tokens.accessToken);
setRefreshToken(tokens.refreshToken || null);
await setupAfterLogin(tokens.accessToken, tokens.refreshToken);
}
} catch (error) {
const message = getErrorMessage(error);
console.error("Failed to login from web session", message);
if (__DEV__) {
console.debug("[AuthContext] loginFromWebSession failed", {
message,
stack: getErrorStack(error),
});
}
throw error;
}
};
const loginWithOAuth = async (): Promise<void> => {
if (!oauthService) {
throw new Error("OAuth service not available. Please check your configuration.");
}
setLoading(true);
try {
const tokens = await oauthService.startAuthorizationFlow();
// Save tokens
await saveOAuthTokens(tokens);
// Update state
setAccessToken(tokens.accessToken);
setRefreshToken(tokens.refreshToken || null);
setIsAuthenticated(true);
setIsWebSession(false);
// Setup API service and refresh function
await setupAfterLogin(tokens.accessToken, tokens.refreshToken);
if (tokens.refreshToken) {
setupRefreshTokenFunction(oauthService);
}
// Clear PKCE parameters
oauthService.clearPKCEParams();
setLoading(false);
} catch (error) {
setLoading(false);
const message = getErrorMessage(error);
console.error("OAuth login failed", message);
if (__DEV__) {
console.debug("[AuthContext] loginWithOAuth failed", {
message,
stack: getErrorStack(error),
});
}
throw error;
}
};
const value: AuthContextType = {
isAuthenticated,
accessToken,
refreshToken,
userInfo,
isWebSession,
loginFromWebSession,
loginWithOAuth,
logout,
loading,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}