Init working UI

This commit is contained in:
Logan Cusano
2025-05-25 23:20:48 -04:00
parent b889210f2b
commit 30707fc0d5
19 changed files with 1458 additions and 1113 deletions

View File

@@ -11,7 +11,7 @@ RUN npm ci
# COPY package.json yarn.lock ./
# RUN yarn install --frozen-lockfile
# Stage 2: Build the Next.js application
# Stage 2: Build the Next.js application (production build)
FROM node:22-alpine AS builder
WORKDIR /app
@@ -19,32 +19,33 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the Next.js application
# This will create the .next directory with optimized production build
# Build the Next.js application for production
ENV NODE_ENV production
RUN npm run build
# Stage 3: Run the Next.js application in production
# Stage 3: Run the Next.js application (flexible for dev and prod)
FROM node:22-alpine AS runner
WORKDIR /app
# Set environment variables for Next.js production server
ENV NODE_ENV production
# Next.js requires specific environment variables for standalone output
# If you are using `output: 'standalone'` in next.config.js, uncomment the following:
# ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp
# COPY --from=builder /app/.next/standalone ./
# COPY --from=builder /app/.next/static ./.next/static
# COPY --from=builder /app/public ./public
# If not using standalone output, copy the necessary files
COPY --from=builder /app/public ./public
# Copy necessary files for both development and production
COPY --from=builder /app/src/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# If you are using `output: 'standalone'` in next.config.js, uncomment the following:
# ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp
# COPY --from=builder /app/.next/standalone ./
# COPY --from=builder /app/.next/static ./.next/static
# COPY --from=builder /app/public ./public # Already copied above, but keep if standalone specific
# Expose the port Next.js runs on (default is 3000)
EXPOSE 3000
# Argument to set NODE_ENV at runtime
ARG NODE_ENV=production
ENV NODE_ENV ${NODE_ENV}
# Command to run the Next.js application
CMD ["npm", "start"]
# This will run 'npm run dev' if NODE_ENV is 'development', otherwise 'npm start' (production)
CMD ["sh", "-c", "if [ \"$NODE_ENV\" = \"development\" ]; then npm run dev; else npm start; fi"]

View File

