new use api handler which does autologin when session is revoked
All checks were successful
Deploy to Server / deploy (push) Successful in 2m6s

This commit is contained in:
rocord01 2025-09-28 21:48:01 -04:00
parent c9ecd95459
commit fcfccf084a
10 changed files with 153 additions and 92 deletions

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useApi } from '@/hooks/use-api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -16,6 +17,7 @@ export default function ConferenceDetails({ conferenceId }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { token, isLoggedIn } = useAuth(); const { token, isLoggedIn } = useAuth();
const apiFetch = useApi();
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [currentUserExtension, setCurrentUserExtension] = useState(null); const [currentUserExtension, setCurrentUserExtension] = useState(null);
@ -23,20 +25,20 @@ export default function ConferenceDetails({ conferenceId }) {
if (isLoggedIn && token) { if (isLoggedIn && token) {
const fetchUserDetails = async () => { const fetchUserDetails = async () => {
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/me`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/me`);
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setCurrentUserExtension(data.extensionId); setCurrentUserExtension(data.extensionId);
} }
} catch (err) { } catch (err) {
if (err.message !== 'Unauthorized') {
console.error("Failed to fetch user details", err); console.error("Failed to fetch user details", err);
} }
}
}; };
fetchUserDetails(); fetchUserDetails();
} }
}, [isLoggedIn, token]); }, [isLoggedIn, token, apiFetch]);
const fetchDetails = useCallback(async () => { const fetchDetails = useCallback(async () => {
setLoading(true); setLoading(true);
@ -44,8 +46,8 @@ export default function ConferenceDetails({ conferenceId }) {
try { try {
// Fetch both participants and conference room info // Fetch both participants and conference room info
const [participantsRes, roomsRes] = await Promise.all([ const [participantsRes, roomsRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences/${conferenceId}`), apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences/${conferenceId}`),
fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences`) apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences`)
]); ]);
if (!participantsRes.ok) { if (!participantsRes.ok) {
@ -66,12 +68,14 @@ export default function ConferenceDetails({ conferenceId }) {
} }
} catch (err) { } catch (err) {
if (err.message !== 'Unauthorized') {
console.error(`Failed to fetch details for ${conferenceId}`, err); console.error(`Failed to fetch details for ${conferenceId}`, err);
}
setError(err.message); setError(err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [conferenceId]); }, [conferenceId, apiFetch]);
useEffect(() => { useEffect(() => {
fetchDetails(); fetchDetails();
@ -83,9 +87,8 @@ export default function ConferenceDetails({ conferenceId }) {
if (!token) return; if (!token) return;
setIsConnecting(true); setIsConnecting(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences/${conferenceId}/connectme`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences/${conferenceId}/connectme`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!res.ok) { if (!res.ok) {
const errorData = await res.json(); const errorData = await res.json();
@ -93,8 +96,10 @@ export default function ConferenceDetails({ conferenceId }) {
} }
// Optionally show a success message // Optionally show a success message
} catch (err) { } catch (err) {
if (err.message !== 'Unauthorized') {
console.error("Failed to connect", err); console.error("Failed to connect", err);
alert(err.message); alert(err.message);
}
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useApi } from '@/hooks/use-api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -12,6 +13,7 @@ export default function ConferenceList() {
const [conferences, setConferences] = useState([]); const [conferences, setConferences] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const apiFetch = useApi();
const fetchConferences = useCallback(async () => { const fetchConferences = useCallback(async () => {
setLoading(true); setLoading(true);
@ -19,7 +21,7 @@ export default function ConferenceList() {
try { try {
// Acting as an unauthenticated user, so no auth token is sent. // Acting as an unauthenticated user, so no auth token is sent.
// This assumes the endpoint is public. // This assumes the endpoint is public.
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences`); const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/conferences`);
if (!res.ok) { if (!res.ok) {
const errorData = await res.json(); const errorData = await res.json();
if (res.status === 401) { if (res.status === 401) {
@ -30,12 +32,14 @@ export default function ConferenceList() {
const data = await res.json(); const data = await res.json();
setConferences(Array.isArray(data) ? data : []); setConferences(Array.isArray(data) ? data : []);
} catch (err) { } catch (err) {
if (err.message !== 'Unauthorized') {
console.error("Failed to fetch conferences", err); console.error("Failed to fetch conferences", err);
}
setError(err.message); setError(err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [apiFetch]);
useEffect(() => { useEffect(() => {
fetchConferences(); fetchConferences();

View file

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useApi } from '@/hooks/use-api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@ -38,6 +39,7 @@ function CredentialRow({ icon, label, value, onCopy, isCopied }) {
export function SipCredentialsCard({ details, loading, refetchDetails }) { export function SipCredentialsCard({ details, loading, refetchDetails }) {
const { token } = useAuth(); const { token } = useAuth();
const apiFetch = useApi();
const [newSecret, setNewSecret] = useState(null); const [newSecret, setNewSecret] = useState(null);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const [copiedState, setCopiedState] = useState({}); const [copiedState, setCopiedState] = useState({});
@ -46,9 +48,8 @@ export function SipCredentialsCard({ details, loading, refetchDetails }) {
if (!token) return; if (!token) return;
setIsResetting(true); setIsResetting(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/resetsecret`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/resetsecret`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
}); });
const data = await res.json(); const data = await res.json();
setNewSecret(data.newSecret); setNewSecret(data.newSecret);
@ -56,8 +57,10 @@ export function SipCredentialsCard({ details, loading, refetchDetails }) {
refetchDetails(); refetchDetails();
} }
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to reset secret", error); console.error("Failed to reset secret", error);
alert('Failed to reset secret.'); alert('Failed to reset secret.');
}
} finally { } finally {
setIsResetting(false); setIsResetting(false);
} }

View file

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useApi } from '@/hooks/use-api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
@ -23,6 +24,7 @@ import {
export function ActionsCard({ details }) { export function ActionsCard({ details }) {
const { token } = useAuth(); const { token } = useAuth();
const apiFetch = useApi();
const [isCalling, setIsCalling] = useState(false); const [isCalling, setIsCalling] = useState(false);
const [callMode, setCallMode] = useState('hold'); const [callMode, setCallMode] = useState('hold');
const [callerId, setCallerId] = useState(''); const [callerId, setCallerId] = useState('');
@ -38,14 +40,15 @@ export function ActionsCard({ details }) {
if (callerId) { if (callerId) {
url += `&callerId=${encodeURIComponent(callerId)}`; url += `&callerId=${encodeURIComponent(callerId)}`;
} }
await fetch(url, { await apiFetch(url, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
}); });
setIsCallMeDialogOpen(false); setIsCallMeDialogOpen(false);
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to initiate call", error); console.error("Failed to initiate call", error);
alert('Failed to initiate call.'); alert('Failed to initiate call.');
}
} finally { } finally {
setIsCalling(false); setIsCalling(false);
} }
@ -55,15 +58,16 @@ export function ActionsCard({ details }) {
if (!token) return; if (!token) return;
setIsResetting(true); setIsResetting(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/resetsecret`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/resetsecret`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
}); });
const data = await res.json(); const data = await res.json();
setNewSecret(data.newSecret); setNewSecret(data.newSecret);
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to reset secret", error); console.error("Failed to reset secret", error);
alert('Failed to reset secret.'); alert('Failed to reset secret.');
}
} finally { } finally {
setIsResetting(false); setIsResetting(false);
} }

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useApi } from '@/hooks/use-api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@ -34,6 +35,7 @@ function CallCard({ call }) {
export function ActiveCalls() { export function ActiveCalls() {
const { token } = useAuth(); const { token } = useAuth();
const apiFetch = useApi();
const [calls, setCalls] = useState([]); const [calls, setCalls] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true); const [isInitialLoad, setIsInitialLoad] = useState(true);
@ -44,13 +46,13 @@ export function ActiveCalls() {
setLoading(true); setLoading(true);
} }
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/calls`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/calls`);
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json(); const data = await res.json();
setCalls(Array.isArray(data) ? data : []); setCalls(Array.isArray(data) ? data : []);
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to fetch active calls", error); console.error("Failed to fetch active calls", error);
}
setCalls([]); setCalls([]);
} finally { } finally {
if (isInitialLoad || isManualRefresh) { if (isInitialLoad || isManualRefresh) {
@ -60,7 +62,7 @@ export function ActiveCalls() {
setIsInitialLoad(false); setIsInitialLoad(false);
} }
} }
}, [token, isInitialLoad]); }, [token, isInitialLoad, apiFetch]);
useEffect(() => { useEffect(() => {
if (token) { if (token) {
@ -75,14 +77,15 @@ export function ActiveCalls() {
const handleHangup = async () => { const handleHangup = async () => {
if (!token) return; if (!token) return;
try { try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/calls`, { await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/calls`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
}); });
fetchCalls(true); // Refresh immediately and show loading state fetchCalls(true); // Refresh immediately and show loading state
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to hangup calls", error); console.error("Failed to hangup calls", error);
} }
}
}; };
return ( return (

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useApi } from '@/hooks/use-api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -25,6 +26,7 @@ import { Badge } from "@/components/ui/badge"
export default function ApiKeys() { export default function ApiKeys() {
const { token } = useAuth(); const { token } = useAuth();
const apiFetch = useApi();
const [keys, setKeys] = useState([]); const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [newKey, setNewKey] = useState(null); const [newKey, setNewKey] = useState(null);
@ -40,18 +42,18 @@ export default function ApiKeys() {
if (!token) return; if (!token) return;
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys`);
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json(); const data = await res.json();
setKeys(Array.isArray(data) ? data : []); setKeys(Array.isArray(data) ? data : []);
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to fetch API keys", error); console.error("Failed to fetch API keys", error);
}
setKeys([]); setKeys([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, [token, apiFetch]);
useEffect(() => { useEffect(() => {
fetchKeys(); fetchKeys();
@ -61,10 +63,9 @@ export default function ApiKeys() {
if (!token) return; if (!token) return;
setIsCreating(true); setIsCreating(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ expiresInDays }) body: JSON.stringify({ expiresInDays })
@ -87,8 +88,10 @@ export default function ApiKeys() {
setKeys(prevKeys => [keyForState, ...prevKeys]); setKeys(prevKeys => [keyForState, ...prevKeys]);
setIsCreateDialogOpen(false); // Close the creation dialog setIsCreateDialogOpen(false); // Close the creation dialog
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to create key", error); console.error("Failed to create key", error);
alert(`Failed to create key: ${error.message}`); alert(`Failed to create key: ${error.message}`);
}
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@ -97,9 +100,8 @@ export default function ApiKeys() {
const handleDeleteKey = async (keyToDelete) => { const handleDeleteKey = async (keyToDelete) => {
if (!token) return; if (!token) return;
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys/${keyToDelete}`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/keys/${keyToDelete}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!res.ok) { if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Failed to delete key.' })); const errorData = await res.json().catch(() => ({ error: 'Failed to delete key.' }));
@ -107,9 +109,11 @@ export default function ApiKeys() {
} }
setKeys(prevKeys => prevKeys.filter(key => key.key !== keyToDelete)); setKeys(prevKeys => prevKeys.filter(key => key.key !== keyToDelete));
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to delete key", error); console.error("Failed to delete key", error);
alert(`Failed to delete key: ${error.message}`); alert(`Failed to delete key: ${error.message}`);
} }
}
}; };
const copyToClipboard = (text) => { const copyToClipboard = (text) => {

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useApi } from '@/hooks/use-api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import ExtensionDetails from './extension-details'; import ExtensionDetails from './extension-details';
@ -43,6 +44,7 @@ export default function DashboardClient() {
const [detailsLoading, setDetailsLoading] = useState(true); const [detailsLoading, setDetailsLoading] = useState(true);
const [deviceStatus, setDeviceStatus] = useState(null); const [deviceStatus, setDeviceStatus] = useState(null);
const [deviceStatusLoading, setDeviceStatusLoading] = useState(true); const [deviceStatusLoading, setDeviceStatusLoading] = useState(true);
const apiFetch = useApi();
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@ -53,9 +55,7 @@ export default function DashboardClient() {
const fetchDetails = async () => { const fetchDetails = async () => {
setDetailsLoading(true); setDetailsLoading(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me`);
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log("Fetched details:", data); console.log("Fetched details:", data);
@ -64,7 +64,9 @@ export default function DashboardClient() {
setDetails(null); setDetails(null);
} }
} catch (err) { } catch (err) {
if (err.message !== 'Unauthorized') {
console.error("Failed to fetch details", err); console.error("Failed to fetch details", err);
}
setDetails(null); setDetails(null);
} finally { } finally {
setDetailsLoading(false); setDetailsLoading(false);
@ -72,7 +74,7 @@ export default function DashboardClient() {
}; };
fetchDetails(); fetchDetails();
}, [token]); }, [token, apiFetch]);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@ -83,9 +85,7 @@ export default function DashboardClient() {
const fetchDeviceStatus = async () => { const fetchDeviceStatus = async () => {
// No need to set loading to true every time for a silent refresh // No need to set loading to true every time for a silent refresh
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/devicestatus`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/devicestatus`);
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setDeviceStatus(data); setDeviceStatus(data);
@ -93,7 +93,9 @@ export default function DashboardClient() {
setDeviceStatus(null); setDeviceStatus(null);
} }
} catch (err) { } catch (err) {
if (err.message !== 'Unauthorized') {
console.error("Failed to fetch device status", err); console.error("Failed to fetch device status", err);
}
setDeviceStatus(null); setDeviceStatus(null);
} finally { } finally {
if (deviceStatusLoading) setDeviceStatusLoading(false); if (deviceStatusLoading) setDeviceStatusLoading(false);
@ -103,7 +105,7 @@ export default function DashboardClient() {
fetchDeviceStatus(); fetchDeviceStatus();
const interval = setInterval(fetchDeviceStatus, 5000); // Poll every 5 seconds const interval = setInterval(fetchDeviceStatus, 5000); // Poll every 5 seconds
return () => clearInterval(interval); return () => clearInterval(interval);
}, [token, deviceStatusLoading]); }, [token, deviceStatusLoading, apiFetch]);
const handleLogin = () => { const handleLogin = () => {
window.location.href = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/auth/discord`; window.location.href = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/auth/discord`;

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useApi } from '@/hooks/use-api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@ -68,6 +69,7 @@ export function RegisteredDevices() {
const [devices, setDevices] = useState([]); const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true); const [isInitialLoad, setIsInitialLoad] = useState(true);
const apiFetch = useApi();
const fetchDevices = useCallback(async (isManualRefresh = false) => { const fetchDevices = useCallback(async (isManualRefresh = false) => {
if (!token) return; if (!token) return;
@ -75,9 +77,7 @@ export function RegisteredDevices() {
setLoading(true); setLoading(true);
} }
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/endpoint`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/extensions/me/endpoint`);
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setDevices(Array.isArray(data) ? data : []); setDevices(Array.isArray(data) ? data : []);
@ -85,7 +85,9 @@ export function RegisteredDevices() {
setDevices([]); setDevices([]);
} }
} catch (error) { } catch (error) {
if (error.message !== 'Unauthorized') {
console.error("Failed to fetch registered devices", error); console.error("Failed to fetch registered devices", error);
}
setDevices([]); setDevices([]);
} finally { } finally {
if (isInitialLoad || isManualRefresh) { if (isInitialLoad || isManualRefresh) {
@ -95,7 +97,7 @@ export function RegisteredDevices() {
setIsInitialLoad(false); setIsInitialLoad(false);
} }
} }
}, [token, isInitialLoad]); }, [token, isInitialLoad, apiFetch]);
useEffect(() => { useEffect(() => {
if (token) { if (token) {

View file

@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useMemo } from "react"; import { useEffect, useState, useCallback, useMemo } from "react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useApi } from "@/hooks/use-api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -48,6 +49,7 @@ function formatVoicemailDate(dateStr) {
export function VoicemailPanel({ extensionId: extProp }) { export function VoicemailPanel({ extensionId: extProp }) {
const { token } = useAuth(); const { token } = useAuth();
const apiFetch = useApi();
const [extensionId, setExtensionId] = useState(extProp || null); const [extensionId, setExtensionId] = useState(extProp || null);
const [loadingExt, setLoadingExt] = useState(!extProp); const [loadingExt, setLoadingExt] = useState(!extProp);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -65,30 +67,28 @@ export function VoicemailPanel({ extensionId: extProp }) {
const load = async () => { const load = async () => {
setLoadingExt(true); setLoadingExt(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/me`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/me`);
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setExtensionId(data.extensionId); setExtensionId(data.extensionId);
} }
} catch (e) { } catch (e) {
if (e.message !== 'Unauthorized') {
// silent // silent
}
} finally { } finally {
setLoadingExt(false); setLoadingExt(false);
} }
}; };
load(); load();
}, [token, extProp]); }, [token, extProp, apiFetch]);
const fetchVoicemails = useCallback(async () => { const fetchVoicemails = useCallback(async () => {
if (!token || !extensionId) return; if (!token || !extensionId) return;
setRefreshing(true); setRefreshing(true);
if (voicemails.length === 0) setLoading(true); if (voicemails.length === 0) setLoading(true);
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails`);
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setVoicemails(Array.isArray(data) ? data : []); setVoicemails(Array.isArray(data) ? data : []);
@ -96,12 +96,14 @@ export function VoicemailPanel({ extensionId: extProp }) {
setVoicemails([]); setVoicemails([]);
} }
} catch (e) { } catch (e) {
if (e.message !== 'Unauthorized') {
setVoicemails([]); setVoicemails([]);
}
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}, [token, extensionId, voicemails.length]); }, [token, extensionId, voicemails.length, apiFetch]);
useEffect(() => { useEffect(() => {
if (extensionId) fetchVoicemails(); if (extensionId) fetchVoicemails();
@ -128,27 +130,26 @@ export function VoicemailPanel({ extensionId: extProp }) {
if (isOpening && vm.hasAudio && !audioMap[vm.messageId]?.url) { if (isOpening && vm.hasAudio && !audioMap[vm.messageId]?.url) {
setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: true } })); setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: true } }));
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`);
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) throw new Error('Download failed'); if (!res.ok) throw new Error('Download failed');
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
setAudioMap(m => ({ ...m, [vm.messageId]: { url, loading: false } })); setAudioMap(m => ({ ...m, [vm.messageId]: { url, loading: false } }));
} catch { } catch(e) {
if (e.message !== 'Unauthorized') {
setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: false, error: true } })); setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: false, error: true } }));
} }
} }
}
}; };
const moveVoicemail = async (vm, target) => { const moveVoicemail = async (vm, target) => {
if (target === vm.folder) return; if (target === vm.folder) return;
setMovingId(vm.messageId); setMovingId(vm.messageId);
try { try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/move`, { await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/move`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ targetFolder: target }) body: JSON.stringify({ targetFolder: target })
@ -157,8 +158,10 @@ export function VoicemailPanel({ extensionId: extProp }) {
if (target !== activeFolder) { if (target !== activeFolder) {
setExpandedId(null); setExpandedId(null);
} }
} catch { } catch(e) {
if (e.message !== 'Unauthorized') {
// silent fail // silent fail
}
} finally { } finally {
setMovingId(null); setMovingId(null);
} }
@ -167,9 +170,7 @@ export function VoicemailPanel({ extensionId: extProp }) {
const downloadVoicemail = async (vm) => { const downloadVoicemail = async (vm) => {
if (!vm.hasAudio) return; if (!vm.hasAudio) return;
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`);
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) return; if (!res.ok) return;
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -180,24 +181,27 @@ export function VoicemailPanel({ extensionId: extProp }) {
a.click(); a.click();
a.remove(); a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch { } catch(e) {
if (e.message !== 'Unauthorized') {
// ignore // ignore
} }
}
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!vmToDelete || !token || !extensionId) return; if (!vmToDelete || !token || !extensionId) return;
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vmToDelete.messageId}`, { const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vmToDelete.messageId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}); });
if (res.ok) { if (res.ok) {
setVoicemails(list => list.filter(v => v.messageId !== vmToDelete.messageId)); setVoicemails(list => list.filter(v => v.messageId !== vmToDelete.messageId));
if (expandedId === vmToDelete.messageId) setExpandedId(null); if (expandedId === vmToDelete.messageId) setExpandedId(null);
} }
} catch { } catch(e) {
if (e.message !== 'Unauthorized') {
// silent // silent
}
} finally { } finally {
setVmToDelete(null); setVmToDelete(null);
} }

30
src/hooks/use-api.js Normal file
View file

@ -0,0 +1,30 @@
"use client";
import { useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
export const useApi = () => {
const { token, logout } = useAuth();
const apiFetch = useCallback(async (url, options = {}) => {
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
await logout();
throw new Error('Unauthorized');
}
return response;
}, [token, logout]);
return apiFetch;
};