Working UI with auth

This commit is contained in:
Logan Cusano
2025-05-26 02:43:35 -04:00
parent 7e55c120b1
commit f1d8012798
7 changed files with 426 additions and 188 deletions

View File

@@ -1,7 +1,8 @@
// components/AppContent.tsx
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/context/AuthContext';
import { useAuth } from '@/context/AuthContext'; // Correct path to useAuth
import LoginPage from '@/components/LoginPage';
import BotsManagement from '@/components/BotsManagement';
import SystemsManagement from '@/components/SystemsManagement';
@@ -9,57 +10,68 @@ import { UserRoles } from '@/types';
const AppContent: React.FC = () => {
const { user, loading, logout, hasPermission, token } = useAuth();
const [currentPage, setCurrentPage] = useState<'login' | 'management'>(loading ? 'login' : (user ? 'management' : 'login'));
const [activeManagementTab, setActiveManagementTab] = useState<'bots' | 'systems'>('bots');
useEffect(() => {
if (!loading) {
if (user && hasPermission(UserRoles.MOD)) {
setCurrentPage('management');
} else {
setCurrentPage('login');
}
}
}, [user, loading, hasPermission]);
// Function to handle logout and redirect to login page
const handleLogoutAndRedirect = () => {
logout();
// No need to set currentPage, as the AppContent will re-render based on useAuth().user becoming null
};
// Display a loading indicator while AuthContext is determining authentication status
if (loading) {
return <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">Loading Authentication...</div>;
}
// Once loading is false, if no user is authenticated, display the LoginPage
if (!user) {
return <LoginPage />;
}
// If a user is authenticated but lacks the required permission, display an access denied message
if (user && !hasPermission(UserRoles.MOD)) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans p-6">
<h2 className="text-xl font-bold text-red-500 mb-4">Access Denied</h2>
<p className="text-lg text-center mb-6">
You do not have sufficient permissions to view this page. Your role: {user.role}. Required: {UserRoles.MOD}.
</p>
<Button onClick={handleLogoutAndRedirect} className="mt-4">Logout</Button>
</div>
);
}
// If loading is false, and we have a user with MOD permission and a token, render the main app content
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans">
<header className="flex justify-between items-center p-4 bg-white dark:bg-gray-800 shadow-md">
<h1 className="text-xl font-bold">Radio App Admin</h1>
{user && (
<div className="flex items-center space-x-4">
<span className="text-sm">Logged in as: {user.username} ({user.role})</span>
<Button onClick={() => {
logout();
setCurrentPage('login');
}} variant="outline">Logout</Button>
</div>
)}
<div className="flex items-center space-x-4">
<span className="text-sm">Logged in as: {user.username} ({user.role})</span>
<Button onClick={handleLogoutAndRedirect} variant="outline">Logout</Button>
</div>
</header>
<main className="p-6">
{currentPage === 'login' && <LoginPage />}
{currentPage === 'management' && user && token && hasPermission(UserRoles.MOD) && (
<div className="space-y-8">
<BotsManagement token={token} />
<SystemsManagement token={token} />
</div>
)}
{currentPage === 'management' && user && !hasPermission(UserRoles.MOD) && (
<div className="text-center text-red-500 text-lg">
You do not have permission to view this page. Your role: {user.role}. Required: {UserRoles.MOD}.
<Button onClick={() => setCurrentPage('login')} className="mt-4">Go to Login</Button>
</div>
)}
{currentPage === 'management' && !user && (
<div className="text-center text-orange-500 text-lg">
Session might have expired. Please login again.
<Button onClick={() => setCurrentPage('login')} className="mt-4">Go to Login</Button>
</div>
)}
<div className="mb-4">
<Button
variant={activeManagementTab === 'bots' ? 'default' : 'outline'}
onClick={() => setActiveManagementTab('bots')}
className="mr-2"
>
Bots Management
</Button>
<Button
variant={activeManagementTab === 'systems' ? 'default' : 'outline'}
onClick={() => setActiveManagementTab('systems')}
>
Systems Management
</Button>
</div>
{activeManagementTab === 'bots' && <BotsManagement token={token} logoutUser={handleLogoutAndRedirect} />}
{activeManagementTab === 'systems' && <SystemsManagement token={token} logoutUser={handleLogoutAndRedirect} />}
</main>
</div>
);

View File