@@ -1,64 +1,97 @@
.PHONY: install build start dev clean docker-build docker-run docker-stop
.PHONY: install build start dev clean docker-build docker-dev docker-prod docker-stop docker-rebuild-dev docker-rebuild-prod
# Define variables
APP_NAME := drb-frontend
DOCKER_IMAGE_NAME := $(APP_NAME):latest
DOCKER_CONTAINER_NAME := $(APP_NAME)-container
DOCKER_DEV_CONTAINER_NAME := $(APP_NAME)-dev-container
DOCKER_PROD_CONTAINER_NAME := $(APP_NAME)-prod-container
# Default target when `make` is run without arguments
# --- Local Development and Build ---
# Default target
all: dev
# Install Node.js dependencies
install:
@echo "Installing Node.js dependencies..."
@echo "➡️ Installing Node.js dependencies..."
npm install
# Build the Next.js application for production
build: install
@echo "Building Next.js application for production..."
@echo "➡️ Building Next.js application for production..."
npm run build
# Start the Next.js application in production mode
# Start the Next.js application in production mode (locally)
start: build
@echo "Starting Next.js application in production mode..."
@echo "➡️ Starting Next.js application in production mode locally..."
npm start
# Start the Next.js application in development mode
# Start the Next.js application in development mode (locally)
dev: install
@echo "Starting Next.js application in development mode..."
@echo "➡️ Starting Next.js application in development mode locally (Fast Refresh enabled)..."
npm run dev
# Clean up build artifacts and node_modules
clean:
@echo "Cleaning up build artifacts and node_modules..."
@echo "➡️ Cleaning up build artifacts and node_modules..."
rm -rf .next out node_modules
@echo "Clean up complete."
@echo "Clean up complete."
# --- Docker Operations ---
# Build the Docker image
docker-build:
@echo "Building Docker image: $(DOCKER_IMAGE_NAME)..."
@echo "🐳 Building Docker image: $(DOCKER_IMAGE_NAME)..."
docker build -t $(DOCKER_IMAGE_NAME) .
@echo "Docker image built successfully."
@echo "Docker image built successfully."
# Run the Docker container
docker-run: docker-build
@echo "Running Docker container: $(DOCKER_CONTAINER_NAME)..."
docker run -it --rm --name $(DOCKER_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME)
@echo "Docker container started. Access at http://localhost:3000"
# Run the Docker container in development mode
docker-dev: #docker-build
@echo "🚀 Running Docker container in DEVELOPMENT mode: $(DOCKER_DEV_CONTAINER_NAME)..."
@echo " (Volume mounted for Fast Refresh: $(PWD):/app)"
# Ensure node_modules is also mounted, as it's typically excluded from the main volume mount
# to avoid issues with host OS vs. container OS binary compatibility.
# The bind mount for node_modules effectively hides the one from the image.
docker run \
-it \
--rm \
--name $(DOCKER_DEV_CONTAINER_NAME) \
-p 3000:3000 \
-v "$(PWD):/app" \
-v "/app/node_modules" \
-e NODE_ENV=development \
$(DOCKER_IMAGE_NAME)
@echo "✨ Docker container started in development. Access at http://localhost:3000"
# Run the Docker container
docker-deploy: docker-build
@echo "Running Docker container: $(DOCKER_CONTAINER_NAME)..."
docker run -d --rm --name $(DOCKER_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME)
@echo "Docker container started. Access at http://localhost:3000"
# Stop and remove the Docker container
docker-stop:
@echo "Stopping and removing Docker container: $(DOCKER_CONTAINER_NAME)..."
docker stop $(DOCKER_CONTAINER_NAME) || true
docker rm $(DOCKER_CONTAINER_NAME) || true
@echo "Docker container stopped and removed."
# Run the Docker container in production mode
docker-prod: docker-build docker-stop-prod
@echo "🚀 Running Docker container in PRODUCTION mode: $(DOCKER_PROD_CONTAINER_NAME)..."
docker run -d --rm --name $(DOCKER_PROD_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME)
@echo "✅ Docker container started in production. Access at http://localhost:3000"
# Optional: Rebuild and rerun the Docker container
docker-rebuild-run: docker-stop docker-run
@echo "Rebuilding and rerunning Docker container."
# Stop and remove the DEVELOPMENT Docker container
docker-stop-dev:
@echo "🛑 Stopping and removing DEVELOPMENT Docker container: $(DOCKER_DEV_CONTAINER_NAME)..."
-docker stop $(DOCKER_DEV_CONTAINER_NAME) 2>/dev/null || true
-docker rm $(DOCKER_DEV_CONTAINER_NAME) 2>/dev/null || true
@echo "✅ Development Docker container stopped and removed (if it was running)."
# Stop and remove the PRODUCTION Docker container
docker-stop-prod:
@echo "🛑 Stopping and removing PRODUCTION Docker container: $(DOCKER_PROD_CONTAINER_NAME)..."
-docker stop $(DOCKER_PROD_CONTAINER_NAME) 2>/dev/null || true
-docker rm $(DOCKER_PROD_CONTAINER_NAME) 2>/dev/null || true
@echo "✅ Production Docker container stopped and removed (if it was running)."
# Stop and remove ALL Docker containers related to this app
docker-stop-all: docker-stop-dev docker-stop-prod
@echo "🛑 Stopped all active containers for $(APP_NAME)."
# Rebuild and rerun the Docker container in development mode
docker-rebuild-dev: docker-build docker-dev
@echo "🔄 Rebuilding and rerunning Docker container in development."
# Rebuild and rerun the Docker container in production mode
docker-rebuild-prod: docker-build docker-prod
@echo "🔄 Rebuilding and rerunning Docker container in production."

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/context/AuthContext';
import LoginPage from '@/components/LoginPage';
import BotsManagement from '@/components/BotsManagement';
import SystemsManagement from '@/components/SystemsManagement';
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'));
useEffect(() => {
if (!loading) {
if (user && hasPermission(UserRoles.MOD)) {
setCurrentPage('management');
} else {
setCurrentPage('login');
}
}
}, [user, loading, hasPermission]);
if (loading) {
return <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">Loading Authentication...</div>;
}
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>
)}
</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>
)}
</main>
</div>
);
}
export default AppContent;

