mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
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:
parent
a2d02d8326
commit
e9c0afd517
8 changed files with 90 additions and 71 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue