diff --git a/companion/.env.example b/companion/.env.example index 4df8ceaad3..1dce561f4e 100644 --- a/companion/.env.example +++ b/companion/.env.example @@ -3,6 +3,38 @@ EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here +# ============================================================================= +# OAUTH REDIRECT URL FORMATS BY BROWSER +# ============================================================================= +# +# | Browser | Redirect URL Format | Notes | +# |---------|------------------------------------------------------------------|----------------------------------------------------| +# | Chrome | https://.chromiumapp.org/oauth/callback | Get ID from `chrome://extensions` | +# | Brave | https://.chromiumapp.org/oauth/callback | Same extension package as Chrome | +# | Edge | https://.chromiumapp.org/oauth/callback | Different ID from Chrome | +# | Firefox | https://.extensions.allizom.org/oauth/callback | UUID from `browser.identity.getRedirectURL()` | +# | Safari | https://..appextension/oauth/callback | Check Xcode project configuration | +# +# ============================================================================= + +# ============================================================================= +# FIREFOX OAUTH CONFIG +# ============================================================================= +EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX= +EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX= + +# ============================================================================= +# SAFARI OAUTH CONFIG +# ============================================================================= +EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI= +EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI= + +# ============================================================================= +# EDGE OAUTH CONFIG +# ============================================================================= +EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE= +EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE= + # =========================================== # CACHE CONFIGURATION (all values in MINUTES) # =========================================== diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx index c0492b94f5..941205d88d 100644 --- a/companion/app/(tabs)/(event-types)/index.tsx +++ b/companion/app/(tabs)/(event-types)/index.tsx @@ -690,17 +690,17 @@ export default function EventTypes() { activeOpacity={1} onPress={(e) => e.stopPropagation()} > - {selectedEventType && ( + {selectedEventType ? ( <> {selectedEventType.title} - {selectedEventType.description && ( + {selectedEventType.description ? ( {normalizeMarkdown(selectedEventType.description)} - )} + ) : null} @@ -759,7 +759,7 @@ export default function EventTypes() { - )} + ) : null} @@ -844,12 +844,12 @@ export default function EventTypes() { Delete Event Type - {eventTypeToDelete && ( + {eventTypeToDelete ? ( <> This will permanently delete the "{eventTypeToDelete.title}" event type. This action cannot be undone. - )} + ) : null} @@ -881,13 +881,13 @@ export default function EventTypes() { {/* Toast for Web Platform */} - {showToast && ( + {showToast ? ( {toastMessage} - )} + ) : null} ); } diff --git a/companion/app/(tabs)/availability.tsx b/companion/app/(tabs)/availability.tsx index eb0fd129e4..1aa8f86882 100644 --- a/companion/app/(tabs)/availability.tsx +++ b/companion/app/(tabs)/availability.tsx @@ -273,11 +273,11 @@ export default function Availability() { {schedule.name} - {schedule.isDefault && ( + {schedule.isDefault ? ( Default - )} + ) : null} {schedule.availability && schedule.availability.length > 0 ? ( @@ -360,7 +360,7 @@ export default function Availability() {
{/* Empty state - no schedules */} - {showEmptyState && ( + {showEmptyState ? ( - )} + ) : null} {/* Search bar and content for non-empty states */} - {!showEmptyState && ( + {!showEmptyState ? ( <> {/* Search empty state */} - {showSearchEmptyState && ( + {showSearchEmptyState ? ( - )} + ) : null} {/* Schedules list */} - {showList && ( + {showList ? ( - )} + ) : null} - )} + ) : null} {/* Create Schedule Modal */} {/* Set as Default - only show if not already default */} - {selectedSchedule && !selectedSchedule.isDefault && ( + {selectedSchedule && !selectedSchedule.isDefault ? ( <> { @@ -553,7 +553,7 @@ export default function Availability() { - )} + ) : null} {/* Duplicate */} - {selectedEventTypeId !== null && ( + {selectedEventTypeId !== null ? ( Filtered by {selectedEventTypeLabel || "event type"} @@ -438,7 +438,7 @@ export default function Bookings() { Clear filter - )} + ) : null} ); @@ -933,6 +933,46 @@ export default function Bookings() { const isCancelled = item.status?.toUpperCase() === "CANCELLED"; const isRejected = item.status?.toUpperCase() === "REJECTED"; + const getHostAndAttendeesDisplay = () => { + const hasHostOrAttendees = + (item.hosts && item.hosts.length > 0) || + item.user || + (item.attendees && item.attendees.length > 0); + + if (!hasHostOrAttendees) return null; + + const currentUserEmail = userInfo?.email?.toLowerCase(); + const hostEmail = item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase(); + const isCurrentUserHost = currentUserEmail && hostEmail && currentUserEmail === hostEmail; + + const hostName = isCurrentUserHost + ? "You" + : item.hosts?.[0]?.name || item.hosts?.[0]?.email || item.user?.name || item.user?.email; + + const attendeesDisplay = + item.attendees && item.attendees.length > 0 + ? item.attendees.length === 1 + ? item.attendees[0].name || item.attendees[0].email + : item.attendees + .slice(0, 2) + .map((att) => att.name || att.email) + .join(", ") + + (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "") + : null; + + if (hostName && attendeesDisplay) { + return `${hostName} and ${attendeesDisplay}`; + } else if (hostName) { + return hostName; + } else if (attendeesDisplay) { + return attendeesDisplay; + } + return null; + }; + + const hostAndAttendeesDisplay = getHostAndAttendeesDisplay(); + const meetingInfo = getMeetingInfo(item.location); + return ( - {/* Badges Row */} - {isPending && ( + {isPending ? ( Unconfirmed - )} + ) : null} - {/* Title */} {item.title} - {/* Description */} - {item.description && ( + {item.description ? ( "{item.description}" - )} - + ) : null} {/* Host and Attendees */} - {((item.hosts && item.hosts.length > 0) || - item.user || - (item.attendees && item.attendees.length > 0)) && ( - - {(() => { - // Check if current user is the host - const currentUserEmail = userInfo?.email?.toLowerCase(); - const hostEmail = - item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase(); - const isCurrentUserHost = - currentUserEmail && hostEmail && currentUserEmail === hostEmail; - - // Get host display name - const hostName = isCurrentUserHost - ? "You" - : item.hosts?.[0]?.name || - item.hosts?.[0]?.email || - item.user?.name || - item.user?.email; - - // Get attendees display - const attendeesDisplay = - item.attendees && item.attendees.length > 0 - ? item.attendees.length === 1 - ? item.attendees[0].name || item.attendees[0].email - : item.attendees - .slice(0, 2) - .map((att) => att.name || att.email) - .join(", ") + - (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "") - : null; - - // Combine host and attendees - if (hostName && attendeesDisplay) { - return `${hostName} and ${attendeesDisplay}`; - } else if (hostName) { - return hostName; - } else if (attendeesDisplay) { - return attendeesDisplay; - } - return null; - })()} - - )} - - {/* Meeting Link - only for video conferencing apps */} - {(() => { - const meetingInfo = getMeetingInfo(item.location); - if (!meetingInfo) return null; - - return ( - - { - e.stopPropagation(); - try { - await Linking.openURL(meetingInfo.cleanUrl); - } catch { - showErrorAlert("Error", "Failed to open meeting link. Please try again."); - } - }} - > - {meetingInfo.iconUrl ? ( - - ) : ( - - )} - {meetingInfo.label} - - - ); - })()} + {hostAndAttendeesDisplay ? ( + {hostAndAttendeesDisplay} + ) : null} + {/* Meeting Link */} + {meetingInfo ? ( + + { + e.stopPropagation(); + try { + await Linking.openURL(meetingInfo.cleanUrl); + } catch { + showErrorAlert("Error", "Failed to open meeting link. Please try again."); + } + }} + > + {meetingInfo.iconUrl ? ( + + ) : ( + + )} + {meetingInfo.label} + + + ) : null} - - {/* Action buttons row */} - {/* Confirm and Reject buttons for unconfirmed bookings */} - {isPending && ( + {isPending ? ( <> Reject - )} - - {/* Three dots button */} + ) : null} - )} + ) : null} {/* Search empty state */} - {showSearchEmptyState && ( + {showSearchEmptyState ? ( - )} + ) : null} {/* Bookings list */} - {showList && ( + {showList ? ( - )} + ) : null} {/* Filter Modal */} {eventType.title} - {selectedEventTypeId === eventType.id && ( + {selectedEventTypeId === eventType.id ? ( - )} + ) : null} ))} - {eventTypes.length === 0 && ( + {eventTypes.length === 0 ? ( No event types found - )} + ) : null} )} @@ -1364,7 +1343,7 @@ export default function Bookings() { }} > - {rescheduleBooking && ( + {rescheduleBooking ? ( <> Reschedule "{rescheduleBooking.title}" @@ -1442,7 +1421,7 @@ export default function Bookings() { Cancel - )} + ) : null} diff --git a/companion/app/availability-detail.tsx b/companion/app/availability-detail.tsx index b09a64802d..42aa63d0fb 100644 --- a/companion/app/availability-detail.tsx +++ b/companion/app/availability-detail.tsx @@ -537,7 +537,7 @@ export default function AvailabilityDetail() { > {DAY_ABBREVIATIONS[dayIndex]} - {isEnabled && firstSlot && ( + {isEnabled && firstSlot ? ( @@ -563,16 +563,16 @@ export default function AvailabilityDetail() { - )} + ) : null} - {isEnabled && ( + {isEnabled ? ( addTimeSlot(dayIndex)} className="p-1"> - )} + ) : null} - {isEnabled && daySlots.length > 1 && ( + {isEnabled && daySlots.length > 1 ? ( {daySlots.slice(1).map((slot, slotIndex) => ( @@ -616,7 +616,7 @@ export default function AvailabilityDetail() { ))} - )} + ) : null} ); })} @@ -641,7 +641,7 @@ export default function AvailabilityDetail() { Add dates when your availability changes from your daily hours. - {overrides.length > 0 && ( + {overrides.length > 0 ? ( {overrides.map((override, index) => ( ))} - )} + ) : null} {/* Time Picker (only if not unavailable) */} - {!isUnavailable && ( + {!isUnavailable ? ( Which hours are you free? @@ -919,7 +919,7 @@ export default function AvailabilityDetail() { - )} + ) : null} {/* Save Button */} Who {/* Show host from user field or hosts array */} - {(booking.user || (booking.hosts && booking.hosts.length > 0)) && ( + {booking.user || (booking.hosts && booking.hosts.length > 0) ? ( {booking.user ? ( @@ -412,8 +412,8 @@ export default function BookingDetail() { )) ) : null} - )} - {booking.attendees && booking.attendees.length > 0 && ( + ) : null} + {booking.attendees && booking.attendees.length > 0 ? ( {booking.attendees.map((attendee, index) => ( 0 ? "mt-4" : ""}`}> @@ -431,11 +431,11 @@ export default function BookingDetail() { ))} - )} + ) : null} {/* Where Section */} - {locationProvider && ( + {locationProvider ? ( Where {locationProvider.url ? ( @@ -443,14 +443,14 @@ export default function BookingDetail() { onPress={handleJoinMeeting} className="flex-row flex-wrap items-center" > - {locationProvider.iconUrl && ( + {locationProvider.iconUrl ? ( - )} + ) : null} {locationProvider.label}: {locationProvider.url} @@ -458,55 +458,55 @@ export default function BookingDetail() { ) : ( - {locationProvider.iconUrl && ( + {locationProvider.iconUrl ? ( - )} + ) : null} {locationProvider.label} )} - )} + ) : null} {/* Recurring Event Section */} - {(booking.recurringEventId || booking.recurringBookingUid) && ( + {booking.recurringEventId || booking.recurringBookingUid ? ( Recurring Event Every 2 weeks for 6 occurrences - )} + ) : null} {/* Description Section */} - {booking.description && ( + {booking.description ? ( Description {booking.description} - )} + ) : null} {/* Join Meeting Button */} - {locationProvider?.url && ( + {locationProvider?.url ? ( - {locationProvider.iconUrl && ( + {locationProvider.iconUrl ? ( - )} + ) : null} Join {locationProvider.label} - )} + ) : null} diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx index d7970e2f83..edafbfc3fc 100644 --- a/companion/app/event-type-detail.tsx +++ b/companion/app/event-type-detail.tsx @@ -1157,7 +1157,7 @@ export default function EventTypeDetail() { }} contentContainerStyle={{ padding: 20, paddingBottom: 200 }} > - {activeTab === "basics" && ( + {activeTab === "basics" ? ( - )} + ) : null} {/* Duration Multi-Select Modal */} {duration} - {selectedDurations.includes(duration) && ( + {selectedDurations.includes(duration) ? ( - )} + ) : null} ))} @@ -1260,9 +1260,9 @@ export default function EventTypeDetail() { > {duration} - {defaultDuration === duration && ( + {defaultDuration === duration ? ( - )} + ) : null} ))} @@ -1302,15 +1302,15 @@ export default function EventTypeDetail() { > {schedule.name} - {schedule.isDefault && ( + {schedule.isDefault ? ( Default - )} + ) : null} - {selectedSchedule?.id === schedule.id && ( + {selectedSchedule?.id === schedule.id ? ( - )} + ) : null} ))} @@ -1368,10 +1368,10 @@ export default function EventTypeDetail() { > {tz} - {(selectedTimezone === tz || - (selectedScheduleDetails?.timeZone === tz && !selectedTimezone)) && ( + {selectedTimezone === tz || + (selectedScheduleDetails?.timeZone === tz && !selectedTimezone) ? ( - )} + ) : null} ))} @@ -1410,9 +1410,9 @@ export default function EventTypeDetail() { > {option} - {beforeEventBuffer === option && ( + {beforeEventBuffer === option ? ( - )} + ) : null} ))} @@ -1450,9 +1450,9 @@ export default function EventTypeDetail() { > {option} - {afterEventBuffer === option && ( + {afterEventBuffer === option ? ( - )} + ) : null} ))} @@ -1490,9 +1490,9 @@ export default function EventTypeDetail() { > {option} - {minimumNoticeUnit === option && ( + {minimumNoticeUnit === option ? ( - )} + ) : null} ))} @@ -1622,9 +1622,9 @@ export default function EventTypeDetail() { > {option} - {slotInterval === option && ( + {slotInterval === option ? ( - )} + ) : null} ))} @@ -1662,16 +1662,16 @@ export default function EventTypeDetail() { > {option === "weekly" ? "week" : option === "monthly" ? "month" : "year"} - {recurringFrequency === option && ( + {recurringFrequency === option ? ( - )} + ) : null} ))} - {activeTab === "availability" && ( + {activeTab === "availability" ? ( - )} + ) : null} - {activeTab === "limits" && ( + {activeTab === "limits" ? ( - )} + ) : null} - {activeTab === "advanced" && ( + {activeTab === "advanced" ? ( - )} + ) : null} - {activeTab === "recurring" && ( + {activeTab === "recurring" ? ( - )} + ) : null} - {activeTab === "other" && ( + {activeTab === "other" ? ( Additional Settings @@ -1884,7 +1884,7 @@ export default function EventTypeDetail() { - )} + ) : null} {/* Bottom Action Bar */} diff --git a/companion/components/BookingActionsModal.tsx b/companion/components/BookingActionsModal.tsx index a51e4a0009..57a460d615 100644 --- a/companion/components/BookingActionsModal.tsx +++ b/companion/components/BookingActionsModal.tsx @@ -163,7 +163,7 @@ export function BookingActionsModal({ {/* View Recordings */} - {hasLocationUrl && ( + {hasLocationUrl ? ( { if (afterEventActionsDisabled) return; @@ -184,10 +184,10 @@ export function BookingActionsModal({ View Recordings - )} + ) : null} {/* Meeting Session Details */} - {hasLocationUrl && ( + {hasLocationUrl ? ( { if (afterEventActionsDisabled) return; @@ -208,7 +208,7 @@ export function BookingActionsModal({ Meeting Session Details - )} + ) : null} {/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */} - {buttonText && onButtonPress && ( + {buttonText && onButtonPress ? ( {buttonText} - )} + ) : null} ); diff --git a/companion/components/Header.tsx b/companion/components/Header.tsx index c8cf54bbe5..1c967229b8 100644 --- a/companion/components/Header.tsx +++ b/companion/components/Header.tsx @@ -209,9 +209,9 @@ export function Header() { {userProfile?.name || "User"} - {userProfile?.email && ( + {userProfile?.email ? ( {userProfile.email} - )} + ) : null} diff --git a/companion/components/LocationsList.tsx b/companion/components/LocationsList.tsx index c49616b8ce..eac87c36c3 100644 --- a/companion/components/LocationsList.tsx +++ b/companion/components/LocationsList.tsx @@ -170,7 +170,7 @@ export const LocationsList: React.FC = ({ {location.displayName} - {!disabled && ( + {!disabled ? ( onRemove(location.id)} className="ml-2 p-1" @@ -178,7 +178,7 @@ export const LocationsList: React.FC = ({ > - )} + ) : null} {renderLocationInput(location)} diff --git a/companion/components/Tooltip.tsx b/companion/components/Tooltip.tsx index b5ff850c25..3dbb300069 100644 --- a/companion/components/Tooltip.tsx +++ b/companion/components/Tooltip.tsx @@ -39,7 +39,7 @@ export function Tooltip({ text, children }: TooltipProps) { style={{ position: "relative" }} > {children} - {showTooltip && ( + {showTooltip ? ( - )} + ) : null} ); } diff --git a/companion/components/event-type-detail/tabs/AdvancedTab.tsx b/companion/components/event-type-detail/tabs/AdvancedTab.tsx index a51a430f70..f16a6f6c34 100644 --- a/companion/components/event-type-detail/tabs/AdvancedTab.tsx +++ b/companion/components/event-type-detail/tabs/AdvancedTab.tsx @@ -291,7 +291,7 @@ export function AdvancedTab(props: AdvancedTabProps) { {/* Seats Configuration - shown when enabled */} - {props.seatsEnabled && ( + {props.seatsEnabled ? ( {/* Number of seats per booking */} @@ -342,14 +342,14 @@ export function AdvancedTab(props: AdvancedTabProps) { : "border-[#D1D5DB] bg-white" }`} > - {props.showAvailabilityCount && ( + {props.showAvailabilityCount ? ( - )} + ) : null} Show the number of available seats - )} + ) : null} @@ -397,7 +397,7 @@ export function AdvancedTab(props: AdvancedTabProps) { {/* Timezone selector - shown when enabled */} - {props.lockTimezone && ( + {props.lockTimezone ? ( Timezone - )} + ) : null} - {props.selectedSchedule && ( + {props.selectedSchedule ? ( <> - )} + ) : null} ); } diff --git a/companion/components/event-type-detail/tabs/BasicsTab.tsx b/companion/components/event-type-detail/tabs/BasicsTab.tsx index fc448ae236..b56dcc609b 100644 --- a/companion/components/event-type-detail/tabs/BasicsTab.tsx +++ b/companion/components/event-type-detail/tabs/BasicsTab.tsx @@ -84,7 +84,7 @@ export function BasicsTab(props: BasicsTabProps) { {/* Duration Card */} - {!props.allowMultipleDurations && ( + {!props.allowMultipleDurations ? ( Duration @@ -99,9 +99,9 @@ export function BasicsTab(props: BasicsTabProps) { Minutes - )} + ) : null} - {props.allowMultipleDurations && ( + {props.allowMultipleDurations ? ( <> @@ -122,7 +122,7 @@ export function BasicsTab(props: BasicsTabProps) { - {props.selectedDurations.length > 0 && ( + {props.selectedDurations.length > 0 ? ( Default duration - )} + ) : null} - )} + ) : null} Allow multiple durations diff --git a/companion/components/event-type-detail/tabs/LimitsTab.tsx b/companion/components/event-type-detail/tabs/LimitsTab.tsx index f7dc002629..7ab42f3799 100644 --- a/companion/components/event-type-detail/tabs/LimitsTab.tsx +++ b/companion/components/event-type-detail/tabs/LimitsTab.tsx @@ -183,7 +183,7 @@ export function LimitsTab(props: LimitsTabProps) { }, ]} > - {props.limitBookingFrequency && ( + {props.limitBookingFrequency ? ( <> {props.frequencyLimits.map((limit) => ( @@ -208,14 +208,14 @@ export function LimitsTab(props: LimitsTabProps) { {limit.unit} - {props.frequencyLimits.length > 1 && ( + {props.frequencyLimits.length > 1 ? ( props.removeFrequencyLimit(limit.id)} > - )} + ) : null} ))} Add Limit - )} + ) : null} @@ -282,7 +282,7 @@ export function LimitsTab(props: LimitsTabProps) { }, ]} > - {props.limitTotalDuration && ( + {props.limitTotalDuration ? ( <> {props.durationLimits.map((limit) => ( @@ -310,14 +310,14 @@ export function LimitsTab(props: LimitsTabProps) { {limit.unit} - {props.durationLimits.length > 1 && ( + {props.durationLimits.length > 1 ? ( props.removeDurationLimit(limit.id)} > - )} + ) : null} ))} Add Limit - )} + ) : null} @@ -350,7 +350,7 @@ export function LimitsTab(props: LimitsTabProps) { thumbColor="#FFFFFF" /> - {props.maxActiveBookingsPerBooker && ( + {props.maxActiveBookingsPerBooker ? ( - )} + ) : null} {/* 5. Limit Future Bookings Card */} @@ -404,7 +404,7 @@ export function LimitsTab(props: LimitsTabProps) { thumbColor="#FFFFFF" /> - {props.limitFutureBookings && ( + {props.limitFutureBookings ? ( {/* Rolling option */} - {props.futureBookingType === "rolling" && ( + {props.futureBookingType === "rolling" ? ( - )} + ) : null} @@ -460,13 +460,13 @@ export function LimitsTab(props: LimitsTabProps) { props.futureBookingType === "range" ? "border-[#007AFF]" : "border-[#C7C7CC]" }`} > - {props.futureBookingType === "range" && ( + {props.futureBookingType === "range" ? ( - )} + ) : null} Within a date range - {props.futureBookingType === "range" && ( + {props.futureBookingType === "range" ? ( - )} + ) : null} - )} + ) : null} ); diff --git a/companion/components/event-type-detail/tabs/RecurringTab.tsx b/companion/components/event-type-detail/tabs/RecurringTab.tsx index 7edd79dae8..17506e960b 100644 --- a/companion/components/event-type-detail/tabs/RecurringTab.tsx +++ b/companion/components/event-type-detail/tabs/RecurringTab.tsx @@ -64,7 +64,7 @@ export function RecurringTab({ {/* Recurring Configuration - shown when enabled */} - {recurringEnabled && ( + {recurringEnabled ? ( {/* Repeats Every */} @@ -126,7 +126,7 @@ export function RecurringTab({ - )} + ) : null} ); diff --git a/companion/components/event-type-list-item/EventTypeListItem.ios.tsx b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx index 9224dcc0af..cc01b013f9 100644 --- a/companion/components/event-type-list-item/EventTypeListItem.ios.tsx +++ b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx @@ -87,11 +87,11 @@ export const EventTypeListItem = ({ {item.title} - {item.description && ( + {item.description ? ( {normalizeMarkdown(item.description)} - )} + ) : null} @@ -100,17 +100,17 @@ export const EventTypeListItem = ({ {(item.price != null && item.price > 0) || item.requiresConfirmation ? ( - {item.price != null && item.price > 0 && ( + {item.price != null && item.price > 0 ? ( {item.currency || "$"} {item.price} - )} - {item.requiresConfirmation && ( + ) : null} + {item.requiresConfirmation ? ( Requires Confirmation - )} + ) : null} ) : null} diff --git a/companion/components/event-type-list-item/EventTypeListItem.tsx b/companion/components/event-type-list-item/EventTypeListItem.tsx index 3bf3491869..2d080b7520 100644 --- a/companion/components/event-type-list-item/EventTypeListItem.tsx +++ b/companion/components/event-type-list-item/EventTypeListItem.tsx @@ -45,11 +45,11 @@ export const EventTypeListItem = ({ {item.title} - {item.description && ( + {item.description ? ( {normalizeMarkdown(item.description)} - )} + ) : null} @@ -58,17 +58,17 @@ export const EventTypeListItem = ({ {(item.price != null && item.price > 0) || item.requiresConfirmation ? ( - {item.price != null && item.price > 0 && ( + {item.price != null && item.price > 0 ? ( {item.currency || "$"} {item.price} - )} - {item.requiresConfirmation && ( + ) : null} + {item.requiresConfirmation ? ( Requires Confirmation - )} + ) : null} ) : null} diff --git a/companion/extension/entrypoints/background/index.ts b/companion/extension/entrypoints/background/index.ts index c390c33ee1..2eeb7702bc 100644 --- a/companion/extension/entrypoints/background/index.ts +++ b/companion/extension/entrypoints/background/index.ts @@ -4,6 +4,7 @@ import type { OAuthTokens } from "../../../services/oauthService"; const DEV_API_KEY = import.meta.env.EXPO_PUBLIC_CAL_API_KEY as string | undefined; const IS_DEV_MODE = Boolean(DEV_API_KEY && DEV_API_KEY.length > 0); +const BROWSER_TARGET = import.meta.env.BROWSER_TARGET || "chrome"; const devLog = { log: (...args: unknown[]) => IS_DEV_MODE && console.log("[Cal.com]", ...args), @@ -11,14 +12,136 @@ const devLog = { error: (...args: unknown[]) => console.error("[Cal.com]", ...args), }; +/** + * Browser type enum (inlined to avoid import issues in service worker) + */ +enum BrowserType { + Chrome = "chrome", + Firefox = "firefox", + Safari = "safari", + Edge = "edge", + Brave = "brave", + Unknown = "unknown", +} + +/** + * Detect browser type based on user agent and APIs + */ +function detectBrowser(): BrowserType { + if (typeof navigator === "undefined") { + return BrowserType.Unknown; + } + + const userAgent = navigator.userAgent.toLowerCase(); + + // Check for Brave + // @ts-ignore - Brave adds this to navigator + if (navigator.brave && typeof navigator.brave.isBrave === "function") { + return BrowserType.Brave; + } + + // Check for Edge + if (userAgent.includes("edg/")) { + return BrowserType.Edge; + } + + // Check for Firefox + if (userAgent.includes("firefox")) { + return BrowserType.Firefox; + } + + // Check for Safari + if ( + userAgent.includes("safari") && + !userAgent.includes("chrome") && + !userAgent.includes("chromium") + ) { + return BrowserType.Safari; + } + + // Default to Chrome + if (userAgent.includes("chrome") || userAgent.includes("chromium")) { + return BrowserType.Chrome; + } + + return BrowserType.Unknown; +} + +/** + * Get browser display name for logging + */ +function getBrowserDisplayName(): string { + const browser = detectBrowser(); + switch (browser) { + case BrowserType.Chrome: + return "Chrome"; + case BrowserType.Firefox: + return "Firefox"; + case BrowserType.Safari: + return "Safari"; + case BrowserType.Edge: + return "Microsoft Edge"; + case BrowserType.Brave: + return "Brave"; + default: + return "Unknown Browser"; + } +} + +/** + * Cross-browser API helpers + * These provide unified access to browser extension APIs across Chrome, Firefox, Safari, and Edge + */ + +// Get the appropriate browser API namespace +function getBrowserAPI(): typeof chrome { + // @ts-ignore - Firefox/Safari use browser namespace + if (typeof browser !== "undefined" && browser.runtime) { + // @ts-ignore + return browser; + } + return chrome; +} + +// Get identity API with cross-browser support +function getIdentityAPI(): typeof chrome.identity | null { + const api = getBrowserAPI(); + return api?.identity || null; +} + +// Get storage API with cross-browser support +function getStorageAPI(): typeof chrome.storage | null { + const api = getBrowserAPI(); + return api?.storage || null; +} + +// Get runtime API with cross-browser support +function getRuntimeAPI(): typeof chrome.runtime | null { + const api = getBrowserAPI(); + return api?.runtime || null; +} + +// Get tabs API with cross-browser support +function getTabsAPI(): typeof chrome.tabs | null { + const api = getBrowserAPI(); + return api?.tabs || null; +} + +// Get action API with cross-browser support +function getActionAPI(): typeof chrome.action | null { + const api = getBrowserAPI(); + // @ts-ignore - Some browsers use browserAction instead of action + return api?.action || api?.browserAction || null; +} + // Check if the URL is a restricted page where content scripts can't run function isRestrictedUrl(url: string | undefined): boolean { if (!url) return true; - // List of restricted URL patterns + // List of restricted URL patterns for all supported browsers const restrictedPatterns = [ /^chrome:\/\//i, // Chrome internal pages (newtab, settings, extensions, etc.) - /^chrome-extension:\/\//i, // Other extension pages + /^chrome-extension:\/\//i, // Chrome extension pages /^edge:\/\//i, // Edge internal pages /^about:/i, // about:blank, about:newtab, etc. /^brave:\/\//i, // Brave internal pages @@ -29,6 +152,9 @@ function isRestrictedUrl(url: string | undefined): boolean { /^devtools:\/\//i, // DevTools pages /^data:/i, // Data URLs /^blob:/i, // Blob URLs + /^moz-extension:\/\//i, // Firefox extension pages + /^safari-extension:\/\//i, // Safari extension pages + /^safari-web-extension:\/\//i, // Safari web extension pages ]; return restrictedPatterns.some((pattern) => pattern.test(url)); @@ -36,160 +162,217 @@ function isRestrictedUrl(url: string | undefined): boolean { // Open cal.com/app (Framer marketing page) in a new tab with auto-open parameter function openAppPage(): void { - chrome.tabs.create({ url: "https://cal.com/app?openExtension=true" }); + const tabsAPI = getTabsAPI(); + if (tabsAPI) { + tabsAPI.create({ url: "https://cal.com/app?openExtension=true" }); + } } // @ts-ignore - WXT provides this globally export default defineBackground(() => { + const browserType = detectBrowser(); + const browserName = getBrowserDisplayName(); + if (IS_DEV_MODE) { devLog.log("DEV MODE - API Key authentication enabled for testing"); + devLog.log(`Browser: ${browserName}, Target: ${BROWSER_TARGET}`); } - chrome.action.onClicked.addListener((tab) => { - // Check if this is a restricted URL where content scripts can't run - if (isRestrictedUrl(tab.url)) { - devLog.log("Restricted URL detected, opening app page:", tab.url); - openAppPage(); - return; - } + const actionAPI = getActionAPI(); + const runtimeAPI = getRuntimeAPI(); + const tabsAPI = getTabsAPI(); + const storageAPI = getStorageAPI(); - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { action: "icon-clicked" }, () => { - // Ignore errors - expected on pages where content script hasn't loaded yet - // The restricted URL check above handles pages where content scripts can't run - void chrome.runtime.lastError; - }); - } - }); + if (actionAPI) { + actionAPI.onClicked.addListener((tab) => { + // Check if this is a restricted URL where content scripts can't run + if (isRestrictedUrl(tab.url)) { + devLog.log("Restricted URL detected, opening app page:", tab.url); + openAppPage(); + return; + } - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.action === "fetch-event-types") { - fetchEventTypes() - .then((eventTypes) => sendResponse({ data: eventTypes })) - .catch((error) => sendResponse({ error: error.message })); - return true; - } - - if (message.action === "start-extension-oauth") { - const authUrl = message.authUrl as string; - const state = new URL(authUrl).searchParams.get("state"); - - if (state) { - chrome.storage.local.set({ oauth_state: state }, () => { - if (chrome.runtime.lastError) { - devLog.warn("Failed to store OAuth state:", chrome.runtime.lastError.message); - } + if (tab.id && tabsAPI) { + tabsAPI.sendMessage(tab.id, { action: "icon-clicked" }, () => { + // Ignore errors - expected on pages where content script hasn't loaded yet + // The restricted URL check above handles pages where content scripts can't run + const runtime = getRuntimeAPI(); + if (runtime) void runtime.lastError; }); } + }); + } - handleExtensionOAuth(authUrl) - .then((responseUrl) => sendResponse({ success: true, responseUrl })) - .catch((error) => sendResponse({ success: false, error: error.message })); - return true; - } - - if (message.action === "exchange-oauth-tokens") { - handleTokenExchange(message.tokenRequest, message.tokenEndpoint, message.state) - .then((tokens) => sendResponse({ success: true, tokens })) - .catch((error) => sendResponse({ success: false, error: error.message })); - return true; - } - - if (message.action === "sync-oauth-tokens") { - const tokens = message.tokens as OAuthTokens | null; - - if (isRateLimited()) { - devLog.warn("Token sync rate limited"); - sendResponse({ success: false, error: "Rate limited. Please try again later." }); + if (runtimeAPI) { + runtimeAPI.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === "fetch-event-types") { + fetchEventTypes() + .then((eventTypes) => sendResponse({ data: eventTypes })) + .catch((error) => sendResponse({ error: error.message })); return true; } - if (!tokens || !tokens.accessToken) { - sendResponse({ success: false, error: "No valid tokens provided" }); + if (message.action === "start-extension-oauth") { + const authUrl = message.authUrl as string; + const state = new URL(authUrl).searchParams.get("state"); + + if (state && storageAPI?.local) { + storageAPI.local.set({ oauth_state: state }, () => { + const runtime = getRuntimeAPI(); + if (runtime?.lastError) { + devLog.warn("Failed to store OAuth state:", runtime.lastError.message); + } + }); + } + + handleExtensionOAuth(authUrl) + .then((responseUrl) => sendResponse({ success: true, responseUrl })) + .catch((error) => sendResponse({ success: false, error: error.message })); return true; } - validateTokens(tokens) - .then((isValid) => { - if (!isValid) { - devLog.warn("Token sync rejected: invalid tokens"); - sendResponse({ success: false, error: "Invalid tokens" }); - return; - } + if (message.action === "exchange-oauth-tokens") { + handleTokenExchange(message.tokenRequest, message.tokenEndpoint, message.state) + .then((tokens) => sendResponse({ success: true, tokens })) + .catch((error) => sendResponse({ success: false, error: error.message })); + return true; + } - recordTokenOperation(); - chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => { - if (chrome.runtime.lastError) { - devLog.error("Failed to sync OAuth tokens:", chrome.runtime.lastError.message); - sendResponse({ success: false, error: chrome.runtime.lastError.message }); + if (message.action === "sync-oauth-tokens") { + const tokens = message.tokens as OAuthTokens | null; + + if (isRateLimited()) { + devLog.warn("Token sync rate limited"); + sendResponse({ success: false, error: "Rate limited. Please try again later." }); + return true; + } + + if (!tokens || !tokens.accessToken) { + sendResponse({ success: false, error: "No valid tokens provided" }); + return true; + } + + validateTokens(tokens) + .then((isValid) => { + if (!isValid) { + devLog.warn("Token sync rejected: invalid tokens"); + sendResponse({ success: false, error: "Invalid tokens" }); + return; + } + + recordTokenOperation(); + if (storageAPI?.local) { + storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => { + const runtime = getRuntimeAPI(); + if (runtime?.lastError) { + devLog.error("Failed to sync OAuth tokens:", runtime.lastError.message); + sendResponse({ success: false, error: runtime.lastError.message }); + } else { + devLog.log("OAuth tokens synced to storage (validated)"); + sendResponse({ success: true }); + } + }); + } + }) + .catch((error) => { + devLog.error("Token validation failed:", error); + sendResponse({ success: false, error: "Token validation failed" }); + }); + + return true; + } + + if (message.action === "clear-oauth-tokens") { + if (isRateLimited()) { + devLog.warn("Token clear rate limited"); + sendResponse({ success: false, error: "Rate limited. Please try again later." }); + return true; + } + + recordTokenOperation(); + if (storageAPI?.local) { + storageAPI.local.remove(["cal_oauth_tokens", "oauth_state"], () => { + const runtime = getRuntimeAPI(); + if (runtime?.lastError) { + devLog.error("Failed to clear OAuth tokens:", runtime.lastError.message); + sendResponse({ success: false, error: runtime.lastError.message }); } else { - devLog.log("OAuth tokens synced to chrome.storage.local (validated)"); + devLog.log("OAuth tokens cleared from storage"); sendResponse({ success: true }); } }); - }) - .catch((error) => { - devLog.error("Token validation failed:", error); - sendResponse({ success: false, error: "Token validation failed" }); - }); - - return true; - } - - if (message.action === "clear-oauth-tokens") { - if (isRateLimited()) { - devLog.warn("Token clear rate limited"); - sendResponse({ success: false, error: "Rate limited. Please try again later." }); + } return true; } - recordTokenOperation(); - chrome.storage.local.remove(["cal_oauth_tokens", "oauth_state"], () => { - if (chrome.runtime.lastError) { - devLog.error("Failed to clear OAuth tokens:", chrome.runtime.lastError.message); - sendResponse({ success: false, error: chrome.runtime.lastError.message }); - } else { - devLog.log("OAuth tokens cleared from chrome.storage.local"); - sendResponse({ success: true }); - } - }); - return true; - } - - return false; - }); + return false; + }); + } }); async function handleExtensionOAuth(authUrl: string): Promise { - return new Promise((resolve, reject) => { - if (!chrome.identity) { - reject(new Error("Chrome identity API not available")); - return; + const identityAPI = getIdentityAPI(); + const browserType = detectBrowser(); + + if (!identityAPI) { + throw new Error(`Identity API not available in ${getBrowserDisplayName()}`); + } + + if (IS_DEV_MODE && identityAPI.getRedirectURL) { + const expectedRedirectUrl = identityAPI.getRedirectURL(); + const redirectUri = new URL(authUrl).searchParams.get("redirect_uri"); + + devLog.log("Starting OAuth flow"); + devLog.log("Browser:", getBrowserDisplayName()); + devLog.log("Expected redirect URL:", expectedRedirectUrl); + devLog.log("Auth URL redirect_uri:", redirectUri); + + if (redirectUri && !redirectUri.startsWith(expectedRedirectUrl.replace(/\/$/, ""))) { + devLog.warn( + "MISMATCH! redirect_uri does not match expected URL.", + "\nExpected:", + expectedRedirectUrl, + "\nGot:", + redirectUri + ); } + } - if (IS_DEV_MODE) { - const expectedRedirectUrl = chrome.identity.getRedirectURL(); - const redirectUri = new URL(authUrl).searchParams.get("redirect_uri"); + return new Promise((resolve, reject) => { + // Firefox and Safari use Promise-based API + if (browserType === BrowserType.Firefox || browserType === BrowserType.Safari) { + try { + // @ts-ignore - Firefox/Safari return Promises + const result = identityAPI.launchWebAuthFlow({ url: authUrl, interactive: true }); - devLog.log("Starting OAuth flow"); - devLog.log("Expected redirect URL:", expectedRedirectUrl); - devLog.log("Auth URL redirect_uri:", redirectUri); - - if (redirectUri && !redirectUri.startsWith(expectedRedirectUrl.replace(/\/$/, ""))) { - devLog.warn( - "MISMATCH! redirect_uri does not match Chrome's expected URL.", - "\nExpected:", - expectedRedirectUrl, - "\nGot:", - redirectUri - ); + if (result && typeof result.then === "function") { + result + .then((responseUrl: string | undefined) => { + if (responseUrl) { + devLog.log("OAuth successful"); + resolve(responseUrl); + } else { + devLog.error("OAuth flow returned no response URL"); + reject(new Error("OAuth flow cancelled or failed")); + } + }) + .catch((error: Error) => { + devLog.error("OAuth flow failed:", error.message); + reject(new Error(`OAuth flow failed: ${error.message}`)); + }); + return; + } + } catch { + // Fall through to callback-based API } } - chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => { - if (chrome.runtime.lastError) { - devLog.error("OAuth flow failed:", chrome.runtime.lastError.message); - reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`)); + // Chrome/Edge use callback-based API + identityAPI.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => { + const runtimeAPI = getRuntimeAPI(); + if (runtimeAPI?.lastError) { + devLog.error("OAuth flow failed:", runtimeAPI.lastError.message); + reject(new Error(`OAuth flow failed: ${runtimeAPI.lastError.message}`)); } else if (responseUrl) { devLog.log("OAuth successful"); resolve(responseUrl); @@ -238,8 +421,11 @@ async function handleTokenExchange( }; try { - await chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }); - devLog.log("OAuth tokens stored in chrome.storage.local"); + const storageAPI = getStorageAPI(); + if (storageAPI?.local) { + await storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }); + devLog.log("OAuth tokens stored in storage"); + } } catch (storageError) { devLog.error("Failed to store OAuth tokens:", storageError); } @@ -248,18 +434,24 @@ async function handleTokenExchange( } async function validateOAuthState(state: string): Promise { + const storageAPI = getStorageAPI(); + if (!storageAPI?.local) { + devLog.warn("Storage API not available for state validation"); + return; + } + try { - const result = await chrome.storage.local.get(["oauth_state"]); + const result = await storageAPI.local.get(["oauth_state"]); const storedState = result.oauth_state as string | undefined; if (storedState && storedState !== state) { - await chrome.storage.local.remove("oauth_state"); + await storageAPI.local.remove("oauth_state"); devLog.error("State parameter mismatch - possible CSRF attack"); throw new Error("Invalid state parameter - possible CSRF attack"); } if (storedState === state) { - await chrome.storage.local.remove("oauth_state"); + await storageAPI.local.remove("oauth_state"); } } catch (error) { if (error instanceof Error && error.message.includes("Invalid state parameter")) { @@ -318,13 +510,17 @@ async function validateTokens(tokens: OAuthTokens): Promise { } async function getAuthHeader(): Promise { - const result = await chrome.storage.local.get(["cal_oauth_tokens"]); - const oauthTokens = result.cal_oauth_tokens - ? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens) - : null; + const storageAPI = getStorageAPI(); - if (oauthTokens?.accessToken) { - return `Bearer ${oauthTokens.accessToken}`; + if (storageAPI?.local) { + const result = await storageAPI.local.get(["cal_oauth_tokens"]); + const oauthTokens = result.cal_oauth_tokens + ? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens) + : null; + + if (oauthTokens?.accessToken) { + return `Bearer ${oauthTokens.accessToken}`; + } } if (IS_DEV_MODE && DEV_API_KEY) { diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts index f6a23dbd0b..e47cc3b4e9 100644 --- a/companion/extension/entrypoints/content.ts +++ b/companion/extension/entrypoints/content.ts @@ -47,13 +47,14 @@ export default defineContentScript({ sidebarContainer.style.transform = "translateX(100%)"; sidebarContainer.style.display = "none"; - // Create iframe container with max width + // Create iframe container - positioned to right side const iframeContainer = document.createElement("div"); - iframeContainer.style.width = "100%"; - iframeContainer.style.height = "100%"; - iframeContainer.style.display = "flex"; - iframeContainer.style.justifyContent = "flex-end"; - iframeContainer.style.pointerEvents = "none"; + iframeContainer.style.position = "absolute"; + iframeContainer.style.top = "0"; + iframeContainer.style.right = "0"; + iframeContainer.style.bottom = "0"; + iframeContainer.style.width = "400px"; + iframeContainer.style.overflow = "hidden"; const iframe = document.createElement("iframe"); // URL is determined at build time: @@ -62,13 +63,18 @@ export default defineContentScript({ const COMPANION_URL = (import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL as string) || "https://companion.cal.com"; iframe.src = COMPANION_URL; - iframe.style.width = "400px"; - iframe.style.height = "100%"; - iframe.style.border = "none"; - iframe.style.borderRadius = "0"; - iframe.style.backgroundColor = "transparent"; - iframe.style.pointerEvents = "auto"; - iframe.style.transition = "none"; + // Use explicit dimensions - Brave has issues with percentage-based sizing + iframe.style.cssText = ` + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 400px !important; + height: 100vh !important; + border: none !important; + background-color: transparent !important; + pointer-events: auto !important; + display: block !important; + `; iframeContainer.appendChild(iframe); @@ -108,15 +114,17 @@ export default defineContentScript({ if (event.data.type === "cal-companion-expand") { // Disable transition for instant expansion iframe.style.transition = "none"; - iframe.style.width = "100%"; - iframeContainer.style.pointerEvents = "auto"; - iframeContainer.style.justifyContent = "center"; + iframe.style.width = "100vw"; + iframeContainer.style.width = "100%"; + iframeContainer.style.left = "0"; + iframeContainer.style.right = "0"; } else if (event.data.type === "cal-companion-collapse") { // Disable transition for instant collapse iframe.style.transition = "none"; iframe.style.width = "400px"; - iframeContainer.style.pointerEvents = "none"; - iframeContainer.style.justifyContent = "flex-end"; + iframeContainer.style.width = "400px"; + iframeContainer.style.left = "auto"; + iframeContainer.style.right = "0"; } else if (event.data.type === "cal-extension-oauth-request") { // Handle OAuth request from iframe handleOAuthRequest(event.data.authUrl, iframe.contentWindow); @@ -521,6 +529,7 @@ export default defineContentScript({ // Listen for extension icon click chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Skip debug-log messages to avoid infinite loop if (message.action === "icon-clicked") { if (isClosed) { openSidebar(); diff --git a/companion/extension/env.d.ts b/companion/extension/env.d.ts index 30d62bf239..7918435a14 100644 --- a/companion/extension/env.d.ts +++ b/companion/extension/env.d.ts @@ -3,8 +3,25 @@ interface ImportMetaEnv { readonly EXPO_PUBLIC_CAL_API_KEY?: string; readonly EXPO_PUBLIC_COMPANION_DEV_URL?: string; + + // Default OAuth config (Chrome/Brave) readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID?: string; readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI?: string; + + // Firefox-specific OAuth config + readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX?: string; + readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX?: string; + + // Safari-specific OAuth config + readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI?: string; + readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI?: string; + + // Edge-specific OAuth config + readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE?: string; + readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE?: string; + + // Browser target set during build + readonly BROWSER_TARGET?: "chrome" | "firefox" | "safari" | "edge"; } interface ImportMeta { diff --git a/companion/package.json b/companion/package.json index a6d0eac369..ae717510f4 100644 --- a/companion/package.json +++ b/companion/package.json @@ -1,22 +1,36 @@ { "name": "cal-companion", "displayName": "Cal.com Companion", - "version": "1.7.3", + "version": "1.7.4", "main": "index.js", "scripts": { "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "ext": "wxt", "ext:build": "wxt build", "ext:build-prod": "BUILD_FOR_STORE=true wxt build", - "build:firefox": "wxt build -b firefox", - "build:firefox-prod": "BUILD_FOR_STORE=true wxt build -b firefox", - "build:safari": "wxt build -b safari", - "build:safari-prod": "BUILD_FOR_STORE=true wxt build -b safari", - "ext": "wxt", "ext:zip": "wxt zip", "ext:zip-prod": "BUILD_FOR_STORE=true wxt zip", + "ext:build-chrome": "BROWSER_TARGET=chrome wxt build -b chrome", + "ext:build-chrome-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=chrome wxt build -b chrome", + "ext:zip-chrome": "BROWSER_TARGET=chrome wxt zip -b chrome", + "ext:zip-chrome-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=chrome wxt zip -b chrome", + "ext:build-firefox": "BROWSER_TARGET=firefox wxt build -b firefox", + "ext:build-firefox-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=firefox wxt build -b firefox", + "ext:zip-firefox": "BROWSER_TARGET=firefox wxt zip -b firefox", + "ext:zip-firefox-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=firefox wxt zip -b firefox", + "ext:build-safari": "BROWSER_TARGET=safari wxt build -b safari", + "ext:build-safari-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=safari wxt build -b safari", + "ext:zip-safari": "BROWSER_TARGET=safari wxt zip -b safari", + "ext:zip-safari-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=safari wxt zip -b safari", + "ext:build-edge": "BROWSER_TARGET=edge wxt build -b edge", + "ext:build-edge-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=edge wxt build -b edge", + "ext:zip-edge": "BROWSER_TARGET=edge wxt zip -b edge", + "ext:zip-edge-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=edge wxt zip -b edge", + "ext:build-all": "npm run ext:build && npm run ext:build-firefox && npm run ext:build-safari && npm run ext:build-edge", + "ext:build-all-prod": "npm run ext:build-prod && npm run ext:build-firefox-prod && npm run ext:build-safari-prod && npm run ext:build-edge-prod", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "prepare": "cd .. && husky companion/.husky" diff --git a/companion/services/oauthService.ts b/companion/services/oauthService.ts index a89e308491..38c6b314ba 100644 --- a/companion/services/oauthService.ts +++ b/companion/services/oauthService.ts @@ -238,6 +238,7 @@ export class CalComOAuthService { private async launchExtensionAuthFlow(authUrl: string): Promise { return new Promise((resolve, reject) => { + // Try Chrome/Chromium identity API first if (typeof chrome !== "undefined" && chrome.identity) { chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => { if (chrome.runtime.lastError) { @@ -251,6 +252,29 @@ export class CalComOAuthService { return; } + // Try Firefox/Safari browser.identity API (Promise-based) + // @ts-ignore - Firefox/Safari use browser namespace + if (typeof browser !== "undefined" && browser.identity) { + try { + // @ts-ignore - Firefox/Safari browser.identity returns Promise + browser.identity + .launchWebAuthFlow({ url: authUrl, interactive: true }) + .then((responseUrl: string | undefined) => { + if (responseUrl) { + resolve(responseUrl); + } else { + reject(new Error("OAuth cancelled")); + } + }) + .catch((error: Error) => { + reject(new Error(`OAuth flow failed: ${error.message}`)); + }); + return; + } catch { + // Fall through to iframe-based flow + } + } + if (this.isRunningInIframe()) { this.requestOAuthViaPostMessage(authUrl, resolve, reject); return; @@ -511,10 +535,108 @@ export class CalComOAuthService { } } +/** + * Browser type for OAuth configuration selection. + * Used to determine which OAuth client credentials to use. + */ +type BrowserType = "chrome" | "firefox" | "safari" | "edge" | "brave" | "unknown"; + +/** + * Detects the current browser type for OAuth configuration. + * This is used in web/extension context to select the appropriate OAuth credentials. + */ +function detectBrowserType(): BrowserType { + if (Platform.OS !== "web" || typeof navigator === "undefined") { + return "unknown"; + } + + const userAgent = navigator.userAgent.toLowerCase(); + + // Check for Brave first (it identifies as Chrome but has Brave-specific properties) + // @ts-ignore - Brave adds this to navigator + if (navigator.brave && typeof navigator.brave.isBrave === "function") { + return "brave"; + } + + // Check for Edge (Chromium-based Edge includes "Edg/" in user agent) + if (userAgent.includes("edg/")) { + return "edge"; + } + + // Check for Firefox + if (userAgent.includes("firefox")) { + return "firefox"; + } + + // Check for Safari (must check after Chrome since Chrome also includes "safari") + if ( + userAgent.includes("safari") && + !userAgent.includes("chrome") && + !userAgent.includes("chromium") + ) { + return "safari"; + } + + // Check for Chrome (or other Chromium-based browsers) + if (userAgent.includes("chrome") || userAgent.includes("chromium")) { + return "chrome"; + } + + return "unknown"; +} + +/** + * Gets browser-specific OAuth configuration. + * Falls back to default (Chrome) config if browser-specific config is not available. + */ +function getBrowserSpecificOAuthConfig(): { 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 || ""; + + // For mobile apps, always use default config + if (Platform.OS !== "web") { + return { clientId: defaultClientId, redirectUri: defaultRedirectUri }; + } + + const browserType = detectBrowserType(); + + switch (browserType) { + case "firefox": + return { + clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId, + redirectUri: + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || 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, + }; + + 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, + }; + + case "chrome": + case "brave": + case "unknown": + default: + // Chrome, Brave, and unknown browsers use the default configuration + return { clientId: defaultClientId, redirectUri: defaultRedirectUri }; + } +} + export function createCalComOAuthService(overrides: Partial = {}): CalComOAuthService { + // Get browser-specific OAuth config + const browserConfig = getBrowserSpecificOAuthConfig(); + const config: OAuthConfig = { - clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "", - redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "", + clientId: browserConfig.clientId, + redirectUri: browserConfig.redirectUri, calcomBaseUrl: "https://app.cal.com", ...overrides, }; diff --git a/companion/wxt.config.ts b/companion/wxt.config.ts index 9239c68fb5..2c3a6d3384 100644 --- a/companion/wxt.config.ts +++ b/companion/wxt.config.ts @@ -4,6 +4,44 @@ import { defineConfig } from "wxt"; // Forces production URL (https://companion.cal.com) and excludes localhost permissions const isBuildForStore = process.env.BUILD_FOR_STORE === "true"; +// BROWSER_TARGET is set during build to determine which OAuth credentials to use +// Values: "chrome" (default), "firefox", "safari", "edge" +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. + */ +function getOAuthConfig() { + const defaultClientId = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || ""; + const defaultRedirectUri = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || ""; + + switch (browserTarget) { + case "firefox": + return { + clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId, + redirectUri: + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || 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, + }; + 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, + }; + case "chrome": + default: + return { + clientId: defaultClientId, + redirectUri: defaultRedirectUri, + }; + } +} + export default defineConfig({ hooks: { "build:manifestGenerated": (_wxt, manifest) => { @@ -20,7 +58,7 @@ export default defineConfig({ outDir: ".output", manifest: { name: "Cal.com Companion", - version: "1.7.3", + version: "1.7.4", description: "Your calendar companion for quick booking and scheduling", permissions: ["activeTab", "storage", "identity"], host_permissions: [ @@ -55,12 +93,16 @@ export default defineConfig({ const devUrl = isBuildForStore ? "" : process.env.EXPO_PUBLIC_COMPANION_DEV_URL || ""; const isLocalDev = Boolean(devUrl && devUrl.includes("localhost")); + // Get OAuth config for the target browser + const oauthConfig = getOAuthConfig(); + // Log build mode for clarity if (isBuildForStore) { console.log("\nšŸŖ STORE BUILD: Using https://companion.cal.com\n"); } else if (isLocalDev) { console.log(`\nšŸ”§ DEV BUILD: Using ${devUrl}\n`); } + console.log(`🌐 Browser Target: ${browserTarget}\n`); return { resolve: { @@ -71,12 +113,44 @@ export default defineConfig({ define: { global: "globalThis", __DEV__: JSON.stringify(false), + + // Browser target for runtime detection + "import.meta.env.BROWSER_TARGET": JSON.stringify(browserTarget), + + // Default OAuth config (Chrome/Brave) - always available for fallback "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID": JSON.stringify( process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID ), "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify( process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI ), + + // Browser-specific OAuth config (resolved based on BROWSER_TARGET) + "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX": JSON.stringify( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX + ), + "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX": JSON.stringify( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX + ), + "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI": JSON.stringify( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI + ), + "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI": JSON.stringify( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI + ), + "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE": JSON.stringify( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE + ), + "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE": JSON.stringify( + process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE + ), + + // Pre-resolved OAuth config for the build target (for convenience) + "process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID": JSON.stringify(oauthConfig.clientId), + "process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify( + oauthConfig.redirectUri + ), + // Use devUrl which respects BUILD_FOR_STORE flag "import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL": JSON.stringify(devUrl), "import.meta.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES": JSON.stringify(