first commit
This commit is contained in:
595
app/admin/devices/page.tsx
Normal file
595
app/admin/devices/page.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
owner?: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
latestLocation?: {
|
||||
latitude: string | number;
|
||||
longitude: string | number;
|
||||
timestamp: string;
|
||||
battery?: number;
|
||||
speed?: number;
|
||||
};
|
||||
_count?: {
|
||||
locations: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { data: session } = useSession();
|
||||
const userRole = (session?.user as any)?.role;
|
||||
const isAdmin = userRole === 'ADMIN';
|
||||
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
id: "",
|
||||
name: "",
|
||||
color: "#3498db",
|
||||
description: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
|
||||
// Auto-refresh every 10 seconds to update online/offline status
|
||||
const interval = setInterval(fetchDevices, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/devices");
|
||||
if (!response.ok) throw new Error("Failed to fetch devices");
|
||||
const data = await response.json();
|
||||
setDevices(data.devices || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch devices", err);
|
||||
setError("Failed to load devices");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch("/api/devices", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to create device");
|
||||
}
|
||||
|
||||
await fetchDevices();
|
||||
setShowAddModal(false);
|
||||
setFormData({ id: "", name: "", color: "#3498db", description: "" });
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedDevice) return;
|
||||
|
||||
try {
|
||||
// If ID changed, we need to delete old and create new device
|
||||
if (formData.id !== selectedDevice.id) {
|
||||
// Delete old device
|
||||
const deleteResponse = await fetch(`/api/devices/${selectedDevice.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!deleteResponse.ok) {
|
||||
throw new Error("Failed to delete old device");
|
||||
}
|
||||
|
||||
// Create new device with new ID
|
||||
const createResponse = await fetch("/api/devices", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
const error = await createResponse.json();
|
||||
throw new Error(error.error || "Failed to create device with new ID");
|
||||
}
|
||||
} else {
|
||||
// Just update existing device
|
||||
const response = await fetch(`/api/devices/${selectedDevice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
color: formData.color,
|
||||
description: formData.description,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to update device");
|
||||
}
|
||||
}
|
||||
|
||||
await fetchDevices();
|
||||
setShowEditModal(false);
|
||||
setSelectedDevice(null);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedDevice) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/devices/${selectedDevice.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to delete device");
|
||||
}
|
||||
|
||||
await fetchDevices();
|
||||
setShowDeleteModal(false);
|
||||
setSelectedDevice(null);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (device: Device) => {
|
||||
setSelectedDevice(device);
|
||||
setFormData({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
color: device.color,
|
||||
description: device.description || "",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (device: Device) => {
|
||||
setSelectedDevice(device);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-600">Loading devices...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Device Management</h2>
|
||||
{!isAdmin && (
|
||||
<p className="text-sm text-gray-600 mt-1">Read-only view</p>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
|
||||
>
|
||||
+ Add Device
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{devices.map((device) => {
|
||||
const lastSeen = device.latestLocation
|
||||
? new Date(device.latestLocation.timestamp)
|
||||
: null;
|
||||
const isRecent = lastSeen
|
||||
? Date.now() - lastSeen.getTime() < 10 * 60 * 1000
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={device.id}
|
||||
className="bg-white rounded-lg shadow-md p-6 space-y-4 border-2"
|
||||
style={{ borderColor: device.isActive ? device.color : "#ccc" }}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-white shadow-md flex items-center justify-center"
|
||||
style={{ backgroundColor: device.color }}
|
||||
>
|
||||
<span className="text-white text-2xl">📱</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{device.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">ID: {device.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
isRecent
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{isRecent ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{device.description && (
|
||||
<p className="text-sm text-gray-600">{device.description}</p>
|
||||
)}
|
||||
|
||||
{device.latestLocation && (
|
||||
<div className="border-t border-gray-200 pt-4 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 flex items-center gap-2">
|
||||
<span className="text-lg">🕒</span>
|
||||
Last Seen:
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{new Date(device.latestLocation.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{device.latestLocation.battery !== undefined && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 flex items-center gap-2">
|
||||
<span className="text-lg">🔋</span>
|
||||
Battery:
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
device.latestLocation.battery > 20 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{device.latestLocation.battery}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.latestLocation.speed !== undefined && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 flex items-center gap-2">
|
||||
<span className="text-lg">🚗</span>
|
||||
Speed:
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{(Number(device.latestLocation.speed) * 3.6).toFixed(1)} km/h
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 flex items-center gap-2">
|
||||
<span className="text-lg">📍</span>
|
||||
Location:
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{Number(device.latestLocation.latitude).toFixed(5)},{" "}
|
||||
{Number(device.latestLocation.longitude).toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device._count && (
|
||||
<div className="text-sm text-gray-600">
|
||||
{device._count.locations} location points
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => openEditModal(device)}
|
||||
className="flex-1 px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 text-sm font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteModal(device)}
|
||||
className="flex-1 px-3 py-2 bg-red-100 text-red-700 rounded-md hover:bg-red-200 text-sm font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{devices.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
No devices found. Add a device to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Device Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="text-xl font-bold text-gray-900">Add New Device</h3>
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.id}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, id: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., 12, 13"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Must match OwnTracks tracker ID (tid)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., iPhone 13"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, color: e.target.value })
|
||||
}
|
||||
className="h-10 w-20 rounded border border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, color: e.target.value })
|
||||
}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="#3498db"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="Additional notes about this device"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setFormData({
|
||||
id: "",
|
||||
name: "",
|
||||
color: "#3498db",
|
||||
description: "",
|
||||
});
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Device Modal */}
|
||||
{showEditModal && selectedDevice && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
Edit Device
|
||||
</h3>
|
||||
<form onSubmit={handleEdit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.id}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, id: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
⚠️ Changing ID will create a new device (location history stays with old ID)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, color: e.target.value })
|
||||
}
|
||||
className="h-10 w-20 rounded border border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, color: e.target.value })
|
||||
}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedDevice(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && selectedDevice && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="text-xl font-bold text-gray-900">Delete Device</h3>
|
||||
<p className="text-gray-600">
|
||||
Are you sure you want to delete <strong>{selectedDevice.name}</strong>{" "}
|
||||
(ID: {selectedDevice.id})?
|
||||
</p>
|
||||
<p className="text-sm text-red-600">
|
||||
This will also delete all location history for this device. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setSelectedDevice(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 font-medium"
|
||||
>
|
||||
Delete Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
app/admin/emails/page.tsx
Normal file
171
app/admin/emails/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { EMAIL_TEMPLATES, EmailTemplate } from "@/lib/types/smtp";
|
||||
|
||||
export default function EmailsPage() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('welcome');
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
|
||||
const handleSendTest = async () => {
|
||||
if (!testEmail) {
|
||||
alert('Please enter a test email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/emails/send-test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template: selectedTemplate,
|
||||
email: testEmail,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send');
|
||||
}
|
||||
|
||||
setMessage({ type: 'success', text: data.message });
|
||||
setShowSendModal(false);
|
||||
setTestEmail('');
|
||||
} catch (error: any) {
|
||||
setMessage({ type: 'error', text: error.message || 'Failed to send test email' });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewUrl = `/api/admin/emails/preview?template=${selectedTemplate}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-6">Email Templates</h2>
|
||||
|
||||
{/* Status Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-6 p-4 rounded ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Template List */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Templates</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-2">
|
||||
{EMAIL_TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.name}
|
||||
onClick={() => setSelectedTemplate(template.name)}
|
||||
className={`w-full text-left px-4 py-3 rounded-md transition-colors ${
|
||||
selectedTemplate === template.name
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-50 hover:bg-gray-100 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium">{template.subject}</p>
|
||||
<p className={`text-sm mt-1 ${
|
||||
selectedTemplate === template.name
|
||||
? 'text-blue-100'
|
||||
: 'text-gray-600'
|
||||
}`}>
|
||||
{template.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Test Button */}
|
||||
<button
|
||||
onClick={() => setShowSendModal(true)}
|
||||
className="w-full mt-4 px-4 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 font-medium"
|
||||
>
|
||||
Send Test Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Preview</h3>
|
||||
<span className="text-sm text-gray-600">
|
||||
{EMAIL_TEMPLATES.find(t => t.name === selectedTemplate)?.subject}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="w-full border border-gray-300 rounded"
|
||||
style={{ height: '600px' }}
|
||||
title="Email Preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Test Email Modal */}
|
||||
{showSendModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-xl font-bold mb-4">Send Test Email</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Template: <strong>{EMAIL_TEMPLATES.find(t => t.name === selectedTemplate)?.subject}</strong>
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Enter your email address to receive a test email.
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder="your-email@example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSendModal(false);
|
||||
setTestEmail('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendTest}
|
||||
disabled={sending || !testEmail}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
|
||||
>
|
||||
{sending ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
app/admin/layout.tsx
Normal file
106
app/admin/layout.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { data: session } = useSession();
|
||||
const userRole = (session?.user as any)?.role;
|
||||
const username = session?.user?.name || '';
|
||||
const isAdmin = userRole === 'ADMIN';
|
||||
const isSuperAdmin = username === 'admin';
|
||||
|
||||
const allNavigation = [
|
||||
{ name: "Dashboard", href: "/admin", roles: ['ADMIN', 'VIEWER'], superAdminOnly: false },
|
||||
{ name: "Devices", href: "/admin/devices", roles: ['ADMIN', 'VIEWER'], superAdminOnly: false },
|
||||
{ name: "MQTT Provisioning", href: "/admin/mqtt", roles: ['ADMIN'], superAdminOnly: false },
|
||||
{ name: "Setup Guide", href: "/admin/setup", roles: ['ADMIN', 'VIEWER'], superAdminOnly: false },
|
||||
{ name: "Users", href: "/admin/users", roles: ['ADMIN'], superAdminOnly: false },
|
||||
{ name: "Settings", href: "/admin/settings", roles: ['ADMIN'], superAdminOnly: true },
|
||||
{ name: "Emails", href: "/admin/emails", roles: ['ADMIN'], superAdminOnly: true },
|
||||
];
|
||||
|
||||
// Filter navigation based on user role and super admin status
|
||||
const navigation = allNavigation.filter(item => {
|
||||
const hasRole = item.roles.includes(userRole as string);
|
||||
const hasAccess = item.superAdminOnly ? isSuperAdmin : true;
|
||||
return hasRole && hasAccess;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
{/* Top row: Title + User Info + Actions */}
|
||||
<div className="flex justify-between items-center mb-3 lg:mb-0">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-black">
|
||||
{isAdmin ? 'Admin Panel' : 'Dashboard'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:gap-4 items-center">
|
||||
{/* User info */}
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<span className="text-gray-600">Angemeldet als:</span>
|
||||
<span className="font-semibold text-black">{username || session?.user?.email}</span>
|
||||
{!isAdmin && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs">Viewer</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<span className="px-2 py-0.5 bg-red-100 text-red-800 rounded text-xs">Admin</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 items-center border-l border-gray-300 pl-2 sm:pl-4">
|
||||
<Link
|
||||
href="/map"
|
||||
className="px-2 sm:px-4 py-2 text-sm text-black font-semibold hover:text-blue-600"
|
||||
>
|
||||
Map
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut({ redirect: false });
|
||||
window.location.href = '/login';
|
||||
}}
|
||||
className="px-2 sm:px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm font-semibold"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation row (scrollable on mobile) */}
|
||||
<nav className="flex gap-2 overflow-x-auto lg:gap-4 pb-2 lg:pb-0 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-md text-sm font-semibold transition-colors whitespace-nowrap ${
|
||||
pathname === item.href
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-black hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
638
app/admin/mqtt/page.tsx
Normal file
638
app/admin/mqtt/page.tsx
Normal file
@@ -0,0 +1,638 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface MqttCredential {
|
||||
id: number;
|
||||
device_id: string;
|
||||
mqtt_username: string;
|
||||
mqtt_password_hash: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
device_name: string;
|
||||
mqtt_password?: string; // Nur bei Erstellung/Regenerierung vorhanden
|
||||
}
|
||||
|
||||
interface AclRule {
|
||||
id: number;
|
||||
device_id: string;
|
||||
topic_pattern: string;
|
||||
permission: 'read' | 'write' | 'readwrite';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
pending_changes: number;
|
||||
last_sync_at: string | null;
|
||||
last_sync_status: string;
|
||||
}
|
||||
|
||||
export default function MqttPage() {
|
||||
const { data: session } = useSession();
|
||||
const userRole = (session?.user as any)?.role;
|
||||
const isAdmin = userRole === 'ADMIN';
|
||||
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [credentials, setCredentials] = useState<MqttCredential[]>([]);
|
||||
const [aclRules, setAclRules] = useState<Record<string, AclRule[]>>({});
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
// Modal States
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [showAclModal, setShowAclModal] = useState(false);
|
||||
const [selectedDevice, setSelectedDevice] = useState<string | null>(null);
|
||||
const [generatedPassword, setGeneratedPassword] = useState<string>("");
|
||||
|
||||
// Form States
|
||||
const [addFormData, setAddFormData] = useState({
|
||||
device_id: "",
|
||||
auto_generate: true,
|
||||
});
|
||||
|
||||
const [aclFormData, setAclFormData] = useState({
|
||||
device_id: "",
|
||||
topic_pattern: "",
|
||||
permission: "readwrite" as 'read' | 'write' | 'readwrite',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
fetchAll();
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchDevices(),
|
||||
fetchCredentials(),
|
||||
fetchSyncStatus(),
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/devices");
|
||||
if (!response.ok) throw new Error("Failed to fetch devices");
|
||||
const data = await response.json();
|
||||
setDevices(data.devices || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch devices", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/mqtt/credentials");
|
||||
if (!response.ok) throw new Error("Failed to fetch credentials");
|
||||
const data = await response.json();
|
||||
setCredentials(data);
|
||||
|
||||
// Lade ACL Regeln für alle Devices mit Credentials
|
||||
for (const cred of data) {
|
||||
await fetchAclRules(cred.device_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch credentials", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAclRules = async (deviceId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/mqtt/acl?device_id=${deviceId}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch ACL rules");
|
||||
const rules = await response.json();
|
||||
setAclRules(prev => ({ ...prev, [deviceId]: rules }));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch ACL rules", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/mqtt/sync");
|
||||
if (!response.ok) throw new Error("Failed to fetch sync status");
|
||||
const status = await response.json();
|
||||
setSyncStatus(status);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch sync status", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredentials = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch("/api/mqtt/credentials", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(addFormData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to create credentials");
|
||||
}
|
||||
|
||||
const newCred = await response.json();
|
||||
|
||||
// Zeige generiertes Passwort an
|
||||
if (newCred.mqtt_password) {
|
||||
setGeneratedPassword(`Username: ${newCred.mqtt_username}\nPassword: ${newCred.mqtt_password}`);
|
||||
setSelectedDevice(addFormData.device_id);
|
||||
setShowPasswordModal(true);
|
||||
}
|
||||
|
||||
await fetchCredentials();
|
||||
await fetchSyncStatus();
|
||||
setShowAddModal(false);
|
||||
setAddFormData({ device_id: "", auto_generate: true });
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCredentials = async (deviceId: string) => {
|
||||
if (!confirm("MQTT Credentials für dieses Device löschen?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mqtt/credentials/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to delete credentials");
|
||||
|
||||
await fetchCredentials();
|
||||
await fetchSyncStatus();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegeneratePassword = async (deviceId: string) => {
|
||||
if (!confirm("Passwort neu generieren? Das alte Passwort wird ungültig.")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mqtt/credentials/${deviceId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ regenerate_password: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to regenerate password");
|
||||
|
||||
const updated = await response.json();
|
||||
if (updated.mqtt_password) {
|
||||
setGeneratedPassword(`Username: ${updated.mqtt_username}\nPassword: ${updated.mqtt_password}`);
|
||||
setSelectedDevice(deviceId);
|
||||
setShowPasswordModal(true);
|
||||
}
|
||||
|
||||
await fetchCredentials();
|
||||
await fetchSyncStatus();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (deviceId: string, enabled: boolean) => {
|
||||
try {
|
||||
const response = await fetch(`/api/mqtt/credentials/${deviceId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to update credentials");
|
||||
|
||||
await fetchCredentials();
|
||||
await fetchSyncStatus();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAclRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch("/api/mqtt/acl", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(aclFormData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to create ACL rule");
|
||||
}
|
||||
|
||||
await fetchAclRules(aclFormData.device_id);
|
||||
await fetchSyncStatus();
|
||||
setShowAclModal(false);
|
||||
setAclFormData({ device_id: "", topic_pattern: "", permission: "readwrite" });
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAclRule = async (ruleId: number, deviceId: string) => {
|
||||
if (!confirm("ACL Regel löschen?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mqtt/acl/${ruleId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to delete ACL rule");
|
||||
|
||||
await fetchAclRules(deviceId);
|
||||
await fetchSyncStatus();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const response = await fetch("/api/mqtt/sync", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
} else {
|
||||
alert(`Sync fehlgeschlagen: ${result.message}`);
|
||||
}
|
||||
|
||||
await fetchSyncStatus();
|
||||
} catch (err: any) {
|
||||
alert("Sync fehlgeschlagen: " + err.message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCredentialsEmail = async () => {
|
||||
if (!selectedDevice) return;
|
||||
|
||||
// Parse username and password from generatedPassword string
|
||||
const lines = generatedPassword.split('\n');
|
||||
const usernameLine = lines.find(l => l.startsWith('Username:'));
|
||||
const passwordLine = lines.find(l => l.startsWith('Password:'));
|
||||
|
||||
if (!usernameLine || !passwordLine) {
|
||||
alert('Fehler beim Extrahieren der Credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
const mqttUsername = usernameLine.replace('Username:', '').trim();
|
||||
const mqttPassword = passwordLine.replace('Password:', '').trim();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mqtt/send-credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deviceId: selectedDevice,
|
||||
mqttUsername,
|
||||
mqttPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to send email');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
alert(result.message);
|
||||
} catch (err: any) {
|
||||
alert('Email senden fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return <div className="p-8">Keine Berechtigung</div>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Lade...</div>;
|
||||
}
|
||||
|
||||
const devicesWithoutCredentials = devices.filter(
|
||||
d => d.isActive && !credentials.find(c => c.device_id === d.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">MQTT Provisioning</h1>
|
||||
<div className="flex gap-4 items-center">
|
||||
{syncStatus && syncStatus.pending_changes > 0 && (
|
||||
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-md text-sm">
|
||||
{syncStatus.pending_changes} ausstehende Änderungen
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || !syncStatus || syncStatus.pending_changes === 0}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{syncing ? "Synchronisiere..." : "MQTT Sync"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Device Provisionieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncStatus && (
|
||||
<div className="mb-6 p-4 bg-gray-100 rounded-md">
|
||||
<h3 className="font-semibold mb-2">Sync Status</h3>
|
||||
<div className="text-sm">
|
||||
<p>Status: <span className={syncStatus.last_sync_status === 'success' ? 'text-green-600' : 'text-red-600'}>{syncStatus.last_sync_status}</span></p>
|
||||
{syncStatus.last_sync_at && (
|
||||
<p>Letzter Sync: {new Date(syncStatus.last_sync_at).toLocaleString('de-DE')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{credentials.map(cred => {
|
||||
const deviceRules = aclRules[cred.device_id] || [];
|
||||
|
||||
return (
|
||||
<div key={cred.id} className="border rounded-lg p-6 bg-white shadow-sm">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{cred.device_name}</h3>
|
||||
<p className="text-sm text-gray-500">Device ID: {cred.device_id}</p>
|
||||
<p className="text-sm text-gray-600 mt-1">Username: <code className="bg-gray-100 px-2 py-1 rounded">{cred.mqtt_username}</code></p>
|
||||
<p className="text-xs text-gray-500 mt-1">Erstellt: {new Date(cred.created_at).toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(cred.device_id, !cred.enabled)}
|
||||
className={`px-3 py-1 rounded text-sm ${cred.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
|
||||
>
|
||||
{cred.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRegeneratePassword(cred.device_id)}
|
||||
className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded text-sm hover:bg-yellow-200"
|
||||
>
|
||||
Passwort Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAclFormData({
|
||||
device_id: cred.device_id,
|
||||
topic_pattern: `owntracks/owntrack/${cred.device_id}`,
|
||||
permission: "readwrite"
|
||||
});
|
||||
setShowAclModal(true);
|
||||
}}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded text-sm hover:bg-blue-200"
|
||||
>
|
||||
ACL Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteCredentials(cred.device_id)}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded text-sm hover:bg-red-200"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-semibold mb-2 text-sm">ACL Regeln:</h4>
|
||||
{deviceRules.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">Topic Pattern</th>
|
||||
<th className="px-4 py-2 text-left">Berechtigung</th>
|
||||
<th className="px-4 py-2 text-left">Erstellt</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deviceRules.map(rule => (
|
||||
<tr key={rule.id} className="border-t">
|
||||
<td className="px-4 py-2"><code className="bg-gray-100 px-2 py-1 rounded">{rule.topic_pattern}</code></td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">{rule.permission}</span></td>
|
||||
<td className="px-4 py-2 text-gray-500">{new Date(rule.created_at).toLocaleString('de-DE')}</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => handleDeleteAclRule(rule.id, cred.device_id)}
|
||||
className="text-red-600 hover:text-red-800 text-xs"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Keine ACL Regeln definiert</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{credentials.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Noch keine Devices provisioniert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Credentials Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Device Provisionieren</h2>
|
||||
<form onSubmit={handleAddCredentials}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Device</label>
|
||||
<select
|
||||
value={addFormData.device_id}
|
||||
onChange={(e) => setAddFormData({ ...addFormData, device_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
required
|
||||
>
|
||||
<option value="">Device auswählen</option>
|
||||
{devicesWithoutCredentials.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name} (ID: {d.id})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addFormData.auto_generate}
|
||||
onChange={(e) => setAddFormData({ ...addFormData, auto_generate: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm">Automatisch Username & Passwort generieren</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="flex-1 px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Display Modal */}
|
||||
{showPasswordModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">MQTT Credentials</h2>
|
||||
<div className="mb-4 p-4 bg-gray-100 rounded-md">
|
||||
<pre className="text-sm whitespace-pre-wrap">{generatedPassword}</pre>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 mb-4">
|
||||
⚠️ Speichere diese Credentials! Das Passwort kann nicht nochmal angezeigt werden.
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Moderne Clipboard API (bevorzugt)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(generatedPassword);
|
||||
alert("✓ In Zwischenablage kopiert!");
|
||||
} else {
|
||||
// Fallback für ältere Browser oder HTTP-Kontext
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = generatedPassword;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
alert("✓ In Zwischenablage kopiert!");
|
||||
} else {
|
||||
throw new Error("Copy failed");
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Clipboard error:", err);
|
||||
alert("❌ Kopieren fehlgeschlagen. Bitte manuell kopieren:\n\n" + generatedPassword);
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 mb-2"
|
||||
>
|
||||
📋 In Zwischenablage kopieren
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendCredentialsEmail}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 mb-2"
|
||||
>
|
||||
Per Email senden
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordModal(false);
|
||||
setSelectedDevice(null);
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add ACL Rule Modal */}
|
||||
{showAclModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">ACL Regel Hinzufügen</h2>
|
||||
<form onSubmit={handleAddAclRule}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Topic Pattern</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aclFormData.topic_pattern}
|
||||
onChange={(e) => setAclFormData({ ...aclFormData, topic_pattern: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder={`owntracks/owntrack/${aclFormData.device_id || '<DeviceID>'}`}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Format: owntracks/owntrack/<DeviceID> (z.B. owntracks/owntrack/10)
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Berechtigung</label>
|
||||
<select
|
||||
value={aclFormData.permission}
|
||||
onChange={(e) => setAclFormData({ ...aclFormData, permission: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="read">Read (Lesen)</option>
|
||||
<option value="write">Write (Schreiben)</option>
|
||||
<option value="readwrite">Read/Write (Lesen & Schreiben)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAclModal(false)}
|
||||
className="flex-1 px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
519
app/admin/page.tsx
Normal file
519
app/admin/page.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { LocationResponse } from "@/types/location";
|
||||
|
||||
interface DeviceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { data: session } = useSession();
|
||||
const userRole = (session?.user as any)?.role;
|
||||
const username = session?.user?.name || '';
|
||||
const isAdmin = userRole === 'ADMIN';
|
||||
const isSuperAdmin = username === 'admin';
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
totalDevices: 0,
|
||||
totalPoints: 0,
|
||||
lastUpdated: "",
|
||||
onlineDevices: 0,
|
||||
});
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
const [cleanupStatus, setCleanupStatus] = useState<{
|
||||
loading: boolean;
|
||||
message: string;
|
||||
type: 'success' | 'error' | '';
|
||||
}>({
|
||||
loading: false,
|
||||
message: '',
|
||||
type: '',
|
||||
});
|
||||
const [optimizeStatus, setOptimizeStatus] = useState<{
|
||||
loading: boolean;
|
||||
message: string;
|
||||
type: 'success' | 'error' | '';
|
||||
}>({
|
||||
loading: false,
|
||||
message: '',
|
||||
type: '',
|
||||
});
|
||||
const [dbStats, setDbStats] = useState<any>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<any>(null);
|
||||
|
||||
// Fetch devices from API
|
||||
useEffect(() => {
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/devices/public");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDevices(data.devices);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch devices:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDevices();
|
||||
// Refresh devices every 30 seconds
|
||||
const interval = setInterval(fetchDevices, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Fetch from local API (reads from SQLite cache)
|
||||
const response = await fetch("/api/locations?sync=false"); // sync=false for faster response
|
||||
const data: LocationResponse = await response.json();
|
||||
|
||||
// Calculate online devices (last location < 10 minutes)
|
||||
const now = Date.now();
|
||||
const tenMinutesAgo = now - 10 * 60 * 1000;
|
||||
|
||||
const recentDevices = new Set(
|
||||
data.history
|
||||
.filter((loc) => {
|
||||
const locationTime = new Date(loc.timestamp).getTime();
|
||||
return locationTime > tenMinutesAgo;
|
||||
})
|
||||
.map((loc) => loc.username)
|
||||
);
|
||||
|
||||
setStats({
|
||||
totalDevices: devices.length,
|
||||
totalPoints: data.total_points || data.history.length,
|
||||
lastUpdated: data.last_updated || new Date().toISOString(),
|
||||
onlineDevices: recentDevices.size,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch stats", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (devices.length > 0) {
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [devices]);
|
||||
|
||||
// Fetch detailed database statistics
|
||||
useEffect(() => {
|
||||
const fetchDbStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/locations/stats');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDbStats(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch DB stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDbStats();
|
||||
// Refresh DB stats every 30 seconds
|
||||
const interval = setInterval(fetchDbStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Fetch system status (uptime, memory)
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSystemStatus(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch system status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemStatus();
|
||||
// Refresh every 10 seconds for live uptime
|
||||
const interval = setInterval(fetchSystemStatus, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Cleanup old locations
|
||||
const handleCleanup = async (retentionHours: number) => {
|
||||
if (!confirm(`Delete all locations older than ${Math.round(retentionHours / 24)} days?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCleanupStatus({ loading: true, message: '', type: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/locations/cleanup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ retentionHours }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setCleanupStatus({
|
||||
loading: false,
|
||||
message: `✓ Deleted ${data.deleted} records. Freed ${data.freedKB} KB.`,
|
||||
type: 'success',
|
||||
});
|
||||
// Refresh stats
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
setCleanupStatus({
|
||||
loading: false,
|
||||
message: `Error: ${data.error}`,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setCleanupStatus({
|
||||
loading: false,
|
||||
message: 'Failed to cleanup locations',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setCleanupStatus({ loading: false, message: '', type: '' });
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// Optimize database
|
||||
const handleOptimize = async () => {
|
||||
if (!confirm('Optimize database? This may take a few seconds.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOptimizeStatus({ loading: true, message: '', type: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/locations/optimize', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setOptimizeStatus({
|
||||
loading: false,
|
||||
message: `✓ Database optimized. Freed ${data.freedMB} MB. (${data.before.sizeMB} → ${data.after.sizeMB} MB)`,
|
||||
type: 'success',
|
||||
});
|
||||
// Refresh stats
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
setOptimizeStatus({
|
||||
loading: false,
|
||||
message: `Error: ${data.error}`,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setOptimizeStatus({
|
||||
loading: false,
|
||||
message: 'Failed to optimize database',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setOptimizeStatus({ loading: false, message: '', type: '' });
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "Total Devices",
|
||||
value: stats.totalDevices,
|
||||
icon: "📱",
|
||||
},
|
||||
{
|
||||
title: "Online Devices",
|
||||
value: stats.onlineDevices,
|
||||
icon: "🟢",
|
||||
},
|
||||
{
|
||||
title: "Total Locations",
|
||||
value: stats.totalPoints,
|
||||
icon: "📍",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-3xl font-bold text-gray-900">Dashboard</h2>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{statCards.map((stat) => (
|
||||
<div
|
||||
key={stat.title}
|
||||
className="bg-white rounded-lg shadow p-6 flex items-center gap-4"
|
||||
>
|
||||
<div className="text-4xl">{stat.icon}</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">{stat.title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
{systemStatus && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
System Status
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<p className="text-sm text-gray-600">App Uptime</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{systemStatus.uptime.formatted}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Running since server start</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<p className="text-sm text-gray-600">Memory Usage</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{systemStatus.memory.heapUsed} MB</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Heap: {systemStatus.memory.heapTotal} MB / RSS: {systemStatus.memory.rss} MB</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<p className="text-sm text-gray-600">Runtime</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{systemStatus.nodejs}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Platform: {systemStatus.platform}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Database Statistics */}
|
||||
{dbStats && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Database Statistics
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<p className="text-sm text-gray-600">Database Size</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{dbStats.sizeMB} MB</p>
|
||||
<p className="text-xs text-gray-500 mt-1">WAL Mode: {dbStats.walMode}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<p className="text-sm text-gray-600">Time Range</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{dbStats.oldest ? new Date(dbStats.oldest).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">to</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{dbStats.newest ? new Date(dbStats.newest).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<p className="text-sm text-gray-600">Average Per Day</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{dbStats.avgPerDay}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">locations (last 7 days)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Locations per Device */}
|
||||
{dbStats.perDevice && dbStats.perDevice.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Locations per Device</h4>
|
||||
<div className="space-y-2">
|
||||
{dbStats.perDevice.map((device: any) => (
|
||||
<div key={device.username} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium text-gray-700">Device {device.username}</span>
|
||||
<span className="text-sm text-gray-900">{device.count.toLocaleString()} locations</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Configured Devices
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">
|
||||
ID
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">
|
||||
Color
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{device.id}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{device.name}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-gray-300"
|
||||
style={{ backgroundColor: device.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
{device.color}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Maintenance - SUPER ADMIN ONLY (username "admin") */}
|
||||
{isSuperAdmin && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Database Maintenance
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Cleanup Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
||||
Clean up old data
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Delete old location data to keep the database size manageable.
|
||||
</p>
|
||||
|
||||
{/* Cleanup Status Message */}
|
||||
{cleanupStatus.message && (
|
||||
<div
|
||||
className={`mb-3 p-3 rounded ${
|
||||
cleanupStatus.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{cleanupStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cleanup Buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => handleCleanup(168)}
|
||||
disabled={cleanupStatus.loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 7 days'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCleanup(360)}
|
||||
disabled={cleanupStatus.loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 15 days'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCleanup(720)}
|
||||
disabled={cleanupStatus.loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 30 days'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCleanup(2160)}
|
||||
disabled={cleanupStatus.loading}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 90 days'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Current database size: {stats.totalPoints} locations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Optimize Section */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
||||
Optimize Database
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Run VACUUM and ANALYZE to reclaim disk space and improve query performance. Recommended after cleanup.
|
||||
</p>
|
||||
|
||||
{/* Optimize Status Message */}
|
||||
{optimizeStatus.message && (
|
||||
<div
|
||||
className={`mb-3 p-3 rounded ${
|
||||
optimizeStatus.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{optimizeStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizeStatus.loading}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<span>{optimizeStatus.loading ? '⚙️' : '⚡'}</span>
|
||||
{optimizeStatus.loading ? 'Optimizing...' : 'Optimize Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="text-sm text-gray-500 text-right">
|
||||
Last updated: {new Date(stats.lastUpdated).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
411
app/admin/settings/page.tsx
Normal file
411
app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { SMTPConfig, SMTPConfigResponse } from "@/lib/types/smtp";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'smtp'>('smtp');
|
||||
const [config, setConfig] = useState<SMTPConfig>({
|
||||
host: '',
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: { user: '', pass: '' },
|
||||
from: { email: '', name: 'Location Tracker' },
|
||||
replyTo: '',
|
||||
timeout: 10000,
|
||||
});
|
||||
const [source, setSource] = useState<'database' | 'env'>('env');
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [showTestModal, setShowTestModal] = useState(false);
|
||||
|
||||
// Fetch current config
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings/smtp');
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
|
||||
const data: SMTPConfigResponse = await response.json();
|
||||
|
||||
if (data.config) {
|
||||
setConfig(data.config);
|
||||
setHasPassword(data.config.auth.pass === '***');
|
||||
}
|
||||
setSource(data.source);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch SMTP config:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to load SMTP configuration' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Save config
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings/smtp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to save');
|
||||
}
|
||||
|
||||
setMessage({ type: 'success', text: 'SMTP settings saved successfully' });
|
||||
setHasPassword(true);
|
||||
setSource('database');
|
||||
|
||||
// Clear password field for security
|
||||
setConfig({ ...config, auth: { ...config.auth, pass: '' } });
|
||||
} catch (error: any) {
|
||||
setMessage({ type: 'error', text: error.message || 'Failed to save settings' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset to defaults
|
||||
const handleReset = async () => {
|
||||
if (!confirm('Reset to environment defaults? This will delete database configuration.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings/smtp', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to reset');
|
||||
|
||||
setMessage({ type: 'success', text: 'Reset to environment defaults' });
|
||||
await fetchConfig();
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to reset settings' });
|
||||
}
|
||||
};
|
||||
|
||||
// Test connection
|
||||
const handleTest = async () => {
|
||||
if (!testEmail) {
|
||||
alert('Please enter a test email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings/smtp/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: hasPassword ? undefined : config,
|
||||
testEmail,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Test failed');
|
||||
}
|
||||
|
||||
setMessage({ type: 'success', text: data.message });
|
||||
setShowTestModal(false);
|
||||
setTestEmail('');
|
||||
} catch (error: any) {
|
||||
setMessage({ type: 'error', text: error.message || 'Connection test failed' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-600">Loading settings...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-6">Settings</h2>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('smtp')}
|
||||
className={`px-4 py-2 border-b-2 font-medium ${
|
||||
activeTab === 'smtp'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
SMTP Settings
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-6 p-4 rounded ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Source Info */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>Current source:</strong> {source === 'database' ? 'Database (Custom)' : 'Environment (.env)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SMTP Form */}
|
||||
<form onSubmit={handleSave} className="bg-white rounded-lg shadow p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Host */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SMTP Host *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={config.host}
|
||||
onChange={(e) => setConfig({ ...config, host: e.target.value.trim() })}
|
||||
placeholder="smtp.gmail.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{config.host.includes('gmail') && (
|
||||
<p className="mt-1 text-xs text-blue-600">
|
||||
Gmail detected: Use App Password, not your regular password.
|
||||
<a
|
||||
href="https://myaccount.google.com/apppasswords"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline ml-1"
|
||||
>
|
||||
Generate App Password
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Port and Secure */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Port *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
value={config.port}
|
||||
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.secure}
|
||||
onChange={(e) => setConfig({ ...config, secure: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Use TLS/SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={config.auth.user}
|
||||
onChange={(e) => setConfig({ ...config, auth: { ...config.auth, user: e.target.value.trim() } })}
|
||||
placeholder="your-email@example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password {hasPassword && '(leave empty to keep current)'}
|
||||
{config.host.includes('gmail') && (
|
||||
<span className="text-red-600 font-semibold ml-2">
|
||||
- Use App Password!
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!hasPassword}
|
||||
value={config.auth.pass}
|
||||
onChange={(e) => setConfig({ ...config, auth: { ...config.auth, pass: e.target.value.trim() } })}
|
||||
placeholder={hasPassword ? '••••••••' : (config.host.includes('gmail') ? 'Gmail App Password (16 chars)' : 'your-password')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{config.host.includes('gmail') && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
Do NOT use your Gmail password. Generate an App Password with 2FA enabled.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* From Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
From Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={config.from.email}
|
||||
onChange={(e) => setConfig({ ...config, from: { ...config.from, email: e.target.value.trim() } })}
|
||||
placeholder="noreply@example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* From Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
From Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={config.from.name}
|
||||
onChange={(e) => setConfig({ ...config, from: { ...config.from, name: e.target.value } })}
|
||||
placeholder="Location Tracker"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reply-To */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reply-To (optional)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={config.replyTo || ''}
|
||||
onChange={(e) => setConfig({ ...config, replyTo: e.target.value })}
|
||||
placeholder="support@example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeout */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Timeout (ms)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1000"
|
||||
value={config.timeout}
|
||||
onChange={(e) => setConfig({ ...config, timeout: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTestModal(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
{source === 'database' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Test Email Modal */}
|
||||
{showTestModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-xl font-bold mb-4">Test SMTP Connection</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Enter your email address to receive a test email.
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder="your-email@example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTestModal(false);
|
||||
setTestEmail('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !testEmail}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
|
||||
>
|
||||
{testing ? 'Sending...' : 'Send Test Email'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
461
app/admin/setup/page.tsx
Normal file
461
app/admin/setup/page.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SetupGuidePage() {
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||
"1": true, // Installation section open by default
|
||||
});
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
📱 OwnTracks App Setup Anleitung
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Diese Anleitung erklärt Schritt-für-Schritt, wie Sie die OwnTracks App
|
||||
auf Ihrem Smartphone installieren und mit dem Location Tracker System verbinden.
|
||||
</p>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">📋 Inhaltsverzeichnis</h2>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="#installation" className="text-blue-600 hover:underline">1. Installation</a></li>
|
||||
<li><a href="#credentials" className="text-blue-600 hover:underline">2. MQTT Credentials erhalten</a></li>
|
||||
<li><a href="#configuration" className="text-blue-600 hover:underline">3. App Konfiguration</a></li>
|
||||
<li><a href="#testing" className="text-blue-600 hover:underline">5. Verbindung testen</a></li>
|
||||
<li><a href="#ports" className="text-blue-600 hover:underline">6. Port 1883 vs. 9001</a></li>
|
||||
<li><a href="#troubleshooting" className="text-blue-600 hover:underline">7. Troubleshooting</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Installation */}
|
||||
<Section
|
||||
id="installation"
|
||||
title="1. Installation"
|
||||
icon="📥"
|
||||
isOpen={openSections["1"]}
|
||||
onToggle={() => toggleSection("1")}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-bold text-lg mb-2">🍎 iOS (iPhone/iPad)</h4>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||||
<li>Öffnen Sie den <strong>App Store</strong></li>
|
||||
<li>Suchen Sie nach <strong>"OwnTracks"</strong></li>
|
||||
<li>Laden Sie die App herunter</li>
|
||||
</ol>
|
||||
<a
|
||||
href="https://apps.apple.com/app/owntracks/id692424691"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-3 text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
→ App Store Link
|
||||
</a>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-bold text-lg mb-2">🤖 Android</h4>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||||
<li>Öffnen Sie den <strong>Google Play Store</strong></li>
|
||||
<li>Suchen Sie nach <strong>"OwnTracks"</strong></li>
|
||||
<li>Laden Sie die App herunter</li>
|
||||
</ol>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=org.owntracks.android"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-3 text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
→ Play Store Link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 2: Credentials */}
|
||||
<Section
|
||||
id="credentials"
|
||||
title="2. MQTT Credentials erhalten"
|
||||
icon="🔑"
|
||||
isOpen={openSections["2"]}
|
||||
onToggle={() => toggleSection("2")}
|
||||
>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm font-semibold">⚠️ Wichtig: Bevor Sie die App konfigurieren, benötigen Sie MQTT-Zugangsdaten!</p>
|
||||
</div>
|
||||
<ol className="list-decimal list-inside space-y-3 text-sm">
|
||||
<li>Navigieren Sie zu <a href="/admin/mqtt" className="text-blue-600 hover:underline font-semibold">MQTT Provisioning</a></li>
|
||||
<li>Klicken Sie auf <strong>"Device Provisionieren"</strong></li>
|
||||
<li>Wählen Sie Ihr Device aus der Liste</li>
|
||||
<li>Aktivieren Sie <strong>"Automatisch Username & Passwort generieren"</strong></li>
|
||||
<li>Klicken Sie auf <strong>"Erstellen"</strong></li>
|
||||
<li>
|
||||
<strong className="text-red-600">Speichern Sie die Credentials sofort!</strong>
|
||||
<div className="bg-gray-100 p-3 rounded mt-2 font-mono text-xs">
|
||||
Username: device_10_abc123<br />
|
||||
Password: ******************
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</Section>
|
||||
|
||||
{/* Section 3: Configuration */}
|
||||
<Section
|
||||
id="configuration"
|
||||
title="3. OwnTracks App Konfiguration"
|
||||
icon="⚙️"
|
||||
isOpen={openSections["3"]}
|
||||
onToggle={() => toggleSection("3")}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-bold mb-3">Schritt 1: Zu Einstellungen navigieren</h4>
|
||||
<ul className="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>iOS:</strong> Tippen Sie auf das ⚙️ Symbol (oben rechts)</li>
|
||||
<li><strong>Android:</strong> Tippen Sie auf ☰ (Hamburger-Menü) → Einstellungen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-3">Schritt 2: Modus auswählen</h4>
|
||||
<p className="text-sm mb-2">Gehen Sie zu <strong>"Verbindung"</strong> oder <strong>"Connection"</strong></p>
|
||||
<p className="text-sm">Wählen Sie <strong>"Modus"</strong> → <strong className="text-green-600">MQTT</strong></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-3">Schritt 3: Server-Einstellungen</h4>
|
||||
<ConfigTable />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-3">Schritt 4: Authentifizierung</h4>
|
||||
<table className="w-full text-sm border">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border p-2 text-left">Einstellung</th>
|
||||
<th className="border p-2 text-left">Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border p-2">Benutzername</td>
|
||||
<td className="border p-2 font-mono text-xs">device_XX_xxxxxxxx</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border p-2">Passwort</td>
|
||||
<td className="border p-2 font-mono text-xs">Ihr generiertes Passwort</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-3">Schritt 5: Device Identifikation</h4>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm font-semibold text-red-800 mb-2">⚠️ Wichtig!</p>
|
||||
<p className="text-sm">Die Device ID und Tracker ID müssen mit der Device-ID übereinstimmen, die Sie im System konfiguriert haben (z.B. <code className="bg-gray-200 px-1 rounded">10</code>, <code className="bg-gray-200 px-1 rounded">12</code>, <code className="bg-gray-200 px-1 rounded">15</code>).</p>
|
||||
</div>
|
||||
<table className="w-full text-sm border mt-4">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border p-2 text-left">Einstellung</th>
|
||||
<th className="border p-2 text-left">Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border p-2">Geräte ID / Device ID</td>
|
||||
<td className="border p-2 font-mono">10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border p-2">Tracker ID</td>
|
||||
<td className="border p-2 font-mono">10</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 5: Testing */}
|
||||
<Section
|
||||
id="testing"
|
||||
title="5. Verbindung testen"
|
||||
icon="✅"
|
||||
isOpen={openSections["5"]}
|
||||
onToggle={() => toggleSection("5")}
|
||||
>
|
||||
<ol className="list-decimal list-inside space-y-3 text-sm">
|
||||
<li>
|
||||
<strong>Verbindung prüfen:</strong> Sie sollten ein <span className="text-green-600 font-semibold">grünes Symbol</span> oder "Connected" sehen
|
||||
</li>
|
||||
<li>
|
||||
<strong>Testpunkt senden:</strong> Tippen Sie auf den Location-Button (Fadenkreuz-Symbol)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Im Location Tracker prüfen:</strong>
|
||||
<a href="/map" className="text-blue-600 hover:underline ml-1 font-semibold">→ Zur Live-Karte</a>
|
||||
<ul className="list-disc list-inside ml-6 mt-2 space-y-1">
|
||||
<li>Marker mit Ihrer Device-Farbe</li>
|
||||
<li>Aktuelle Koordinaten</li>
|
||||
<li>Zeitstempel der letzten Position</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</Section>
|
||||
|
||||
{/* Section 6: Ports */}
|
||||
<Section
|
||||
id="ports"
|
||||
title="6. Port 1883 vs. 9001 - Was ist der Unterschied?"
|
||||
icon="🔌"
|
||||
isOpen={openSections["6"]}
|
||||
onToggle={() => toggleSection("6")}
|
||||
>
|
||||
<PortComparison />
|
||||
</Section>
|
||||
|
||||
{/* Section 7: Troubleshooting */}
|
||||
<Section
|
||||
id="troubleshooting"
|
||||
title="7. Troubleshooting - Häufige Probleme"
|
||||
icon="🔧"
|
||||
isOpen={openSections["7"]}
|
||||
onToggle={() => toggleSection("7")}
|
||||
>
|
||||
<TroubleshootingSection />
|
||||
</Section>
|
||||
|
||||
{/* Quick Start Checklist */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mt-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">✅ Schnellstart-Checkliste</h3>
|
||||
<ChecklistItems />
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 mt-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">📞 Weiterführende Informationen</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">OwnTracks Dokumentation:</h4>
|
||||
<ul className="space-y-1">
|
||||
<li><a href="https://owntracks.org" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">→ Website</a></li>
|
||||
<li><a href="https://owntracks.org/booklet/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">→ Dokumentation</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Location Tracker System:</h4>
|
||||
<ul className="space-y-1">
|
||||
<li><a href="/admin" className="text-blue-600 hover:underline">→ Dashboard</a></li>
|
||||
<li><a href="/map" className="text-blue-600 hover:underline">→ Live-Karte</a></li>
|
||||
<li><a href="/admin/mqtt" className="text-blue-600 hover:underline">→ MQTT Provisioning</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Section Component
|
||||
function Section({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div id={id} className="border-b border-gray-200 py-6">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between w-full text-left hover:bg-gray-50 p-2 rounded"
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{icon} {title}
|
||||
</h2>
|
||||
<span className="text-2xl text-gray-400">
|
||||
{isOpen ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Config Table Component
|
||||
function ConfigTable() {
|
||||
return (
|
||||
<table className="w-full text-sm border">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border p-2 text-left">Einstellung</th>
|
||||
<th className="border p-2 text-left">Wert</th>
|
||||
<th className="border p-2 text-left">Hinweis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border p-2">Hostname</td>
|
||||
<td className="border p-2 font-mono">192.168.10.118</td>
|
||||
<td className="border p-2 text-gray-600">IP-Adresse des Servers</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border p-2">Port</td>
|
||||
<td className="border p-2 font-mono">1883</td>
|
||||
<td className="border p-2 text-gray-600">Standard MQTT Port</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="border p-2">Websockets nutzen</td>
|
||||
<td className="border p-2 font-bold text-red-600">❌ DEAKTIVIERT</td>
|
||||
<td className="border p-2 text-gray-600">Nur bei Port 9001!</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="border p-2">TLS</td>
|
||||
<td className="border p-2 font-bold text-red-600">❌ DEAKTIVIERT</td>
|
||||
<td className="border p-2 text-gray-600">Lokales Netzwerk</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border p-2">Client ID</td>
|
||||
<td className="border p-2 text-gray-500">Automatisch</td>
|
||||
<td className="border p-2 text-gray-600">Kann leer bleiben</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// Port Comparison Component
|
||||
function PortComparison() {
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="border-2 border-green-500 rounded-lg p-4 bg-green-50">
|
||||
<h4 className="font-bold text-lg mb-3 text-green-800">Port 1883 (Standard MQTT)</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>✅ <strong>Protokoll:</strong> Standard MQTT (TCP)</li>
|
||||
<li>✅ <strong>Verwendung:</strong> Mobile Apps, IoT-Geräte</li>
|
||||
<li>❌ <strong>Websockets:</strong> Nein</li>
|
||||
<li className="mt-3 pt-3 border-t border-green-300">
|
||||
<strong>Empfohlen für OwnTracks App!</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-4 bg-white p-2 rounded text-xs font-mono">
|
||||
Port: 1883<br />
|
||||
Websockets: DEAKTIVIERT
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-2 border-blue-500 rounded-lg p-4 bg-blue-50">
|
||||
<h4 className="font-bold text-lg mb-3 text-blue-800">Port 9001 (MQTT over WebSockets)</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>✅ <strong>Protokoll:</strong> MQTT über WebSocket</li>
|
||||
<li>✅ <strong>Verwendung:</strong> Browser, Web-Apps</li>
|
||||
<li>✅ <strong>Websockets:</strong> Ja</li>
|
||||
<li className="mt-3 pt-3 border-t border-blue-300">
|
||||
<strong>Für Web-Anwendungen</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-4 bg-white p-2 rounded text-xs font-mono">
|
||||
Port: 9001<br />
|
||||
Websockets: AKTIVIERT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Troubleshooting Component
|
||||
function TroubleshootingSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TroubleshootingItem
|
||||
problem="Verbindung fehlgeschlagen"
|
||||
solutions={[
|
||||
"Überprüfen Sie Hostname (192.168.10.118) und Port (1883)",
|
||||
"Stellen Sie sicher, dass Smartphone im selben Netzwerk ist",
|
||||
"Deaktivieren Sie TLS/SSL",
|
||||
"Deaktivieren Sie Websockets bei Port 1883",
|
||||
"Prüfen Sie Username und Passwort",
|
||||
]}
|
||||
/>
|
||||
<TroubleshootingItem
|
||||
problem="Verbunden, aber keine Daten auf der Karte"
|
||||
solutions={[
|
||||
"Device ID und Tracker ID müssen übereinstimmen",
|
||||
"Standortberechtigungen 'Immer' erteilen",
|
||||
"Akkuoptimierung deaktivieren (Android)",
|
||||
]}
|
||||
/>
|
||||
<TroubleshootingItem
|
||||
problem="Tracking stoppt im Hintergrund"
|
||||
solutions={[
|
||||
"iOS: Hintergrundaktualisierung aktivieren",
|
||||
"iOS: Standortzugriff auf 'Immer' setzen",
|
||||
"Android: Akkuoptimierung deaktivieren",
|
||||
"Android: Standort 'Immer zulassen'",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TroubleshootingItem({ problem, solutions }: { problem: string; solutions: string[] }) {
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg p-4">
|
||||
<h4 className="font-bold text-red-600 mb-2">❌ {problem}</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{solutions.map((solution, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-green-600 font-bold">✓</span>
|
||||
<span>{solution}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Checklist Items Component
|
||||
function ChecklistItems() {
|
||||
const items = [
|
||||
"OwnTracks App installiert",
|
||||
"MQTT Credentials generiert und gespeichert",
|
||||
"Modus auf MQTT gesetzt",
|
||||
"Hostname: 192.168.10.118 eingetragen",
|
||||
"Port: 1883 eingetragen",
|
||||
"Websockets: ❌ Deaktiviert",
|
||||
"TLS: ❌ Deaktiviert",
|
||||
"Benutzername und Passwort eingetragen",
|
||||
"Device ID und Tracker ID korrekt gesetzt",
|
||||
"Standortberechtigungen 'Immer' erteilt",
|
||||
"Akkuoptimierung deaktiviert (Android)",
|
||||
"Verbindung erfolgreich (grünes Symbol)",
|
||||
"Position auf Karte sichtbar",
|
||||
];
|
||||
|
||||
return (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<input type="checkbox" className="mt-1" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
543
app/admin/users/page.tsx
Normal file
543
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "VIEWER",
|
||||
});
|
||||
|
||||
// Fetch users
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/users");
|
||||
if (!response.ok) throw new Error("Failed to fetch users");
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError("Failed to load users");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
// Handle Add User
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to create user");
|
||||
}
|
||||
|
||||
await fetchUsers();
|
||||
setShowAddModal(false);
|
||||
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Failed to create user");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Edit User
|
||||
const handleEdit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
const updateData: any = {
|
||||
username: formData.username,
|
||||
email: formData.email || null,
|
||||
role: formData.role,
|
||||
};
|
||||
|
||||
// Only include password if it's been changed
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${selectedUser.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to update user");
|
||||
}
|
||||
|
||||
await fetchUsers();
|
||||
setShowEditModal(false);
|
||||
setSelectedUser(null);
|
||||
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Failed to update user");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Delete User
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${selectedUser.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to delete user");
|
||||
}
|
||||
|
||||
await fetchUsers();
|
||||
setShowDeleteModal(false);
|
||||
setSelectedUser(null);
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Failed to delete user");
|
||||
}
|
||||
};
|
||||
|
||||
// Resend welcome email
|
||||
const handleResendWelcome = async (user: User) => {
|
||||
if (!user.email) {
|
||||
alert('This user has no email address');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Send welcome email to ${user.email}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/emails/send-test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template: 'welcome',
|
||||
email: user.email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to send email');
|
||||
}
|
||||
|
||||
alert('Welcome email sent successfully');
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to send welcome email');
|
||||
}
|
||||
};
|
||||
|
||||
// Send password reset
|
||||
const handleSendPasswordReset = async (user: User) => {
|
||||
if (!user.email) {
|
||||
alert('This user has no email address');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Send password reset email to ${user.email}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: user.email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to send email');
|
||||
}
|
||||
|
||||
alert('Password reset email sent successfully');
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to send password reset email');
|
||||
}
|
||||
};
|
||||
|
||||
// Open Edit Modal
|
||||
const openEditModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email || "",
|
||||
password: "", // Leave empty unless user wants to change it
|
||||
role: user.role,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Open Delete Modal
|
||||
const openDeleteModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-600">Loading users...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-3xl font-bold text-gray-900">User Management</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="bg-white rounded-lg shadow-md p-6 border-l-4"
|
||||
style={{
|
||||
borderLeftColor: user.role === "ADMIN" ? "#ef4444" : "#3b82f6",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
user.role === "ADMIN"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<p>
|
||||
<span className="font-medium text-gray-700">Username:</span>{" "}
|
||||
<span className="text-gray-900">{user.username}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium text-gray-700">Email:</span>{" "}
|
||||
<span className="text-gray-900">{user.email || "—"}</span>
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
Created: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{user.lastLoginAt && (
|
||||
<p className="text-gray-600">
|
||||
Last login: {new Date(user.lastLoginAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteModal(user)}
|
||||
className="flex-1 px-3 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Email Actions */}
|
||||
{user.email && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => handleResendWelcome(user)}
|
||||
className="flex-1 px-3 py-2 bg-green-600 text-white text-xs rounded-md hover:bg-green-700"
|
||||
>
|
||||
Resend Welcome
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSendPasswordReset(user)}
|
||||
className="flex-1 px-3 py-2 bg-orange-600 text-white text-xs rounded-md hover:bg-orange-700"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">No users found. Create your first user!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add User Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-xl font-bold mb-4">Add New User</h3>
|
||||
<form onSubmit={handleAdd}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, role: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="VIEWER">VIEWER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{showEditModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-xl font-bold mb-4">Edit User</h3>
|
||||
<form onSubmit={handleEdit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Leave empty to keep current password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, role: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="VIEWER">VIEWER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedUser(null);
|
||||
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete User Modal */}
|
||||
{showDeleteModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-xl font-bold mb-4 text-red-600">
|
||||
Delete User
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Are you sure you want to delete user <strong>{selectedUser.username}</strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user