@@ -1,3 +1,4 @@
// BotsManagement.tsx
"use client";
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
@@ -10,17 +11,19 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { API_BASE_URL } from '@/constants/api';
import { DiscordId, System, ErrorResponse } from '@/types'; // Import types
import { DiscordId, System, ErrorResponse } from '@/types';
import { authAwareFetch } from '@/utils/AuthAwareFetch';
interface BotsManagementProps {
token: string;
token: string | null; // Allow token to be null initially
logoutUser: () => void;
}
const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) => {
const [bots, setBots] = useState<string[]>([]);
const [discordIds, setDiscordIds] = useState<DiscordId[]>([]);
const [systems, setSystems] = useState<System[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(true); // Set to true initially
const [error, setError] = useState<string>('');
const [isAddIdDialogOpen, setIsAddIdDialogOpen] = useState<boolean>(false);
@@ -39,15 +42,22 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
const [assignDismissAction, setAssignDismissAction] = useState<'assign' | 'dismiss'>('assign');
const fetchData = async (): Promise<void> => {
// Only attempt to fetch if a token is present
if (!token) {
setLoading(false); // Stop loading if no token
setError('Authentication token is missing. Please log in.');
return;
}
setLoading(true);
setError('');
let accumulatedError = '';
try {
const [nodesRes, discordIdsRes, systemsRes] = await Promise.all([
fetch(`${API_BASE_URL}/nodes/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API_BASE_URL}/bots/tokens/`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${API_BASE_URL}/systems/`, { headers: { Authorization: `Bearer ${token}` } }),
authAwareFetch('/nodes/', { token }, logoutUser),
authAwareFetch('/bots/tokens/', { token }, logoutUser),
authAwareFetch('/systems/', { token }, logoutUser),
]);
if (nodesRes.ok) {
@@ -91,6 +101,11 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
}
} catch (err: any) {
// Catch specific unauthorized errors and let authAwareFetch handle logout
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError('Failed to fetch data. Check server connection or console.');
console.error(err);
} finally {
@@ -99,30 +114,43 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
};
useEffect(() => {
// This effect runs whenever 'token' changes.
// If token becomes available, it triggers fetchData.
if (token) {
fetchData();
} else {
// If token is explicitly null (e.g., after logout or initial load without a token)
setLoading(false);
// No need to set error "Please log in" here, as AppContent handles the redirect
// This is a component internal message if it somehow renders without a token when it shouldn't
setError('Authentication required to load data.');
setBots([]); // Clear data if token is gone
setDiscordIds([]);
setSystems([]);
}
}, [token]);
}, [token]); // Dependency on token ensures fetchData is called when token becomes available
const handleAddId = async (): Promise<void> => {
setError('');
if (!token) {
setError('Authentication token is missing. Please log in.');
return;
}
try {
const payload: Omit<DiscordId, '_id'> = {
...newIdData,
guild_ids: newIdData.guild_ids.split(',').map(id => id.trim()).filter(id => id),
};
const method = editingId ? 'PUT' : 'POST';
const url = editingId ? `${API_BASE_URL}/bots/token/${editingId._id}` : `${API_BASE_URL}/bots/token`;
const url = editingId ? `/bots/token/${editingId._id}` : `/bots/token`;
const response = await fetch(url, {
const response = await authAwareFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
token,
}, logoutUser);
if (response.ok) {
fetchData();
@@ -138,6 +166,10 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError(`Network error during ${editingId ? 'update' : 'add'} Discord ID.`);
console.error(err);
}
@@ -146,11 +178,12 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
const handleDeleteId = async (id: string): Promise<void> => {
if (!window.confirm('Are you sure you want to delete this Discord ID?')) return;
setError('');
if (!token) {
setError('Authentication token is missing. Please log in.');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/bots/token/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
const response = await authAwareFetch(`/bots/token/${id}`, { method: 'DELETE', token }, logoutUser);
if (response.ok) {
fetchData();
} else {
@@ -162,6 +195,10 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError('Network error during delete Discord ID.');
console.error(err);
}
@@ -173,16 +210,18 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
setError("Bot Client ID and System must be selected.");
return;
}
if (!token) {
setError('Authentication token is missing. Please log in.');
return;
}
try {
const endpoint = assignDismissAction === 'assign' ? 'assign' : 'dismiss';
const response = await fetch(`${API_BASE_URL}/systems/${selectedSystemId}/${endpoint}`, {
const response = await authAwareFetch(`/systems/${selectedSystemId}/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: selectedBotClientId }),
});
token,
}, logoutUser);
if (response.ok) {
fetchData();
setIsAssignDismissDialogOpen(false);
@@ -197,13 +236,21 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError(`Network error during ${assignDismissAction} bot.`);
console.error(err);
}
};
if (loading) return <p>Loading bots and Discord IDs...</p>;
if (error && !loading) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
// Only show loading if actively fetching or waiting for token
if (loading && token) return <p>Loading bots and Discord IDs...</p>;
// Show error if there's an error
if (error) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
// If not loading and no token, it implies no action can be taken or a login is needed
if (!loading && !token) return <p className="text-orange-500">Please log in to view and manage bots.</p>;
return (
@@ -252,8 +299,8 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
<div>
<h3 className="text-lg font-semibold mb-2">Manage Discord IDs</h3>
<Button onClick={() => { setEditingId(null); setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' }); setIsAddIdDialogOpen(true);}} className="mb-4">Add New Discord ID</Button>
<Button onClick={() => setIsAssignDismissDialogOpen(true)} className="mb-4 ml-2">Assign/Dismiss Bot System</Button>
<Button onClick={() => { setEditingId(null); setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' }); setIsAddIdDialogOpen(true);}} className="mb-4" disabled={!token}>Add New Discord ID</Button>
<Button onClick={() => setIsAssignDismissDialogOpen(true)} className="mb-4 ml-2" disabled={!token}>Assign/Dismiss Bot System</Button>
<Table>
<TableHeader>
@@ -287,10 +334,11 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
guild_ids: dId.guild_ids?.join(', '),
});
setIsAddIdDialogOpen(true);
}}>
}}
disabled={!token}>
Edit
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteId(dId._id)}>
<Button variant="destructive" size="sm" onClick={() => handleDeleteId(dId._id)} disabled={!token}>
Delete
</Button>
</TableCell>
@@ -359,7 +407,9 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
</div>
</div>
<DialogFooter>
<Button onClick={handleAddId}>{editingId ? 'Save Changes' : 'Add Discord ID'}</Button>
<Button onClick={handleAddId} disabled={!token}>
{editingId ? 'Save Changes' : 'Add Discord ID'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -413,7 +463,7 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
</div>
</div>
<DialogFooter>
<Button onClick={handleAssignDismiss} disabled={!selectedBotClientId || !selectedSystemId}>Perform Action</Button>
<Button onClick={handleAssignDismiss} disabled={!selectedBotClientId || !selectedSystemId || !token}>Perform Action</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -9,16 +9,18 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { authAwareFetch } from '@/utils/AuthAwareFetch';
interface IndividualClientPageProps {
clientId: string;
token: string;
token: string | null; // Allow token to be null initially
logoutUser: () => void;
}
const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, token }) => {
const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, token, logoutUser }) => {
const [message, setMessage] = useState<string>('');
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true); // Set to true initially
const [currentClientDiscordStatus, setCurrentClientDiscordStatus] = useState<string>('Unknown');
const [currentClientOp25Status, setCurrentClientOp25Status] = useState<string>('Unknown');
@@ -38,11 +40,15 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
const fetchClientStatus = async () => {
if (!token) {
setLoading(false);
setCurrentClientDiscordStatus('Authentication token is missing.');
setCurrentClientOp25Status('Authentication token is missing.');
return;
}
setLoading(true);
try {
setLoading(true);
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/status`, {
headers: { Authorization: `Bearer ${token}` },
});
const response = await authAwareFetch(`/nodes/${clientId}/status`, { token }, logoutUser);
if (response.ok) {
const data: NodeStatusResponse = await response.json();
console.log(data)
@@ -53,6 +59,10 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
setCurrentClientOp25Status('Failed to fetch status');
}
} catch (err) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setCurrentClientDiscordStatus('Error fetching status');
setCurrentClientOp25Status('Error fetching status');
} finally {
@@ -62,13 +72,16 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
const fetchSystems = async (): Promise<void> => {
setError('');
if (!token) {
setLoading(false);
setError('Authentication token is missing. Please log in.');
return;
}
setLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/systems/`, {
headers: { Authorization: `Bearer ${token}` },
});
const response = await authAwareFetch('/systems/', { token }, logoutUser);
if (response.ok) {
const data: System[] = await response.json();
// Filter systems to those available on the current clientId
const filteredSystems = data.filter(system => system.avail_on_nodes.includes(clientId));
setAvailableSystems(filteredSystems);
} else {
@@ -80,32 +93,47 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError('Failed to fetch systems. Check server connection or console.');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchClientStatus();
fetchSystems(); // Fetch systems when the component mounts or clientId/token changes
}, [clientId, token]);
if (token) {
fetchClientStatus();
fetchSystems();
} else {
setLoading(false);
setError('Please log in to manage this client.');
}
}, [clientId, token, logoutUser]); // Added logoutUser to dependencies to avoid lint warnings
const handleAction = async (
action: 'join' | 'leave' | 'op25_start' | 'op25_stop' | 'op25_set',
// Optional parameters for 'join' and 'leave' actions
payloadData?: { server_id?: string; channel_id?: string; guild_id?: string }
): Promise<void> => {
setMessage('');
setError('');
setLoading(true);
if (!token) {
setError('Authentication token is missing. Please log in.');
setLoading(false);
return;
}
try {
const httpOptions: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
}
@@ -139,21 +167,25 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
});
}
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/${action}`, httpOptions);
const response = await authAwareFetch(`/nodes/${clientId}/${action}`, { ...httpOptions, token }, logoutUser);
const data = await response.json();
if (response.ok) {
setMessage(`Client "${clientId}" successfully ${action === 'op25_set' ? 'set config for system ' + selectedSystem : action + 'ed'}.`);
fetchClientStatus();
setIsSetConfigDialogOpen(false); // Close set config dialog on success
setIsJoinDiscordDialogOpen(false); // Close join discord dialog on success
setIsLeaveDiscordDialogOpen(false); // Close leave discord dialog on success
setIsSetConfigDialogOpen(false);
setIsJoinDiscordDialogOpen(false);
setIsLeaveDiscordDialogOpen(false);
} else {
const errorData = data as ErrorResponse;
setError(`Failed to ${action} client "${clientId}": ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError(`Network error during ${action} client: ${err.message}`);
console.error(err);
} finally {
@@ -162,8 +194,8 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
};
const handleJoinClick = () => {
setDiscordServerId(''); // Clear previous values
setDiscordChannelId(''); // Clear previous values
setDiscordServerId('');
setDiscordChannelId('');
setIsJoinDiscordDialogOpen(true);
};
@@ -172,7 +204,7 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
};
const handleLeaveClick = () => {
setLeaveGuildId(''); // Clear previous value
setLeaveGuildId('');
setIsLeaveDiscordDialogOpen(true);
};
@@ -187,18 +219,22 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
<CardTitle>Manage Client: {clientId}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && <p className="text-red-500 text-sm">{error}</p>}
{loading && <p className="text-blue-500 text-sm">Processing request...</p>}
{!loading && !token && <p className="text-orange-500 text-sm">Please log in to manage this client.</p>}
<p>Current Discord Status: {currentClientDiscordStatus}</p>
<div className="flex space-x-2">
<Button
onClick={handleJoinClick} // Open dialog for join
disabled={loading}
onClick={handleJoinClick}
disabled={loading || !token}
className="flex-1"
>
Join Client
</Button>
<Button
onClick={handleLeaveClick} // Open dialog for leave
disabled={loading}
onClick={handleLeaveClick}
disabled={loading || !token}
variant="outline"
className="flex-1"
>
@@ -209,15 +245,15 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
<div className="flex space-x-2">
<Button
onClick={() => handleAction('op25_start')}
disabled={loading}
disabled={loading || !token}
className="flex-1"
>
{loading && message.includes('start') ? 'Starting...' : 'Start OP25'}
</Button>
<Button
onClick={() => setIsSetConfigDialogOpen(true)} // Open dialog for set config
disabled={loading}
onClick={() => setIsSetConfigDialogOpen(true)}
disabled={loading || !token}
className="flex-1"
>
Set OP25 Config
@@ -225,7 +261,7 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
<Button
onClick={() => handleAction('op25_stop')}
disabled={loading}
disabled={loading || !token}
variant="outline"
className="flex-1"
>
@@ -233,8 +269,6 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
</Button>
</div>
{message && <p className="text-green-500 text-sm">{message}</p>}
{error && <p className="text-red-500 text-sm">{error}</p>}
{loading && <p className="text-blue-500 text-sm">Processing request...</p>}
</CardContent>
{/* Set Config Dialog */}
@@ -261,7 +295,7 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
</div>
</div>
<DialogFooter>
<Button onClick={() => handleAction('op25_set')} disabled={!selectedSystem || loading}>
<Button onClick={() => handleAction('op25_set')} disabled={!selectedSystem || loading || !token}>
{loading ? 'Setting...' : 'Confirm Set Config'}
</Button>
<Button variant="outline" onClick={() => setIsSetConfigDialogOpen(false)}>
@@ -300,7 +334,7 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
</div>
</div>
<DialogFooter>
<Button onClick={handleConfirmJoin} disabled={!discordServerId || !discordChannelId || loading}>
<Button onClick={handleConfirmJoin} disabled={!discordServerId || !discordChannelId || loading || !token}>
{loading ? 'Joining...' : 'Confirm Join'}
</Button>
<Button variant="outline" onClick={() => setIsJoinDiscordDialogOpen(false)}>
@@ -329,7 +363,7 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
</div>
</div>
<DialogFooter>
<Button onClick={handleConfirmLeave} disabled={!leaveGuildId || loading}>
<Button onClick={handleConfirmLeave} disabled={!leaveGuildId || loading || !token}>
{loading ? 'Leaving...' : 'Confirm Leave'}
</Button>
<Button variant="outline" onClick={() => setIsLeaveDiscordDialogOpen(false)}>

