fix: fix audio permission issue on safari (#26)

* fix: fix audio permission issue on safari

* fix: fix service key creation in oss mode

* fix: fix hydration error for usage page
This commit is contained in:
Abhishek 2025-10-07 16:44:45 +05:30 committed by GitHub
parent a2d02d8326
commit e9c0afd517
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 90 additions and 71 deletions

View file

@ -22,6 +22,7 @@ import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { isOSSMode } from '@/lib/utils';
export default function APIKeysPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
@ -310,6 +311,11 @@ export default function APIKeysPage() {
);
}
// In OSS mode, check if there's already an active service key
const activeServiceKeys = serviceKeys.filter(key => !key.archived_at);
const canCreateServiceKey = !isOSSMode() || activeServiceKeys.length === 0;
const showServiceKeyArchiveControls = !isOSSMode();
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
@ -444,21 +450,25 @@ export default function APIKeysPage() {
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowServiceArchived(!showServiceArchived)}
>
{showServiceArchived ? <Eye className="w-4 h-4 mr-2" /> : <EyeOff className="w-4 h-4 mr-2" />}
{showServiceArchived ? 'Hide' : 'Show'} Archived
</Button>
<Button
onClick={() => setIsCreateServiceDialogOpen(true)}
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Create Service Key
</Button>
{showServiceKeyArchiveControls && (
<Button
variant="outline"
size="sm"
onClick={() => setShowServiceArchived(!showServiceArchived)}
>
{showServiceArchived ? <Eye className="w-4 h-4 mr-2" /> : <EyeOff className="w-4 h-4 mr-2" />}
{showServiceArchived ? 'Hide' : 'Show'} Archived
</Button>
)}
{canCreateServiceKey && (
<Button
onClick={() => setIsCreateServiceDialogOpen(true)}
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Create Service Key
</Button>
)}
</div>
</div>
</CardHeader>
@ -479,9 +489,11 @@ export default function APIKeysPage() {
<div className="text-center py-12">
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No service keys found</p>
<Button onClick={() => setIsCreateServiceDialogOpen(true)}>
Create Your First Service Key
</Button>
{canCreateServiceKey && (
<Button onClick={() => setIsCreateServiceDialogOpen(true)}>
Create Your First Service Key
</Button>
)}
</div>
) : (
<div className="space-y-4">
@ -520,7 +532,7 @@ export default function APIKeysPage() {
</div>
</div>
<div className="flex gap-2">
{!key.archived_at && (
{!key.archived_at && showServiceKeyArchiveControls && (
<Button
variant="ghost"
size="sm"

View file

@ -2,7 +2,7 @@
import { Calendar, ChevronLeft, ChevronRight, Globe } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
import { getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet,getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
@ -61,18 +61,11 @@ export default function UsagePage() {
// Media preview dialog
const mediaPreview = MediaPreviewDialog({ accessToken });
// Timezone state - wait for userConfig to load before setting default
// Timezone state - initialize with empty string to avoid hydration mismatch
const localTimezone = getLocalTimezone();
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>(() => {
// Only use local timezone if we know for sure there's no saved timezone
// (i.e., userConfig has loaded and has no timezone)
if (!userConfigLoading && !userConfig?.timezone) {
return localTimezone;
}
// Otherwise return the saved timezone or empty string while loading
return userConfig?.timezone || '';
});
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>('');
const [savingTimezone, setSavingTimezone] = useState(false);
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
// Fetch current usage
const fetchCurrentUsage = useCallback(async () => {
@ -328,6 +321,7 @@ export default function UsagePage() {
<Globe className="h-4 w-4 text-gray-500" />
<div className="w-[300px]">
<TimezoneSelect
instanceId={timezoneSelectId}
value={selectedTimezone}
onChange={handleTimezoneChange}
isDisabled={savingTimezone || userConfigLoading}
@ -408,14 +402,6 @@ export default function UsagePage() {
Total Duration: <span className="font-medium text-gray-900">{formatDuration(currentUsage.total_duration_seconds)}</span>
</div>
</div>
{!currentUsage.quota_enabled && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<p className="text-sm text-yellow-800">
Quota enforcement is not enabled for your organization.
</p>
</div>
)}
</div>
) : (
<p className="text-gray-500">Unable to load usage data</p>

View file

@ -39,7 +39,8 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
connectionStatus,
start,
stop,
isStarting
isStarting,
getAudioInputDevices
} = useWebSocketRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
// Poll for recording availability after call ends
@ -118,6 +119,7 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
start={start}
stop={stop}
isStarting={isStarting}
getAudioInputDevices={getAudioInputDevices}
/>
<ConnectionStatus

View file

@ -1,4 +1,5 @@
import { Mic, Phone, PhoneOff } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
@ -12,6 +13,7 @@ interface AudioControlsProps {
start: () => Promise<void>;
stop: () => void;
isStarting: boolean;
getAudioInputDevices: () => Promise<void>;
}
export const AudioControls = ({
@ -23,28 +25,35 @@ export const AudioControls = ({
permissionError,
start,
stop,
isStarting
isStarting,
getAudioInputDevices
}: AudioControlsProps) => {
// Check if we have valid audio devices (permissions granted)
const hasValidDevices = audioInputs.length > 0 && audioInputs.some(device => device.deviceId && device.deviceId.trim() !== '');
// Browsers only provide device labels after permission is granted
const hasValidDevices = audioInputs.length > 0 && audioInputs.some(device => device.label && device.label.trim() !== '');
const requestAudioPermissions = async () => {
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
// This will trigger the parent component to refresh the device list
window.location.reload();
// Request audio permissions - this triggers the browser permission prompt
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Stop the stream immediately - we just needed to trigger the permission prompt
stream.getTracks().forEach(track => track.stop());
// Refresh the device list now that we have permissions
await getAudioInputDevices();
} catch (error) {
console.error('Failed to request audio permissions:', error);
}
};
// Handle auto-selection of first device if none selected
if (hasValidDevices && !selectedAudioInput) {
const firstValidDevice = audioInputs.find(device => device.deviceId && device.deviceId.trim() !== '');
if (firstValidDevice) {
setSelectedAudioInput(firstValidDevice.deviceId);
useEffect(() => {
if (hasValidDevices && !selectedAudioInput) {
const firstValidDevice = audioInputs.find(device => device.label && device.label.trim() !== '');
if (firstValidDevice) {
setSelectedAudioInput(firstValidDevice.deviceId);
}
}
}
}, [hasValidDevices, selectedAudioInput, audioInputs, setSelectedAudioInput]);
if (isCompleted) {
return null; // The parent component will handle showing the loading state

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import logger from '@/lib/logger';
@ -7,30 +7,32 @@ export const useDeviceInputs = () => {
const [selectedAudioInput, setSelectedAudioInput] = useState('');
const [permissionError, setPermissionError] = useState<string | null>(null);
useEffect(() => {
const getAudioInputs = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioDevices = devices.filter(device => device.kind === 'audioinput');
setAudioInputs(audioDevices);
const getAudioInputDevices = useCallback(async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioDevices = devices.filter(device => device.kind === 'audioinput');
setAudioInputs(audioDevices);
const defaultAudioInput = audioDevices.find(device => device.deviceId === 'default');
if (defaultAudioInput) {
setSelectedAudioInput(defaultAudioInput.deviceId);
}
} catch (error) {
setPermissionError('Could not enumerate devices');
logger.error(`Error enumerating devices: ${error}`);
const defaultAudioInput = audioDevices.find(device => device.deviceId === 'default');
if (defaultAudioInput) {
setSelectedAudioInput(defaultAudioInput.deviceId);
}
};
getAudioInputs();
} catch (error) {
setPermissionError('Could not enumerate devices');
logger.error(`Error enumerating devices: ${error}`);
}
}, []);
useEffect(() => {
getAudioInputDevices();
}, [getAudioInputDevices]);
return {
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
permissionError,
setPermissionError
setPermissionError,
getAudioInputDevices
};
};

View file

@ -31,7 +31,8 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
selectedAudioInput,
setSelectedAudioInput,
permissionError,
setPermissionError
setPermissionError,
getAudioInputDevices
} = useDeviceInputs();
const useStun = true;
@ -435,6 +436,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
start,
stop,
isStarting,
initialContext
initialContext,
getAudioInputDevices
};
};

View file

@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { isOSSMode } from "@/lib/utils";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
@ -170,8 +171,6 @@ const StartCallEditForm = ({
delayedStartDuration,
setDelayedStartDuration
}: StartCallEditFormProps) => {
const isOSS = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
return (
<div className="grid gap-2">
<Label>Name</Label>
@ -241,7 +240,7 @@ const StartCallEditForm = ({
</Label>
</div>
</div>
{!isOSS && (
{!isOSSMode() && (
<div className="flex items-center space-x-2">
<Switch
id="detect-voicemail"

View file

@ -98,6 +98,13 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[
}
}
/**
* Check if the application is running in OSS (Open Source Software) mode
*/
export function isOSSMode(): boolean {
return process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
}
/**
* --------------------------------------------------------------------------
* Cookie helpers