diff --git a/companion/app.json b/companion/app.json
index 476ca44096..07beee1266 100644
--- a/companion/app.json
+++ b/companion/app.json
@@ -19,6 +19,9 @@
"bundleIdentifier": "com.calcom.companion",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
+ },
+ "entitlements": {
+ "com.apple.developer.default-data-protection": "NSFileProtectionComplete"
}
},
"android": {
@@ -28,7 +31,8 @@
},
"edgeToEdgeEnabled": true,
"package": "com.calcom.companion",
- "softwareKeyboardLayoutMode": "pan"
+ "softwareKeyboardLayoutMode": "pan",
+ "usesCleartextTraffic": false
},
"web": {
"favicon": "./assets/favicon.png",
diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx
index 154c747526..8a4e401b84 100644
--- a/companion/app/(tabs)/(availability)/index.tsx
+++ b/companion/app/(tabs)/(availability)/index.tsx
@@ -1,15 +1,86 @@
import { isLiquidGlassAvailable } from "expo-glass-effect";
-import { Stack } from "expo-router";
+import { Stack, useRouter } from "expo-router";
import { useState } from "react";
-import { Platform } from "react-native";
+import { Alert, Platform } from "react-native";
import { AvailabilityListScreen } from "@/components/screens/AvailabilityListScreen";
+import { useCreateSchedule } from "@/hooks";
+import { CalComAPIService } from "@/services/calcom";
+import { showErrorAlert } from "@/utils/alerts";
export default function Availability() {
+ const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
+ const { mutate: createScheduleMutation } = useCreateSchedule();
const handleCreateNew = () => {
- setShowCreateModal(true);
+ // Use native iOS Alert.prompt for a native look
+ Alert.prompt(
+ "Add a new schedule",
+ "Create a new availability schedule.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Continue",
+ onPress: async (name?: string) => {
+ if (!name?.trim()) {
+ Alert.alert("Error", "Please enter a schedule name");
+ return;
+ }
+
+ // Get user's timezone (default to America/New_York if not available)
+ let userTimezone = "America/New_York";
+ try {
+ const userProfile = await CalComAPIService.getUserProfile();
+ if (userProfile.timeZone) {
+ userTimezone = userProfile.timeZone;
+ }
+ } catch {
+ console.log("Could not get user timezone, using default");
+ }
+
+ // Create schedule with Monday-Friday 9 AM - 5 PM default
+ createScheduleMutation(
+ {
+ name: name.trim(),
+ timeZone: userTimezone,
+ isDefault: false,
+ availability: [
+ {
+ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
+ startTime: "09:00",
+ endTime: "17:00",
+ },
+ ],
+ },
+ {
+ onSuccess: (newSchedule) => {
+ // Navigate to edit the newly created schedule
+ router.push({
+ pathname: "/availability-detail",
+ params: {
+ id: newSchedule.id.toString(),
+ },
+ });
+ },
+ onError: (error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error("Failed to create schedule", message);
+ if (__DEV__) {
+ const stack = error instanceof Error ? error.stack : undefined;
+ console.debug("[Availability] createSchedule failed", { message, stack });
+ }
+ showErrorAlert("Error", "Failed to create schedule. Please try again.");
+ },
+ }
+ );
+ },
+ },
+ ],
+ "plain-text",
+ "",
+ "default"
+ );
};
return (
@@ -21,8 +92,18 @@ export default function Availability() {
>
Availability
-
- New
+ {/* New Menu */}
+
+
+
+
+ New Availability
+
+
+
+ {/* Profile Button */}
+ router.push("/profile-sheet")}>
+
();
@@ -17,7 +18,7 @@ export default function ViewRecordings() {
CalComAPIService.getRecordings(uid)
.then(setRecordings)
.catch((error) => {
- console.error("Failed to load recordings:", error);
+ safeLogError("Failed to load recordings:", error);
Alert.alert("Error", "Failed to load recordings");
router.back();
})
diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx
new file mode 100644
index 0000000000..40d9f4f573
--- /dev/null
+++ b/companion/app/(tabs)/(event-types)/index.ios.tsx
@@ -0,0 +1,560 @@
+import { Button, ContextMenu, Host, HStack, Image as SwiftUIImage } from "@expo/ui/swift-ui";
+import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
+import { Ionicons } from "@expo/vector-icons";
+import * as Clipboard from "expo-clipboard";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Stack, useRouter } from "expo-router";
+import { useMemo, useState } from "react";
+import {
+ ActionSheetIOS,
+ Alert,
+ RefreshControl,
+ ScrollView,
+ Share,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { EmptyScreen } from "@/components/EmptyScreen";
+import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem";
+import { FullScreenModal } from "@/components/FullScreenModal";
+import { LoadingSpinner } from "@/components/LoadingSpinner";
+import {
+ useCreateEventType,
+ useDeleteEventType,
+ useDuplicateEventType,
+ useEventTypes,
+} from "@/hooks";
+import { CalComAPIService, type EventType } from "@/services/calcom";
+import { showErrorAlert } from "@/utils/alerts";
+import { openInAppBrowser } from "@/utils/browser";
+import { getEventDuration } from "@/utils/getEventDuration";
+import { offlineAwareRefresh } from "@/utils/network";
+import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
+import { slugify } from "@/utils/slugify";
+
+export default function EventTypesIOS() {
+ const router = useRouter();
+ const [searchQuery, setSearchQuery] = useState("");
+
+ // No modal state needed for iOS - using native Alert.prompt
+
+ // Use React Query hooks
+ const {
+ data: eventTypes = [],
+ isLoading: loading,
+ isFetching,
+ error: queryError,
+ refetch,
+ } = useEventTypes();
+
+ // Show refresh indicator when fetching
+ const refreshing = isFetching && !loading;
+
+ const { mutate: createEventTypeMutation } = useCreateEventType();
+ const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType();
+ const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
+
+ // Convert query error to string
+ // Don't show error UI for authentication errors (user will be redirected to login)
+ // Only show error UI in development mode for other errors
+ const isAuthError =
+ queryError?.message?.includes("Authentication") ||
+ queryError?.message?.includes("sign in") ||
+ queryError?.message?.includes("401");
+ const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null;
+
+ // Modal state for delete confirmation
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [eventTypeToDelete, setEventTypeToDelete] = useState(null);
+
+ // Handle pull-to-refresh (offline-aware)
+ const onRefresh = () => offlineAwareRefresh(refetch);
+
+ // Filter event types based on search query
+ const filteredEventTypes = useMemo(() => {
+ if (searchQuery.trim() === "") {
+ return eventTypes;
+ }
+ const searchLower = searchQuery.toLowerCase();
+ return eventTypes.filter(
+ (eventType) =>
+ eventType.title.toLowerCase().includes(searchLower) ||
+ eventType.description?.toLowerCase().includes(searchLower)
+ );
+ }, [eventTypes, searchQuery]);
+
+ const handleSearch = (query: string) => {
+ setSearchQuery(query);
+ };
+
+ const handleEventTypePress = (eventType: EventType) => {
+ handleEdit(eventType);
+ };
+
+ const handleEventTypeLongPress = (eventType: EventType) => {
+ ActionSheetIOS.showActionSheetWithOptions(
+ {
+ options: ["Cancel", "Edit", "Duplicate", "Delete"],
+ destructiveButtonIndex: 3, // Delete button
+ cancelButtonIndex: 0,
+ title: eventType.title,
+ message: eventType.description ? normalizeMarkdown(eventType.description) : undefined,
+ },
+ (buttonIndex) => {
+ switch (buttonIndex) {
+ case 1: // Edit
+ handleEdit(eventType);
+ break;
+ case 2: // Duplicate
+ handleDuplicate(eventType);
+ break;
+ case 3: // Delete
+ handleDelete(eventType);
+ break;
+ default:
+ // Cancel - do nothing
+ break;
+ }
+ }
+ );
+ };
+
+ const handleCopyLink = async (eventType: EventType) => {
+ try {
+ const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
+ await Clipboard.setStringAsync(link);
+ Alert.alert("Link Copied", "Event type link copied!");
+ } catch {
+ showErrorAlert("Error", "Failed to copy link. Please try again.");
+ }
+ };
+
+ const _handleShare = async (eventType: EventType) => {
+ try {
+ const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
+ await Share.share({
+ message: `Book a meeting: ${eventType.title}`,
+ url: link,
+ });
+ } catch {
+ showErrorAlert("Error", "Failed to share link. Please try again.");
+ }
+ };
+
+ const handleEdit = (eventType: EventType) => {
+ const duration = getEventDuration(eventType);
+ router.push({
+ pathname: "/event-type-detail",
+ params: {
+ id: eventType.id.toString(),
+ title: eventType.title,
+ description: eventType.description || "",
+ duration: duration.toString(),
+ price: eventType.price?.toString() || "",
+ currency: eventType.currency || "",
+ slug: eventType.slug || "",
+ },
+ });
+ };
+
+ const handleDelete = (eventType: EventType) => {
+ setEventTypeToDelete(eventType);
+ setShowDeleteModal(true);
+ };
+
+ const confirmDelete = () => {
+ if (!eventTypeToDelete) return;
+
+ deleteEventTypeMutation(eventTypeToDelete.id, {
+ onSuccess: () => {
+ // Close modal and reset state
+ setShowDeleteModal(false);
+ setEventTypeToDelete(null);
+ Alert.alert("Success", "Event type deleted successfully");
+ },
+ onError: (deleteError) => {
+ const message = deleteError instanceof Error ? deleteError.message : String(deleteError);
+ console.error("Failed to delete event type", message);
+ if (__DEV__) {
+ const stack = deleteError instanceof Error ? deleteError.stack : undefined;
+ console.debug("[EventTypes] deleteEventType failed", { message, stack });
+ }
+ showErrorAlert("Error", "Failed to delete event type. Please try again.");
+ },
+ });
+ };
+
+ const handleDuplicate = (eventType: EventType) => {
+ duplicateEventTypeMutation(
+ { eventType, existingEventTypes: eventTypes },
+ {
+ onSuccess: (duplicatedEventType) => {
+ Alert.alert("Success", "Event type duplicated successfully");
+
+ const duration = getEventDuration(eventType);
+
+ // Navigate to edit the newly created duplicate
+ router.push({
+ pathname: "/event-type-detail",
+ params: {
+ id: duplicatedEventType.id.toString(),
+ title: duplicatedEventType.title,
+ description: duplicatedEventType.description || "",
+ duration: (
+ duplicatedEventType.lengthInMinutes ||
+ duplicatedEventType.length ||
+ duration
+ ).toString(),
+ slug: duplicatedEventType.slug || "",
+ },
+ });
+ },
+ onError: (duplicateError) => {
+ const message =
+ duplicateError instanceof Error ? duplicateError.message : String(duplicateError);
+ console.error("Failed to duplicate event type", message);
+ if (__DEV__) {
+ const stack = duplicateError instanceof Error ? duplicateError.stack : undefined;
+ console.debug("[EventTypes] duplicateEventType failed", { message, stack });
+ }
+ showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
+ },
+ }
+ );
+ };
+
+ const handlePreview = async (eventType: EventType) => {
+ try {
+ const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
+ // For mobile, use in-app browser
+ await openInAppBrowser(link, "event type preview");
+ } catch {
+ console.error("Failed to open preview");
+ showErrorAlert("Error", "Failed to open preview. Please try again.");
+ }
+ };
+
+ const handleOpenCreateModal = () => {
+ // Use native iOS Alert.prompt for a native look
+ Alert.prompt(
+ "Add a new event type",
+ "Set up event types to offer different types of meetings.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Continue",
+ onPress: (title?: string) => {
+ if (!title?.trim()) {
+ Alert.alert("Error", "Please enter a title for your event type");
+ return;
+ }
+
+ const autoSlug = slugify(title.trim());
+ if (!autoSlug) {
+ Alert.alert("Error", "Title must contain at least one letter or number");
+ return;
+ }
+
+ createEventTypeMutation(
+ {
+ title: title.trim(),
+ slug: autoSlug,
+ lengthInMinutes: 15, // Default duration
+ description: undefined, // Empty description
+ },
+ {
+ onSuccess: (newEventType) => {
+ // Navigate to edit the newly created event type
+ router.push({
+ pathname: "/event-type-detail",
+ params: {
+ id: newEventType.id.toString(),
+ title: newEventType.title,
+ description: newEventType.description || "",
+ duration: (
+ newEventType.lengthInMinutes ||
+ newEventType.length ||
+ 15
+ ).toString(),
+ slug: newEventType.slug || "",
+ },
+ });
+ },
+ onError: (createError) => {
+ const message =
+ createError instanceof Error ? createError.message : String(createError);
+ console.error("Failed to create event type", message);
+ if (__DEV__) {
+ const stack = createError instanceof Error ? createError.stack : undefined;
+ console.debug("[EventTypes] createEventType failed", { message, stack });
+ }
+ showErrorAlert("Error", "Failed to create event type. Please try again.");
+ },
+ }
+ );
+ },
+ },
+ ],
+ "plain-text",
+ "",
+ "default"
+ );
+ };
+
+ // Sort by menu handler (dummy for now)
+ const handleSortByOption = (option: string) => {
+ console.log("Sort by:", option);
+ // TODO: Implement actual sorting logic
+ };
+
+ // Filter menu handler (dummy for now)
+ const handleFilterOption = (option: string) => {
+ console.log("Filter by:", option);
+ // TODO: Implement actual filtering logic
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ Unable to load event types
+
+ {error}
+ refetch()}>
+ Retry
+
+
+ );
+ }
+
+ if (eventTypes.length === 0) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {/* iOS Native Header with Glass UI */}
+
+ Event Types
+
+
+ {/* Filter/Sort Menu */}
+
+
+
+ {/* Sort by Submenu - opens as separate submenu */}
+
+ handleSortByOption("alphabetical")}
+ >
+ Alphabetical
+
+ handleSortByOption("newest")}
+ >
+ Newest First
+
+ handleSortByOption("duration")}>
+ By Duration
+
+
+
+ {/* Filter Submenu - opens as separate submenu */}
+
+ handleFilterOption("all")}
+ >
+ All Event Types
+
+ handleFilterOption("active")}>
+ Active Only
+
+ handleFilterOption("paid")}
+ >
+ Paid Events
+
+
+
+
+ {/* Profile Button - Opens bottom sheet */}
+ router.push("/profile-sheet")}>
+
+
+
+
+ {/* Search Bar */}
+ handleSearch(e.nativeEvent.text)}
+ obscureBackground={false}
+ barTintColor="#fff"
+ />
+
+
+ {/* Event Types List */}
+ }
+ showsVerticalScrollIndicator={false}
+ contentInsetAdjustmentBehavior="automatic"
+ >
+ {filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? (
+
+
+
+ ) : (
+
+
+ {filteredEventTypes.map((item, index) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* Floating Action Button for New Event Type with Glass UI Menu */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+ {
+ if (!isDeleting) {
+ setShowDeleteModal(false);
+ setEventTypeToDelete(null);
+ }
+ }}
+ >
+
+
+ {/* Header with icon and title */}
+
+
+ {/* Danger icon */}
+
+
+
+
+ {/* Title and description */}
+
+
+ Delete Event Type
+
+
+ {eventTypeToDelete ? (
+ <>
+ This will permanently delete the "{eventTypeToDelete.title}" event type.
+ This action cannot be undone.
+ >
+ ) : null}
+
+
+
+
+
+ {/* Footer with buttons */}
+
+
+ Delete
+
+
+ {
+ setShowDeleteModal(false);
+ setEventTypeToDelete(null);
+ }}
+ disabled={isDeleting}
+ >
+ Cancel
+
+
+
+
+
+ >
+ );
+}
diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx
index 608ca9a03f..7d3ce636f9 100644
--- a/companion/app/(tabs)/(event-types)/index.tsx
+++ b/companion/app/(tabs)/(event-types)/index.tsx
@@ -25,7 +25,6 @@ import {
useDeleteEventType,
useDuplicateEventType,
useEventTypes,
- useUsername,
} from "@/hooks";
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
@@ -43,10 +42,6 @@ export default function EventTypes() {
// Modal state for creating new event type
const [showCreateModal, setShowCreateModal] = useState(false);
const [newEventTitle, setNewEventTitle] = useState("");
- const [newEventSlug, setNewEventSlug] = useState("");
- const [newEventDescription, setNewEventDescription] = useState("");
- const [newEventDuration, setNewEventDuration] = useState("15");
- const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
// Use React Query hooks
const {
@@ -60,7 +55,6 @@ export default function EventTypes() {
// Show refresh indicator when fetching
const refreshing = isFetching && !loading;
- const { data: username = "" } = useUsername();
const { mutate: createEventTypeMutation, isPending: creating } = useCreateEventType();
const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType();
const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
@@ -334,10 +328,6 @@ export default function EventTypes() {
const handleCloseCreateModal = () => {
setShowCreateModal(false);
setNewEventTitle("");
- setNewEventSlug("");
- setNewEventDescription("");
- setNewEventDuration("15");
- setIsSlugManuallyEdited(false);
};
const handleCreateEventType = () => {
@@ -346,23 +336,18 @@ export default function EventTypes() {
return;
}
- if (!newEventSlug.trim()) {
- Alert.alert("Error", "Please enter a URL for your event type");
- return;
- }
-
- const duration = parseInt(newEventDuration, 10);
- if (Number.isNaN(duration) || duration <= 0) {
- Alert.alert("Error", "Please enter a valid duration");
+ const autoSlug = slugify(newEventTitle.trim());
+ if (!autoSlug) {
+ Alert.alert("Error", "Title must contain at least one letter or number");
return;
}
createEventTypeMutation(
{
title: newEventTitle.trim(),
- slug: newEventSlug.trim(),
- lengthInMinutes: duration,
- description: newEventDescription.trim() || undefined,
+ slug: autoSlug,
+ lengthInMinutes: 15, // Default duration
+ description: undefined, // Empty description
},
{
onSuccess: (newEventType) => {
@@ -580,7 +565,7 @@ export default function EventTypes() {
{/* Content */}
-
+
{/* Title */}
Title
@@ -589,74 +574,14 @@ export default function EventTypes() {
placeholder="Quick Chat"
placeholderTextColor="#9CA3AF"
value={newEventTitle}
- onChangeText={(text) => {
- setNewEventTitle(text);
- // Auto-generate slug from title if user hasn't manually edited it
- if (!isSlugManuallyEdited) {
- setNewEventSlug(slugify(text, true));
- }
- }}
+ onChangeText={setNewEventTitle}
autoFocus
autoCapitalize="words"
- returnKeyType="next"
+ returnKeyType="done"
+ onSubmitEditing={handleCreateEventType}
/>
-
- {/* URL */}
-
- URL
-
- https://cal.com/{username}/
- {
- setIsSlugManuallyEdited(true);
- setNewEventSlug(slugify(text, true));
- }}
- autoCapitalize="none"
- autoCorrect={false}
- returnKeyType="next"
- />
-
-
-
- {/* Description */}
-
- Description
-
-
-
- {/* Duration */}
-
- Duration
-
-
- minutes
-
-
-
+
{/* Footer */}
diff --git a/companion/app/(tabs)/(more)/_layout.tsx b/companion/app/(tabs)/(more)/_layout.tsx
new file mode 100644
index 0000000000..0540f7eb85
--- /dev/null
+++ b/companion/app/(tabs)/(more)/_layout.tsx
@@ -0,0 +1,17 @@
+import { Stack } from "expo-router";
+import { Platform } from "react-native";
+
+export default function MoreLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/companion/app/(tabs)/(more)/index.ios.tsx b/companion/app/(tabs)/(more)/index.ios.tsx
new file mode 100644
index 0000000000..410c38ea6b
--- /dev/null
+++ b/companion/app/(tabs)/(more)/index.ios.tsx
@@ -0,0 +1,162 @@
+import { Ionicons } from "@expo/vector-icons";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Stack, useRouter } from "expo-router";
+import { useState } from "react";
+import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native";
+import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
+import { useAuth } from "@/contexts/AuthContext";
+import { showErrorAlert } from "@/utils/alerts";
+import { openInAppBrowser } from "@/utils/browser";
+
+interface MoreMenuItem {
+ name: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ isExternal?: boolean;
+ href?: string;
+ onPress?: () => void;
+}
+
+export default function More() {
+ const router = useRouter();
+ const { logout } = useAuth();
+ const [showLogoutModal, setShowLogoutModal] = useState(false);
+
+ const performLogout = async () => {
+ try {
+ await logout();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error("Logout error", message);
+ if (__DEV__) {
+ const stack = error instanceof Error ? error.stack : undefined;
+ console.debug("[More] logout failed", { message, stack });
+ }
+ showErrorAlert("Error", "Failed to sign out. Please try again.");
+ }
+ };
+
+ const handleSignOut = () => {
+ Alert.alert("Sign Out", "Are you sure you want to sign out?", [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Sign Out",
+ style: "destructive",
+ onPress: performLogout,
+ },
+ ]);
+ };
+
+ const menuItems: MoreMenuItem[] = [
+ {
+ name: "Apps",
+ icon: "grid-outline",
+ isExternal: true,
+ onPress: () => openInAppBrowser("https://app.cal.com/apps", "Apps page"),
+ },
+ {
+ name: "Routing",
+ icon: "git-branch-outline",
+ isExternal: true,
+ onPress: () => openInAppBrowser("https://app.cal.com/routing", "Routing page"),
+ },
+ {
+ name: "Workflows",
+ icon: "flash-outline",
+ isExternal: true,
+ onPress: () => openInAppBrowser("https://app.cal.com/workflows", "Workflows page"),
+ },
+ {
+ name: "Insights",
+ icon: "bar-chart-outline",
+ isExternal: true,
+ onPress: () => openInAppBrowser("https://app.cal.com/insights", "Insights page"),
+ },
+ {
+ name: "Support",
+ icon: "help-circle-outline",
+ isExternal: true,
+ onPress: () => openInAppBrowser("https://go.cal.com/support", "Support"),
+ },
+ ];
+
+ return (
+ <>
+ {/* iOS Native Header with Glass UI */}
+
+ More
+
+ {/* Profile Button */}
+ router.push("/profile-sheet")}>
+
+
+
+
+
+ {/* Content */}
+
+
+ {menuItems.map((item, index) => (
+
+
+
+ {item.name}
+
+ {item.isExternal ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ {/* Sign Out Button */}
+
+
+
+ Sign Out
+
+
+
+ {/* Footer Note */}
+
+ The companion app is an extension of the web application.{"\n"}
+ For advanced features, visit{" "}
+ openInAppBrowser("https://app.cal.com", "Cal.com")}
+ >
+ app.cal.com
+
+
+
+
+ {/* Logout Confirmation Modal - kept for consistency */}
+ {
+ setShowLogoutModal(false);
+ performLogout();
+ }}
+ onCancel={() => setShowLogoutModal(false)}
+ />
+ >
+ );
+}
diff --git a/companion/app/(tabs)/more.tsx b/companion/app/(tabs)/(more)/index.tsx
similarity index 95%
rename from companion/app/(tabs)/more.tsx
rename to companion/app/(tabs)/(more)/index.tsx
index b91788c0e0..9aacffd424 100644
--- a/companion/app/(tabs)/more.tsx
+++ b/companion/app/(tabs)/(more)/index.tsx
@@ -38,7 +38,7 @@ export default function More() {
// Use modal for web/extension since Alert.alert doesn't work
setShowLogoutModal(true);
} else {
- // Use native Alert for iOS/Android
+ // Use native Alert for Android
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
@@ -51,13 +51,6 @@ export default function More() {
};
const menuItems: MoreMenuItem[] = [
- {
- name: "Profile",
- icon: "person-outline",
- isExternal: true,
- onPress: () =>
- openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
- },
{
name: "Apps",
icon: "grid-outline",
diff --git a/companion/app/(tabs)/_layout.tsx b/companion/app/(tabs)/_layout.tsx
index 1bb7fc2a93..0a2f65aaff 100644
--- a/companion/app/(tabs)/_layout.tsx
+++ b/companion/app/(tabs)/_layout.tsx
@@ -10,7 +10,11 @@ export default function TabLayout() {
return (
}
/>
Availability
-
+
}
/>
More
@@ -96,7 +100,7 @@ function WebTabs() {
/>
(
diff --git a/companion/app/_layout.tsx b/companion/app/_layout.tsx
index 3371721fb5..88c8931bef 100644
--- a/companion/app/_layout.tsx
+++ b/companion/app/_layout.tsx
@@ -1,3 +1,4 @@
+import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import { Platform, StatusBar, View } from "react-native";
import LoginScreenComponent from "@/components/LoginScreen";
@@ -12,6 +13,33 @@ function RootLayoutContent() {
const content = isAuthenticated ? (
+
) : (
diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx
index 7f923f57a0..3671a7cc84 100644
--- a/companion/app/event-type-detail.tsx
+++ b/companion/app/event-type-detail.tsx
@@ -37,6 +37,7 @@ import {
mapItemToApiLocation,
validateLocationItem,
} from "@/utils/locationHelpers";
+import { safeLogError } from "@/utils/safeLogger";
// Type definitions for extended EventType fields not in the base type
interface EventTypeExtended {
@@ -370,7 +371,7 @@ export default function EventTypeDetail() {
}
setScheduleDetailsLoading(false);
} catch (error) {
- console.error("Failed to fetch schedule details:", error);
+ safeLogError("Failed to fetch schedule details:", error);
setSelectedScheduleDetails(null);
setScheduleDetailsLoading(false);
}
@@ -390,7 +391,7 @@ export default function EventTypeDetail() {
}
setSchedulesLoading(false);
} catch (error) {
- console.error("Failed to fetch schedules:", error);
+ safeLogError("Failed to fetch schedules:", error);
setSchedulesLoading(false);
}
}, [fetchScheduleDetails]);
@@ -402,7 +403,7 @@ export default function EventTypeDetail() {
setConferencingOptions(options);
setConferencingLoading(false);
} catch (error) {
- console.error("Failed to fetch conferencing options:", error);
+ safeLogError("Failed to fetch conferencing options:", error);
setConferencingLoading(false);
}
}, []);
@@ -742,7 +743,7 @@ export default function EventTypeDetail() {
}
}
} catch (error) {
- console.error("Failed to fetch event type data:", error);
+ safeLogError("Failed to fetch event type data:", error);
}
}, [id]);
@@ -767,7 +768,7 @@ export default function EventTypeDetail() {
const userUsername = await CalComAPIService.getUsername();
setUsername(userUsername);
} catch (error) {
- console.error("Failed to fetch username:", error);
+ safeLogError("Failed to fetch username:", error);
}
};
fetchUsername();
@@ -851,7 +852,7 @@ export default function EventTypeDetail() {
const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug);
await openInAppBrowser(link, "event type preview");
} catch (error) {
- console.error("Failed to generate preview link:", error);
+ safeLogError("Failed to generate preview link:", error);
showErrorAlert("Error", "Failed to generate preview link. Please try again.");
}
};
@@ -864,7 +865,7 @@ export default function EventTypeDetail() {
await Clipboard.setStringAsync(link);
Alert.alert("Success", "Link copied!");
} catch (error) {
- console.error("Failed to copy link:", error);
+ safeLogError("Failed to copy link:", error);
showErrorAlert("Error", "Failed to copy link. Please try again.");
}
};
@@ -893,9 +894,8 @@ export default function EventTypeDetail() {
},
]);
} catch (error) {
- console.error("Failed to delete event type:", error);
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
- showErrorAlert("Error", `Failed to delete event type: ${errorMessage}`);
+ safeLogError("Failed to delete event type:", error);
+ showErrorAlert("Error", "Failed to delete event type. Please try again.");
}
},
},
@@ -1060,7 +1060,7 @@ export default function EventTypeDetail() {
setSaving(false);
}
} catch (error) {
- console.error("Failed to save event type:", error);
+ safeLogError("Failed to save event type:", error);
const action = isCreateMode ? "create" : "update";
showErrorAlert("Error", `Failed to ${action} event type. Please try again.`);
setSaving(false);
diff --git a/companion/app/oauth/callback.tsx b/companion/app/oauth/callback.tsx
index 7fa2a100ff..9ef75197e2 100644
--- a/companion/app/oauth/callback.tsx
+++ b/companion/app/oauth/callback.tsx
@@ -2,6 +2,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import { useAuth } from "@/contexts";
+import { safeLogError } from "@/utils/safeLogger";
export default function OAuthCallback() {
const router = useRouter();
@@ -26,7 +27,7 @@ export default function OAuthCallback() {
// Handle OAuth error response
if (error) {
const errorMessage = errorDescription || error || "OAuth authorization failed";
- console.error("OAuth error:", error, errorDescription);
+ safeLogError("OAuth error occurred", { error, errorDescription });
if (typeof window !== "undefined") {
if (window.opener) {
@@ -75,7 +76,7 @@ export default function OAuthCallback() {
}
}
} else {
- console.error("No authorization code or state in OAuth callback");
+ safeLogError("No authorization code or state in OAuth callback", { code, state });
// Handle error case
if (typeof window !== "undefined") {
diff --git a/companion/app/profile-sheet.ios.tsx b/companion/app/profile-sheet.ios.tsx
new file mode 100644
index 0000000000..229a0e4fcd
--- /dev/null
+++ b/companion/app/profile-sheet.ios.tsx
@@ -0,0 +1,213 @@
+import { Ionicons } from "@expo/vector-icons";
+import * as Clipboard from "expo-clipboard";
+import { osName } from "expo-device";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import * as Haptics from "expo-haptics";
+import { Stack, useRouter } from "expo-router";
+import { ActivityIndicator, Alert, Image, Text, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useUserProfile } from "@/hooks";
+import { openInAppBrowser } from "@/utils/browser";
+import { getAvatarUrl } from "@/utils/getAvatarUrl";
+
+interface ProfileMenuItem {
+ id: string;
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ external?: boolean;
+}
+
+/**
+ * Get the presentation style for the profile sheet
+ * - Uses formSheet on iPhone with liquid glass support
+ * - Uses modal on iPad or older iOS devices
+ */
+function getPresentationStyle(): "formSheet" | "modal" {
+ if (isLiquidGlassAvailable() && osName !== "iPadOS") {
+ return "formSheet";
+ }
+ return "modal";
+}
+
+export default function ProfileSheet() {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { data: userProfile, isLoading } = useUserProfile();
+
+ const publicPageUrl = userProfile?.username ? `https://cal.com/${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"),
+ external: true,
+ },
+ {
+ id: "settings",
+ label: "My Settings",
+ icon: "settings-outline",
+ onPress: () =>
+ openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"),
+ external: true,
+ },
+ {
+ id: "outOfOffice",
+ label: "Out of Office",
+ icon: "moon-outline",
+ onPress: () =>
+ openInAppBrowser(
+ "https://app.cal.com/settings/my-account/out-of-office",
+ "Out of Office page"
+ ),
+ external: true,
+ },
+ {
+ id: "publicPage",
+ label: "View public page",
+ icon: "globe-outline",
+ onPress: () => {
+ if (publicPageUrl) openInAppBrowser(publicPageUrl, "Public page");
+ },
+ external: true,
+ },
+ {
+ id: "copyPublicPage",
+ label: "Copy public page link",
+ icon: "copy-outline",
+ onPress: async () => {
+ if (publicPageUrl) {
+ await Clipboard.setStringAsync(publicPageUrl);
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ Alert.alert("Copied!", "Public page link copied to clipboard");
+ }
+ },
+ external: false,
+ },
+ {
+ id: "roadmap",
+ label: "Roadmap",
+ icon: "map-outline",
+ onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"),
+ external: true,
+ },
+ {
+ id: "help",
+ label: "Help",
+ icon: "help-circle-outline",
+ onPress: () => openInAppBrowser("https://cal.com/help", "Help page"),
+ external: true,
+ },
+ ];
+
+ const handleClose = () => {
+ router.back();
+ };
+
+ const presentationStyle = getPresentationStyle();
+ const useGlassEffect = isLiquidGlassAvailable();
+
+ return (
+ <>
+ null,
+ headerRight: () => (
+
+
+
+ ),
+ }}
+ />
+
+
+ {/* Profile Header */}
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {/* Avatar */}
+ {userProfile?.avatarUrl ? (
+
+ ) : (
+
+
+ {userProfile?.name?.charAt(0).toUpperCase() ||
+ userProfile?.email?.charAt(0).toUpperCase() ||
+ "?"}
+
+
+ )}
+
+ {/* Name and Status */}
+
+
+ {userProfile?.name || "User"}
+
+ {userProfile?.email ? (
+ {userProfile.email}
+ ) : null}
+
+
+ )}
+
+
+ {/* Menu Items */}
+
+ {menuItems.map((item, index) => (
+
+
+
+ {item.label}
+
+ {item.external ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/companion/app/profile-sheet.tsx b/companion/app/profile-sheet.tsx
new file mode 100644
index 0000000000..3e0403741b
--- /dev/null
+++ b/companion/app/profile-sheet.tsx
@@ -0,0 +1,195 @@
+import { Ionicons } from "@expo/vector-icons";
+import * as Clipboard from "expo-clipboard";
+import * as Haptics from "expo-haptics";
+import { Stack, useRouter } from "expo-router";
+import {
+ ActivityIndicator,
+ Alert,
+ Image,
+ ScrollView,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useUserProfile } from "@/hooks";
+import { openInAppBrowser } from "@/utils/browser";
+import { getAvatarUrl } from "@/utils/getAvatarUrl";
+
+interface ProfileMenuItem {
+ id: string;
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ external?: boolean;
+}
+
+export default function ProfileSheet() {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { data: userProfile, isLoading } = useUserProfile();
+
+ const publicPageUrl = userProfile?.username ? `https://cal.com/${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"),
+ external: true,
+ },
+ {
+ id: "settings",
+ label: "My Settings",
+ icon: "settings-outline",
+ onPress: () =>
+ openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"),
+ external: true,
+ },
+ {
+ id: "outOfOffice",
+ label: "Out of Office",
+ icon: "moon-outline",
+ onPress: () =>
+ openInAppBrowser(
+ "https://app.cal.com/settings/my-account/out-of-office",
+ "Out of Office page"
+ ),
+ external: true,
+ },
+ {
+ id: "publicPage",
+ label: "View public page",
+ icon: "globe-outline",
+ onPress: () => {
+ if (publicPageUrl) openInAppBrowser(publicPageUrl, "Public page");
+ },
+ external: true,
+ },
+ {
+ id: "copyPublicPage",
+ label: "Copy public page link",
+ icon: "copy-outline",
+ onPress: async () => {
+ if (publicPageUrl) {
+ await Clipboard.setStringAsync(publicPageUrl);
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ Alert.alert("Copied!", "Public page link copied to clipboard");
+ }
+ },
+ external: false,
+ },
+ {
+ id: "roadmap",
+ label: "Roadmap",
+ icon: "map-outline",
+ onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"),
+ external: true,
+ },
+ {
+ id: "help",
+ label: "Help",
+ icon: "help-circle-outline",
+ onPress: () => openInAppBrowser("https://cal.com/help", "Help page"),
+ external: true,
+ },
+ ];
+
+ const handleClose = () => {
+ router.back();
+ };
+
+ return (
+ <>
+ null,
+ headerRight: () => (
+
+
+
+ ),
+ }}
+ />
+
+
+ {/* Profile Header */}
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {/* Avatar */}
+ {userProfile?.avatarUrl ? (
+
+ ) : (
+
+
+ {userProfile?.name?.charAt(0).toUpperCase() ||
+ userProfile?.email?.charAt(0).toUpperCase() ||
+ "?"}
+
+
+ )}
+
+ {/* Name and Email */}
+
+
+ {userProfile?.name || "User"}
+
+ {userProfile?.email ? (
+ {userProfile.email}
+ ) : null}
+
+
+ )}
+
+
+ {/* Menu Items */}
+
+ {menuItems.map((item, index) => (
+
+
+
+ {item.label}
+
+ {item.external ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/companion/components/Header.tsx b/companion/components/Header.tsx
index 81a450a442..ae9cd241e8 100644
--- a/companion/components/Header.tsx
+++ b/companion/components/Header.tsx
@@ -1,28 +1,17 @@
import { Ionicons } from "@expo/vector-icons";
-import * as Clipboard from "expo-clipboard";
+import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
-import {
- ActionSheetIOS,
- ActivityIndicator,
- Alert,
- Image,
- Platform,
- Text,
- TouchableOpacity,
- View,
-} from "react-native";
+import { ActivityIndicator, Image, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
-import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { CalComLogo } from "./CalComLogo";
-import { FullScreenModal } from "./FullScreenModal";
export function Header() {
+ const router = useRouter();
const insets = useSafeAreaInsets();
const [userProfile, setUserProfile] = useState(null);
const [loading, setLoading] = useState(true);
- const [showProfileModal, setShowProfileModal] = useState(false);
const fetchUserProfile = useCallback(async () => {
try {
@@ -44,290 +33,46 @@ export function Header() {
fetchUserProfile();
}, [fetchUserProfile]);
- // Build public page URL
- const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
-
- const handleViewPublicPage = () => {
- if (publicPageUrl) {
- openInAppBrowser(publicPageUrl, "Public page");
- }
- };
-
- const handleCopyPublicPageLink = async () => {
- if (!publicPageUrl) return;
- try {
- await Clipboard.setStringAsync(publicPageUrl);
- Alert.alert("Link Copied!", "Your public page link has been copied to clipboard.");
- } catch (err) {
- console.error("Failed to copy public page link");
- if (__DEV__) {
- const message = err instanceof Error ? err.message : String(err);
- const stack = err instanceof Error ? err.stack : undefined;
- console.debug("[Header] copyPublicPageLink failed", { message, stack });
- }
- Alert.alert("Error", "Failed to copy link. Please try again.");
- }
- };
-
const handleProfile = () => {
- if (Platform.OS === "ios") {
- const options = [
- "Cancel",
- "My Profile",
- "My Settings",
- "Out of Office",
- "View public page",
- "Copy public page link",
- "Help",
- ];
-
- ActionSheetIOS.showActionSheetWithOptions(
- {
- options,
- cancelButtonIndex: 0,
- title: userProfile?.name || "Profile Menu",
- },
- (buttonIndex) => {
- switch (buttonIndex) {
- case 1: // My Profile
- handleMenuOption("profile");
- break;
- case 2: // My Settings
- handleMenuOption("settings");
- break;
- case 3: // Out of Office
- handleMenuOption("outOfOffice");
- break;
- case 4: // View public page
- handleViewPublicPage();
- break;
- case 5: // Copy public page link
- handleCopyPublicPageLink();
- break;
- case 6: // Help
- handleMenuOption("help");
- break;
- }
- }
- );
- } else {
- setShowProfileModal(true);
- }
- };
-
- const handleMenuOption = (option: string) => {
- if (Platform.OS !== "ios") {
- setShowProfileModal(false);
- }
- switch (option) {
- case "profile":
- openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page");
- break;
- case "settings":
- openInAppBrowser("https://app.cal.com/settings/my-account", "Settings page");
- break;
- case "outOfOffice":
- openInAppBrowser(
- "https://app.cal.com/settings/my-account/out-of-office",
- "Out of Office page"
- );
- break;
- case "roadmap":
- openInAppBrowser("https://cal.com/roadmap", "Roadmap");
- break;
- case "help":
- openInAppBrowser("https://cal.com/help", "Help page");
- break;
- }
+ // Navigate to profile sheet on all platforms (Android, Web, Extension)
+ router.push("/profile-sheet");
};
return (
- <>
-
- {/* Left: Cal.com Logo */}
-
-
-
-
- {/* Right: Icons */}
-
- {/* Profile Picture */}
-
- {loading ? (
-
- ) : userProfile?.avatarUrl ? (
-
- ) : (
-
-
-
- )}
-
-
+
+ {/* Left: Cal.com Logo */}
+
+
- {/* Profile Menu Modal */}
- setShowProfileModal(false)}
+ {/* Right: Icons */}
+
- setShowProfileModal(false)}
- >
- e.stopPropagation()}
- >
- {/* Header with profile info */}
-
-
-
- {userProfile?.avatarUrl ? (
-
- ) : (
-
- {userProfile?.name
- ? userProfile.name.charAt(0).toUpperCase()
- : userProfile?.email?.charAt(0).toUpperCase() || "?"}
-
- )}
-
-
-
- {userProfile?.name || "User"}
-
- {userProfile?.email ? (
- {userProfile.email}
- ) : null}
-
-
+ {/* Profile Picture */}
+
+ {loading ? (
+
+ ) : userProfile?.avatarUrl ? (
+
+ ) : (
+
+
-
- {/* Menu Items */}
-
- handleMenuOption("profile")}
- >
-
-
- My Profile
-
-
-
-
- handleMenuOption("settings")}
- >
-
-
- My Settings
-
-
-
-
- handleMenuOption("outOfOffice")}
- >
-
-
- Out of Office
-
-
-
-
-
-
- {/* View public page */}
- {
- setShowProfileModal(false);
- handleViewPublicPage();
- }}
- >
-
-
- View public page
-
-
-
-
- {/* Copy public page link */}
- {
- setShowProfileModal(false);
- handleCopyPublicPageLink();
- }}
- >
-
-
- Copy public page link
-
-
-
-
-
- handleMenuOption("roadmap")}
- >
-
-
- Roadmap
-
-
-
-
- handleMenuOption("help")}
- >
-
-
- Help
-
-
-
-
-
- {/* Cancel button */}
-
- setShowProfileModal(false)}
- >
- Cancel
-
-
-
+ )}
-
- >
+
+
);
}
diff --git a/companion/components/LoginScreen.tsx b/companion/components/LoginScreen.tsx
index 120599d7d7..2e896536ca 100644
--- a/companion/components/LoginScreen.tsx
+++ b/companion/components/LoginScreen.tsx
@@ -42,7 +42,7 @@ export function LoginScreen() {
disabled={loading}
className="flex-row items-center justify-center rounded-2xl py-[18px]"
style={[
- { backgroundColor: loading ? "#9CA3AF" : "#111827" },
+ { backgroundColor: loading ? "#9CA3AF" : "#000000" },
Platform.select({
web: {
boxShadow: loading ? "none" : "0 4px 12px rgba(0, 0, 0, 0.2)",
diff --git a/companion/components/screens/AddGuestsScreen.tsx b/companion/components/screens/AddGuestsScreen.tsx
index 2a080d04c4..4ac4c8f31b 100644
--- a/companion/components/screens/AddGuestsScreen.tsx
+++ b/companion/components/screens/AddGuestsScreen.tsx
@@ -20,6 +20,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Booking } from "@/services/calcom";
import { CalComAPIService } from "@/services/calcom";
+import { safeLogError } from "@/utils/safeLogger";
export interface AddGuestsScreenProps {
booking: Booking | null;
@@ -93,7 +94,8 @@ export const AddGuestsScreen = forwardRef {
- console.log("[RescheduleScreen] Opening date picker");
+ safeLogInfo("[RescheduleScreen] Opening date picker");
setShowDatePicker(true);
}}
disabled={isSaving}
@@ -206,7 +204,7 @@ export const RescheduleScreen = forwardRef {
- console.log("[RescheduleScreen] Opening time picker");
+ safeLogInfo("[RescheduleScreen] Opening time picker");
setShowTimePicker(true);
}}
disabled={isSaving}
diff --git a/companion/extension/entrypoints/background/index.ts b/companion/extension/entrypoints/background/index.ts
index f0f20b5ce3..0b863d0f72 100644
--- a/companion/extension/entrypoints/background/index.ts
+++ b/companion/extension/entrypoints/background/index.ts
@@ -12,6 +12,156 @@ const devLog = {
error: (...args: unknown[]) => console.error("[Cal.com]", ...args),
};
+/**
+ * Request timeout in milliseconds (30 seconds)
+ */
+const REQUEST_TIMEOUT_MS = 30000;
+
+/**
+ * Fetch with timeout to prevent hanging requests
+ */
+async function fetchWithTimeout(
+ url: string,
+ options: RequestInit = {},
+ timeoutMs: number = REQUEST_TIMEOUT_MS
+): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ signal: controller.signal,
+ });
+ return response;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Safely parse JSON with validation for OAuthTokens
+ */
+function safeParseOAuthTokens(jsonString: string): OAuthTokens | null {
+ if (typeof jsonString !== "string" || !jsonString.trim()) {
+ return null;
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(jsonString);
+
+ // Validate it's a plain object (not null, array, or primitive)
+ if (
+ parsed === null ||
+ typeof parsed !== "object" ||
+ Array.isArray(parsed) ||
+ Object.getPrototypeOf(parsed) !== Object.prototype
+ ) {
+ devLog.warn("Invalid OAuth tokens structure - not a plain object");
+ return null;
+ }
+
+ const obj = parsed as Record;
+
+ // Validate required fields: accessToken and tokenType must be strings
+ if (typeof obj.accessToken !== "string") {
+ devLog.warn("Invalid OAuth tokens structure - missing accessToken");
+ return null;
+ }
+
+ if (typeof obj.tokenType !== "string") {
+ devLog.warn("Invalid OAuth tokens structure - missing tokenType");
+ return null;
+ }
+
+ // Validate optional fields have correct types if present
+ if (obj.refreshToken !== undefined && typeof obj.refreshToken !== "string") {
+ devLog.warn("Invalid OAuth tokens structure - invalid refreshToken type");
+ return null;
+ }
+
+ if (obj.expiresAt !== undefined && typeof obj.expiresAt !== "number") {
+ devLog.warn("Invalid OAuth tokens structure - invalid expiresAt type");
+ return null;
+ }
+
+ if (obj.scope !== undefined && typeof obj.scope !== "string") {
+ devLog.warn("Invalid OAuth tokens structure - invalid scope type");
+ return null;
+ }
+
+ // Construct validated OAuthTokens object
+ const tokens: OAuthTokens = {
+ accessToken: obj.accessToken,
+ tokenType: obj.tokenType,
+ };
+
+ if (typeof obj.refreshToken === "string") {
+ tokens.refreshToken = obj.refreshToken;
+ }
+
+ if (typeof obj.expiresAt === "number") {
+ tokens.expiresAt = obj.expiresAt;
+ }
+
+ if (typeof obj.scope === "string") {
+ tokens.scope = obj.scope;
+ }
+
+ return tokens;
+ } catch (error) {
+ devLog.warn("Failed to parse OAuth tokens:", error);
+ return null;
+ }
+}
+
+/**
+ * Safely parse JSON error response with structure validation.
+ * Validates the expected error response structure before returning.
+ */
+function safeParseErrorJson(
+ jsonString: string
+): { error?: { message?: string }; message?: string } | null {
+ if (typeof jsonString !== "string" || !jsonString.trim()) {
+ return null;
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(jsonString);
+
+ // Validate it's a plain object
+ if (
+ parsed === null ||
+ typeof parsed !== "object" ||
+ Array.isArray(parsed) ||
+ Object.getPrototypeOf(parsed) !== Object.prototype
+ ) {
+ return null;
+ }
+
+ const obj = parsed as Record;
+
+ // Validate expected error structure - must have error or message property with correct types
+ const hasValidErrorProp =
+ obj.error === undefined ||
+ (typeof obj.error === "object" &&
+ obj.error !== null &&
+ !Array.isArray(obj.error) &&
+ ((obj.error as Record).message === undefined ||
+ typeof (obj.error as Record).message === "string"));
+
+ const hasValidMessageProp = obj.message === undefined || typeof obj.message === "string";
+
+ if (hasValidErrorProp && hasValidMessageProp) {
+ return obj as { error?: { message?: string }; message?: string };
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Browser type enum (inlined to avoid import issues in service worker)
*/
@@ -427,7 +577,7 @@ async function handleTokenExchange(
const body = new URLSearchParams(tokenRequest);
- const response = await fetch(tokenEndpoint, {
+ const response = await fetchWithTimeout(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -520,7 +670,7 @@ async function validateTokens(tokens: OAuthTokens): Promise {
}
try {
- const response = await fetch(`${API_BASE_URL}/me`, {
+ const response = await fetchWithTimeout(`${API_BASE_URL}/me`, {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
"Content-Type": "application/json",
@@ -547,7 +697,7 @@ async function getAuthHeader(): Promise {
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)
+ ? safeParseOAuthTokens(result.cal_oauth_tokens as string)
: null;
if (oauthTokens?.accessToken) {
@@ -566,7 +716,7 @@ async function getAuthHeader(): Promise {
async function fetchEventTypes(): Promise {
const authHeader = await getAuthHeader();
- const userResponse = await fetch(`${API_BASE_URL}/me`, {
+ const userResponse = await fetchWithTimeout(`${API_BASE_URL}/me`, {
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
@@ -588,7 +738,7 @@ async function fetchEventTypes(): Promise {
const queryString = params.toString();
const endpoint = `${API_BASE_URL}/event-types${queryString ? `?${queryString}` : ""}`;
- const response = await fetch(endpoint, {
+ const response = await fetchWithTimeout(endpoint, {
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
@@ -628,7 +778,7 @@ async function checkAuthStatus(): Promise {
try {
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)
+ ? safeParseOAuthTokens(result.cal_oauth_tokens as string)
: null;
return Boolean(oauthTokens?.accessToken);
@@ -644,7 +794,7 @@ async function checkAuthStatus(): Promise {
async function getBookingStatus(bookingUid: string): Promise {
const authHeader = await getAuthHeader();
- const response = await fetch(`${API_BASE_URL}/bookings/${bookingUid}`, {
+ const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}`, {
method: "GET",
headers: {
Authorization: authHeader,
@@ -657,10 +807,10 @@ async function getBookingStatus(bookingUid: string): Promise {
const errorBody = await response.text();
let errorMessage = response.statusText;
- try {
- const errorJson = JSON.parse(errorBody);
+ const errorJson = safeParseErrorJson(errorBody);
+ if (errorJson) {
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
- } catch {
+ } else {
errorMessage = errorBody || response.statusText;
}
@@ -691,7 +841,7 @@ async function markAttendeeNoShow(
): Promise {
const authHeader = await getAuthHeader();
- const response = await fetch(`${API_BASE_URL}/bookings/${bookingUid}/mark-absent`, {
+ const response = await fetchWithTimeout(`${API_BASE_URL}/bookings/${bookingUid}/mark-absent`, {
method: "POST",
headers: {
Authorization: authHeader,
@@ -707,10 +857,10 @@ async function markAttendeeNoShow(
const errorBody = await response.text();
let errorMessage = response.statusText;
- try {
- const errorJson = JSON.parse(errorBody);
+ const errorJson = safeParseErrorJson(errorBody);
+ if (errorJson) {
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
- } catch {
+ } else {
errorMessage = errorBody || response.statusText;
}
diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts
index 36c1ba4a3b..1422a85e80 100644
--- a/companion/extension/entrypoints/content.ts
+++ b/companion/extension/entrypoints/content.ts
@@ -2,6 +2,19 @@
import { initGoogleCalendarIntegration } from "../lib/google-calendar";
import { initLinkedInIntegration } from "../lib/linkedin";
+/**
+ * Development-only logging utility for content scripts.
+ * In production builds, these logs are suppressed to prevent sensitive data exposure.
+ */
+const IS_DEV_MODE =
+ typeof process !== "undefined" && process.env && process.env.NODE_ENV === "development";
+
+const devLog = {
+ log: (...args: unknown[]) => IS_DEV_MODE && console.log("[Cal.com]", ...args),
+ warn: (...args: unknown[]) => IS_DEV_MODE && console.warn("[Cal.com]", ...args),
+ error: (...args: unknown[]) => IS_DEV_MODE && console.error("[Cal.com]", ...args),
+};
+
export default defineContentScript({
matches: [""],
main() {
@@ -94,11 +107,11 @@ export default defineContentScript({
const validateSessionToken = (providedToken: string | undefined): boolean => {
if (!iframeSessionValidated) {
- console.warn("Cal.com: Session not validated yet");
+ devLog.warn("Session not validated yet");
return false;
}
if (providedToken !== sessionToken) {
- console.warn("Cal.com: Invalid session token");
+ devLog.warn("Invalid session token");
return false;
}
return true;
@@ -165,7 +178,7 @@ export default defineContentScript({
{ action: "start-extension-oauth", authUrl: authUrl },
(response) => {
if (chrome.runtime.lastError) {
- console.error(
+ devLog.error(
"Failed to communicate with background script:",
chrome.runtime.lastError.message
);
@@ -221,7 +234,7 @@ export default defineContentScript({
},
(response) => {
if (chrome.runtime.lastError) {
- console.error(
+ devLog.error(
"Failed to communicate with background script:",
chrome.runtime.lastError.message
);
@@ -266,7 +279,7 @@ export default defineContentScript({
) {
chrome.runtime.sendMessage({ action: "sync-oauth-tokens", tokens }, (response) => {
if (chrome.runtime.lastError) {
- console.error(
+ devLog.error(
"Failed to communicate with background script:",
chrome.runtime.lastError.message
);
@@ -295,7 +308,7 @@ export default defineContentScript({
function handleClearTokensRequest(iframeWindow: Window | null) {
chrome.runtime.sendMessage({ action: "clear-oauth-tokens" }, (response) => {
if (chrome.runtime.lastError) {
- console.error(
+ devLog.error(
"Failed to communicate with background script:",
chrome.runtime.lastError.message
);
@@ -2292,7 +2305,7 @@ export default defineContentScript({
try {
// Validate input
if (!html || typeof html !== "string") {
- console.warn("Cal.com: Invalid HTML to insert");
+ devLog.warn("Invalid HTML to insert");
return false;
}
@@ -2322,7 +2335,7 @@ export default defineContentScript({
}
if (!composeBody) {
- console.warn("Cal.com: Gmail compose field not found");
+ devLog.warn("Gmail compose field not found");
return false;
}
@@ -2330,7 +2343,7 @@ export default defineContentScript({
try {
(composeBody as HTMLElement).focus();
} catch (focusError) {
- console.warn("Cal.com: Failed to focus compose field:", focusError);
+ devLog.warn("Failed to focus compose field:", focusError);
}
const selection = window.getSelection();
@@ -2342,7 +2355,7 @@ export default defineContentScript({
composeBody.appendChild(tempDiv);
return true;
} catch (appendError) {
- console.warn("Cal.com: Failed to append HTML:", appendError);
+ devLog.warn("Failed to append HTML:", appendError);
return false;
}
}
@@ -2372,11 +2385,11 @@ export default defineContentScript({
return true;
} catch (insertError) {
- console.warn("Cal.com: Failed to insert HTML at cursor:", insertError);
+ devLog.warn("Failed to insert HTML at cursor:", insertError);
return false;
}
} catch (_error) {
- console.error("Cal.com: Critical error inserting HTML");
+ devLog.error("Critical error inserting HTML");
return false;
}
}
@@ -2390,7 +2403,7 @@ export default defineContentScript({
try {
// Validate input
if (!text || typeof text !== "string") {
- console.warn("Cal.com: Invalid text to insert");
+ devLog.warn("Invalid text to insert");
return false;
}
@@ -2423,7 +2436,7 @@ export default defineContentScript({
}
if (!composeBody) {
- console.warn("Cal.com: Gmail compose field not found (structure may have changed)");
+ devLog.warn("Gmail compose field not found (structure may have changed)");
return false;
}
@@ -2431,7 +2444,7 @@ export default defineContentScript({
try {
(composeBody as HTMLElement).focus();
} catch (focusError) {
- console.warn("Cal.com: Failed to focus compose field:", focusError);
+ devLog.warn("Failed to focus compose field:", focusError);
// Continue anyway - might still work
}
@@ -2447,7 +2460,7 @@ export default defineContentScript({
selection?.addRange(range);
return true;
} catch (appendError) {
- console.warn("Cal.com: Failed to append text (fallback method):", appendError);
+ devLog.warn("Failed to append text (fallback method):", appendError);
return false;
}
}
@@ -2465,14 +2478,11 @@ export default defineContentScript({
composeBody.dispatchEvent(new Event("change", { bubbles: true }));
return true;
} catch (insertError) {
- console.warn("Cal.com: Failed to insert text at cursor:", insertError);
+ devLog.warn("Failed to insert text at cursor:", insertError);
return false;
}
} catch (error) {
- console.error(
- "Cal.com: Critical error inserting text (Gmail structure may have changed):",
- error
- );
+ devLog.error("Critical error inserting text (Gmail structure may have changed):", error);
return false;
}
}
@@ -2550,7 +2560,7 @@ export default defineContentScript({
}, 3000);
} catch (error) {
// Silently fail - notifications are non-critical
- console.warn("Cal.com: Failed to show notification:", error);
+ devLog.warn("Failed to show notification:", error);
}
}
@@ -2571,12 +2581,12 @@ export default defineContentScript({
handleGoogleChipDetected(chip as HTMLElement);
} catch (error) {
// Silently fail for individual chips to prevent breaking other chips
- console.warn("Cal.com: Failed to process chip:", error);
+ devLog.warn("Failed to process chip:", error);
}
});
} catch (error) {
// Silently fail to prevent Gmail UI from breaking
- console.warn("Cal.com: Failed to detect chips:", error);
+ devLog.warn("Failed to detect chips:", error);
}
});
@@ -2596,16 +2606,16 @@ export default defineContentScript({
chip.setAttribute("data-calcom-chip-processed", "true");
handleGoogleChipDetected(chip as HTMLElement);
} catch (error) {
- console.warn("Cal.com: Failed to process existing chip:", error);
+ devLog.warn("Failed to process existing chip:", error);
}
});
} catch (error) {
- console.warn("Cal.com: Failed to find existing chips:", error);
+ devLog.warn("Failed to find existing chips:", error);
}
}, 1000);
} catch (_error) {
// Critical failure - log but don't break Gmail
- console.error("Cal.com: Failed to initialize chip watcher");
+ devLog.error("Failed to initialize chip watcher");
}
}
@@ -2614,11 +2624,11 @@ export default defineContentScript({
*/
function handleGoogleChipDetected(chipElement: HTMLElement) {
try {
- console.log("Cal.com: handleGoogleChipDetected called");
+ devLog.log("handleGoogleChipDetected called");
// Validate chip element exists and is in DOM
if (!chipElement || !chipElement.isConnected) {
- console.warn("Cal.com: Invalid or disconnected chip element");
+ devLog.warn("Invalid or disconnected chip element");
return;
}
@@ -2638,8 +2648,8 @@ export default defineContentScript({
return;
}
- console.log(
- `Cal.com: ✅ Google chip detected - ${parsedData.slots.length} slot${
+ devLog.log(
+ `✅ Google chip detected - ${parsedData.slots.length} slot${
parsedData.slots.length > 1 ? "s" : ""
} (${parsedData.detectedDuration}min)`
);
@@ -2668,7 +2678,7 @@ export default defineContentScript({
return;
}
// Something changed - remove old action bar and create new one
- console.log(`Cal.com: 🔄 Chip updated - ${parsedData.detectedDuration}min`);
+ devLog.log(`🔄 Chip updated - ${parsedData.detectedDuration}min`);
try {
existingActionBar.remove();
} catch (_e) {
@@ -2687,7 +2697,7 @@ export default defineContentScript({
// Check if schedule ID changed (this changes when time range or duration changes)
if (currentScheduleId && newScheduleId && currentScheduleId !== newScheduleId) {
- console.log(`Cal.com: 🔄 Time range/duration changed`);
+ devLog.log(`🔄 Time range/duration changed`);
try {
actionBar?.remove();
// Recreate action bar with new data
@@ -2811,15 +2821,15 @@ export default defineContentScript({
e.stopPropagation();
// Re-parse the chip to get fresh data (in case it changed)
const freshParsedData = parseGoogleChip(chipElement);
- console.log(
- "Cal.com: Suggest Links clicked, fresh parsed data:",
+ devLog.log(
+ "Suggest Links clicked, fresh parsed data:",
freshParsedData?.detectedDuration,
"min"
);
if (freshParsedData && freshParsedData.slots.length > 0) {
showCalcomSuggestionMenu(chipElement, freshParsedData);
} else {
- console.log("Cal.com: Failed to parse fresh data or no slots found");
+ devLog.log("Failed to parse fresh data or no slots found");
}
});
@@ -2854,8 +2864,8 @@ export default defineContentScript({
// Re-parse the chip to get fresh data
const freshParsedData = parseGoogleChip(chipElement);
- console.log(
- "Cal.com: Insert Embed clicked, fresh parsed data:",
+ devLog.log(
+ "Insert Embed clicked, fresh parsed data:",
freshParsedData?.detectedDuration,
"min"
);
@@ -2939,7 +2949,7 @@ export default defineContentScript({
if (inserted) {
showGmailNotification("Cal.com embed inserted!", "success");
- console.log("Cal.com: ✅ Email embed inserted successfully");
+ devLog.log("Email embed inserted successfully");
// Immediately remove the Google chip and action bar
try {
@@ -2947,7 +2957,7 @@ export default defineContentScript({
(actionBar as HTMLElement & { __cleanup?: () => void }).__cleanup?.();
actionBar.remove();
} catch (removeError) {
- console.warn("Cal.com: Failed to remove chip/action bar:", removeError);
+ devLog.warn("Failed to remove chip/action bar:", removeError);
}
} else {
showGmailNotification("Failed to insert embed", "error");
@@ -2957,7 +2967,7 @@ export default defineContentScript({
embedButton.style.opacity = "1";
embedButton.style.cursor = "pointer";
} catch (error) {
- console.error("Cal.com: Failed to insert embed");
+ devLog.error("Failed to insert embed");
// Check if this is the "Extension context invalidated" error
const isContextInvalidated =
@@ -3119,7 +3129,7 @@ export default defineContentScript({
window.removeEventListener("resize", updatePosition);
};
} catch (_error) {
- console.error("Cal.com: Failed to create or insert action bar");
+ devLog.error("Failed to create or insert action bar");
// Clean up if something failed
try {
actionBar.remove();
@@ -3129,7 +3139,7 @@ export default defineContentScript({
}
} catch (_error) {
// Catch-all to prevent breaking Gmail UI
- console.error("Cal.com: Error handling Google chip");
+ devLog.error("Error handling Google chip");
return;
}
}
@@ -3153,8 +3163,8 @@ export default defineContentScript({
timezoneOffset: string;
}
) {
- console.log(
- "Cal.com: Opening menu for",
+ devLog.log(
+ "Opening menu for",
parsedData.detectedDuration,
"min with",
parsedData.slots.length,
@@ -3164,7 +3174,7 @@ export default defineContentScript({
// Remove existing menu if any (to support reopening with new data)
const existingBackdrop = document.querySelector(".cal-companion-google-chip-backdrop");
if (existingBackdrop) {
- console.log("Cal.com: Removing existing backdrop");
+ devLog.log("Removing existing backdrop");
existingBackdrop.remove();
// Wait a tick to ensure DOM is updated before creating new menu
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -3389,7 +3399,7 @@ export default defineContentScript({
"success"
);
- console.log("Cal.com: Opening create URL:", createUrl);
+ devLog.log("Opening create URL:", createUrl);
});
createBtn?.addEventListener("mouseenter", (e) => {
@@ -3525,7 +3535,7 @@ export default defineContentScript({
selectedText.textContent = `${et.title} (${et.lengthInMinutes}min)`;
}
optionsContainer.style.display = "none";
- console.log("Cal.com: Event type changed to:", et.slug);
+ devLog.log("Event type changed to:", et.slug);
});
optionsContainer.appendChild(option);
@@ -3619,7 +3629,7 @@ export default defineContentScript({
// Hover effect on entire slot item
slotItem.addEventListener("mouseenter", () => {
- console.log("Cal.com: Mouse entered slot", index);
+ devLog.log("Mouse entered slot", index);
slotItem.style.borderColor = "#000";
slotItem.style.backgroundColor = "#f8f9fa";
const btn = slotItem.querySelector(".insert-slot-btn") as HTMLElement;
@@ -3641,18 +3651,18 @@ export default defineContentScript({
// Add event listeners for insert buttons
const insertButtons = menu.querySelectorAll(".insert-slot-btn");
- console.log("Cal.com: Found", insertButtons.length, "insert buttons");
+ devLog.log("Found", insertButtons.length, "insert buttons");
insertButtons.forEach((btn) => {
btn.addEventListener("click", (e) => {
- console.log("Cal.com: Insert button clicked!");
+ devLog.log("Insert button clicked!");
e.preventDefault();
e.stopPropagation();
const slotIndexAttr = btn.getAttribute("data-slot-index");
if (!slotIndexAttr) return;
const slotIndex = parseInt(slotIndexAttr, 10);
const slot = parsedData.slots[slotIndex];
- console.log("Cal.com: Inserting link for slot", slotIndex, slot);
+ devLog.log("Inserting link for slot", slotIndex, slot);
// Get selected event type from the tracked index
const selectedEventType = matchingEventTypes[selectedEventTypeIndex];
@@ -3668,7 +3678,7 @@ export default defineContentScript({
});
const calcomUrl = `${baseUrl}?${params.toString()}`;
- console.log("Cal.com: Generated URL:", calcomUrl);
+ devLog.log("Generated URL:", calcomUrl);
// Insert link into Gmail compose (pass chipElement to target the correct compose window)
const inserted = insertGmailText(calcomUrl, chipElement);
@@ -3693,7 +3703,7 @@ export default defineContentScript({
actionBar.remove();
}
} catch (removeError) {
- console.warn("Cal.com: Failed to remove chip/action bar:", removeError);
+ devLog.warn("Failed to remove chip/action bar:", removeError);
}
} else {
showGmailNotification("Failed to insert link", "error");
@@ -3701,7 +3711,7 @@ export default defineContentScript({
});
});
} catch (error) {
- console.error("Error showing Cal.com suggestion menu");
+ devLog.error("Error showing suggestion menu");
loadingDiv.remove();
const errorMessage = error instanceof Error ? error.message : "";
@@ -3810,7 +3820,7 @@ export default defineContentScript({
try {
// Validate input
if (!chipElement || typeof chipElement.getAttribute !== "function") {
- console.warn("Cal.com: Invalid chip element passed to parser");
+ devLog.warn("Invalid chip element passed to parser");
return null;
}
@@ -3823,10 +3833,7 @@ export default defineContentScript({
return null;
}
- console.log(
- "Cal.com: Valid chip detected - Schedule ID:",
- `${scheduleId?.slice(0, 20)}...`
- );
+ devLog.log("Valid chip detected - Schedule ID:", `${scheduleId?.slice(0, 20)}...`);
// Parse timezone (non-critical - fallback to UTC if structure changed)
let timezone = "UTC";
@@ -3835,7 +3842,7 @@ export default defineContentScript({
try {
// First, try to get the IANA timezone from data-ad-hoc-v2-params
const paramsAttr = chipElement.getAttribute("data-ad-hoc-v2-params");
- console.log("Cal.com: data-ad-hoc-v2-params:", paramsAttr);
+ devLog.log("data-ad-hoc-v2-params:", paramsAttr);
if (paramsAttr) {
// The timezone is at the end of the params string, like: "Asia/Kolkata"
@@ -3854,12 +3861,12 @@ export default defineContentScript({
if (tzMatch?.[1]) {
timezone = tzMatch[1]; // e.g., "Asia/Kolkata"
- console.log("Cal.com: ✅ Parsed IANA timezone from data attribute:", timezone);
+ devLog.log("Parsed IANA timezone from data attribute:", timezone);
} else {
- console.warn("Cal.com: ⚠️ Failed to extract timezone from params attribute");
+ devLog.warn("Failed to extract timezone from params attribute");
}
} else {
- console.warn("Cal.com: ⚠️ No data-ad-hoc-v2-params attribute found");
+ devLog.warn("No data-ad-hoc-v2-params attribute found");
}
// Also get the display timezone and offset from the UI text
@@ -3868,11 +3875,11 @@ export default defineContentScript({
if (timezoneMatch) {
timezoneOffset = timezoneMatch[3]?.trim() || "GMT+00:00";
- console.log("Cal.com: Parsed timezone offset from UI:", timezoneOffset);
+ devLog.log("Parsed timezone offset from UI:", timezoneOffset);
}
} catch (tzError) {
// Non-critical error - continue with default timezone
- console.warn("Cal.com: Failed to parse timezone, using UTC:", tzError);
+ devLog.warn("Failed to parse timezone, using UTC:", tzError);
}
// Find all time slot links - critical for functionality
@@ -3880,7 +3887,7 @@ export default defineContentScript({
try {
slotLinks = Array.from(chipElement.querySelectorAll("a[href*='slotStartTime']"));
} catch (error) {
- console.warn("Cal.com: Failed to find slot links:", error);
+ devLog.warn("Failed to find slot links:", error);
return null;
}
@@ -3955,7 +3962,7 @@ export default defineContentScript({
});
} catch (slotError) {
// Skip individual slot if it fails - don't break entire parsing
- console.warn("Cal.com: Failed to parse individual slot:", slotError);
+ devLog.warn("Failed to parse individual slot:", slotError);
}
});
@@ -3964,8 +3971,8 @@ export default defineContentScript({
return null;
}
- console.log(
- `Cal.com: ✅ Parsed chip - ${slots.length} slots, ${detectedDuration}min, timezone: ${timezone}`
+ devLog.log(
+ `Parsed chip - ${slots.length} slots, ${detectedDuration}min, timezone: ${timezone}`
);
return {
@@ -3977,10 +3984,7 @@ export default defineContentScript({
};
} catch (error) {
// Critical error in parsing - fail gracefully without breaking Gmail
- console.warn(
- "Cal.com: Failed to parse Google chip (Gmail structure may have changed):",
- error
- );
+ devLog.warn("Failed to parse Google chip (Gmail structure may have changed):", error);
return null;
}
}
@@ -3996,9 +4000,9 @@ export default defineContentScript({
if (window.location.hostname === "mail.google.com") {
try {
initGmailIntegration();
- console.log("Cal.com: Gmail integration initialized successfully");
+ devLog.log("Gmail integration initialized successfully");
} catch (error) {
- console.error("Cal.com: Failed to initialize Gmail integration", error);
+ devLog.error("Failed to initialize Gmail integration", error);
}
}
@@ -4006,9 +4010,9 @@ export default defineContentScript({
if (window.location.hostname === "www.linkedin.com") {
try {
initLinkedInIntegration();
- console.log("Cal.com: LinkedIn integration initialized successfully");
+ devLog.log("LinkedIn integration initialized successfully");
} catch (error) {
- console.error("Cal.com: Failed to initialize LinkedIn integration", error);
+ devLog.error("Failed to initialize LinkedIn integration", error);
}
}
@@ -4016,9 +4020,9 @@ export default defineContentScript({
if (window.location.hostname === "calendar.google.com") {
try {
initGoogleCalendarIntegration();
- console.log("Cal.com: Google Calendar integration initialized successfully");
+ devLog.log("Google Calendar integration initialized successfully");
} catch (error) {
- console.error("Cal.com: Failed to initialize Google Calendar integration", error);
+ devLog.error("Failed to initialize Google Calendar integration", error);
}
}
},
diff --git a/companion/services/calcom.ts b/companion/services/calcom.ts
index 5d2f4bdf23..25cc3ce6f0 100644
--- a/companion/services/calcom.ts
+++ b/companion/services/calcom.ts
@@ -1,3 +1,6 @@
+import { fetchWithTimeout } from "@/utils/network";
+import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
+
import type {
AddGuestInput,
AddGuestsResponse,
@@ -30,6 +33,88 @@ import type {
const API_BASE_URL = "https://api.cal.com/v2";
+const REQUEST_TIMEOUT_MS = 30000;
+
+/**
+ * Safely parse JSON response with structure validation.
+ * Validates that the input is a non-null object before returning.
+ * This prevents prototype pollution and ensures type safety.
+ */
+function safeParseJson(jsonString: string): Record | null {
+ if (typeof jsonString !== "string" || !jsonString.trim()) {
+ return null;
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(jsonString);
+
+ // Validate it's a plain object (not null, array, or primitive)
+ if (
+ parsed !== null &&
+ typeof parsed === "object" &&
+ !Array.isArray(parsed) &&
+ Object.getPrototypeOf(parsed) === Object.prototype
+ ) {
+ return parsed as Record;
+ }
+
+ // Also accept arrays as valid JSON responses
+ if (Array.isArray(parsed)) {
+ return { data: parsed } as Record;
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Safely parse JSON error response with structure validation.
+ * Validates the expected error response structure before returning.
+ */
+function safeParseErrorJson(
+ jsonString: string
+): { error?: { message?: string }; message?: string } | null {
+ if (typeof jsonString !== "string" || !jsonString.trim()) {
+ return null;
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(jsonString);
+
+ // Validate it's a plain object
+ if (
+ parsed === null ||
+ typeof parsed !== "object" ||
+ Array.isArray(parsed) ||
+ Object.getPrototypeOf(parsed) !== Object.prototype
+ ) {
+ return null;
+ }
+
+ const obj = parsed as Record;
+
+ // Validate expected error structure - must have error or message property with correct types
+ const hasValidErrorProp =
+ obj.error === undefined ||
+ (typeof obj.error === "object" &&
+ obj.error !== null &&
+ ((obj.error as Record).message === undefined ||
+ typeof (obj.error as Record).message === "string"));
+
+ const hasValidMessageProp = obj.message === undefined || typeof obj.message === "string";
+
+ if (hasValidErrorProp && hasValidMessageProp) {
+ return obj as { error?: { message?: string }; message?: string };
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
// Authentication configuration
interface AuthConfig {
accessToken?: string;
@@ -235,20 +320,29 @@ async function testRawBookingsAPI(): Promise {
try {
const url = `${API_BASE_URL}/bookings?status=upcoming&status=unconfirmed&limit=50`;
- const response = await fetch(url, {
- headers: {
- Authorization: getAuthHeader(),
- "Content-Type": "application/json",
- "cal-api-version": "2024-08-13",
+ const response = await fetchWithTimeout(
+ url,
+ {
+ headers: {
+ Authorization: getAuthHeader(),
+ "Content-Type": "application/json",
+ "cal-api-version": "2024-08-13",
+ },
},
- });
+ REQUEST_TIMEOUT_MS
+ );
const responseText = await response.text();
- try {
- const _responseJson = JSON.parse(responseText);
- } catch (_parseError) {}
- } catch (_error) {}
+ if (responseText?.trim()) {
+ const _responseJson = safeParseJson(responseText);
+ if (!_responseJson) {
+ safeLogError("[CalComAPIService] Failed to parse bookings response", { responseText });
+ }
+ }
+ } catch (_error) {
+ safeLogError("[CalComAPIService] testRawBookingsAPI failed", { error: _error });
+ }
}
async function makeRequest(
@@ -259,15 +353,19 @@ async function makeRequest(
): Promise {
const url = `${API_BASE_URL}${endpoint}`;
- const response = await fetch(url, {
- ...options,
- headers: {
- Authorization: getAuthHeader(),
- "Content-Type": "application/json",
- "cal-api-version": apiVersion,
- ...options.headers,
+ const response = await fetchWithTimeout(
+ url,
+ {
+ ...options,
+ headers: {
+ Authorization: getAuthHeader(),
+ "Content-Type": "application/json",
+ "cal-api-version": apiVersion,
+ ...options.headers,
+ },
},
- });
+ REQUEST_TIMEOUT_MS
+ );
if (!response.ok) {
const errorBody = await response.text();
@@ -275,10 +373,10 @@ async function makeRequest(
// Parse error for better user messages
let errorMessage = response.statusText;
- try {
- const errorJson = JSON.parse(errorBody);
+ const errorJson = safeParseErrorJson(errorBody);
+ if (errorJson) {
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
- } catch (_parseError) {
+ } else {
// If JSON parsing fails, use the raw error body
errorMessage = errorBody || response.statusText;
}
@@ -300,7 +398,7 @@ async function makeRequest(
// Retry the original request with the new token
return makeRequest(endpoint, options, apiVersion, true);
} catch (refreshError) {
- console.error("Token refresh failed:", refreshError);
+ safeLogError("Token refresh failed:", refreshError);
clearAuth();
throw new Error("Authentication failed. Please sign in again.");
}
@@ -329,7 +427,7 @@ async function deleteEventType(eventTypeId: number): Promise {
"2024-06-14"
);
} catch (error) {
- console.error("Delete API error");
+ safeLogError("Delete API error", { error, eventTypeId });
throw error;
}
}
@@ -391,7 +489,7 @@ async function rescheduleBooking(
}
): Promise {
try {
- console.log("[CalComAPIService] rescheduleBooking request:", {
+ safeLogInfo("[CalComAPIService] rescheduleBooking request:", {
bookingUid,
input,
});
@@ -415,10 +513,10 @@ async function rescheduleBooking(
throw new Error("Invalid response from reschedule booking API");
} catch (error) {
- console.error("[CalComAPIService] rescheduleBooking error:", error);
+ safeLogError("[CalComAPIService] rescheduleBooking error:", error);
if (error instanceof Error) {
- console.error("[CalComAPIService] Error message:", error.message);
- console.error("[CalComAPIService] Error stack:", error.stack);
+ safeLogError("[CalComAPIService] Error message:", error.message);
+ safeLogError("[CalComAPIService] Error stack:", error.stack);
}
throw error;
}
@@ -444,7 +542,7 @@ async function confirmBooking(bookingUid: string): Promise {
throw new Error("Invalid response from confirm booking API");
} catch (error) {
- console.error("confirmBooking error");
+ safeLogError("confirmBooking error", { error, bookingUid });
throw error;
}
}
@@ -622,7 +720,7 @@ async function updateLocationV2(
throw new Error("Invalid response from update location API");
} catch (error) {
- console.error("[CalComAPIService] updateLocationV2 error:", error);
+ safeLogError("[CalComAPIService] updateLocationV2 error:", error);
throw error;
}
}
diff --git a/companion/services/oauthService.ts b/companion/services/oauthService.ts
index 4dfa029a2f..abd9bc6728 100644
--- a/companion/services/oauthService.ts
+++ b/companion/services/oauthService.ts
@@ -5,6 +5,9 @@ import * as Crypto from "expo-crypto";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
+import { fetchWithTimeout } from "@/utils/network";
+import { safeLogWarn } from "@/utils/safeLogger";
+
WebBrowser.maybeCompleteAuthSession();
// Message types for extension communication
@@ -342,14 +345,18 @@ export class CalComOAuthService {
tokenRequest: Record,
tokenEndpoint: string
): Promise {
- const response = await fetch(tokenEndpoint, {
- method: "POST",
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- Accept: "application/json",
+ const response = await fetchWithTimeout(
+ tokenEndpoint,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ },
+ body: new URLSearchParams(tokenRequest).toString(),
},
- body: new URLSearchParams(tokenRequest).toString(),
- });
+ 30000
+ );
if (!response.ok) {
throw new Error("Token exchange failed");
@@ -434,7 +441,7 @@ export class CalComOAuthService {
const sessionToken = await getExtensionSessionToken();
if (!sessionToken) {
- console.warn("No session token available for token sync");
+ safeLogWarn("No session token available for token sync");
return;
}
@@ -458,7 +465,7 @@ export class CalComOAuthService {
if (event.data.success) {
resolve();
} else {
- console.warn("Failed to sync tokens to extension:", event.data.error);
+ safeLogWarn("Failed to sync tokens to extension", event.data.error);
resolve();
}
};
@@ -478,7 +485,7 @@ export class CalComOAuthService {
const sessionToken = await getExtensionSessionToken();
if (!sessionToken) {
- console.warn("No session token available for token clear");
+ safeLogWarn("No session token available for token clear");
return;
}
@@ -502,7 +509,7 @@ export class CalComOAuthService {
if (event.data.success) {
resolve();
} else {
- console.warn("Failed to clear tokens from extension:", event.data.error);
+ safeLogWarn("Failed to clear tokens from extension", event.data.error);
resolve();
}
};
diff --git a/companion/services/webAuth.ts b/companion/services/webAuth.ts
index a38bd9fd6e..8afebe90aa 100644
--- a/companion/services/webAuth.ts
+++ b/companion/services/webAuth.ts
@@ -1,7 +1,11 @@
import { Platform } from "react-native";
+import { fetchWithTimeout } from "@/utils/network";
+
import type { UserProfile } from "./types/users.types";
+const REQUEST_TIMEOUT_MS = 30000;
+
export interface WebSessionInfo {
isLoggedIn: boolean;
accessToken?: string;
@@ -73,13 +77,17 @@ async function validateWebSession(): Promise {
try {
// First try to call the NextAuth session endpoint
- const sessionResponse = await fetch("https://app.cal.com/api/auth/session", {
- method: "GET",
- credentials: "include", // Include cookies
- headers: {
- "Content-Type": "application/json",
+ const sessionResponse = await fetchWithTimeout(
+ "https://app.cal.com/api/auth/session",
+ {
+ method: "GET",
+ credentials: "include", // Include cookies
+ headers: {
+ "Content-Type": "application/json",
+ },
},
- });
+ REQUEST_TIMEOUT_MS
+ );
if (sessionResponse.ok) {
const sessionData = await sessionResponse.json();
@@ -94,13 +102,17 @@ async function validateWebSession(): Promise {
}
// Try the internal Cal.com me endpoint (this might work with cookies)
- const meResponse = await fetch("https://app.cal.com/api/me", {
- method: "GET",
- credentials: "include",
- headers: {
- "Content-Type": "application/json",
+ const meResponse = await fetchWithTimeout(
+ "https://app.cal.com/api/me",
+ {
+ method: "GET",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ },
},
- });
+ REQUEST_TIMEOUT_MS
+ );
if (meResponse.ok) {
const userData = await meResponse.json();
@@ -114,13 +126,17 @@ async function validateWebSession(): Promise {
}
// Try to check if user is logged in by attempting to access a protected page
- const dashboardResponse = await fetch("https://app.cal.com/api/trpc/viewer.me", {
- method: "GET",
- credentials: "include",
- headers: {
- "Content-Type": "application/json",
+ const dashboardResponse = await fetchWithTimeout(
+ "https://app.cal.com/api/trpc/viewer.me",
+ {
+ method: "GET",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ },
},
- });
+ REQUEST_TIMEOUT_MS
+ );
if (dashboardResponse.ok) {
const dashboardData = await dashboardResponse.json();
diff --git a/companion/utils/bookings-utils.ts b/companion/utils/bookings-utils.ts
index e0f7f29407..c371b9b43b 100644
--- a/companion/utils/bookings-utils.ts
+++ b/companion/utils/bookings-utils.ts
@@ -1,6 +1,8 @@
import type { BookingFilter } from "@/hooks";
import type { Booking } from "@/services/calcom";
+import { safeLogError, safeLogWarn } from "./safeLogger";
+
export const getEmptyStateContent = (activeFilter: BookingFilter) => {
switch (activeFilter) {
case "upcoming":
@@ -48,7 +50,7 @@ export const formatTime = (dateString: string): string => {
try {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
- console.warn("Invalid date string:", dateString);
+ safeLogWarn("Invalid date string provided to formatTime", { dateString });
return "";
}
return date.toLocaleTimeString("en-US", {
@@ -57,7 +59,7 @@ export const formatTime = (dateString: string): string => {
hour12: true,
});
} catch (error) {
- console.error("Error formatting time:", error, dateString);
+ safeLogError("Error formatting time:", error);
return "";
}
};
@@ -75,7 +77,7 @@ export const formatDate = (dateString: string, isUpcoming: boolean): string => {
try {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
- console.warn("Invalid date string:", dateString);
+ safeLogWarn("Invalid date string provided to formatDate", { dateString });
return "";
}
const bookingYear = date.getFullYear();
@@ -100,7 +102,7 @@ export const formatDate = (dateString: string, isUpcoming: boolean): string => {
});
}
} catch (error) {
- console.error("Error formatting date:", error, dateString);
+ safeLogError("Error formatting date:", error);
return "";
}
};
diff --git a/companion/utils/index.ts b/companion/utils/index.ts
index 6f0d34caf8..b035591b82 100644
--- a/companion/utils/index.ts
+++ b/companion/utils/index.ts
@@ -59,9 +59,11 @@ export {
validateLocationItem,
} from "./locationHelpers";
// Network utilities
-export { isOnline } from "./network";
+export { fetchWithTimeout, isOnline } from "./network";
// Query persistence utilities
export { clearQueryCache, createQueryPersister, getCacheMetadata } from "./queryPersister";
+// Safe logging utilities
+export { getSafeErrorMessage, safeLogError, safeLogInfo, safeLogWarn } from "./safeLogger";
// Slug utilities
export { slugify } from "./slugify";
// Storage utilities
diff --git a/companion/utils/network.ts b/companion/utils/network.ts
index 3ef97b0820..884c7870ce 100644
--- a/companion/utils/network.ts
+++ b/companion/utils/network.ts
@@ -7,6 +7,48 @@
import NetInfo from "@react-native-community/netinfo";
import { Alert } from "react-native";
+/**
+ * Default timeout for fetch requests in milliseconds (30 seconds)
+ */
+const DEFAULT_FETCH_TIMEOUT = 30000;
+
+/**
+ * Fetch with timeout using AbortSignal.
+ * This prevents requests from hanging indefinitely.
+ *
+ * @param url - The URL to fetch
+ * @param options - Fetch options (RequestInit)
+ * @param timeoutMs - Timeout in milliseconds (default: 30000)
+ * @returns Promise
+ * @throws Error if the request times out or fails
+ *
+ * @example
+ * ```tsx
+ * const response = await fetchWithTimeout('https://api.example.com/data', {
+ * method: 'GET',
+ * headers: { 'Content-Type': 'application/json' }
+ * });
+ * ```
+ */
+export const fetchWithTimeout = async (
+ url: string,
+ options: RequestInit = {},
+ timeoutMs: number = DEFAULT_FETCH_TIMEOUT
+): Promise => {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ signal: controller.signal,
+ });
+ return response;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+};
+
/**
* Check if the device is currently online
*
diff --git a/companion/utils/queryPersister.ts b/companion/utils/queryPersister.ts
index d263b74e9d..423464e225 100644
--- a/companion/utils/queryPersister.ts
+++ b/companion/utils/queryPersister.ts
@@ -6,6 +6,7 @@
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { CACHE_CONFIG } from "@/config/cache.config";
+import { safeLogWarn } from "./safeLogger";
import { generalStorage } from "./storage";
// Use the shared general storage adapter for cache persistence
@@ -33,7 +34,7 @@ export const createQueryPersister = (): Persister => {
const serialized = JSON.stringify(client);
await storage.setItem(storageKey, serialized);
} catch (error) {
- console.warn("[QueryPersister] Failed to persist client:", error);
+ safeLogWarn("[QueryPersister] Failed to persist client:", error);
// Fail silently - persistence is a nice-to-have, not critical
}
},
@@ -52,7 +53,7 @@ export const createQueryPersister = (): Persister => {
// Validate timestamp exists and is a valid number
if (typeof client.timestamp !== "number" || Number.isNaN(client.timestamp)) {
- console.warn("[QueryPersister] Invalid or missing timestamp, discarding cache");
+ safeLogWarn("[QueryPersister] Invalid or missing timestamp, discarding cache");
await storage.removeItem(storageKey);
return undefined;
}
@@ -68,7 +69,7 @@ export const createQueryPersister = (): Persister => {
return client;
} catch (error) {
- console.warn("[QueryPersister] Failed to restore client:", error);
+ safeLogWarn("[QueryPersister] Failed to restore client:", error);
// If restoration fails, start fresh
return undefined;
}
@@ -81,7 +82,7 @@ export const createQueryPersister = (): Persister => {
try {
await storage.removeItem(storageKey);
} catch (error) {
- console.warn("[QueryPersister] Failed to remove client:", error);
+ safeLogWarn("[QueryPersister] Failed to remove client:", error);
}
},
};
@@ -100,7 +101,7 @@ export const clearQueryCache = async (): Promise => {
try {
await storage.removeItem(CACHE_CONFIG.persistence.storageKey);
} catch (error) {
- console.warn("[QueryPersister] Failed to clear cache:", error);
+ safeLogWarn("[QueryPersister] Failed to clear cache:", error);
}
};
@@ -136,7 +137,7 @@ export const getCacheMetadata = async (): Promise<{
isExpired: age > CACHE_CONFIG.persistence.maxAge,
};
} catch (error) {
- console.warn("[QueryPersister] Failed to get cache metadata:", error);
+ safeLogWarn("[QueryPersister] Failed to get cache metadata:", error);
return null;
}
};
diff --git a/companion/utils/safeLogger.ts b/companion/utils/safeLogger.ts
new file mode 100644
index 0000000000..113fffd158
--- /dev/null
+++ b/companion/utils/safeLogger.ts
@@ -0,0 +1,72 @@
+/**
+ * Safe Logger Utility
+ *
+ * Provides secure logging functions that only output in development mode.
+ * This prevents sensitive data from being exposed in production logs.
+ *
+ * Security: All error objects, stack traces, and potentially sensitive data
+ * should be logged using these functions to ensure they are not exposed in production.
+ */
+
+/**
+ * Safely log an error message only in development mode.
+ * Use this instead of console.error when logging error objects that may contain sensitive data.
+ *
+ * @param message - The error message prefix
+ * @param error - Optional error object (will only be logged in __DEV__ mode)
+ */
+export const safeLogError = (message: string, error?: unknown): void => {
+ if (__DEV__) {
+ if (error !== undefined) {
+ console.error(message, error);
+ } else {
+ console.error(message);
+ }
+ }
+};
+
+/**
+ * Safely log a warning message only in development mode.
+ * Use this instead of console.warn when logging potentially sensitive data.
+ *
+ * @param message - The warning message prefix
+ * @param data - Optional additional data (will only be logged in __DEV__ mode)
+ */
+export const safeLogWarn = (message: string, data?: unknown): void => {
+ if (__DEV__) {
+ if (data !== undefined) {
+ console.warn(message, data);
+ } else {
+ console.warn(message);
+ }
+ }
+};
+
+/**
+ * Safely log an info message only in development mode.
+ * Use this instead of console.log when logging potentially sensitive data.
+ *
+ * @param message - The info message prefix
+ * @param data - Optional additional data (will only be logged in __DEV__ mode)
+ */
+export const safeLogInfo = (message: string, data?: unknown): void => {
+ if (__DEV__) {
+ if (data !== undefined) {
+ console.log(message, data);
+ } else {
+ console.log(message);
+ }
+ }
+};
+
+/**
+ * Get a safe error message for displaying to users.
+ * Returns a generic message instead of exposing raw error details.
+ *
+ * @param error - The error object
+ * @param fallbackMessage - The generic message to show to users
+ * @returns The fallback message (never exposes raw error details to users)
+ */
+export const getSafeErrorMessage = (_error: unknown, fallbackMessage: string): string => {
+ return fallbackMessage;
+};