* feat: companion expo and chrome * fix: api-v2-fetch-bookings (#25023) * migrated to app expo router * feat(companion): added availability screen and fix event type fetch (#25025) * fix(companion): add host check in bookings (#25036) * using liquid glass bottom nav experimental * added action sheet * added icons to event type tabs * added action bar for event-type details * long press for edit event types * added limits * added more limits * added more limits * added username to event types * feat(companion): config to set and show all the available meeting options a user has stalled (#25081) * feat: Add tailwind support using nativewind. (#25028) * init nativewind * replace styles with tailwind classes * changes * feat(companion): all locations options, icons, and apply nativewind to remaining files (#25143) * feat: all locations options and icons and apply nativwind to reamaining files * icon bug fix * cal video icon * other location frontend * other location api * address cubics comment about correctly detects SVGs * feat(companion): edit availability (#25149) * feat: companion-edit-availability * date override and use correct config for patch api endpoint * Android Alert supports a maximum of 3 buttons * feat: companion-ui-release-1 (#25150) * feat(companion): Add event type & schedule creation flows with API expansion (#25187) * feat(companion): event schedule creation and apis * profile section * limits, advanced, recurring tab * Clean Folder Structure * addressed cubics comments * fix cubiics comments * replaced text with cal.com svg logo * added correct more button for event-types * added copy link button * added extension popup * added chrome extension sidebar * achieved liquid glass buttons * nit * more progress * fixed ios bug, added icon * added cal icon to gmail * nit * nit * added dialog for one-off meeting * added one-time dialog * feat(companion): gmail plugin (#25327) * Update Cal.com Companion to version 1.7.0 with enhanced background and content scripts. The background script now handles fetching event types from the Cal.com API and improved error handling. The content script has been updated to manage sidebar visibility and respond to messages more effectively. Removed deprecated dev background script and updated manifest permissions for better functionality. * chore: update companion .gitignore to exclude build outputs and env files * feat: insert Cal.com event link directly at cursor position in Gmail compose - Add insertTextAtCursor function to insert booking link where user is typing - Automatically positions cursor after inserted link for seamless typing - Fallback to clipboard copy if compose field not found - Improved UX: no need to manually paste the link * feat(companion): improve notification UI and add event types caching - Update notification to Cal.com brand style (black bg, check icon, smooth fade) - Change notification text to 'Link inserted' for subtlety - Move notification to bottom-right position (matching Cal.com toasts) - Add event types caching (5min duration) to reduce API calls - Cache automatically refreshes on page reload - Fix TypeScript error with composeBody.focus() type assertion * Notification toast border * conditional description * conditional description * feat(companion): Enhance UI with action buttons, tooltips, and Gmail extension improvements (#25353) * feat: companion-and-gmail-ui-upgrade * feat: companion-and-gmail-ui-upgrade * feat: companion-and-gmail-ui-upgrade * feat: companion-and-gmail-ui-upgrade * feat(companion): one-off links (#25355) * added one off prototype * resolve merge conflicts --------- Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com> * fixed sidebar iframe * fix: enable pointer events on sidebar while maintaining click-through on transparent area - Added pointerEvents: 'auto' to sidebar View component in _layout.tsx - Changed iframe width from 100% to 400px to match sidebar width - Changed iframe pointerEvents from 'none' to 'auto' to enable clicking - This allows sidebar items to be clickable while transparent area remains click-through * feat(companion): center all modals on full screen in browser extension (#25380) * fix: companion-full-width-iframe * center the popups on extension view * feat(companion): center all modals on full screen in browser extension - Created FullScreenModal component that expands iframe to full width - Updated content script to handle instant iframe expansion/collapse - Converted 17 modals across 6 files to use FullScreenModal - All modals now appear centered on full browser viewport - Removed animations for instant modal appearance - Mobile app behavior remains unchanged Files updated: - companion/components/FullScreenModal.tsx (new) - companion/extension/entrypoints/content.ts - companion/app/(tabs)/event-types.tsx (5 modals) - companion/app/(tabs)/bookings.tsx (2 modals) - companion/app/(tabs)/availability.tsx (3 modals) - companion/app/booking-detail.tsx (2 modals) - companion/app/availability-detail.tsx (4 modals) - companion/components/Header.tsx (1 modal) * security(companion): validate postMessage origin in content script Add origin and source validation to postMessage listener to prevent malicious scripts on host pages from manipulating the companion iframe. - Verify event.source matches iframe.contentWindow - Verify event.origin matches iframe origin (localhost:8081) - Reject all unauthorized messages before processing This prevents external scripts from: - Forcing full-screen overlays - Intercepting pointer events - Manipulating the companion UI * fix(companion): center 'Add new event type' modal on full screen Replace KeyboardAvoidingView with TouchableOpacity pattern in the create event type modal to ensure it appears centered on the full screen like all other modals. This was the last remaining modal that wasn't properly centered. * fix(companion): resolve modal transition timing issue Fixed the 'Add new event type' modal not centering properly by: - Added 100ms delay when transitioning between modals - Prevents conflicting expand/collapse messages - Removed redundant postMessage calls from modal handlers - Added max-height constraint to modal content - Cleaned up debug console logs The issue was that when clicking 'New Event Type' from the 'New' menu, both modals were transitioning simultaneously, causing the create modal to collapse immediately. The delay ensures proper sequencing. All 17 modals now work correctly and appear centered on full screen. * added working date range * removed all of one-off event code * nit * feat(companion): Suggest `Cal.com` links and Embed using Gemini Ai in Gmail (#25406) Co-authored-by: Peer Richelsen <peeroke@gmail.com> * add companion build on CI * feat: oauth companion (#25526) * first implemenation of oauth * fix logout for web * revert app name * remove isUsingOAuth * clean up log out button * remove logs * clean up authcontext * simplify code generation * code clean up * clean up oauthService.ts * oauth browser extension * add redirect uri to .env.example * remove api key support * clean up _layout file * add identity * fix + clean up * improvements to oauth flow * code clean up * remove comments * clean up comments * add log out dialog * add confirm modal --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> --------- Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Co-authored-by: Abhijeet Singh <asingh9829@gmail.com> Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com> Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com>
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
/// <reference types="chrome" />
|
|
|
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
|
import * as SecureStore from "expo-secure-store";
|
|
import { Platform } from "react-native";
|
|
import { WebAuthService } from "../services/webAuth";
|
|
import { CalComAPIService } from "../services/calcom";
|
|
import {
|
|
createCalComOAuthService,
|
|
OAuthTokens,
|
|
CalComOAuthService,
|
|
} from "../services/oauthService";
|
|
|
|
interface AuthContextType {
|
|
isAuthenticated: boolean;
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
userInfo: any;
|
|
isWebSession: boolean;
|
|
loginFromWebSession: (userInfo: any) => 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;
|
|
}
|
|
|
|
// Check if chrome.storage is available (browser extension context)
|
|
const isChromeStorageAvailable = (): boolean => {
|
|
return (
|
|
Platform.OS === "web" &&
|
|
typeof chrome !== "undefined" &&
|
|
chrome.storage !== undefined &&
|
|
chrome.storage.local !== undefined
|
|
);
|
|
};
|
|
|
|
// Unified storage helper to abstract web/mobile/extension differences
|
|
const storage = {
|
|
get: async (key: string): Promise<string | null> => {
|
|
// Use chrome.storage in browser extension context (most secure)
|
|
if (isChromeStorageAvailable()) {
|
|
return new Promise((resolve) => {
|
|
chrome.storage.local.get([key], (result) => {
|
|
resolve(result[key] || null);
|
|
});
|
|
});
|
|
}
|
|
// Fall back to localStorage for regular web apps
|
|
if (Platform.OS === "web") {
|
|
return localStorage.getItem(key);
|
|
}
|
|
// Use SecureStore for mobile
|
|
return await SecureStore.getItemAsync(key);
|
|
},
|
|
set: async (key: string, value: string): Promise<void> => {
|
|
// Use chrome.storage in browser extension context (most secure)
|
|
if (isChromeStorageAvailable()) {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.storage.local.set({ [key]: value }, () => {
|
|
if (chrome.runtime.lastError) {
|
|
reject(new Error(chrome.runtime.lastError.message));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
// Fall back to localStorage for regular web apps
|
|
if (Platform.OS === "web") {
|
|
localStorage.setItem(key, value);
|
|
return;
|
|
}
|
|
// Use SecureStore for mobile
|
|
await SecureStore.setItemAsync(key, value);
|
|
},
|
|
remove: async (key: string): Promise<void> => {
|
|
// Use chrome.storage in browser extension context (most secure)
|
|
if (isChromeStorageAvailable()) {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.storage.local.remove(key, () => {
|
|
if (chrome.runtime.lastError) {
|
|
reject(new Error(chrome.runtime.lastError.message));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
// Fall back to localStorage for regular web apps
|
|
if (Platform.OS === "web") {
|
|
localStorage.removeItem(key);
|
|
return;
|
|
}
|
|
// Use SecureStore for mobile
|
|
await SecureStore.deleteItemAsync(key);
|
|
},
|
|
removeAll: async (keys: string[]): Promise<void> => {
|
|
// Use chrome.storage in browser extension context (most secure)
|
|
if (isChromeStorageAvailable()) {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.storage.local.remove(keys, () => {
|
|
if (chrome.runtime.lastError) {
|
|
reject(new Error(chrome.runtime.lastError.message));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
// Fall back to localStorage for regular web apps
|
|
if (Platform.OS === "web") {
|
|
keys.forEach((key) => localStorage.removeItem(key));
|
|
return;
|
|
}
|
|
// Use SecureStore for mobile
|
|
await Promise.all(keys.map((key) => SecureStore.deleteItemAsync(key)));
|
|
},
|
|
};
|
|
|
|
export function AuthProvider({ children }: AuthProviderProps) {
|
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
|
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
|
const [userInfo, setUserInfo] = useState<any>(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 = (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 = async (token: string, refreshToken?: string) => {
|
|
CalComAPIService.setAccessToken(token, refreshToken);
|
|
|
|
try {
|
|
await CalComAPIService.getUserProfile();
|
|
} catch (profileError) {
|
|
console.error("Failed to fetch user profile:", profileError);
|
|
// Don't fail login if profile fetch fails
|
|
}
|
|
};
|
|
|
|
// Save OAuth tokens to storage
|
|
const saveOAuthTokens = async (tokens: OAuthTokens) => {
|
|
await storage.set(OAUTH_TOKENS_KEY, JSON.stringify(tokens));
|
|
await storage.set(AUTH_TYPE_KEY, "oauth");
|
|
};
|
|
|
|
// Clear all auth data from storage
|
|
const clearAuth = async () => {
|
|
await storage.removeAll([ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, OAUTH_TOKENS_KEY, AUTH_TYPE_KEY]);
|
|
};
|
|
|
|
// Reset all auth state
|
|
const resetAuthState = () => {
|
|
setAccessToken(null);
|
|
setRefreshToken(null);
|
|
setUserInfo(null);
|
|
setIsAuthenticated(false);
|
|
setIsWebSession(false);
|
|
CalComAPIService.clearAuth();
|
|
CalComAPIService.clearUserProfile();
|
|
};
|
|
|
|
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) {
|
|
console.error("Failed to handle token refresh:", error);
|
|
await logout();
|
|
}
|
|
};
|
|
|
|
CalComAPIService.setTokenRefreshCallback(handleTokenRefresh);
|
|
|
|
return () => {
|
|
CalComAPIService.setTokenRefreshCallback(() => Promise.resolve());
|
|
};
|
|
}, [refreshToken]);
|
|
|
|
// Handle OAuth authentication
|
|
const handleOAuthAuth = async (storedTokens: OAuthTokens) => {
|
|
if (!oauthService) return;
|
|
|
|
// Refresh token if expired
|
|
let tokens = storedTokens;
|
|
if (oauthService.isTokenExpired(storedTokens) && storedTokens.refreshToken) {
|
|
try {
|
|
console.log("Access token expired, refreshing...");
|
|
tokens = await oauthService.refreshAccessToken(storedTokens.refreshToken);
|
|
await saveOAuthTokens(tokens);
|
|
} 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);
|
|
}
|
|
};
|
|
|
|
// Handle web session authentication
|
|
const handleWebSessionAuth = () => {
|
|
setIsWebSession(true);
|
|
};
|
|
|
|
const checkAuthState = 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();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check auth state:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loginFromWebSession = async (sessionUserInfo: any) => {
|
|
try {
|
|
setUserInfo(sessionUserInfo);
|
|
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) {
|
|
console.error("Failed to login from web session:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const loginWithOAuth = async (): Promise<void> => {
|
|
if (!oauthService) {
|
|
throw new Error("OAuth service not available. Please check your configuration.");
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
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();
|
|
} catch (error) {
|
|
console.error("OAuth login failed:", error);
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const logout = async () => {
|
|
try {
|
|
await clearAuth();
|
|
resetAuthState();
|
|
} catch (error) {
|
|
console.error("Failed to logout:", 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;
|
|
}
|