diff --git a/apps/extension/entrypoints/background/index.ts b/apps/extension/entrypoints/background/index.ts index d847df3..dec4455 100644 --- a/apps/extension/entrypoints/background/index.ts +++ b/apps/extension/entrypoints/background/index.ts @@ -456,7 +456,7 @@ export default defineBackground(() => { recordTokenOperation(); if (storageAPI?.local) { - storageAPI.local.remove(["cal_oauth_tokens", "oauth_state"], () => { + storageAPI.local.remove(["cal_oauth_tokens", "oauth_state", "cal_region"], () => { const runtime = getRuntimeAPI(); if (runtime?.lastError) { devLog.error("Failed to clear OAuth tokens:", runtime.lastError.message); @@ -861,7 +861,24 @@ async function validateOAuthState(state: string): Promise { } } -const API_BASE_URL = "https://api.cal.com/v2"; +const REGION_STORAGE_KEY = "cal_region"; + +async function getStoredRegion(): Promise<"us" | "eu"> { + const storageAPI = getStorageAPI(); + if (!storageAPI?.local) return "us"; + try { + const result = await storageAPI.local.get([REGION_STORAGE_KEY]); + const value = result[REGION_STORAGE_KEY]; + return value === "eu" ? "eu" : "us"; + } catch { + return "us"; + } +} + +async function getApiBaseUrl(): Promise { + const region = await getStoredRegion(); + return region === "eu" ? "https://api.cal.eu/v2" : "https://api.cal.com/v2"; +} const tokenOperationTimestamps: number[] = []; const TOKEN_RATE_LIMIT_WINDOW_MS = 60000; @@ -888,7 +905,8 @@ async function validateTokens(tokens: OAuthTokens): Promise { } try { - const response = await fetchWithTimeout(`${API_BASE_URL}/me`, { + const apiBaseUrl = await getApiBaseUrl(); + const response = await fetchWithTimeout(`${apiBaseUrl}/me`, { headers: { Authorization: `Bearer ${tokens.accessToken}`, "Content-Type": "application/json", @@ -934,9 +952,10 @@ async function getAuthHeader(): Promise { async function fetchEventTypes(): Promise { const authHeader = await getAuthHeader(); + const apiBaseUrl = await getApiBaseUrl(); // For authenticated users, no username/orgSlug params needed - API uses auth token // This also ensures hidden event types are returned (they're filtered out when username is provided) - const response = await fetchWithTimeout(`${API_BASE_URL}/event-types`, { + const response = await fetchWithTimeout(`${apiBaseUrl}/event-types`, { headers: { Authorization: authHeader, "Content-Type": "application/json", @@ -992,7 +1011,8 @@ async function checkAuthStatus(): Promise { async function getBookingStatus(bookingUid: string): Promise { const authHeader = await getAuthHeader(); - const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}`, { + const apiBaseUrl = await getApiBaseUrl(); + const response = await fetchWithTimeout(`${apiBaseUrl}/bookings/${bookingUid}`, { method: "GET", headers: { Authorization: authHeader, @@ -1039,7 +1059,8 @@ async function markAttendeeNoShow( ): Promise { const authHeader = await getAuthHeader(); - const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}/mark-absent`, { + const apiBaseUrl = await getApiBaseUrl(); + const response = await fetchWithTimeout(`${apiBaseUrl}/bookings/${bookingUid}/mark-absent`, { method: "POST", headers: { Authorization: authHeader, diff --git a/apps/extension/entrypoints/content.ts b/apps/extension/entrypoints/content.ts index 2c7459c..e1abcc9 100644 --- a/apps/extension/entrypoints/content.ts +++ b/apps/extension/entrypoints/content.ts @@ -160,7 +160,7 @@ export default defineContentScript({ ); return; } - handleTokenSyncRequest(event.data.tokens, iframe.contentWindow); + handleTokenSyncRequest(event.data.tokens, event.data.region, iframe.contentWindow); } else if (event.data.type === "cal-extension-clear-tokens") { if (!validateSessionToken(event.data.sessionToken)) { iframe.contentWindow?.postMessage( @@ -281,9 +281,10 @@ export default defineContentScript({ function handleTokenSyncRequest( tokens: { accessToken?: string; refreshToken?: string; expiresAt?: number }, + region: "us" | "eu" | undefined, iframeWindow: Window | null ) { - chrome.runtime.sendMessage({ action: "sync-oauth-tokens", tokens }, (response) => { + chrome.runtime.sendMessage({ action: "sync-oauth-tokens", tokens, region }, (response) => { if (chrome.runtime.lastError) { devLog.error( "Failed to communicate with background script:", diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index a5b1347..c3d729e 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -11,10 +11,15 @@ const browserTarget = process.env.BROWSER_TARGET || "chrome"; /** * Get browser-specific OAuth configuration. * Falls back to default (Chrome) config if browser-specific config is not set. + * + * Returns both US (default) and EU values so the runtime can pick the right + * one based on the data region selected on the login screen. */ function getOAuthConfig() { const defaultClientId = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || ""; const defaultRedirectUri = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || ""; + const defaultClientIdEu = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EU || ""; + const defaultRedirectUriEu = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EU || ""; switch (browserTarget) { case "firefox": @@ -22,21 +27,32 @@ function getOAuthConfig() { clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId, redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || defaultRedirectUri, + clientIdEu: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX_EU || defaultClientIdEu, + redirectUriEu: + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX_EU || defaultRedirectUriEu, }; case "safari": return { clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI || defaultClientId, redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI || defaultRedirectUri, + clientIdEu: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI_EU || defaultClientIdEu, + redirectUriEu: + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI_EU || defaultRedirectUriEu, }; case "edge": return { clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE || defaultClientId, redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE || defaultRedirectUri, + clientIdEu: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE_EU || defaultClientIdEu, + redirectUriEu: + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE_EU || defaultRedirectUriEu, }; default: return { clientId: defaultClientId, redirectUri: defaultRedirectUri, + clientIdEu: defaultClientIdEu, + redirectUriEu: defaultRedirectUriEu, }; } } @@ -71,6 +87,8 @@ export default defineConfig({ "https://companion.cal.com/*", "https://api.cal.com/*", "https://app.cal.com/*", + "https://api.cal.eu/*", + "https://app.cal.eu/*", "https://mail.google.com/*", "https://calendar.google.com/*", // Include localhost permission for dev builds (needed for iframe to load) diff --git a/apps/mobile/app/(tabs)/(event-types)/event-type-detail.tsx b/apps/mobile/app/(tabs)/(event-types)/event-type-detail.tsx index 172f8b2..ceea98f 100644 --- a/apps/mobile/app/(tabs)/(event-types)/event-type-detail.tsx +++ b/apps/mobile/app/(tabs)/(event-types)/event-type-detail.tsx @@ -47,6 +47,7 @@ import { showSuccessAlert, } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; +import { getCalAppUrl } from "@/utils/region"; import { buildLocationOptions, mapApiLocationToItem, @@ -1526,7 +1527,10 @@ export default function EventTypeDetail() { Select Available Durations - + {availableDurations.map((duration, index) => ( Select Available Durations - + {availableDurations.map((duration, index) => ( The companion app is an extension of the web application.{"\n"} For advanced features, visit{" "} - app.cal.com + + {getCalAppUrl().replace(/^https?:\/\//, "")} + diff --git a/apps/mobile/app/(tabs)/(more)/index.tsx b/apps/mobile/app/(tabs)/(more)/index.tsx index 4923d6a..130afe7 100644 --- a/apps/mobile/app/(tabs)/(more)/index.tsx +++ b/apps/mobile/app/(tabs)/(more)/index.tsx @@ -18,6 +18,7 @@ import { useQueryContext } from "@/contexts/QueryContext"; import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences"; import { showErrorAlert, showSilentSuccessAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; +import { getCalAppUrl } from "@/utils/region"; interface MoreMenuItem { name: string; @@ -79,30 +80,31 @@ export default function More() { } }; + const calAppUrl = getCalAppUrl(); const menuItems: MoreMenuItem[] = [ { name: "Apps", icon: "grid-outline", isExternal: true, - onPress: () => openInAppBrowser("https://app.cal.com/apps", "Apps page"), + onPress: () => openInAppBrowser(`${calAppUrl}/apps`, "Apps page"), }, { name: "Routing", icon: "git-branch-outline", isExternal: true, - onPress: () => openInAppBrowser("https://app.cal.com/routing", "Routing page"), + onPress: () => openInAppBrowser(`${calAppUrl}/routing`, "Routing page"), }, { name: "Workflows", icon: "flash-outline", isExternal: true, - onPress: () => openInAppBrowser("https://app.cal.com/workflows", "Workflows page"), + onPress: () => openInAppBrowser(`${calAppUrl}/workflows`, "Workflows page"), }, { name: "Insights", icon: "bar-chart-outline", isExternal: true, - onPress: () => openInAppBrowser("https://app.cal.com/insights", "Insights page"), + onPress: () => openInAppBrowser(`${calAppUrl}/insights`, "Insights page"), }, { name: "Support", @@ -202,7 +204,7 @@ export default function More() { > - openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account") + openInAppBrowser(`${getCalAppUrl()}/settings/my-account/profile`, "Delete Account") } className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-red-50" style={{ backgroundColor: theme.backgroundSecondary }} @@ -243,9 +245,9 @@ export default function More() { For advanced features, visit{" "} openInAppBrowser("https://app.cal.com", "Cal.com")} + onPress={() => openInAppBrowser(calAppUrl, "Cal.com")} > - app.cal.com + {calAppUrl.replace(/^https?:\/\//, "")} diff --git a/apps/mobile/app/profile-sheet.ios.tsx b/apps/mobile/app/profile-sheet.ios.tsx index 0d055d1..1e87011 100644 --- a/apps/mobile/app/profile-sheet.ios.tsx +++ b/apps/mobile/app/profile-sheet.ios.tsx @@ -18,6 +18,7 @@ import { useUserProfile } from "@/hooks"; import { showSuccessAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; import { getAvatarUrl } from "@/utils/getAvatarUrl"; +import { getCalAppUrl, getCalWebUrl } from "@/utils/region"; interface ProfileMenuItem { id: string; @@ -57,23 +58,23 @@ export default function ProfileSheet() { avatarText: isDark ? "#FFFFFF" : "#4B5563", }; - const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null; + const calAppUrl = getCalAppUrl(); + const calWebUrl = getCalWebUrl(); + const publicPageUrl = userProfile?.username ? `${calWebUrl}/${userProfile.username}` : null; const menuItems: ProfileMenuItem[] = [ { id: "profile", label: "My Profile", icon: "person-outline", - onPress: () => - openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"), + onPress: () => openInAppBrowser(`${calAppUrl}/settings/my-account/profile`, "Profile page"), external: true, }, { id: "settings", label: "My Settings", icon: "settings-outline", - onPress: () => - openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"), + onPress: () => openInAppBrowser(`${calAppUrl}/settings/my-account/general`, "Settings page"), external: true, }, { @@ -81,10 +82,7 @@ export default function ProfileSheet() { label: "Out of Office", icon: "moon-outline", onPress: () => - openInAppBrowser( - "https://app.cal.com/settings/my-account/out-of-office", - "Out of Office page" - ), + openInAppBrowser(`${calAppUrl}/settings/my-account/out-of-office`, "Out of Office page"), external: true, }, @@ -105,14 +103,14 @@ export default function ProfileSheet() { id: "roadmap", label: "Roadmap", icon: "map-outline", - onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"), + onPress: () => openInAppBrowser(`${calWebUrl}/roadmap`, "Roadmap page"), external: true, }, { id: "help", label: "Help", icon: "help-circle-outline", - onPress: () => openInAppBrowser("https://cal.com/help", "Help page"), + onPress: () => openInAppBrowser(`${calWebUrl}/help`, "Help page"), external: true, }, ]; diff --git a/apps/mobile/app/profile-sheet.tsx b/apps/mobile/app/profile-sheet.tsx index e341e1c..447ac8a 100644 --- a/apps/mobile/app/profile-sheet.tsx +++ b/apps/mobile/app/profile-sheet.tsx @@ -17,6 +17,7 @@ import { useUserProfile } from "@/hooks"; import { showSuccessAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; import { getAvatarUrl } from "@/utils/getAvatarUrl"; +import { getCalAppUrl, getCalWebUrl } from "@/utils/region"; interface ProfileMenuItem { id: string; @@ -44,23 +45,23 @@ export default function ProfileSheet() { activeBackground: isDark ? "#171717" : "#F3F4F6", }; - const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null; + const calAppUrl = getCalAppUrl(); + const calWebUrl = getCalWebUrl(); + const publicPageUrl = userProfile?.username ? `${calWebUrl}/${userProfile.username}` : null; const menuItems: ProfileMenuItem[] = [ { id: "profile", label: "My Profile", icon: "person-outline", - onPress: () => - openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"), + onPress: () => openInAppBrowser(`${calAppUrl}/settings/my-account/profile`, "Profile page"), external: true, }, { id: "settings", label: "My Settings", icon: "settings-outline", - onPress: () => - openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"), + onPress: () => openInAppBrowser(`${calAppUrl}/settings/my-account/general`, "Settings page"), external: true, }, { @@ -68,10 +69,7 @@ export default function ProfileSheet() { label: "Out of Office", icon: "moon-outline", onPress: () => - openInAppBrowser( - "https://app.cal.com/settings/my-account/out-of-office", - "Out of Office page" - ), + openInAppBrowser(`${calAppUrl}/settings/my-account/out-of-office`, "Out of Office page"), external: true, }, { @@ -100,14 +98,14 @@ export default function ProfileSheet() { id: "roadmap", label: "Roadmap", icon: "map-outline", - onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"), + onPress: () => openInAppBrowser(`${calWebUrl}/roadmap`, "Roadmap page"), external: true, }, { id: "help", label: "Help", icon: "help-circle-outline", - onPress: () => openInAppBrowser("https://cal.com/help", "Help page"), + onPress: () => openInAppBrowser(`${calWebUrl}/help`, "Help page"), external: true, }, ]; diff --git a/apps/mobile/components/LoginScreen.tsx b/apps/mobile/components/LoginScreen.tsx index cdc9a2a..e23ebde 100644 --- a/apps/mobile/components/LoginScreen.tsx +++ b/apps/mobile/components/LoginScreen.tsx @@ -1,15 +1,48 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useEffect, useState } from "react"; import { Platform, Text, TouchableOpacity, useColorScheme, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuth } from "@/contexts/AuthContext"; import { showErrorAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; +import { + type CalRegion, + getCalAppUrl, + getRegion, + preloadRegion, + setRegion, + subscribeRegion, +} from "@/utils/region"; import { CalComLogo } from "./CalComLogo"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +const REGION_OPTIONS: { value: CalRegion; label: string }[] = [ + { value: "us", label: "United States" }, + { value: "eu", label: "European Union" }, +]; + +function getRegionLabel(region: CalRegion): string { + return REGION_OPTIONS.find((option) => option.value === region)?.label ?? "United States"; +} export function LoginScreen() { const { loginWithOAuth, loading } = useAuth(); const insets = useSafeAreaInsets(); const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; + const [region, setRegionState] = useState(getRegion()); + + useEffect(() => { + preloadRegion().then((loaded) => { + setRegionState(loaded); + }); + return subscribeRegion(setRegionState); + }, []); const handleOAuthLogin = async () => { try { @@ -26,7 +59,11 @@ export function LoginScreen() { }; const handleSignUp = async () => { - await openInAppBrowser("https://app.cal.com/signup", "Sign up page"); + await openInAppBrowser(`${getCalAppUrl(region)}/signup`, "Sign up page"); + }; + + const handleRegionChange = async (next: CalRegion) => { + await setRegion(next); }; return ( @@ -36,8 +73,48 @@ export function LoginScreen() { - {/* Bottom section with button */} + {/* Bottom section with region select + CTA */} + {/* Region picker */} + + + Data region + + + + + + {getRegionLabel(region)} + + + + + + {REGION_OPTIONS.map((option) => ( + handleRegionChange(option.value)} + > + {option.label} + + ))} + + + + {/* Primary CTA button */} openInAppBrowser("https://app.cal.com/event-types", "Select Timezone")} + onPress={() => openInAppBrowser(`${getCalAppUrl()}/event-types`, "Select Timezone")} /> ) : null} diff --git a/apps/mobile/contexts/AuthContext.tsx b/apps/mobile/contexts/AuthContext.tsx index cf198aa..18f1651 100644 --- a/apps/mobile/contexts/AuthContext.tsx +++ b/apps/mobile/contexts/AuthContext.tsx @@ -9,6 +9,7 @@ import { import type { UserProfile } from "@/services/types/users.types"; import { WebAuthService } from "@/services/webAuth"; import { clearQueryCache } from "@/utils/queryPersister"; +import { preloadRegion, subscribeRegion } from "@/utils/region"; import { generalStorage, secureStorage } from "@/utils/storage"; /** @@ -62,7 +63,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const [userInfo, setUserInfo] = useState(null); const [isWebSession, setIsWebSession] = useState(false); const [loading, setLoading] = useState(true); - const [oauthService] = useState(() => { + const [oauthService, setOauthService] = useState(() => { try { return createCalComOAuthService(); } catch (error) { @@ -71,6 +72,31 @@ export function AuthProvider({ children }: AuthProviderProps) { } }); + // Recreate the OAuth service when the user changes data region on the login + // screen so subsequent authorization flows target the correct cal.{com|eu}. + useEffect(() => { + let cancelled = false; + preloadRegion().then(() => { + if (cancelled) return; + try { + setOauthService(createCalComOAuthService()); + } catch (error) { + console.warn("Failed to initialize OAuth service:", error); + } + }); + const unsubscribe = subscribeRegion(() => { + try { + setOauthService(createCalComOAuthService()); + } catch (error) { + console.warn("Failed to re-initialize OAuth service after region change:", error); + } + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + // Setup refresh token function for OAuth const setupRefreshTokenFunction = useCallback((service: CalComOAuthService) => { CalComAPIService.setRefreshTokenFunction(async (refreshToken: string) => { @@ -246,7 +272,11 @@ export function AuthProvider({ children }: AuthProviderProps) { checkAuthState(); // Set up token refresh callback - const handleTokenRefresh = async (newAccessToken: string, newRefreshToken?: string, expiresAt?: number) => { + const handleTokenRefresh = async ( + newAccessToken: string, + newRefreshToken?: string, + expiresAt?: number + ) => { try { const tokens: OAuthTokens = { accessToken: newAccessToken, diff --git a/apps/mobile/hooks/useBookingActionModals.ts b/apps/mobile/hooks/useBookingActionModals.ts index 5a38159..c138cda 100644 --- a/apps/mobile/hooks/useBookingActionModals.ts +++ b/apps/mobile/hooks/useBookingActionModals.ts @@ -16,6 +16,7 @@ import type { ConferencingSession, } from "@/services/types/bookings.types"; import { showErrorAlert, showSilentSuccessAlert, showSuccessAlert } from "@/utils/alerts"; +import { getCalAppUrl } from "@/utils/region"; interface UseBookingActionModalsReturn { // Selected booking for actions @@ -280,7 +281,7 @@ export function useBookingActionModals(): UseBookingActionModalsReturn { const handleRequestReschedule = useCallback((booking: Booking) => { // Request reschedule is a server-driven operation that requires the web app // We deep link to the booking detail page where the user can trigger it - const webUrl = `https://app.cal.com/booking/${booking.uid}`; + const webUrl = `${getCalAppUrl()}/booking/${booking.uid}`; Alert.alert( "Request Reschedule", diff --git a/apps/mobile/services/calcom/request.ts b/apps/mobile/services/calcom/request.ts index 597d5e3..91a9b16 100644 --- a/apps/mobile/services/calcom/request.ts +++ b/apps/mobile/services/calcom/request.ts @@ -3,6 +3,7 @@ */ import { fetchWithTimeout } from "@/utils/network"; +import { getCalApiUrl } from "@/utils/region"; import { safeLogError } from "@/utils/safeLogger"; import { @@ -15,7 +16,14 @@ import { } from "./auth"; import { safeParseErrorJson, safeParseJson } from "./utils"; -export const API_BASE_URL = "https://api.cal.com/v2"; +/** + * Returns the region-aware Cal.com API base URL (e.g. `https://api.cal.eu/v2` + * when the user selected EU on the login screen). + */ +export function getApiBaseUrl(): string { + return `${getCalApiUrl()}/v2`; +} + export const REQUEST_TIMEOUT_MS = 30000; /** @@ -23,7 +31,7 @@ export const REQUEST_TIMEOUT_MS = 30000; */ export async function testRawBookingsAPI(): Promise { try { - const url = `${API_BASE_URL}/bookings?status=upcoming&status=unconfirmed&limit=50`; + const url = `${getApiBaseUrl()}/bookings?status=upcoming&status=unconfirmed&limit=50`; const response = await fetchWithTimeout( url, @@ -63,7 +71,7 @@ export async function makeRequest( apiVersion: string = "2024-08-13", isRetry: boolean = false ): Promise { - const url = `${API_BASE_URL}${endpoint}`; + const url = `${getApiBaseUrl()}${endpoint}`; const response = await fetchWithTimeout( url, @@ -109,7 +117,11 @@ export async function makeRequest( } // Notify AuthContext to update stored tokens (including expiresAt for proactive refresh) - await tokenRefreshCallback(newTokens.accessToken, newTokens.refreshToken, newTokens.expiresAt); + await tokenRefreshCallback( + newTokens.accessToken, + newTokens.refreshToken, + newTokens.expiresAt + ); // Retry the original request with the new token return makeRequest(endpoint, options, apiVersion, true); diff --git a/apps/mobile/services/oauthService.ts b/apps/mobile/services/oauthService.ts index a65a753..decbafa 100644 --- a/apps/mobile/services/oauthService.ts +++ b/apps/mobile/services/oauthService.ts @@ -6,6 +6,7 @@ import * as WebBrowser from "expo-web-browser"; import { Platform } from "react-native"; import { fetchWithTimeout } from "@/utils/network"; +import { type CalRegion, getCalAppUrl, getRegion } from "@/utils/region"; import { safeLogWarn } from "@/utils/safeLogger"; WebBrowser.maybeCompleteAuthSession(); @@ -477,7 +478,7 @@ export class CalComOAuthService { window.addEventListener("message", messageHandler); window.parent.postMessage( - { type: EXTENSION_MESSAGE_TYPES.SYNC_TOKENS, tokens, sessionToken }, + { type: EXTENSION_MESSAGE_TYPES.SYNC_TOKENS, tokens, sessionToken, region: getRegion() }, "*" ); }); @@ -597,13 +598,38 @@ function detectBrowserType(): BrowserType { } /** - * Gets browser-specific OAuth configuration. - * Falls back to default (Chrome) config if browser-specific config is not available. + * Resolve an OAuth env var with an optional EU-region suffix, falling back + * to the US/default value when the EU-specific value is not configured. */ -function getBrowserSpecificOAuthConfig(): { clientId: string; redirectUri: string } { +function pickRegionalEnv( + baseValue: string | undefined, + euValue: string | undefined, + region: CalRegion +): string { + if (region === "eu" && euValue) return euValue; + return baseValue || ""; +} + +/** + * Gets browser- and region-specific OAuth configuration. + * Falls back to default (Chrome/US) config if browser- or region-specific + * config is not available. + */ +function getBrowserSpecificOAuthConfig(region: CalRegion): { + clientId: string; + redirectUri: string; +} { // Default values (Chrome/Brave) - const defaultClientId = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || ""; - const defaultRedirectUri = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || ""; + const defaultClientId = pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EU, + region + ); + const defaultRedirectUri = pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EU, + region + ); // For mobile apps, always use default config if (Platform.OS !== "web") { @@ -615,21 +641,50 @@ function getBrowserSpecificOAuthConfig(): { clientId: string; redirectUri: strin switch (browserType) { case "firefox": return { - clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId, + clientId: + pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX_EU, + region + ) || defaultClientId, redirectUri: - process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || defaultRedirectUri, + pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX_EU, + region + ) || defaultRedirectUri, }; case "safari": return { - clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI || defaultClientId, - redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI || defaultRedirectUri, + clientId: + pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI_EU, + region + ) || defaultClientId, + redirectUri: + pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI_EU, + region + ) || defaultRedirectUri, }; case "edge": return { - clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE || defaultClientId, - redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE || defaultRedirectUri, + clientId: + pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE_EU, + region + ) || defaultClientId, + redirectUri: + pickRegionalEnv( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE, + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE_EU, + region + ) || defaultRedirectUri, }; default: // Chrome, Brave, and unknown browsers use the default configuration @@ -638,13 +693,14 @@ function getBrowserSpecificOAuthConfig(): { clientId: string; redirectUri: strin } export function createCalComOAuthService(overrides: Partial = {}): CalComOAuthService { - // Get browser-specific OAuth config - const browserConfig = getBrowserSpecificOAuthConfig(); + const region = getRegion(); + // Get browser- and region-specific OAuth config + const browserConfig = getBrowserSpecificOAuthConfig(region); const config: OAuthConfig = { clientId: browserConfig.clientId, redirectUri: browserConfig.redirectUri, - calcomBaseUrl: "https://app.cal.com", + calcomBaseUrl: getCalAppUrl(region), ...overrides, }; diff --git a/apps/mobile/services/webAuth.ts b/apps/mobile/services/webAuth.ts index 8afebe9..b59a4dc 100644 --- a/apps/mobile/services/webAuth.ts +++ b/apps/mobile/services/webAuth.ts @@ -1,6 +1,7 @@ import { Platform } from "react-native"; import { fetchWithTimeout } from "@/utils/network"; +import { getCalAppUrl } from "@/utils/region"; import type { UserProfile } from "./types/users.types"; @@ -76,9 +77,11 @@ async function validateWebSession(): Promise { } try { + const calAppUrl = getCalAppUrl(); + // First try to call the NextAuth session endpoint const sessionResponse = await fetchWithTimeout( - "https://app.cal.com/api/auth/session", + `${calAppUrl}/api/auth/session`, { method: "GET", credentials: "include", // Include cookies @@ -103,7 +106,7 @@ async function validateWebSession(): Promise { // Try the internal Cal.com me endpoint (this might work with cookies) const meResponse = await fetchWithTimeout( - "https://app.cal.com/api/me", + `${calAppUrl}/api/me`, { method: "GET", credentials: "include", @@ -127,7 +130,7 @@ async function validateWebSession(): Promise { // Try to check if user is logged in by attempting to access a protected page const dashboardResponse = await fetchWithTimeout( - "https://app.cal.com/api/trpc/viewer.me", + `${calAppUrl}/api/trpc/viewer.me`, { method: "GET", credentials: "include", @@ -202,7 +205,7 @@ function redirectToWebLogin(): void { // For web, redirect directly to Cal.com login const currentUrl = window.location.href; - const loginUrl = `https://app.cal.com/auth/signin?callbackUrl=${encodeURIComponent(currentUrl)}`; + const loginUrl = `${getCalAppUrl()}/auth/signin?callbackUrl=${encodeURIComponent(currentUrl)}`; window.location.href = loginUrl; } diff --git a/apps/mobile/utils/browser.ts b/apps/mobile/utils/browser.ts index 16d0414..2cf1581 100644 --- a/apps/mobile/utils/browser.ts +++ b/apps/mobile/utils/browser.ts @@ -7,8 +7,8 @@ import * as WebBrowser from "expo-web-browser"; import { Linking, Platform } from "react-native"; - import { showErrorAlert } from "./alerts"; +import { CAL_APP_HOSTNAMES } from "./region"; /** * Handle errors from browser functions in a consistent way. @@ -42,12 +42,13 @@ export interface BrowserOptions { } /** - * Appends ?standalone=true to app.cal.com URLs on iOS. + * Appends ?standalone=true to Cal.com app URLs on iOS. * This hides navigation elements when pages are opened in the in-app browser, - * which is required for Apple App Store compliance. + * which is required for Apple App Store compliance. Applies to both regions + * (`app.cal.com` and `app.cal.eu`). * * @param url - The URL to process - * @returns The URL with standalone=true appended if it's an app.cal.com URL on iOS + * @returns The URL with standalone=true appended if it's a Cal.com app URL on iOS */ const appendStandaloneParam = (url: string): string => { // Only apply to iOS @@ -58,8 +59,7 @@ const appendStandaloneParam = (url: string): string => { try { const urlObj = new URL(url); - // Only apply to app.cal.com URLs - if (urlObj.hostname !== "app.cal.com") { + if (!CAL_APP_HOSTNAMES.has(urlObj.hostname)) { return url; } diff --git a/apps/mobile/utils/deep-links.ts b/apps/mobile/utils/deep-links.ts index 39b1879..9db5d89 100644 --- a/apps/mobile/utils/deep-links.ts +++ b/apps/mobile/utils/deep-links.ts @@ -10,17 +10,13 @@ import * as WebBrowser from "expo-web-browser"; import { Alert, Platform } from "react-native"; import { showErrorAlert } from "@/utils/alerts"; - -// Default Cal.com web URL - can be overridden for self-hosted instances -const DEFAULT_CAL_WEB_URL = "https://app.cal.com"; +import { CAL_APP_HOSTNAMES, getCalAppUrl } from "@/utils/region"; /** - * Get the Cal.com web URL from environment or use default + * Get the Cal.com web URL for the currently selected region. */ function getCalWebUrl(): string { - // In a real implementation, this would read from environment config - // For now, use the default Cal.com URL - return DEFAULT_CAL_WEB_URL; + return getCalAppUrl(); } /** @@ -40,8 +36,7 @@ function appendStandaloneParam(url: string): string { try { const urlObj = new URL(url); - // Only apply to app.cal.com URLs - if (urlObj.hostname !== "app.cal.com") { + if (!CAL_APP_HOSTNAMES.has(urlObj.hostname)) { return url; } diff --git a/apps/mobile/utils/region.ts b/apps/mobile/utils/region.ts new file mode 100644 index 0000000..ff9e8f0 --- /dev/null +++ b/apps/mobile/utils/region.ts @@ -0,0 +1,115 @@ +/** + * Cal.com data region (US vs. EU). + * + * Stores the user-selected region so OAuth (`app.cal.{com|eu}`) and API + * (`api.cal.{com|eu}`) calls can be routed to the correct infrastructure. + * + * Region is persisted in general storage and cached in memory for synchronous + * access. Listeners are notified when the region changes so dependent modules + * (API client, OAuth service) can refresh their base URLs. + */ + +import { Platform } from "react-native"; + +import { generalStorage, isChromeStorageAvailable } from "./storage"; + +export type CalRegion = "us" | "eu"; + +const REGION_STORAGE_KEY = "cal_region"; +const DEFAULT_REGION: CalRegion = "us"; + +let currentRegion: CalRegion = DEFAULT_REGION; +const listeners = new Set<(region: CalRegion) => void>(); + +function isValidRegion(value: string | null): value is CalRegion { + return value === "us" || value === "eu"; +} + +function readSync(): CalRegion | null { + if (isChromeStorageAvailable()) { + // chrome.storage is async; caller should await preloadRegion() on startup. + return null; + } + if (Platform.OS === "web" && typeof localStorage !== "undefined") { + const raw = localStorage.getItem(REGION_STORAGE_KEY); + return isValidRegion(raw) ? raw : null; + } + return null; +} + +const initial = readSync(); +if (initial) { + currentRegion = initial; +} + +/** + * Preload the region from persistent storage. Call once on app startup before + * any OAuth / API call is made. On web (localStorage) this is effectively a + * no-op because the sync read above already populated the cache. + */ +export async function preloadRegion(): Promise { + try { + const raw = await generalStorage.getItem(REGION_STORAGE_KEY); + if (isValidRegion(raw) && raw !== currentRegion) { + currentRegion = raw; + notify(); + } + } catch { + // Fall back to in-memory default; not worth failing app startup over. + } + return currentRegion; +} + +export function getRegion(): CalRegion { + return currentRegion; +} + +export async function setRegion(region: CalRegion): Promise { + if (region === currentRegion) return; + currentRegion = region; + notify(); + try { + await generalStorage.setItem(REGION_STORAGE_KEY, region); + } catch { + // Persisting is best-effort; region stays in-memory for this session. + } +} + +export function subscribeRegion(listener: (region: CalRegion) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notify(): void { + for (const listener of listeners) { + try { + listener(currentRegion); + } catch { + // Ignore listener errors so one bad subscriber can't break others. + } + } +} + +/** Fully-qualified origin of the Cal.com web app for the current region. */ +export function getCalAppUrl(region: CalRegion = currentRegion): string { + return region === "eu" ? "https://app.cal.eu" : "https://app.cal.com"; +} + +/** Fully-qualified origin of the Cal.com API for the current region. */ +export function getCalApiUrl(region: CalRegion = currentRegion): string { + return region === "eu" ? "https://api.cal.eu" : "https://api.cal.com"; +} + +/** Fully-qualified origin of the Cal.com marketing site for the current region. */ +export function getCalWebUrl(region: CalRegion = currentRegion): string { + return region === "eu" ? "https://cal.eu" : "https://cal.com"; +} + +/** + * Returns the set of Cal.com app hostnames the app talks to across regions. + * Useful for code (e.g. `appendStandaloneParam`) that needs to recognize any + * Cal.com app URL regardless of the user's current region. + */ +export const CAL_APP_HOSTNAMES: ReadonlySet = new Set(["app.cal.com", "app.cal.eu"]);