View File

@@ -1,3 +1,4 @@
// LoginPage.tsx
"use client";
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -10,14 +11,17 @@ const LoginPage: React.FC = () => {
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [error, setError] = useState<string>('');
const { login } = useAuth();
const { login } = useAuth(); // Assuming login function is now capable of handling 401 internally or through a mechanism
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
setError('');
const success = await login(username, password);
// The login function in AuthContext should be updated to use authAwareFetch with isLoginAttempt: true
// This example assumes useAuth().login handles the network call and error setting.
// If not, you'd directly use authAwareFetch here and manage isLoginAttempt.
const success = await login(username, password); //
if (!success) {
setError('Invalid username or password. Please try again.');
setError('Invalid username or password. Please try again.'); //
}
};

View File

@@ -1,3 +1,4 @@
// SystemsManagement.tsx
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
@@ -9,15 +10,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { API_BASE_URL } from '@/constants/api';
import { DemodTypes, TalkgroupTag, System, ErrorResponse } from '@/types'; // Import types
import { DemodTypes, TalkgroupTag, System, ErrorResponse } from '@/types';
import { authAwareFetch } from '@/utils/AuthAwareFetch';
interface SystemsManagementProps {
token: string;
token: string | null; // Allow token to be null initially
logoutUser: () => void;
}
const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
const SystemsManagement: React.FC<SystemsManagementProps> = ({ token, logoutUser }) => {
const [systems, setSystems] = useState<System[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(true); // Set to true initially
const [error, setError] = useState<string>('');
const [isAddSystemDialogOpen, setIsAddSystemDialogOpen] = useState<boolean>(false);
@@ -42,12 +45,16 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
const [editingSystem, setEditingSystem] = useState<System | null>(null);
const fetchSystems = async (): Promise<void> => {
if (!token) {
setLoading(false);
setError('Authentication token is missing. Please log in.');
return;
}
setLoading(true);
setError('');
try {
const response = await fetch(`${API_BASE_URL}/systems/`, {
headers: { Authorization: `Bearer ${token}` },
});
const response = await authAwareFetch('/systems/', { token }, logoutUser);
if (response.ok) {
const data: System[] = await response.json();
setSystems(data);
@@ -60,6 +67,10 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError('Failed to fetch systems. Check server connection or console.');
console.error(err);
} finally {
@@ -70,11 +81,18 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
useEffect(() => {
if (token) {
fetchSystems();
} else {
setLoading(false);
setError('Please log in to manage systems.');
}
}, [token]);
const handleAddSystem = async (): Promise<void> => {
setError('');
if (!token) {
setError('Authentication token is missing. Please log in.');
return;
}
try {
let parsedTags: TalkgroupTag[] | undefined = undefined;
try {
@@ -110,18 +128,16 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
const method = editingSystem ? 'PUT' : 'POST';
const url = editingSystem ? `${API_BASE_URL}/systems/${editingSystem._id}` :
(payload._id ? `${API_BASE_URL}/systems/${payload._id}` : `${API_BASE_URL}/systems/`);
const url = editingSystem ? `/systems/${editingSystem._id}` :
(payload._id ? `/systems/${payload._id}` : `/systems/`);
const response = await fetch(url, {
const response = await authAwareFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
token,
}, logoutUser);
if (response.ok) {
fetchSystems();
@@ -140,6 +156,10 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError(`Network error during ${editingSystem ? 'update' : 'add'} system. ${err.message}`);
console.error(err);
}
@@ -148,11 +168,12 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
const handleDeleteSystem = async (id: string): Promise<void> => {
if (!window.confirm('Are you sure you want to delete this system?')) return;
setError('');
if (!token) {
setError('Authentication token is missing. Please log in.');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/systems/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
const response = await authAwareFetch(`/systems/${id}`, { method: 'DELETE', token }, logoutUser);
if (response.ok) {
fetchSystems();
} else {
@@ -164,13 +185,22 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError('Network error during delete system.');
console.error(err);
}
};
if (loading) return <p>Loading systems...</p>;
if (error && !loading) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
// Only show loading if actively fetching or waiting for token
if (loading && token) return <p>Loading systems...</p>;
// Show error if there's an error OR if no token and not loading
if (error) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
// If not loading and no token, it implies no action can be taken or a login is needed
if (!loading && !token) return <p className="text-orange-500">Please log in to view and manage systems.</p>;
return (
<Card className="w-full">
@@ -188,7 +218,7 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
avail_on_nodes: '', description: '', tags: '[]', whitelist: '',
});
setIsAddSystemDialogOpen(true);
}} className="mb-4">Add New System</Button>
}} className="mb-4" disabled={!token}>Add New System</Button>
<Table>
<TableHeader>
@@ -226,10 +256,12 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
whitelist: system.whitelist ? system.whitelist.join(', ') : '',
});
setIsAddSystemDialogOpen(true);
}}>
}}
disabled={!token}
>
Edit
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteSystem(system._id)}>
<Button variant="destructive" size="sm" onClick={() => handleDeleteSystem(system._id)} disabled={!token}>
Delete
</Button>
</TableCell>
@@ -343,7 +375,7 @@ const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
</div>
</div>
<DialogFooter>
<Button onClick={handleAddSystem}>{editingSystem ? 'Save Changes' : 'Add System'}</Button>
<Button onClick={handleAddSystem} disabled={!token}>{editingSystem ? 'Save Changes' : 'Add System'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,89 +1,159 @@
// context/AuthContext.tsx
"use client";
import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react';
import { API_BASE_URL } from '@/constants/api';
import { UserDetails, UserRoles, ErrorResponse, AuthContextType } from '@/types'; // Import types
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
useCallback
} from 'react';
import { API_BASE_URL } from '@/constants/api'; // Assuming you have this
import { authAwareFetch } from '@/utils/AuthAwareFetch'; // Import authAwareFetch
export const AuthContext = createContext<AuthContextType | null>(null);
// Define your User and UserRoles types if not already in types.ts
interface User {
username: string;
role: string; // Or UserRoles enum
}
export enum UserRoles {
USER = 'user',
MOD = 'mod',
ADMIN = 'admin',
}
interface AuthContextType {
user: User | null;
token: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
hasPermission: (requiredRole: UserRoles) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<UserDetails | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(true); // Start as true to indicate initial check
useEffect(() => {
const storedToken = localStorage.getItem('jwt_token');
const storedUser = localStorage.getItem('user_data');
if (storedToken && storedUser) {
setToken(storedToken);
try {
setUser(JSON.parse(storedUser));
} catch (error) {
console.error("Failed to parse stored user data:", error);
localStorage.removeItem('user_data');
}
// Function to perform the actual logout steps
const performLogout = useCallback(() => {
setUser(null);
setToken(null);
if (typeof window !== 'undefined') { // Ensure localStorage is available
localStorage.removeItem('token');
localStorage.removeItem('user');
}
setLoading(false);
console.log("User logged out.");
}, []);
// Effect to load token from localStorage on initial mount
useEffect(() => {
let isMounted = true; // Flag to prevent state updates on unmounted component
if (typeof window !== 'undefined' && isMounted) { // Ensure localStorage is available
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
try {
const parsedUser: User = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser);
console.log("Token and user loaded from localStorage.");
} catch (e) {
console.error("Failed to parse user from localStorage, logging out:", e);
performLogout(); // Clear corrupted data
}
}
}
if (isMounted) { // Only set loading to false if component is still mounted
setLoading(false); // Authentication check is complete
}
return () => { isMounted = false; }; // Cleanup for unmount to prevent memory leaks
}, [performLogout]); // Dependency on performLogout for stable reference
const login = async (username: string, password: string): Promise<boolean> => {
setLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
// Use authAwareFetch but explicitly mark as a login attempt
// This will prevent authAwareFetch from triggering logout if it receives a 401
// because that 401 is an expected outcome for invalid credentials.
const response = await authAwareFetch(
'/auth/login', // Your login endpoint
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
isLoginAttempt: true, // Crucial: tell authAwareFetch this is a login attempt
},
performLogout // Pass the logout function
);
if (response.ok) {
setToken(data.access_token);
const tempUser: UserDetails = { id: data.user_id || 'some-id', username: data.username, role: data.role || UserRoles.USER };
setUser(tempUser);
localStorage.setItem('jwt_token', data.access_token);
localStorage.setItem('user_data', JSON.stringify(tempUser));
const data = await response.json();
const receivedToken = data.access_token; // Adjust based on your API response
const receivedUser: User = { // Adjust based on your API response
username: data.username,
role: data.role,
};
if (typeof window !== 'undefined') {
localStorage.setItem('token', receivedToken);
localStorage.setItem('user', JSON.stringify(receivedUser));
}
setToken(receivedToken);
setUser(receivedUser);
console.log("Login successful.");
return true;
} else {
const errorData = data as ErrorResponse;
console.error('Login failed:', errorData.message || errorData.detail || response.statusText);
const errorData = await response.json();
console.error('Login failed:', errorData.message || response.statusText);
return false;
}
} catch (error) {
console.error('Network error during login:', error);
} catch (err) {
console.error('Network error during login:', err);
return false;
} finally {
setLoading(false);
}
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('jwt_token');
localStorage.removeItem('user_data');
};
const logout = useCallback(() => {
performLogout();
}, [performLogout]);
const hasPermission = useCallback((requiredRole: UserRoles) => {
if (!user) return false;
// Simple role-based permission check. You might need a more complex hierarchy.
// Example: Admin has all permissions, Mod has Mod and User permissions.
const userRoleIndex = Object.values(UserRoles).indexOf(user.role as UserRoles);
const requiredRoleIndex = Object.values(UserRoles).indexOf(requiredRole);
console.log(userRoleIndex, requiredRoleIndex, user)
return userRoleIndex >= requiredRoleIndex;
}, [user]);
const hasPermission = (requiredRole: UserRoles): boolean => {
if (!user || !user.role) return false;
const roleOrder: Record<UserRoles, number> = {
[UserRoles.USER]: 0,
[UserRoles.MOD]: 1,
[UserRoles.ADMIN]: 2
};
return roleOrder[user.role] >= roleOrder[requiredRole];
};
return (
<AuthContext.Provider value={{ token, user, loading, login, logout, hasPermission }}>
<AuthContext.Provider value={{ user, token, loading, login, logout, hasPermission }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === null) {
throw new Error("useAuth must be used within an AuthProvider");
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,36 @@
// utils/AuthAwareFetch.ts
import { API_BASE_URL } from '@/constants/api'; // Make sure this path is correct
interface AuthAwareFetchOptions extends RequestInit {
token?: string;
isLoginAttempt?: boolean; // New option to bypass 401 handling for login
}
export const authAwareFetch = async (
url: string,
options: AuthAwareFetchOptions = {},
logoutUser: () => void // Function to call on 401
): Promise<Response> => {
const { token, isLoginAttempt, headers, ...rest } = options;
const requestHeaders = {
...headers,
...(token && { Authorization: `Bearer ${token}` }),
};
console.log(requestHeaders, rest, url)
const response = await fetch(`${API_BASE_URL}${url}`, {
headers: requestHeaders,
...rest,
});
if (response.status === 401 && !isLoginAttempt) {
console.error('Authentication failed (401). Logging out...');
logoutUser();
// Potentially throw an error or return a specific response to indicate logout
throw new Error('Unauthorized: Session expired or invalid token.');
}
return response;
};