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