View File

@@ -0,0 +1,425 @@
"use client";
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
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
interface BotsManagementProps {
token: string;
}
const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
const [bots, setBots] = useState<string[]>([]);
const [discordIds, setDiscordIds] = useState<DiscordId[]>([]);
const [systems, setSystems] = useState<System[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
const [isAddIdDialogOpen, setIsAddIdDialogOpen] = useState<boolean>(false);
const [newIdData, setNewIdData] = useState<Omit<DiscordId, '_id' | 'guild_ids'> & { guild_ids: string }>({
discord_id: '',
name: '',
token: '',
active: false,
guild_ids: '',
});
const [editingId, setEditingId] = useState<DiscordId | null>(null);
const [isAssignDismissDialogOpen, setIsAssignDismissDialogOpen] = useState<boolean>(false);
const [selectedBotClientId, setSelectedBotClientId] = useState<string>('');
const [selectedSystemId, setSelectedSystemId] = useState<string>('');
const [assignDismissAction, setAssignDismissAction] = useState<'assign' | 'dismiss'>('assign');
const fetchData = async (): Promise<void> => {
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}` } }),
]);
if (nodesRes.ok) {
const botsData: any = await nodesRes.json();
setBots(botsData);
} else {
let errorMsg = `Failed to fetch bots (${nodesRes.status})`;
try {
const errorData: ErrorResponse = await nodesRes.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || nodesRes.statusText}`;
} catch (e) { errorMsg += `: ${nodesRes.statusText}`; }
accumulatedError += errorMsg + '\n';
}
if (discordIdsRes.ok) {
const discordIdsData: DiscordId[] = await discordIdsRes.json();
setDiscordIds(discordIdsData);
} else {
let errorMsg = `Failed to fetch Discord IDs (${discordIdsRes.status})`;
try {
const errorData: ErrorResponse = await discordIdsRes.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || discordIdsRes.statusText}`;
} catch (e) { errorMsg += `: ${discordIdsRes.statusText}`; }
accumulatedError += errorMsg + '\n';
}
if (systemsRes.ok) {
const systemsData: System[] = await systemsRes.json();
setSystems(systemsData);
} else {
let errorMsg = `Failed to fetch systems (${systemsRes.status})`;
try {
const errorData: ErrorResponse = await systemsRes.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || systemsRes.statusText}`;
} catch (e) { errorMsg += `: ${systemsRes.statusText}`; }
accumulatedError += errorMsg + '\n';
}
if (accumulatedError) {
setError(accumulatedError.trim());
}
} catch (err: any) {
setError('Failed to fetch data. Check server connection or console.');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) {
fetchData();
}
}, [token]);
const handleAddId = async (): Promise<void> => {
setError('');
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}/discord_ids/${editingId._id}` : `${API_BASE_URL}/discord_ids/`;
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (response.ok) {
fetchData();
setIsAddIdDialogOpen(false);
setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' });
setEditingId(null);
} else {
let errorMsg = `Failed to ${editingId ? 'update' : 'add'} Discord ID (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError(`Network error during ${editingId ? 'update' : 'add'} Discord ID.`);
console.error(err);
}
};
const handleDeleteId = async (id: string): Promise<void> => {
if (!window.confirm('Are you sure you want to delete this Discord ID?')) return;
setError('');
try {
const response = await fetch(`${API_BASE_URL}/discord_ids/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
fetchData();
} else {
let errorMsg = `Failed to delete Discord ID (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError('Network error during delete Discord ID.');
console.error(err);
}
};
const handleAssignDismiss = async (): Promise<void> => {
setError('');
if (!selectedBotClientId || !selectedSystemId) {
setError("Bot Client ID and System must be selected.");
return;
}
try {
const endpoint = assignDismissAction === 'assign' ? 'assign' : 'dismiss';
const response = await fetch(`${API_BASE_URL}/systems/${selectedSystemId}/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ client_id: selectedBotClientId }),
});
if (response.ok) {
fetchData();
setIsAssignDismissDialogOpen(false);
setSelectedBotClientId('');
setSelectedSystemId('');
} else {
let errorMsg = `Failed to ${assignDismissAction} bot (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
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>;
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Bots Management</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{error && <p className="text-red-500 whitespace-pre-line">{error}</p>}
<div>
<h3 className="text-lg font-semibold mb-2">Online Bots (Clients)</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Client ID</TableHead>
<TableHead>Assigned Discord ID (Name)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bots.length > 0 ? (
bots.map((botClientId) => {
const assignedSystem = systems.find(sys => sys.avail_on_nodes.includes(botClientId));
const assignedDiscordBot = discordIds.find(did => did.name === assignedSystem?.name);
return (
<TableRow key={botClientId}>
<TableCell className="font-medium">
<Link href={`/nodes/${botClientId}`} className="text-blue-600 hover:underline">
{botClientId}
</Link>
</TableCell>
<TableCell>
{assignedSystem ? `${assignedSystem.name} (System)` : "N/A or requires different mapping"}
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={2} className="text-center">No bots currently online or reported by /bots.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<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>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Discord ID</TableHead>
<TableHead>Token (Partial)</TableHead>
<TableHead>Active</TableHead>
<TableHead>Guilds</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{discordIds.length > 0 ? (
discordIds.map((dId) => (
<TableRow key={dId._id}>
<TableCell className="font-medium">{dId.name}</TableCell>
<TableCell>{dId.discord_id}</TableCell>
<TableCell className="truncate max-w-xs">{dId.token ? dId.token.substring(0, 8) + '...' : 'N/A'}</TableCell>
<TableCell>{dId.active ? 'Yes' : 'No'}</TableCell>
<TableCell>{dId.guild_ids.join(', ')}</TableCell>
<TableCell>
<Button variant="outline" size="sm" className="mr-2"
onClick={() => {
setEditingId(dId);
setNewIdData({
discord_id: dId.discord_id,
name: dId.name,
token: dId.token,
active: dId.active,
guild_ids: dId.guild_ids.join(', '),
});
setIsAddIdDialogOpen(true);
}}>
Edit
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteId(dId._id)}>
Delete
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center">No Discord IDs found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Dialog open={isAddIdDialogOpen} onOpenChange={setIsAddIdDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Discord ID' : 'Add New Discord ID'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Name</Label>
<Input
id="name"
value={newIdData.name}
onChange={(e) => setNewIdData({ ...newIdData, name: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="discord_id" className="text-right">Discord ID</Label>
<Input
id="discord_id"
value={newIdData.discord_id}
onChange={(e) => setNewIdData({ ...newIdData, discord_id: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="token" className="text-right">Token</Label>
<Input
id="token"
value={newIdData.token}
onChange={(e) => setNewIdData({ ...newIdData, token: e.target.value })}
className="col-span-3"
placeholder={editingId ? "Token hidden for security, re-enter to change" : ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="guild_ids" className="text-right">Guild IDs (comma-separated)</Label>
<Input
id="guild_ids"
value={newIdData.guild_ids}
onChange={(e) => setNewIdData({ ...newIdData, guild_ids: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="active" className="text-right">Active</Label>
<Checkbox
id="active"
checked={newIdData.active}
onCheckedChange={(checked) => setNewIdData({ ...newIdData, active: checked === true })}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddId}>{editingId ? 'Save Changes' : 'Add Discord ID'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAssignDismissDialogOpen} onOpenChange={setIsAssignDismissDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Assign/Dismiss Bot to System</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="bot-client-id" className="text-right">Bot Client ID</Label>
<Select onValueChange={setSelectedBotClientId} value={selectedBotClientId}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select a bot client ID" />
</SelectTrigger>
<SelectContent>
{bots.map((botId) => (
<SelectItem key={botId} value={botId}>{botId}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="system-id" className="text-right">System</Label>
<Select onValueChange={setSelectedSystemId} value={selectedSystemId}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select a system" />
</SelectTrigger>
<SelectContent>
{systems.map((system) => (
<SelectItem key={system._id} value={system._id}>{system.name} ({system._id})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="action" className="text-right">Action</Label>
<Select
onValueChange={(value: string) => setAssignDismissAction(value as 'assign' | 'dismiss')}
value={assignDismissAction}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="assign">Assign</SelectItem>
<SelectItem value="dismiss">Dismiss</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={handleAssignDismiss} disabled={!selectedBotClientId || !selectedSystemId}>Perform Action</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
};
export default BotsManagement;

View File

@@ -0,0 +1,221 @@
// components/IndividualClientPage.tsx
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { API_BASE_URL } from '@/constants/api';
import { ErrorResponse, NodeStatusResponse, System } from '@/types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
interface IndividualClientPageProps {
clientId: string; // Now received as a prop
token: string;
}
const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, token }) => {
const [message, setMessage] = useState<string>('');
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [currentClientDiscordStatus, setCurrentClientDiscordStatus] = useState<string>('Unknown');
const [currentClientOp25Status, setCurrentClientOp25Status] = useState<string>('Unknown');
// OP25 Set Config Popup States
const [selectedSystem, setSelectedSystem] = useState<string | null>(null);
const [isSetConfigDialogOpen, setIsSetConfigDialogOpen] = useState<boolean>(false);
const [availableSystems, setAvailableSystems] = useState<System[]>([]);
const fetchClientStatus = async () => {
try {
setLoading(true);
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data: NodeStatusResponse = await response.json();
console.log(data)
setCurrentClientDiscordStatus(data.status.discord_status);
setCurrentClientOp25Status(data.status.op25_status)
} else {
setCurrentClientDiscordStatus('Failed to fetch status');
setCurrentClientOp25Status('Failed to fetch status');
}
} catch (err) {
setCurrentClientDiscordStatus('Error fetching status');
setCurrentClientOp25Status('Error fetching status');
} finally {
setLoading(false);
}
};
const fetchSystems = async (): Promise<void> => {
setError('');
try {
const response = await fetch(`${API_BASE_URL}/systems/`, {
headers: { Authorization: `Bearer ${token}` },
});
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 {
let errorMsg = `Failed to fetch systems (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError('Failed to fetch systems. Check server connection or console.');
console.error(err);
}
};
useEffect(() => {
fetchClientStatus();
fetchSystems(); // Fetch systems when the component mounts or clientId/token changes
}, [clientId, token]);
const handleAction = async (action: 'join' | 'leave' | 'op25_start' | 'op25_stop' | 'op25_set'): Promise<void> => {
setMessage('');
setError('');
setLoading(true);
try {
const httpOptions: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
}
if (action === "op25_set") {
if (!selectedSystem) {
setError("Please select a system to set configuration.");
setLoading(false);
return;
}
httpOptions.body = JSON.stringify({
"system_id": selectedSystem
});
}
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/${action}`, httpOptions);
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 dialog on success
} 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) {
setError(`Network error during ${action} client: ${err.message}`);
console.error(err);
} finally {
setLoading(false);
}
};
return (
<Card className="w-full max-w-md mx-auto mt-8">
<CardHeader>
<CardTitle>Manage Client: {clientId}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p>Current Discord Status: {currentClientDiscordStatus}</p>
<div className="flex space-x-2">
<Button
onClick={() => handleAction('join')}
disabled={loading}
className="flex-1"
>
{loading && message.includes('join') ? 'Joining...' : 'Join Client'}
</Button>
<Button
onClick={() => handleAction('leave')}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading && message.includes('leave') ? 'Leaving...' : 'Leave Client'}
</Button>
</div>
<p>Current OP25 Status: {currentClientOp25Status}</p>
<div className="flex space-x-2">
<Button
onClick={() => handleAction('op25_start')}
disabled={loading}
className="flex-1"
>
{loading && message.includes('start') ? 'Starting...' : 'Start OP25'}
</Button>
<Button
onClick={() => setIsSetConfigDialogOpen(true)} // Open dialog for set config
disabled={loading}
className="flex-1"
>
Set OP25 Config
</Button>
<Button
onClick={() => handleAction('op25_stop')}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading && message.includes('stop') ? 'Stopping...' : 'Stop OP25'}
</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 */}
<Dialog open={isSetConfigDialogOpen} onOpenChange={setIsSetConfigDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set OP25 System Configuration</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="system-select" className="text-right">Select System</Label>
<Select onValueChange={setSelectedSystem} value={selectedSystem || ''}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Choose a system" />
</SelectTrigger>
<SelectContent>
{availableSystems.map((system) => (
<SelectItem key={system._id} value={system._id}>
{system.name} ({system._id})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={() => handleAction('op25_set')} disabled={!selectedSystem || loading}>
{loading ? 'Setting...' : 'Confirm Set Config'}
</Button>
<Button variant="outline" onClick={() => setIsSetConfigDialogOpen(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
export default IndividualClientPage;

View File

@@ -0,0 +1,67 @@
"use client";
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/context/AuthContext';
const LoginPage: React.FC = () => {
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [error, setError] = useState<string>('');
const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (!success) {
setError('Invalid username or password. Please try again.');
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<Card className="w-full max-w-md p-6 space-y-4 rounded-lg shadow-lg">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
required
className="mt-1"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
required
className="mt-1"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" className="w-full">
Login
</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,354 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
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
interface SystemsManagementProps {
token: string;
}
const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
const [systems, setSystems] = useState<System[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
const [isAddSystemDialogOpen, setIsAddSystemDialogOpen] = useState<boolean>(false);
const [newSystemData, setNewSystemData] = useState<
Omit<System, '_id' | 'frequencies' | 'avail_on_nodes' | 'tags' | 'whitelist'> & {
_id?: string;
frequencies: string;
avail_on_nodes: string;
tags: string;
whitelist: string;
}
>({
type: DemodTypes.P25,
name: '',
frequencies: '',
location: '',
avail_on_nodes: '',
description: '',
tags: '[]',
whitelist: '',
});
const [editingSystem, setEditingSystem] = useState<System | null>(null);
const fetchSystems = async (): Promise<void> => {
setLoading(true);
setError('');
try {
const response = await fetch(`${API_BASE_URL}/systems/`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data: System[] = await response.json();
setSystems(data);
} else {
let errorMsg = `Failed to fetch systems (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError('Failed to fetch systems. Check server connection or console.');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) {
fetchSystems();
}
}, [token]);
const handleAddSystem = async (): Promise<void> => {
setError('');
try {
let parsedTags: TalkgroupTag[] | undefined = undefined;
try {
parsedTags = newSystemData.tags ? JSON.parse(newSystemData.tags) : [];
if (!Array.isArray(parsedTags)) throw new Error("Tags must be a JSON array.");
} catch (e: any) {
setError(`Invalid JSON format for Tags: ${e.message}`);
return;
}
const payload: Omit<System, '_id' | 'frequencies' | 'avail_on_nodes' | 'tags' | 'whitelist'> & {
_id?: string;
frequencies: number[];
avail_on_nodes: string[];
tags?: TalkgroupTag[];
whitelist?: number[];
} = {
name: newSystemData.name,
type: newSystemData.type,
location: newSystemData.location,
description: newSystemData.description || undefined,
frequencies: newSystemData.frequencies.split(',').map(f => parseInt(f.trim())).filter(f => !isNaN(f)),
avail_on_nodes: newSystemData.avail_on_nodes.split(',').map(node => node.trim()).filter(node => node),
tags: parsedTags,
whitelist: newSystemData.whitelist.split(',').map(w => parseInt(w.trim())).filter(w => !isNaN(w)),
};
if (editingSystem) {
payload._id = editingSystem._id;
} else if (newSystemData._id) {
payload._id = newSystemData._id;
}
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 response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (response.ok) {
fetchSystems();
setIsAddSystemDialogOpen(false);
setNewSystemData({
_id: '', type: DemodTypes.P25, name: '', frequencies: '', location: '',
avail_on_nodes: '', description: '', tags: '[]', whitelist: '',
});
setEditingSystem(null);
} else {
let errorMsg = `Failed to ${editingSystem ? 'update' : 'add'} system (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
setError(`Network error during ${editingSystem ? 'update' : 'add'} system. ${err.message}`);
console.error(err);
}
};
const handleDeleteSystem = async (id: string): Promise<void> => {
if (!window.confirm('Are you sure you want to delete this system?')) return;
setError('');
try {
const response = await fetch(`${API_BASE_URL}/systems/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
fetchSystems();
} else {
let errorMsg = `Failed to delete system (${response.status})`;
try {
const errorData: ErrorResponse = await response.json();
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`;
} catch (e) { errorMsg += `: ${response.statusText}`; }
setError(errorMsg);
}
} catch (err: any) {
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>;
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Systems Management</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{error && <p className="text-red-500 whitespace-pre-line">{error}</p>}
<div>
<h3 className="text-lg font-semibold mb-2">All Systems</h3>
<Button onClick={() => {
setEditingSystem(null);
setNewSystemData({
_id: '', type: DemodTypes.P25, name: '', frequencies: '', location: '',
avail_on_nodes: '', description: '', tags: '[]', whitelist: '',
});
setIsAddSystemDialogOpen(true);
}} className="mb-4">Add New System</Button>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name (_id)</TableHead>
<TableHead>Type</TableHead>
<TableHead>Frequencies</TableHead>
<TableHead>Location</TableHead>
<TableHead>Available Nodes</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{systems.length > 0 ? (
systems.map((system: System) => (
<TableRow key={system._id}>
<TableCell className="font-medium">{system.name} <span className="text-xs text-gray-500">({system._id})</span></TableCell>
<TableCell>{system.type}</TableCell>
<TableCell>{system.frequencies.join(', ')}</TableCell>
<TableCell>{system.location}</TableCell>
<TableCell>{system.avail_on_nodes.join(', ')}</TableCell>
<TableCell>
<Button variant="outline" size="sm" className="mr-2"
onClick={() => {
setEditingSystem(system);
setNewSystemData({
_id: system._id,
type: system.type,
name: system.name,
frequencies: system.frequencies.join(', '),
location: system.location,
avail_on_nodes: system.avail_on_nodes.join(', '),
description: system.description || '',
tags: system.tags ? JSON.stringify(system.tags, null, 2) : '[]',
whitelist: system.whitelist ? system.whitelist.join(', ') : '',
});
setIsAddSystemDialogOpen(true);
}}>
Edit
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteSystem(system._id)}>
Delete
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center">No systems found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
<Dialog open={isAddSystemDialogOpen} onOpenChange={setIsAddSystemDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{editingSystem ? 'Edit System' : 'Add New System'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4 max-h-[70vh] overflow-y-auto pr-2">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="system_id_input" className="text-right">ID (Optional for new)</Label>
<Input
id="system_id_input"
value={newSystemData._id || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, _id: e.target.value })}
className="col-span-3"
disabled={!!editingSystem}
placeholder={editingSystem ? newSystemData._id : "Leave blank to auto-generate, or specify"}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Name</Label>
<Input
id="name"
value={newSystemData.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, name: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">Type</Label>
<Select onValueChange={(value: DemodTypes) => setNewSystemData({ ...newSystemData, type: value })} value={newSystemData.type}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DemodTypes.P25}>P25</SelectItem>
<SelectItem value={DemodTypes.DMR}>DMR</SelectItem>
<SelectItem value={DemodTypes.NBFM}>NBFM (Analog)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="frequencies" className="text-right">Frequencies (comma-separated)</Label>
<Input
id="frequencies"
value={newSystemData.frequencies}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, frequencies: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">Location</Label>
<Input
id="location"
value={newSystemData.location}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, location: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="avail_on_nodes" className="text-right">Available Nodes (comma-separated)</Label>
<Input
id="avail_on_nodes"
value={newSystemData.avail_on_nodes}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, avail_on_nodes: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">Description</Label>
<Textarea
id="description"
value={newSystemData.description || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewSystemData({ ...newSystemData, description: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="tags" className="text-right">Tags (JSON string)</Label>
<Textarea
id="tags"
value={newSystemData.tags}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewSystemData({ ...newSystemData, tags: e.target.value })}
className="col-span-3"
placeholder='[{"talkgroup": "TG1", "tagDec": 123}, {"talkgroup": "TG2", "tagDec": 456}]'
rows={3}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="whitelist" className="text-right">Whitelist TGs (comma-separated)</Label>
<Input
id="whitelist"
value={newSystemData.whitelist}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, whitelist: e.target.value })}
className="col-span-3"
placeholder='123,456,789'
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddSystem}>{editingSystem ? 'Save Changes' : 'Add System'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
export default SystemsManagement;

1
src/constants/api.ts Normal file
View File

@@ -0,0 +1 @@
export const API_BASE_URL = "http://172.16.100.81:5000";

View File

@@ -0,0 +1,89 @@
"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
export const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<UserDetails | null>(null);
const [loading, setLoading] = useState<boolean>(true);
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');
}
}
setLoading(false);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
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();
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));
return true;
} else {
const errorData = data as ErrorResponse;
console.error('Login failed:', errorData.message || errorData.detail || response.statusText);
return false;
}
} catch (error) {
console.error('Network error during login:', error);
return false;
}
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('jwt_token');
localStorage.removeItem('user_data');
};
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 }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === null) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

14
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,14 @@
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { AuthProvider } from '@/context/AuthContext';
import '@/app/globals.css'; // Assuming your global styles are here
function DRB({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
export default DRB;

View File

@@ -0,0 +1,64 @@
"use client";
import React from 'react';
import { useRouter } from 'next/router'; // For Pages Router
import { useAuth } from '@/context/AuthContext';
import IndividualClientPage from '@/components/IndividualClientPage';
import LoginPage from '@/components/LoginPage';
import { UserRoles } from '@/types';
import {Button} from '@/components/ui/button';
const ClientDetailPage: React.FC = () => {
const router = useRouter();
const { clientId } = router.query; // Get clientId from the URL
const { user, loading, token, hasPermission } = useAuth();
if (loading) {
return <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">Loading Authentication...</div>;
}
if (!user || !token || !hasPermission(UserRoles.MOD)) {
// Redirect to login or show access denied if not authenticated or authorized
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>
</header>
<main className="p-6">
{!user ? (
<LoginPage />
) : (
<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}.
</div>
)}
</main>
</div>
);
}
// Ensure clientId is a string before passing
const clientIdentifier = Array.isArray(clientId) ? clientId[0] : clientId;
if (!clientIdentifier) {
return <div className="text-center text-red-500 text-lg mt-10">Client ID not found in URL.</div>;
}
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>
<div className="flex items-center space-x-4">
<span className="text-sm">Logged in as: {user.username} ({user.role})</span>
<Button onClick={() => router.push('/')} variant="outline">Back to Management</Button> {/* Add a back button */}
<Button onClick={useAuth().logout} variant="outline">Logout</Button>
</div>
</header>
<main className="p-6">
<IndividualClientPage clientId={clientIdentifier} token={token} />
</main>
</div>
);
};
export default ClientDetailPage;

View File

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

View File

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

65
src/types/index.ts Normal file
View File

@@ -0,0 +1,65 @@
export enum DemodTypes {
P25 = "P25",
DMR = "DMR",
NBFM = "NBFM"
}
export interface TalkgroupTag {
talkgroup: string;
tagDec: number;
}
export interface DiscordId {
_id: string;
discord_id: string;
name: string;
token: string;
active: boolean;
guild_ids: string[];
}
export interface System {
_id: string;
type: DemodTypes;
name: string;
frequencies: number[];
location: string;
avail_on_nodes: string[];
description?: string;
tags?: TalkgroupTag[];
whitelist?: number[];
}
export enum UserRoles {
ADMIN = "admin",
MOD = "mod",
USER = "user"
}
export interface UserDetails {
id: string;
username: string;
role: UserRoles;
}
export interface ErrorResponse {
message?: string;
detail?: string | any;
}
export interface NodeStatusResponse {
status: {
op25_status: string;
discord_status: string;
}
}
// Auth Context Types
export interface AuthContextType {
token: string | null;
user: UserDetails | null;
loading: boolean;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
hasPermission: (requiredRole: UserRoles) => boolean;
}