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>
|
||||
);
|
||||
}
|
||||
60
app/api/admin/emails/preview/route.ts
Normal file
60
app/api/admin/emails/preview/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { renderEmailTemplate } from '@/lib/email-renderer';
|
||||
|
||||
/**
|
||||
* GET /api/admin/emails/preview?template=welcome
|
||||
* Render email template with sample data for preview
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const template = searchParams.get('template');
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Template parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sample data for each template
|
||||
const sampleData: Record<string, any> = {
|
||||
welcome: {
|
||||
username: 'John Doe',
|
||||
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
|
||||
temporaryPassword: 'TempPass123!',
|
||||
},
|
||||
'password-reset': {
|
||||
username: 'John Doe',
|
||||
resetUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=sample-token-123`,
|
||||
expiresIn: '1 hour',
|
||||
},
|
||||
};
|
||||
|
||||
if (!sampleData[template]) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown template: ${template}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const html = await renderEmailTemplate(template, sampleData[template]);
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Email preview failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to render email template' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
app/api/admin/emails/send-test/route.ts
Normal file
106
app/api/admin/emails/send-test/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
// Simple rate limiting (in-memory)
|
||||
const rateLimitMap = new Map<string, number[]>();
|
||||
const RATE_LIMIT = 5; // max requests
|
||||
const RATE_WINDOW = 60 * 1000; // per minute
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const requests = rateLimitMap.get(ip) || [];
|
||||
|
||||
// Filter out old requests
|
||||
const recentRequests = requests.filter(time => now - time < RATE_WINDOW);
|
||||
|
||||
if (recentRequests.length >= RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
recentRequests.push(now);
|
||||
rateLimitMap.set(ip, recentRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/emails/send-test
|
||||
* Send test email with specific template
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown';
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please wait a minute.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { template, email } = body;
|
||||
|
||||
if (!template || !email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Template and email are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
|
||||
// Send appropriate template
|
||||
switch (template) {
|
||||
case 'welcome':
|
||||
await emailService.sendWelcomeEmail({
|
||||
email,
|
||||
username: 'Test User',
|
||||
loginUrl: `${baseUrl}/login`,
|
||||
temporaryPassword: 'TempPass123!',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'password-reset':
|
||||
await emailService.sendPasswordResetEmail({
|
||||
email,
|
||||
username: 'Test User',
|
||||
resetUrl: `${baseUrl}/reset-password?token=sample-token-123`,
|
||||
expiresIn: '1 hour',
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown template: ${template}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Test email sent to ${email}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Send test email failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
149
app/api/admin/settings/smtp/route.ts
Normal file
149
app/api/admin/settings/smtp/route.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { settingsDb } from '@/lib/settings-db';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
import { SMTPConfig, SMTPConfigResponse } from '@/lib/types/smtp';
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/smtp
|
||||
* Returns current SMTP configuration (password masked)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const dbConfig = settingsDb.getSMTPConfig();
|
||||
|
||||
let response: SMTPConfigResponse;
|
||||
|
||||
if (dbConfig) {
|
||||
// Mask password
|
||||
const maskedConfig = {
|
||||
...dbConfig,
|
||||
auth: {
|
||||
...dbConfig.auth,
|
||||
pass: '***',
|
||||
},
|
||||
};
|
||||
response = { config: maskedConfig, source: 'database' };
|
||||
} else {
|
||||
// Check if env config exists
|
||||
const hasEnvConfig =
|
||||
process.env.SMTP_HOST &&
|
||||
process.env.SMTP_USER &&
|
||||
process.env.SMTP_PASS;
|
||||
|
||||
if (hasEnvConfig) {
|
||||
const envConfig: SMTPConfig = {
|
||||
host: process.env.SMTP_HOST!,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER!,
|
||||
pass: '***',
|
||||
},
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL || '',
|
||||
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
|
||||
},
|
||||
replyTo: process.env.SMTP_REPLY_TO,
|
||||
timeout: 10000,
|
||||
};
|
||||
response = { config: envConfig, source: 'env' };
|
||||
} else {
|
||||
response = { config: null, source: 'env' };
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to get SMTP config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get SMTP configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/smtp
|
||||
* Save SMTP configuration to database
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const config = body.config as SMTPConfig;
|
||||
|
||||
// Trim whitespace from credentials to prevent auth errors
|
||||
if (config.host) config.host = config.host.trim();
|
||||
if (config.auth?.user) config.auth.user = config.auth.user.trim();
|
||||
if (config.auth?.pass) config.auth.pass = config.auth.pass.trim();
|
||||
if (config.from?.email) config.from.email = config.from.email.trim();
|
||||
|
||||
// Validation
|
||||
if (!config.host || !config.port || !config.auth?.user || !config.auth?.pass) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required SMTP configuration fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (config.port < 1 || config.port > 65535) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Port must be between 1 and 65535' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save to database (password will be encrypted)
|
||||
settingsDb.setSMTPConfig(config);
|
||||
|
||||
// Reset the cached transporter to use new config
|
||||
emailService.resetTransporter();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to save SMTP config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save SMTP configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/settings/smtp
|
||||
* Reset to environment config
|
||||
*/
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
settingsDb.delete('smtp_config');
|
||||
|
||||
// Reset the cached transporter to use env config
|
||||
emailService.resetTransporter();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to delete SMTP config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reset SMTP configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
app/api/admin/settings/smtp/test/route.ts
Normal file
78
app/api/admin/settings/smtp/test/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
import { SMTPConfig } from '@/lib/types/smtp';
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/smtp/test
|
||||
* Test SMTP configuration by sending a test email
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { config, testEmail } = body as { config?: SMTPConfig; testEmail: string };
|
||||
|
||||
if (!testEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Test email address is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(testEmail)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
await emailService.testConnection(config);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'SMTP connection failed. Please check your settings.'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Send test email
|
||||
try {
|
||||
await emailService.sendWelcomeEmail({
|
||||
email: testEmail,
|
||||
username: 'Test User',
|
||||
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
|
||||
temporaryPassword: undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${testEmail}`,
|
||||
});
|
||||
} catch (sendError) {
|
||||
console.error('[API] Test email send failed:', sendError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Email send failed: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[API] SMTP test failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'SMTP test failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
77
app/api/auth/forgot-password/route.ts
Normal file
77
app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { userDb } from '@/lib/db';
|
||||
import { passwordResetDb } from '@/lib/password-reset-db';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
/**
|
||||
* POST /api/auth/forgot-password
|
||||
* Request password reset email
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const users = userDb.findAll();
|
||||
const user = users.find(u => u.email?.toLowerCase() === email.toLowerCase());
|
||||
|
||||
// SECURITY: Always return success to prevent user enumeration
|
||||
// Even if user doesn't exist, return success but don't send email
|
||||
if (!user) {
|
||||
console.log('[ForgotPassword] Email not found, but returning success (security)');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'If an account exists with this email, a password reset link has been sent.',
|
||||
});
|
||||
}
|
||||
|
||||
// Create password reset token
|
||||
const token = passwordResetDb.create(user.id, 1); // 1 hour expiry
|
||||
|
||||
// Send password reset email
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
|
||||
|
||||
try {
|
||||
await emailService.sendPasswordResetEmail({
|
||||
email: user.email!,
|
||||
username: user.username,
|
||||
resetUrl,
|
||||
expiresIn: '1 hour',
|
||||
});
|
||||
|
||||
console.log('[ForgotPassword] Password reset email sent to:', user.email);
|
||||
} catch (emailError) {
|
||||
console.error('[ForgotPassword] Failed to send email:', emailError);
|
||||
// Don't fail the request if email fails - log and continue
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'If an account exists with this email, a password reset link has been sent.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ForgotPassword] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/api/auth/register/route.ts
Normal file
121
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { userDb } from '@/lib/db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Public user registration endpoint
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username, email, password } = body;
|
||||
|
||||
// Validation
|
||||
if (!username || !email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: username, email, password' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Username validation (at least 3 characters, alphanumeric + underscore)
|
||||
if (username.length < 3) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username must be at least 3 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username can only contain letters, numbers, and underscores' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Password validation (at least 6 characters)
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = userDb.findByUsername(username);
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username already taken' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const allUsers = userDb.findAll();
|
||||
const emailExists = allUsers.find(u => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (emailExists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email already registered' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user with VIEWER role (new users get viewer access by default)
|
||||
const user = userDb.create({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
role: 'VIEWER', // New registrations get VIEWER role
|
||||
});
|
||||
|
||||
console.log('[Register] New user registered:', username);
|
||||
|
||||
// Send welcome email (don't fail registration if email fails)
|
||||
try {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
await emailService.sendWelcomeEmail({
|
||||
email,
|
||||
username,
|
||||
loginUrl: `${baseUrl}/login`,
|
||||
});
|
||||
console.log('[Register] Welcome email sent to:', email);
|
||||
} catch (emailError) {
|
||||
console.error('[Register] Failed to send welcome email:', emailError);
|
||||
// Don't fail registration if email fails
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Account created successfully',
|
||||
user: safeUser,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Register] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Registration failed. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
app/api/auth/reset-password/route.ts
Normal file
106
app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { userDb } from '@/lib/db';
|
||||
import { passwordResetDb } from '@/lib/password-reset-db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* POST /api/auth/reset-password
|
||||
* Reset password with token
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token, newPassword } = body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token and new password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (newPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate token
|
||||
if (!passwordResetDb.isValid(token)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired reset token' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get token details
|
||||
const resetToken = passwordResetDb.findByToken(token);
|
||||
if (!resetToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid reset token' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = userDb.findById(resetToken.user_id);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update user password
|
||||
userDb.update(user.id, { passwordHash });
|
||||
|
||||
// Mark token as used
|
||||
passwordResetDb.markUsed(token);
|
||||
|
||||
console.log('[ResetPassword] Password reset successful for user:', user.username);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Password has been reset successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ResetPassword] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reset password' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/reset-password?token=xxx
|
||||
* Validate reset token (for checking if link is still valid)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = passwordResetDb.isValid(token);
|
||||
|
||||
return NextResponse.json({ valid: isValid });
|
||||
} catch (error) {
|
||||
console.error('[ResetPassword] Validation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to validate token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/api/devices/[id]/route.ts
Normal file
129
app/api/devices/[id]/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deviceDb } from "@/lib/db";
|
||||
|
||||
// GET /api/devices/[id] - Get single device
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const device = deviceDb.findById(id);
|
||||
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Device not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
device: {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
color: device.color,
|
||||
isActive: device.isActive === 1,
|
||||
createdAt: device.createdAt,
|
||||
updatedAt: device.updatedAt,
|
||||
description: device.description,
|
||||
icon: device.icon,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/devices/[id] - Update device (ADMIN only)
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only ADMIN can update devices
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const device = deviceDb.findById(id);
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Device not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, color, description, icon } = body;
|
||||
|
||||
const updated = deviceDb.update(id, {
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
icon,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Failed to update device" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ device: updated });
|
||||
} catch (error) {
|
||||
console.error("Error updating device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/devices/[id] - Soft delete device (ADMIN only)
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only ADMIN can delete devices
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const device = deviceDb.findById(id);
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Device not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const success = deviceDb.delete(id);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Failed to delete device" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Device deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/api/devices/public/route.ts
Normal file
44
app/api/devices/public/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deviceDb, userDb } from "@/lib/db";
|
||||
|
||||
// GET /api/devices/public - Authenticated endpoint for device names and colors
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const role = (session.user as any).role;
|
||||
const username = session.user.name || '';
|
||||
|
||||
// Get list of device IDs the user is allowed to access
|
||||
const allowedDeviceIds = userDb.getAllowedDeviceIds(userId, role, username);
|
||||
|
||||
// Fetch all active devices
|
||||
const allDevices = deviceDb.findAll();
|
||||
|
||||
// Filter to only devices the user can access
|
||||
const userDevices = allDevices.filter(device =>
|
||||
allowedDeviceIds.includes(device.id)
|
||||
);
|
||||
|
||||
// Return only public information (id, name, color)
|
||||
const publicDevices = userDevices.map((device) => ({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
color: device.color,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ devices: publicDevices });
|
||||
} catch (error) {
|
||||
console.error("Error fetching public devices:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch devices" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
120
app/api/devices/route.ts
Normal file
120
app/api/devices/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deviceDb, locationDb, userDb } from "@/lib/db";
|
||||
|
||||
// GET /api/devices - List all devices (from database)
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get devices from database (filtered by user ownership)
|
||||
const userId = (session.user as any).id;
|
||||
let targetUserId = userId;
|
||||
|
||||
// If user is a VIEWER with a parent, show parent's devices instead
|
||||
const currentUser = userDb.findById(userId);
|
||||
if (currentUser && currentUser.role === 'VIEWER' && currentUser.parent_user_id) {
|
||||
targetUserId = currentUser.parent_user_id;
|
||||
console.log(`[Devices] VIEWER ${currentUser.username} accessing parent's devices (parent_id: ${targetUserId})`);
|
||||
}
|
||||
|
||||
const devices = deviceDb.findAll({ userId: targetUserId });
|
||||
|
||||
// Fetch location data from local SQLite cache (24h history)
|
||||
const allLocations = locationDb.findMany({
|
||||
user_id: 0, // MQTT devices only
|
||||
timeRangeHours: 24, // Last 24 hours
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
// Merge devices with latest location data
|
||||
const devicesWithLocation = devices.map((device) => {
|
||||
// Find all locations for this device
|
||||
const deviceLocations = allLocations.filter((loc) => loc.username === device.id);
|
||||
|
||||
// Get latest location (first one, already sorted by timestamp DESC)
|
||||
const latestLocation = deviceLocations[0] || null;
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
color: device.color,
|
||||
isActive: device.isActive === 1,
|
||||
createdAt: device.createdAt,
|
||||
updatedAt: device.updatedAt,
|
||||
description: device.description,
|
||||
icon: device.icon,
|
||||
latestLocation: latestLocation,
|
||||
_count: {
|
||||
locations: deviceLocations.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ devices: devicesWithLocation });
|
||||
} catch (error) {
|
||||
console.error("Error fetching devices:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch devices" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/devices - Create new device (ADMIN only)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only ADMIN can create devices
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, name, color, description, icon } = body;
|
||||
|
||||
// Validation
|
||||
if (!id || !name || !color) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: id, name, color" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device with this ID already exists
|
||||
const existing = deviceDb.findById(id);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Device with this ID already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create device
|
||||
const device = deviceDb.create({
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
ownerId: (session.user as any).id,
|
||||
description,
|
||||
icon,
|
||||
});
|
||||
|
||||
return NextResponse.json({ device }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating device:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create device" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
app/api/locations/cleanup/route.ts
Normal file
73
app/api/locations/cleanup/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { locationDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/cleanup (ADMIN only)
|
||||
*
|
||||
* Delete old location records and optimize database
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* "retentionHours": 168 // 7 days default
|
||||
* }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Super admin only (username "admin")
|
||||
const session = await auth();
|
||||
const username = session?.user?.name || '';
|
||||
if (!session?.user || username !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const body = await request.json();
|
||||
const retentionHours = body.retentionHours || 168; // Default: 7 days
|
||||
|
||||
// Validate retention period
|
||||
if (retentionHours <= 0 || retentionHours > 8760) { // Max 1 year
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid retention period. Must be between 1 and 8760 hours (1 year)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get stats before cleanup
|
||||
const statsBefore = locationDb.getStats();
|
||||
|
||||
// Delete old records
|
||||
const deletedCount = locationDb.deleteOlderThan(retentionHours);
|
||||
|
||||
// Get stats after cleanup
|
||||
const statsAfter = locationDb.getStats();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deleted: deletedCount,
|
||||
retentionHours,
|
||||
retentionDays: Math.round(retentionHours / 24),
|
||||
before: {
|
||||
total: statsBefore.total,
|
||||
sizeKB: statsBefore.sizeKB,
|
||||
oldest: statsBefore.oldest,
|
||||
newest: statsBefore.newest,
|
||||
},
|
||||
after: {
|
||||
total: statsAfter.total,
|
||||
sizeKB: statsAfter.sizeKB,
|
||||
oldest: statsAfter.oldest,
|
||||
newest: statsAfter.newest,
|
||||
},
|
||||
freedKB: statsBefore.sizeKB - statsAfter.sizeKB,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cleanup error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to cleanup locations',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
101
app/api/locations/ingest/route.ts
Normal file
101
app/api/locations/ingest/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { locationDb, Location } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/ingest
|
||||
*
|
||||
* Endpoint for n8n to push location data to local SQLite cache.
|
||||
* This is called AFTER n8n stores the data in NocoDB.
|
||||
*
|
||||
* Expected payload (single location or array):
|
||||
* {
|
||||
* "latitude": 48.1351,
|
||||
* "longitude": 11.5820,
|
||||
* "timestamp": "2024-01-15T10:30:00Z",
|
||||
* "user_id": 0,
|
||||
* "username": "10",
|
||||
* "marker_label": "Device A",
|
||||
* "battery": 85,
|
||||
* "speed": 2.5,
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* Security: Add API key validation in production!
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Support both single location and array of locations
|
||||
const locations = Array.isArray(body) ? body : [body];
|
||||
|
||||
if (locations.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No location data provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Debug logging for speed and battery values
|
||||
console.log('[Ingest Debug] Received locations:', locations.map(loc => ({
|
||||
username: loc.username,
|
||||
speed: loc.speed,
|
||||
speed_type: typeof loc.speed,
|
||||
battery: loc.battery,
|
||||
battery_type: typeof loc.battery
|
||||
})));
|
||||
|
||||
// Validate required fields
|
||||
for (const loc of locations) {
|
||||
if (!loc.latitude || !loc.longitude || !loc.timestamp) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: latitude, longitude, timestamp' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into SQLite
|
||||
let insertedCount = 0;
|
||||
if (locations.length === 1) {
|
||||
locationDb.create(locations[0] as Location);
|
||||
insertedCount = 1;
|
||||
} else {
|
||||
insertedCount = locationDb.createMany(locations as Location[]);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
inserted: insertedCount,
|
||||
message: `Successfully stored ${insertedCount} location(s)`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Location ingest error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to store location data',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/locations/ingest/stats
|
||||
*
|
||||
* Get database statistics (for debugging)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const stats = locationDb.getStats();
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
console.error('Stats error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
app/api/locations/optimize/route.ts
Normal file
62
app/api/locations/optimize/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getLocationsDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/optimize (ADMIN only)
|
||||
*
|
||||
* Optimize database by running VACUUM and ANALYZE
|
||||
* This reclaims unused space and updates query planner statistics
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
// Super admin only (username "admin")
|
||||
const session = await auth();
|
||||
const username = session?.user?.name || '';
|
||||
if (!session?.user || username !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const db = getLocationsDb();
|
||||
|
||||
// Get size before optimization
|
||||
const sizeBefore = db.prepare(
|
||||
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
|
||||
).get() as { sizeMB: number };
|
||||
|
||||
// Run VACUUM to reclaim space
|
||||
db.exec('VACUUM');
|
||||
|
||||
// Run ANALYZE to update query planner statistics
|
||||
db.exec('ANALYZE');
|
||||
|
||||
// Get size after optimization
|
||||
const sizeAfter = db.prepare(
|
||||
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
|
||||
).get() as { sizeMB: number };
|
||||
|
||||
db.close();
|
||||
|
||||
const freedMB = sizeBefore.sizeMB - sizeAfter.sizeMB;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
before: {
|
||||
sizeMB: Math.round(sizeBefore.sizeMB * 100) / 100,
|
||||
},
|
||||
after: {
|
||||
sizeMB: Math.round(sizeAfter.sizeMB * 100) / 100,
|
||||
},
|
||||
freedMB: Math.round(freedMB * 100) / 100,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Optimize error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to optimize database',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
230
app/api/locations/route.ts
Normal file
230
app/api/locations/route.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { LocationResponse } from "@/types/location";
|
||||
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
|
||||
|
||||
/**
|
||||
* GET /api/locations
|
||||
*
|
||||
* Hybrid approach:
|
||||
* 1. Fetch fresh data from n8n webhook
|
||||
* 2. Store new locations in local SQLite cache
|
||||
* 3. Return filtered data from SQLite (enables 24h+ history)
|
||||
*
|
||||
* Query parameters:
|
||||
* - username: Filter by device tracker ID
|
||||
* - timeRangeHours: Filter by time range (e.g., 1, 3, 6, 12, 24)
|
||||
* - startTime: Custom range start (ISO string)
|
||||
* - endTime: Custom range end (ISO string)
|
||||
* - limit: Maximum number of records (default: 1000)
|
||||
* - sync: Set to 'false' to skip n8n fetch and read only from cache
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user's allowed device IDs for filtering locations
|
||||
const userId = (session.user as any).id;
|
||||
const role = (session.user as any).role;
|
||||
const sessionUsername = session.user.name || '';
|
||||
|
||||
// Get list of device IDs the user is allowed to access
|
||||
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
|
||||
|
||||
// If user has no devices, return empty response
|
||||
if (userDeviceIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
current: null,
|
||||
history: [],
|
||||
total_points: 0,
|
||||
last_updated: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const username = searchParams.get('username') || undefined;
|
||||
const timeRangeHours = searchParams.get('timeRangeHours')
|
||||
? parseInt(searchParams.get('timeRangeHours')!, 10)
|
||||
: undefined;
|
||||
const startTime = searchParams.get('startTime') || undefined;
|
||||
const endTime = searchParams.get('endTime') || undefined;
|
||||
const limit = searchParams.get('limit')
|
||||
? parseInt(searchParams.get('limit')!, 10)
|
||||
: 1000;
|
||||
const sync = searchParams.get('sync') !== 'false'; // Default: true
|
||||
|
||||
// Variable to store n8n data as fallback
|
||||
let n8nData: LocationResponse | null = null;
|
||||
|
||||
// Step 1: Optionally fetch and sync from n8n
|
||||
if (sync) {
|
||||
try {
|
||||
const response = await fetch(N8N_API_URL, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(3000), // 3 second timeout
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LocationResponse = await response.json();
|
||||
|
||||
// Debug: Log first location from n8n
|
||||
if (data.history && data.history.length > 0) {
|
||||
console.log('[N8N Debug] First location from n8n:', {
|
||||
username: data.history[0].username,
|
||||
speed: data.history[0].speed,
|
||||
speed_type: typeof data.history[0].speed,
|
||||
speed_exists: 'speed' in data.history[0],
|
||||
battery: data.history[0].battery,
|
||||
battery_type: typeof data.history[0].battery,
|
||||
battery_exists: 'battery' in data.history[0]
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize data: Ensure speed and battery fields exist (treat 0 explicitly)
|
||||
if (data.history && Array.isArray(data.history)) {
|
||||
data.history = data.history.map(loc => {
|
||||
// Generate display_time in German locale (Europe/Berlin timezone)
|
||||
const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
return {
|
||||
...loc,
|
||||
display_time: displayTime,
|
||||
// Explicit handling: 0 is valid, only undefined/null → null
|
||||
speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null),
|
||||
battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Store n8n data for fallback
|
||||
n8nData = data;
|
||||
|
||||
// Store new locations in SQLite
|
||||
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
|
||||
// Get latest timestamp from our DB
|
||||
const stats = locationDb.getStats();
|
||||
const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z';
|
||||
|
||||
// Filter for only newer locations
|
||||
const newLocations = data.history.filter(loc =>
|
||||
loc.timestamp > lastLocalTimestamp
|
||||
);
|
||||
|
||||
if (newLocations.length > 0) {
|
||||
const inserted = locationDb.createMany(newLocations as Location[]);
|
||||
console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (syncError) {
|
||||
// n8n not reachable - that's ok, we'll use cached data
|
||||
console.warn('[Location Sync] n8n webhook not reachable, using cache only:',
|
||||
syncError instanceof Error ? syncError.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read from local SQLite with filters
|
||||
let locations = locationDb.findMany({
|
||||
user_id: 0, // Always filter for MQTT devices
|
||||
username,
|
||||
timeRangeHours,
|
||||
startTime,
|
||||
endTime,
|
||||
limit,
|
||||
});
|
||||
|
||||
// Filter locations to only include user's devices
|
||||
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
||||
|
||||
// Step 3: If DB is empty, use n8n data as fallback
|
||||
if (locations.length === 0 && n8nData && n8nData.history) {
|
||||
console.log('[API] DB empty, using n8n data as fallback');
|
||||
// Filter n8n data if needed
|
||||
let filteredHistory = n8nData.history;
|
||||
|
||||
// Filter by user's devices
|
||||
filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
||||
|
||||
if (username) {
|
||||
filteredHistory = filteredHistory.filter(loc => loc.username === username);
|
||||
}
|
||||
|
||||
// Apply time filters
|
||||
if (startTime && endTime) {
|
||||
// Custom range
|
||||
filteredHistory = filteredHistory.filter(loc =>
|
||||
loc.timestamp >= startTime && loc.timestamp <= endTime
|
||||
);
|
||||
} else if (timeRangeHours) {
|
||||
// Quick filter
|
||||
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString();
|
||||
filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...n8nData,
|
||||
history: filteredHistory,
|
||||
total_points: filteredHistory.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize locations: Ensure speed, battery, and display_time are correct
|
||||
locations = locations.map(loc => {
|
||||
// Generate display_time if missing or regenerate from timestamp
|
||||
const displayTime = loc.display_time || new Date(loc.timestamp).toLocaleString('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
return {
|
||||
...loc,
|
||||
display_time: displayTime,
|
||||
speed: loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null,
|
||||
battery: loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Get actual total count from database (not limited by 'limit' parameter)
|
||||
const stats = locationDb.getStats();
|
||||
|
||||
// Step 4: Return data in n8n-compatible format
|
||||
const response: LocationResponse = {
|
||||
success: true,
|
||||
current: locations.length > 0 ? locations[0] : null,
|
||||
history: locations,
|
||||
total_points: stats.total, // Use actual total from DB, not limited results
|
||||
last_updated: locations.length > 0 ? locations[0].timestamp : new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching locations:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to fetch locations",
|
||||
details: error instanceof Error ? error.message : "Unknown error"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
app/api/locations/stats/route.ts
Normal file
77
app/api/locations/stats/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getLocationsDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* GET /api/locations/stats
|
||||
*
|
||||
* Get detailed database statistics
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getLocationsDb();
|
||||
|
||||
// Overall stats
|
||||
const totalCount = db.prepare('SELECT COUNT(*) as count FROM Location').get() as { count: number };
|
||||
|
||||
// Time range
|
||||
const timeRange = db.prepare(
|
||||
'SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM Location'
|
||||
).get() as { oldest: string | null; newest: string | null };
|
||||
|
||||
// Database size
|
||||
const dbSize = db.prepare(
|
||||
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
|
||||
).get() as { sizeMB: number };
|
||||
|
||||
// WAL mode check
|
||||
const walMode = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string };
|
||||
|
||||
// Locations per device
|
||||
const perDevice = db.prepare(`
|
||||
SELECT username, COUNT(*) as count
|
||||
FROM Location
|
||||
WHERE user_id = 0
|
||||
GROUP BY username
|
||||
ORDER BY count DESC
|
||||
`).all() as Array<{ username: string; count: number }>;
|
||||
|
||||
// Locations per day (last 7 days)
|
||||
const perDay = db.prepare(`
|
||||
SELECT
|
||||
DATE(timestamp) as date,
|
||||
COUNT(*) as count
|
||||
FROM Location
|
||||
WHERE timestamp >= datetime('now', '-7 days')
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY date DESC
|
||||
`).all() as Array<{ date: string; count: number }>;
|
||||
|
||||
// Average locations per day
|
||||
const avgPerDay = perDay.length > 0
|
||||
? Math.round(perDay.reduce((sum, day) => sum + day.count, 0) / perDay.length)
|
||||
: 0;
|
||||
|
||||
db.close();
|
||||
|
||||
return NextResponse.json({
|
||||
total: totalCount.count,
|
||||
oldest: timeRange.oldest,
|
||||
newest: timeRange.newest,
|
||||
sizeMB: Math.round(dbSize.sizeMB * 100) / 100,
|
||||
walMode: walMode.journal_mode,
|
||||
perDevice,
|
||||
perDay,
|
||||
avgPerDay,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Stats error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get database stats',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
86
app/api/locations/sync/route.ts
Normal file
86
app/api/locations/sync/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { locationDb, Location } from '@/lib/db';
|
||||
import type { LocationResponse } from "@/types/location";
|
||||
|
||||
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
|
||||
|
||||
/**
|
||||
* POST /api/locations/sync (ADMIN only)
|
||||
*
|
||||
* Manually sync location data from n8n webhook to local SQLite cache.
|
||||
* This fetches all available data from n8n and stores only new records.
|
||||
*
|
||||
* Useful for:
|
||||
* - Initial database population
|
||||
* - Recovery after downtime
|
||||
* - Manual refresh
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
// ADMIN only
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
// Get stats before sync
|
||||
const statsBefore = locationDb.getStats();
|
||||
|
||||
// Fetch from n8n webhook
|
||||
const response = await fetch(N8N_API_URL, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout for manual sync
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`n8n webhook returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data: LocationResponse = await response.json();
|
||||
|
||||
let insertedCount = 0;
|
||||
|
||||
// Store new locations in SQLite
|
||||
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
|
||||
// Get latest timestamp from our DB
|
||||
const lastLocalTimestamp = statsBefore.newest || '1970-01-01T00:00:00Z';
|
||||
|
||||
// Filter for only newer locations
|
||||
const newLocations = data.history.filter(loc =>
|
||||
loc.timestamp > lastLocalTimestamp
|
||||
);
|
||||
|
||||
if (newLocations.length > 0) {
|
||||
insertedCount = locationDb.createMany(newLocations as Location[]);
|
||||
console.log(`[Manual Sync] Inserted ${insertedCount} new locations from n8n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get stats after sync
|
||||
const statsAfter = locationDb.getStats();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
synced: insertedCount,
|
||||
n8nTotal: data.total_points || data.history.length,
|
||||
before: {
|
||||
total: statsBefore.total,
|
||||
newest: statsBefore.newest,
|
||||
},
|
||||
after: {
|
||||
total: statsAfter.total,
|
||||
newest: statsAfter.newest,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to sync locations',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
app/api/locations/test/route.ts
Normal file
91
app/api/locations/test/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { locationDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* POST /api/locations/test
|
||||
*
|
||||
* Create a test location entry (for development/testing)
|
||||
* Body: { username, latitude, longitude, speed?, battery? }
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username, latitude, longitude, speed, battery } = body;
|
||||
|
||||
// Validation
|
||||
if (!username || latitude === undefined || longitude === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: username, latitude, longitude' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const lat = parseFloat(latitude);
|
||||
const lon = parseFloat(longitude);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid latitude or longitude' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Latitude must be between -90 and 90' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (lon < -180 || lon > 180) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Longitude must be between -180 and 180' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create location
|
||||
const now = new Date();
|
||||
const location = locationDb.create({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: now.toISOString(),
|
||||
user_id: 0,
|
||||
username: String(username),
|
||||
display_time: now.toLocaleString('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}),
|
||||
chat_id: 0,
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
marker_label: null,
|
||||
battery: battery !== undefined ? Number(battery) : null,
|
||||
speed: speed !== undefined ? Number(speed) : null,
|
||||
});
|
||||
|
||||
if (!location) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create location (possibly duplicate)' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
location,
|
||||
message: 'Test location created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Test location creation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create test location' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
146
app/api/mqtt/acl/[id]/route.ts
Normal file
146
app/api/mqtt/acl/[id]/route.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// API Route für einzelne ACL Regel
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { deviceDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* PATCH /api/mqtt/acl/[id]
|
||||
* Aktualisiere eine ACL Regel
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid ACL rule ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { topic_pattern, permission } = body;
|
||||
|
||||
// Validation
|
||||
if (permission && !['read', 'write', 'readwrite'].includes(permission)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Permission must be read, write, or readwrite' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get current ACL rule to check device ownership
|
||||
const userId = (session.user as any).id;
|
||||
const currentRule = mqttAclRuleDb.findByDeviceId(''); // We need to get by ID first
|
||||
const aclRules = mqttAclRuleDb.findAll();
|
||||
const rule = aclRules.find(r => r.id === id);
|
||||
|
||||
if (!rule) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ACL rule not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const device = deviceDb.findById(rule.device_id);
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = mqttAclRuleDb.update(id, {
|
||||
topic_pattern,
|
||||
permission
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update ACL rule:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mqtt/acl/[id]
|
||||
* Lösche eine ACL Regel
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid ACL rule ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get current ACL rule to check device ownership
|
||||
const userId = (session.user as any).id;
|
||||
const aclRules = mqttAclRuleDb.findAll();
|
||||
const rule = aclRules.find(r => r.id === id);
|
||||
|
||||
if (!rule) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ACL rule not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const device = deviceDb.findById(rule.device_id);
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = mqttAclRuleDb.delete(id);
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete ACL rule:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
app/api/mqtt/acl/route.ts
Normal file
104
app/api/mqtt/acl/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// API Route für MQTT ACL Management
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { deviceDb } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/acl?device_id=xxx
|
||||
* Hole ACL Regeln für ein Device
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const device_id = searchParams.get('device_id');
|
||||
|
||||
if (!device_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'device_id query parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const userId = (session.user as any).id;
|
||||
const device = deviceDb.findById(device_id);
|
||||
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device not found or access denied' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const rules = mqttAclRuleDb.findByDeviceId(device_id);
|
||||
return NextResponse.json(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ACL rules:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch ACL rules' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/acl
|
||||
* Erstelle neue ACL Regel
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { device_id, topic_pattern, permission } = body;
|
||||
|
||||
// Validierung
|
||||
if (!device_id || !topic_pattern || !permission) {
|
||||
return NextResponse.json(
|
||||
{ error: 'device_id, topic_pattern, and permission are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['read', 'write', 'readwrite'].includes(permission)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'permission must be one of: read, write, readwrite' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device belongs to user
|
||||
const userId = (session.user as any).id;
|
||||
const device = deviceDb.findById(device_id);
|
||||
|
||||
if (!device || device.ownerId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device not found or access denied' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const rule = mqttAclRuleDb.create({
|
||||
device_id,
|
||||
topic_pattern,
|
||||
permission
|
||||
});
|
||||
|
||||
return NextResponse.json(rule, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create ACL rule:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create ACL rule' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
143
app/api/mqtt/credentials/[device_id]/route.ts
Normal file
143
app/api/mqtt/credentials/[device_id]/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// API Route für einzelne MQTT Credentials
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { hashPassword } from '@/lib/mosquitto-sync';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/credentials/[device_id]
|
||||
* Hole MQTT Credentials für ein Device
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ device_id: string }> }
|
||||
) {
|
||||
const { device_id } = await params;
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const credential = mqttCredentialDb.findByDeviceId(device_id);
|
||||
|
||||
if (!credential) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credentials not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(credential);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/mqtt/credentials/[device_id]
|
||||
* Aktualisiere MQTT Credentials
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ device_id: string }> }
|
||||
) {
|
||||
const { device_id } = await params;
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { regenerate_password, enabled } = body;
|
||||
|
||||
const credential = mqttCredentialDb.findByDeviceId(device_id);
|
||||
if (!credential) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credentials not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let newPassword: string | undefined;
|
||||
let updateData: any = {};
|
||||
|
||||
// Regeneriere Passwort wenn angefordert
|
||||
if (regenerate_password) {
|
||||
newPassword = randomBytes(16).toString('base64');
|
||||
const password_hash = await hashPassword(newPassword);
|
||||
updateData.mqtt_password_hash = password_hash;
|
||||
}
|
||||
|
||||
// Update enabled Status
|
||||
if (enabled !== undefined) {
|
||||
updateData.enabled = enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
// Update Credentials
|
||||
const updated = mqttCredentialDb.update(device_id, updateData);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
// Sende neues Passwort nur wenn regeneriert
|
||||
...(newPassword && { mqtt_password: newPassword })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mqtt/credentials/[device_id]
|
||||
* Lösche MQTT Credentials für ein Device
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ device_id: string }> }
|
||||
) {
|
||||
const { device_id } = await params;
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Lösche zuerst alle ACL Regeln
|
||||
mqttAclRuleDb.deleteByDeviceId(device_id);
|
||||
|
||||
// Dann lösche Credentials
|
||||
const deleted = mqttCredentialDb.delete(device_id);
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credentials not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/api/mqtt/credentials/route.ts
Normal file
126
app/api/mqtt/credentials/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// API Route für MQTT Credentials Management
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
|
||||
import { deviceDb } from '@/lib/db';
|
||||
import { hashPassword } from '@/lib/mosquitto-sync';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/credentials
|
||||
* Liste alle MQTT Credentials
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const credentials = mqttCredentialDb.findAll();
|
||||
|
||||
// Filter credentials to only show user's devices
|
||||
const credentialsWithDevices = credentials
|
||||
.map(cred => {
|
||||
const device = deviceDb.findById(cred.device_id);
|
||||
return {
|
||||
...cred,
|
||||
device_name: device?.name || 'Unknown Device',
|
||||
device_owner: device?.ownerId
|
||||
};
|
||||
})
|
||||
.filter(cred => cred.device_owner === userId);
|
||||
|
||||
return NextResponse.json(credentialsWithDevices);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/credentials
|
||||
* Erstelle neue MQTT Credentials für ein Device
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { device_id, mqtt_username, mqtt_password, auto_generate } = body;
|
||||
|
||||
// Validierung
|
||||
if (!device_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'device_id is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prüfe ob Device existiert
|
||||
const device = deviceDb.findById(device_id);
|
||||
if (!device) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prüfe ob bereits Credentials existieren
|
||||
const existing = mqttCredentialDb.findByDeviceId(device_id);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'MQTT credentials already exist for this device' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generiere oder verwende übergebene Credentials
|
||||
let username = mqtt_username;
|
||||
let password = mqtt_password;
|
||||
|
||||
if (auto_generate || !username) {
|
||||
// Generiere Username: device_[device-id]_[random]
|
||||
username = `device_${device_id}_${randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
if (auto_generate || !password) {
|
||||
// Generiere sicheres Passwort
|
||||
password = randomBytes(16).toString('base64');
|
||||
}
|
||||
|
||||
// Hash Passwort
|
||||
const password_hash = await hashPassword(password);
|
||||
|
||||
// Erstelle Credentials
|
||||
const credential = mqttCredentialDb.create({
|
||||
device_id,
|
||||
mqtt_username: username,
|
||||
mqtt_password_hash: password_hash,
|
||||
enabled: 1
|
||||
});
|
||||
|
||||
// Erstelle Default ACL Regel
|
||||
mqttAclRuleDb.createDefaultRule(device_id);
|
||||
|
||||
return NextResponse.json({
|
||||
...credential,
|
||||
// Sende Plaintext-Passwort nur bei Erstellung zurück
|
||||
mqtt_password: password,
|
||||
device_name: device.name
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create MQTT credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
app/api/mqtt/send-credentials/route.ts
Normal file
90
app/api/mqtt/send-credentials/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { emailService } from '@/lib/email-service';
|
||||
import { deviceDb, userDb } from '@/lib/db';
|
||||
|
||||
// POST /api/mqtt/send-credentials - Send MQTT credentials via email
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can send credentials
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { deviceId, mqttUsername, mqttPassword } = body;
|
||||
|
||||
if (!deviceId || !mqttUsername || !mqttPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: deviceId, mqttUsername, mqttPassword' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get device info
|
||||
const device = deviceDb.findById(deviceId);
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get device owner
|
||||
if (!device.ownerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device has no owner assigned' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const owner = userDb.findById(device.ownerId);
|
||||
if (!owner) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device owner not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!owner.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Device owner has no email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse broker URL from environment or use default
|
||||
const brokerUrl = process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883';
|
||||
const brokerHost = brokerUrl.replace(/^mqtt:\/\//, '').replace(/:\d+$/, '');
|
||||
const brokerPortMatch = brokerUrl.match(/:(\d+)$/);
|
||||
const brokerPort = brokerPortMatch ? brokerPortMatch[1] : '1883';
|
||||
|
||||
// Send email
|
||||
await emailService.sendMqttCredentialsEmail({
|
||||
email: owner.email,
|
||||
deviceName: device.name,
|
||||
deviceId: device.id,
|
||||
mqttUsername,
|
||||
mqttPassword,
|
||||
brokerUrl,
|
||||
brokerHost,
|
||||
brokerPort,
|
||||
});
|
||||
|
||||
console.log(`[MQTT] Credentials sent via email to ${owner.email} for device ${device.name}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Credentials sent to ${owner.email}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending MQTT credentials email:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to send email' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/mqtt/sync/route.ts
Normal file
50
app/api/mqtt/sync/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// API Route für Mosquitto Configuration Sync
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { syncMosquittoConfig, getMosquittoSyncStatus } from '@/lib/mosquitto-sync';
|
||||
|
||||
/**
|
||||
* GET /api/mqtt/sync
|
||||
* Hole den aktuellen Sync Status
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const status = getMosquittoSyncStatus();
|
||||
return NextResponse.json(status || { pending_changes: 0, last_sync_status: 'unknown' });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sync status:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sync status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/sync
|
||||
* Trigger Mosquitto Configuration Sync
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const result = await syncMosquittoConfig();
|
||||
return NextResponse.json(result, {
|
||||
status: result.success ? 200 : 500
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync Mosquitto config:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to sync Mosquitto configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/api/system/status/route.ts
Normal file
42
app/api/system/status/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/system/status
|
||||
*
|
||||
* Returns system status information
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const uptimeSeconds = process.uptime();
|
||||
|
||||
// Calculate days, hours, minutes, seconds
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(uptimeSeconds % 60);
|
||||
|
||||
return NextResponse.json({
|
||||
uptime: {
|
||||
total: Math.floor(uptimeSeconds),
|
||||
formatted: `${days}d ${hours}h ${minutes}m ${seconds}s`,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
},
|
||||
memory: {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
},
|
||||
nodejs: process.version,
|
||||
platform: process.platform,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('System status error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get system status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
209
app/api/users/[id]/route.ts
Normal file
209
app/api/users/[id]/route.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userDb } from "@/lib/db";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// GET /api/users/[id] - Get single user (admin only)
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can view users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const user = userDb.findById(id);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can view any user details
|
||||
// ADMIN users can only view their own created viewers
|
||||
if (currentUsername !== 'admin') {
|
||||
// Check if this user is a child of the current user
|
||||
if (user.parent_user_id !== currentUserId) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash, ...safeUser } = user;
|
||||
|
||||
return NextResponse.json({ user: safeUser });
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/users/[id] - Update user (admin only)
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can update users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const user = userDb.findById(id);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can modify any user
|
||||
// ADMIN users can only modify their own created viewers
|
||||
if (currentUsername !== 'admin') {
|
||||
// Check if this user is a child of the current user
|
||||
if (user.parent_user_id !== currentUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden: Cannot modify this user" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { username, email, password, role } = body;
|
||||
|
||||
// Validation
|
||||
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role. Must be ADMIN or VIEWER" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username is taken by another user
|
||||
if (username && username !== user.username) {
|
||||
const existing = userDb.findByUsername(username);
|
||||
if (existing && existing.id !== id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: {
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
passwordHash?: string;
|
||||
role?: string;
|
||||
} = {};
|
||||
|
||||
if (username !== undefined) updateData.username = username;
|
||||
if (email !== undefined) updateData.email = email;
|
||||
if (role !== undefined) updateData.role = role;
|
||||
if (password) {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const updated = userDb.update(id, updateData);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Failed to update user" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash, ...safeUser } = updated;
|
||||
|
||||
return NextResponse.json({ user: safeUser });
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/users/[id] - Delete user (admin only)
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can delete users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const user = userDb.findById(id);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can delete any user
|
||||
// ADMIN users can only delete their own created viewers
|
||||
if (currentUsername !== 'admin') {
|
||||
// Check if this user is a child of the current user
|
||||
if (user.parent_user_id !== currentUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden: Cannot delete this user" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent deleting yourself
|
||||
if ((session.user as any).id === id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot delete your own account" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const success = userDb.delete(id);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "User deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
155
app/api/users/route.ts
Normal file
155
app/api/users/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userDb } from "@/lib/db";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { emailService } from '@/lib/email-service';
|
||||
|
||||
// GET /api/users - List all users (admin only)
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can view users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
|
||||
// Only the "admin" user can see all users
|
||||
// Other ADMIN users see only their created viewers (parent-child relationship)
|
||||
let users: any[];
|
||||
|
||||
if (currentUsername === 'admin') {
|
||||
// Super admin sees all users
|
||||
users = userDb.findAll();
|
||||
} else if ((session.user as any).role === 'ADMIN') {
|
||||
// ADMIN users see only their child viewers
|
||||
users = userDb.findAll({ parentUserId: currentUserId });
|
||||
} else {
|
||||
// VIEWER users see nobody
|
||||
users = [];
|
||||
}
|
||||
|
||||
// Remove password hashes from response
|
||||
const safeUsers = users.map(({ passwordHash, ...user }) => user);
|
||||
|
||||
return NextResponse.json({ users: safeUsers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch users" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/users - Create new user (admin only)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can create users
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { username, email, password, role } = body;
|
||||
|
||||
// Validation
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: username, password" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role. Must be ADMIN or VIEWER" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existing = userDb.findByUsername(username);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Determine parent_user_id
|
||||
// If current user is not "admin", set parent_user_id to current user's ID
|
||||
// If creating a VIEWER, set parent_user_id to current user's ID
|
||||
const currentUsername = session.user.name || '';
|
||||
const currentUserId = (session.user as any).id || '';
|
||||
let parent_user_id: string | null = null;
|
||||
|
||||
if (currentUsername !== 'admin') {
|
||||
// Non-admin ADMIN users create viewers that belong to them
|
||||
parent_user_id = currentUserId;
|
||||
|
||||
// Force role to VIEWER for non-admin ADMIN users
|
||||
if (role && role !== 'VIEWER') {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only create VIEWER users' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = userDb.create({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
email: email || null,
|
||||
passwordHash,
|
||||
role: role || 'VIEWER',
|
||||
parent_user_id,
|
||||
});
|
||||
|
||||
// Send welcome email (don't fail if email fails)
|
||||
if (email) {
|
||||
try {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
await emailService.sendWelcomeEmail({
|
||||
email,
|
||||
username,
|
||||
loginUrl: `${baseUrl}/login`,
|
||||
temporaryPassword: password, // Send the original password
|
||||
});
|
||||
console.log('[UserCreate] Welcome email sent to:', email);
|
||||
} catch (emailError) {
|
||||
console.error('[UserCreate] Failed to send welcome email:', emailError);
|
||||
// Don't fail user creation if email fails
|
||||
}
|
||||
}
|
||||
|
||||
// Remove password hash from response
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
|
||||
return NextResponse.json({ user: safeUser }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
app/forgot-password/page.tsx
Normal file
113
app/forgot-password/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send reset email');
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl mb-4">✅</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
If an account exists with the email <strong>{email}</strong>, you will receive a password reset link shortly.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Forgot Password
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="your-email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-blue-600 hover:text-blue-700 text-sm"
|
||||
>
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
app/globals.css
Normal file
26
app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Leaflet CSS overrides */
|
||||
.leaflet-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
23
app/layout.tsx
Normal file
23
app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import AuthProvider from "@/components/AuthProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Location Tracker - POC",
|
||||
description: "MQTT Location Tracking with Admin Panel",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
146
app/login/page.tsx
Normal file
146
app/login/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const registered = searchParams.get("registered") === "true";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid username or password");
|
||||
} else {
|
||||
// Redirect to callbackUrl if present, otherwise to /map
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/map";
|
||||
console.log('[Login] Redirecting to:', callbackUrl);
|
||||
router.push(callbackUrl);
|
||||
// Force a hard refresh to ensure middleware is applied
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
setError("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">
|
||||
Location Tracker Admin
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-right mb-4">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{registered && (
|
||||
<div className="bg-green-50 text-green-700 px-4 py-2 rounded-md text-sm mb-4">
|
||||
✓ Account created successfully! Please sign in.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-4 py-2 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-gray-600">Don't have an account? </span>
|
||||
<Link href="/register" className="text-blue-600 hover:text-blue-700 font-medium">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
<p>Demo credentials:</p>
|
||||
<p className="font-mono mt-1">
|
||||
admin / admin123
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Suspense fallback={
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
|
||||
<p className="text-center text-gray-600">Loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
app/map/page.tsx
Normal file
174
app/map/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/map/MapView"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
Loading map...
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const TIME_FILTERS = [
|
||||
{ label: "1 Hour", value: 1 },
|
||||
{ label: "3 Hours", value: 3 },
|
||||
{ label: "6 Hours", value: 6 },
|
||||
{ label: "12 Hours", value: 12 },
|
||||
{ label: "24 Hours", value: 24 },
|
||||
{ label: "All", value: 0 },
|
||||
];
|
||||
|
||||
interface DeviceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function MapPage() {
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>("all");
|
||||
const [timeFilter, setTimeFilter] = useState<number>(1); // Default 1 hour
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
|
||||
// Custom range state
|
||||
const [filterMode, setFilterMode] = useState<"quick" | "custom">("quick");
|
||||
const [startTime, setStartTime] = useState<string>("");
|
||||
const [endTime, setEndTime] = useState<string>("");
|
||||
|
||||
// Fetch user's 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 || []);
|
||||
} else {
|
||||
console.error("Failed to fetch devices:", response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch devices:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDevices();
|
||||
// Refresh devices every 30 seconds
|
||||
const interval = setInterval(fetchDevices, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header with controls */}
|
||||
<div className="bg-white shadow-md p-3 sm:p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Top row: Title and Admin link */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg sm:text-xl font-bold text-black">Location Tracker</h1>
|
||||
<a
|
||||
href="/admin"
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Controls row - responsive grid */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-stretch sm:items-center">
|
||||
{/* Device Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs sm:text-sm font-medium text-black whitespace-nowrap">Device:</label>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="flex-1 sm:flex-none px-2 sm:px-3 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All My Devices</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Time Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs sm:text-sm font-medium text-black whitespace-nowrap">Time:</label>
|
||||
<select
|
||||
value={timeFilter}
|
||||
onChange={(e) => setTimeFilter(Number(e.target.value))}
|
||||
className="flex-1 sm:flex-none px-2 sm:px-3 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{TIME_FILTERS.map((filter) => (
|
||||
<option key={filter.value} value={filter.value}>
|
||||
{filter.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setFilterMode(filterMode === "quick" ? "custom" : "quick")}
|
||||
className="px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors whitespace-nowrap"
|
||||
title="Toggle Custom Range"
|
||||
>
|
||||
📅 {filterMode === "quick" ? "Custom" : "Quick"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pause/Resume Button */}
|
||||
<button
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
className={`px-3 sm:px-4 py-1 text-sm rounded-md font-semibold transition-colors whitespace-nowrap ${
|
||||
isPaused
|
||||
? "bg-green-500 hover:bg-green-600 text-white"
|
||||
: "bg-red-500 hover:bg-red-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{isPaused ? "▶ Resume" : "⏸ Pause"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom Range (only visible when active) */}
|
||||
{filterMode === "custom" && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 border border-blue-300 bg-blue-50 rounded-md p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-xs font-medium text-black whitespace-nowrap">From:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="flex-1 sm:flex-none px-2 py-1 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-xs font-medium text-black whitespace-nowrap">To:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="flex-1 sm:flex-none px-2 py-1 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1">
|
||||
<MapView
|
||||
selectedDevice={selectedDevice}
|
||||
timeFilter={timeFilter}
|
||||
isPaused={isPaused}
|
||||
filterMode={filterMode}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
app/page.tsx
Normal file
253
app/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Dynamically import DemoMap (client-side only)
|
||||
const DemoMap = dynamic(() => import('@/components/demo/DemoMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<p className="text-gray-600">Loading interactive demo...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50">
|
||||
{/* Header/Navigation */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Location Tracker</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-24">
|
||||
<div className="text-center">
|
||||
<h2 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 mb-6">
|
||||
Real-Time GPS Location Tracking
|
||||
</h2>
|
||||
<p className="text-xl sm:text-2xl text-gray-600 mb-8 max-w-3xl mx-auto">
|
||||
Track multiple devices in real-time with our powerful MQTT-based location tracking system.
|
||||
Monitor your fleet, family, or assets with precision and ease.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Sign Up Free
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-4 bg-white text-blue-600 border-2 border-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-semibold text-lg"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 bg-white rounded-2xl shadow-xl mb-16">
|
||||
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
Powerful Features
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{/* Feature 1 */}
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Real-Time Updates</h4>
|
||||
<p className="text-gray-600">
|
||||
Live location updates via MQTT protocol with automatic 5-second refresh intervals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Interactive Map</h4>
|
||||
<p className="text-gray-600">
|
||||
OpenStreetMap integration with multiple layers, movement paths, and device markers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Time Filtering</h4>
|
||||
<p className="text-gray-600">
|
||||
Filter locations by time range with quick filters (1h, 3h, 6h) or custom date ranges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 4 */}
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Multi-Device Support</h4>
|
||||
<p className="text-gray-600">
|
||||
Track unlimited devices with color-coded markers and individual device filtering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 5 */}
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Admin Controls</h4>
|
||||
<p className="text-gray-600">
|
||||
Comprehensive admin panel for device management, user access, and MQTT configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 6 */}
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">SQLite Storage</h4>
|
||||
<p className="text-gray-600">
|
||||
Dual-database system for high-performance location storage with automatic cleanup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Live Demo Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<h3 className="text-3xl font-bold text-center text-gray-900 mb-4">
|
||||
See It In Action
|
||||
</h3>
|
||||
<p className="text-center text-gray-600 mb-12 max-w-2xl mx-auto">
|
||||
Watch live as 3 demo devices move through Munich in real-time.
|
||||
This is exactly how your own devices will appear on the map!
|
||||
</p>
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div className="aspect-video bg-gray-100">
|
||||
<DemoMap />
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-r from-blue-50 to-blue-100">
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">City Tour</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">Olympiapark Route</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-orange-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">Isar Tour</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-600 mt-4">
|
||||
💡 Devices update every 3 seconds - just like real-time tracking!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mt-8">
|
||||
{/* Demo Feature 1 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="aspect-video bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 text-green-600 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p className="font-semibold text-gray-700">Device Analytics</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Feature 2 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="aspect-video bg-gradient-to-br from-purple-100 to-purple-200 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 text-purple-600 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<p className="font-semibold text-gray-700">Admin Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-2xl shadow-2xl p-12">
|
||||
<h3 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
Ready to Start Tracking?
|
||||
</h3>
|
||||
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
|
||||
Create your free account or log in to access the real-time map and start monitoring your devices instantly.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-block px-8 py-4 bg-white text-blue-600 rounded-lg hover:bg-gray-100 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Create Free Account
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block px-8 py-4 bg-blue-500 text-white border-2 border-white rounded-lg hover:bg-blue-400 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-gray-400 py-8 mt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<p className="text-sm">
|
||||
© {new Date().getFullYear()} Location Tracker. Built with Next.js 14 and MQTT.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
app/register/page.tsx
Normal file
166
app/register/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Registration failed");
|
||||
}
|
||||
|
||||
// Success - redirect to login
|
||||
router.push("/login?registered=true");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Registration failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-blue-600 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create Account</h1>
|
||||
<p className="text-gray-600 mt-2">Sign up for Location Tracker</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
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"
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">At least 3 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
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"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">At least 6 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password *
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: 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"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-4 py-2 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Creating Account..." : "Create Account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-gray-600">Already have an account? </span>
|
||||
<Link href="/login" className="text-blue-600 hover:text-blue-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/" className="text-sm text-gray-600 hover:text-gray-700">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
app/reset-password/page.tsx
Normal file
216
app/reset-password/page.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validating, setValidating] = useState(true);
|
||||
const [tokenValid, setTokenValid] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Validate token on mount
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Invalid reset link');
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/reset-password?token=${token}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
setTokenValid(true);
|
||||
} else {
|
||||
setError('This reset link is invalid or has expired');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to validate reset link');
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateToken();
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to reset password');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (validating) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-gray-600">Validating reset link...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tokenValid) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl mb-4">❌</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Invalid Reset Link
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{error || 'This password reset link is invalid or has expired.'}
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Request New Reset Link →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl mb-4">✅</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Password Reset Successful
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your password has been reset successfully. Redirecting to login...
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Go to Login →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Reset Password
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="At least 6 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Re-enter password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
74
app/unauthorized/page.tsx
Normal file
74
app/unauthorized/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
function UnauthorizedContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const attemptedUrl = searchParams.get('from');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
|
||||
<div className="mb-4">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Access Denied
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
You don't have permission to access this area. Admin privileges are required.
|
||||
</p>
|
||||
|
||||
{attemptedUrl && (
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Attempted to access: <span className="font-mono">{attemptedUrl}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="block w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Go to Homepage
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="block w-full border border-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Login with Different Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<UnauthorizedContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user