first commit

This commit is contained in:
2025-11-24 16:30:37 +00:00
commit 843e93a274
114 changed files with 25585 additions and 0 deletions

595
app/admin/devices/page.tsx Normal file
View 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
View 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
View 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
View 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/&lt;DeviceID&gt; (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
View 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
View 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
View 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
View 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>
);
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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
View 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 }
);
}
}

View 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
View 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
View 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
View 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
View 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
View 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">
&copy; {new Date().getFullYear()} Location Tracker. Built with Next.js 14 and MQTT.
</p>
</div>
</footer>
</div>
);
}

166
app/register/page.tsx Normal file
View 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
View 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
View 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>
);
}