Initial implementation: Logbuch Sternwarte Welzheim

Vollständige Next.js 16 Webanwendung als Logbuch für die Sternwarte Welzheim.
4 Kuppeln (West/Ost/Süd/Pluto), BEO-basierte Authentifizierung mit erzwungenem
Passwort-Wechsel beim Erstlogin, MySQL-Backend, Docker-Deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 17:11:27 +02:00
parent f0a86627e5
commit 4e53a7a5cd
29 changed files with 1827 additions and 97 deletions

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM node:22-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_DATE
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

135
app/MainClient.tsx Normal file
View File

@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { KUPPELN } from '@/types/logbuch';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
import LogbuchForm from '@/components/LogbuchForm';
import LogbuchList from '@/components/LogbuchList';
import packageJson from '@/package.json';
interface Props {
kuerzel: string;
beoId: number;
beoName: string;
}
export default function MainClient({ kuerzel, beoId, beoName }: Props) {
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste'>('eingabe');
const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
const version = packageJson.version;
const buildDate =
process.env.NEXT_PUBLIC_BUILD_DATE ||
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
const currentUserBeo = { ID: beoId, Kuerzel: kuerzel, Name: beoName };
function handleSaved() {
setRefreshKey((k) => k + 1);
setEditEntry(null);
if (editEntry) setActiveTab('liste');
}
function handleEdit(entry: LogbuchEintrag) {
setEditEntry(entry);
setActiveTab('eingabe');
}
async function handleLogout() {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
}
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{kuerzel} {beoName}
</span>
<button
onClick={handleLogout}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Abmelden
</button>
</div>
</div>
{/* Kuppel-Tabs */}
<div className="flex gap-1 mb-4 border-b-2 border-gray-300">
{KUPPELN.map((k) => (
<button
key={k}
onClick={() => { setActiveKuppel(k); setEditEntry(null); }}
className={`px-5 py-2 text-sm font-medium rounded-t-lg transition-colors ${
activeKuppel === k
? 'bg-[#85B7D7] text-black border-2 border-b-0 border-gray-300'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Kuppel {k}
</button>
))}
</div>
{/* Eingabe/Liste-Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200">
{(['eingabe', 'liste'] as const).map((tab) => (
<button
key={tab}
onClick={() => { setActiveTab(tab); if (tab === 'eingabe' && editEntry) setEditEntry(null); }}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab
? 'border-[#85B7D7] text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab === 'eingabe' ? 'Eingabe' : 'Liste'}
</button>
))}
</div>
{activeTab === 'eingabe' && (
<div>
{editEntry && (
<div className="mb-4 text-sm text-amber-700 bg-amber-50 border border-amber-300 rounded-lg px-3 py-2">
Eintrag bearbeiten (ID {editEntry.ID})
</div>
)}
<LogbuchForm
key={`${activeKuppel}-${editEntry?.ID ?? 'new'}`}
kuppel={activeKuppel}
currentUserBeo={currentUserBeo}
editEntry={editEntry}
onSaved={handleSaved}
/>
</div>
)}
{activeTab === 'liste' && (
<LogbuchList
kuppel={activeKuppel}
refreshKey={refreshKey}
onEdit={handleEdit}
/>
)}
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
<div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
mailto:rxf@gmx.de
</a>
</div>
<div className="text-right">
Version {version} {buildDate}
</div>
</footer>
</main>
</div>
);
}

12
app/api/beos/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
export async function GET() {
try {
const rows = await query('SELECT ID, Kuerzel, Name FROM beos ORDER BY Name ASC');
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/beos:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session';
import type { SelectedObjekt } from '@/types/logbuch';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { id } = await params;
const logbuchId = parseInt(id);
try {
const body = await request.json();
const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await getPool().execute(
`UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, Beginn=?, Ende=?, Besucher=?,
Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=?
WHERE ID=?`,
[
Kuppel, ArtFuehrung, Beginn, Ende,
Besucher ?? 0,
Bemerkungen?.slice(0, 500) || null,
Wetter?.temp ?? null,
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
logbuchId,
]
);
await query('DELETE FROM logbuch_beos WHERE LogbuchID = ?', [logbuchId]);
await query('DELETE FROM logbuch_objekte WHERE LogbuchID = ?', [logbuchId]);
for (const beoId of (beoIds as number[]) || []) {
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
}
for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID;
if (!objektId) {
const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[];
if (existing[0]) {
objektId = existing[0].ID;
} else {
const [ins] = await getPool().execute(
'INSERT INTO objekte (Name) VALUES (?)', [obj.Name]
) as [{ insertId: number }, unknown];
objektId = ins.insertId;
}
}
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)',
[logbuchId, objektId, obj.Name]
);
}
return NextResponse.json({ ok: true });
} catch (error) {
console.error('PUT /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { id } = await params;
try {
await query('DELETE FROM logbuch WHERE ID = ?', [parseInt(id)]);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('DELETE /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}

92
app/api/logbuch/route.ts Normal file
View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session';
import type { SelectedObjekt } from '@/types/logbuch';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West';
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
try {
const rows = await query(
`SELECT
l.ID, l.Kuppel, l.ArtFuehrung,
DATE_FORMAT(l.Beginn, '%Y-%m-%dT%H:%i') AS Beginn,
DATE_FORMAT(l.Ende, '%Y-%m-%dT%H:%i') AS Ende,
l.Besucher, l.Bemerkungen,
l.WetterTemp, l.WetterFeuchte, l.WetterDruck,
l.created_by, l.created_at,
GROUP_CONCAT(DISTINCT b.Kuerzel ORDER BY b.Kuerzel SEPARATOR ', ') AS BEOs,
GROUP_CONCAT(DISTINCT lo.ObjektName ORDER BY lo.ObjektName SEPARATOR ', ') AS Objekte
FROM logbuch l
LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID
LEFT JOIN beos b ON b.ID = lb.BeoID
LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID
WHERE l.Kuppel = ?
GROUP BY l.ID
ORDER BY l.Beginn DESC
LIMIT ?`,
[kuppel, limit]
);
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try {
const body = await request.json();
const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const [result] = await (await import('@/lib/db')).getPool().execute(
`INSERT INTO logbuch (Kuppel, ArtFuehrung, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
Kuppel, ArtFuehrung, Beginn, Ende,
Besucher ?? 0,
Bemerkungen?.slice(0, 500) || null,
Wetter?.temp ?? null,
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
session.beoId,
]
) as [{ insertId: number }, unknown];
const logbuchId = result.insertId;
for (const beoId of (beoIds as number[]) || []) {
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
}
for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID;
if (!objektId) {
const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[];
if (existing[0]) {
objektId = existing[0].ID;
} else {
const [ins] = await (await import('@/lib/db')).getPool().execute(
'INSERT INTO objekte (Name) VALUES (?)', [obj.Name]
) as [{ insertId: number }, unknown];
objektId = ins.insertId;
}
}
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)',
[logbuchId, objektId, obj.Name]
);
}
return NextResponse.json({ id: logbuchId }, { status: 201 });
} catch (error) {
console.error('POST /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}

7
app/api/logout/route.ts Normal file
View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { deleteSession } from '@/lib/session';
export async function POST() {
await deleteSession();
return NextResponse.json({ ok: true });
}

12
app/api/objekte/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
export async function GET() {
try {
const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100');
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/objekte:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}

8
app/api/wetter/route.ts Normal file
View File

@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
export async function GET() {
const temp = Math.round((8 + Math.random() * 15) * 10) / 10;
const feuchte = Math.round((40 + Math.random() * 50) * 10) / 10;
const druck = Math.round((990 + Math.random() * 30) * 10) / 10;
return NextResponse.json({ temp, feuchte, druck });
}

View File

@@ -0,0 +1,41 @@
'use server';
import { redirect } from 'next/navigation';
import { getSession, createSession } from '@/lib/session';
import { hashPassword } from '@/lib/auth';
import { query } from '@/lib/db';
export async function changePassword(
_prevState: { error: string } | undefined,
formData: FormData
): Promise<{ error: string }> {
const session = await getSession();
if (!session) redirect('/login');
const newPassword = formData.get('newPassword') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (!newPassword || newPassword.length < 6) {
return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' };
}
if (newPassword !== confirmPassword) {
return { error: 'Die Passwörter stimmen nicht überein.' };
}
const hashed = await hashPassword(newPassword);
await query(
'UPDATE beos SET Passwort = ?, MustChangePassword = 0 WHERE ID = ?',
[hashed, session.beoId]
);
await createSession({
kuerzel: session.kuerzel,
beoId: session.beoId,
beoName: session.beoName,
mustChangePassword: false,
isAuthenticated: true,
});
redirect('/');
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useActionState, useState } from 'react';
import { changePassword } from './actions';
export default function ChangePasswordPage() {
const [state, action, isPending] = useActionState(changePassword, undefined);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<h1 className="text-3xl font-bold mb-6">Logbuch Sternwarte Welzheim</h1>
<div className="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
<h2 className="text-xl font-semibold text-gray-900 mb-2 text-center">Passwort ändern</h2>
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-300 rounded-lg px-3 py-2 mb-6 text-center">
Bitte wählen Sie ein neues Passwort, bevor Sie fortfahren.
</p>
<form action={action} className="space-y-5">
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
Neues Passwort
</label>
<div className="relative">
<input
id="newPassword"
name="newPassword"
type={showNew ? 'text' : 'password'}
required
minLength={6}
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="mind. 6 Zeichen"
disabled={isPending}
/>
<button
type="button"
onClick={() => setShowNew((v) => !v)}
tabIndex={-1}
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
>
{showNew ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Passwort bestätigen
</label>
<div className="relative">
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirm ? 'text' : 'password'}
required
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Passwort wiederholen"
disabled={isPending}
/>
<button
type="button"
onClick={() => setShowConfirm((v) => !v)}
tabIndex={-1}
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
>
{showConfirm ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isPending ? 'Wird gespeichert...' : 'Passwort speichern'}
</button>
</form>
</div>
</div>
</main>
</div>
);
}

View File

@@ -1,34 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { Geist, Geist_Mono } from "next/font/google"; import './globals.css';
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Logbuch — Sternwarte Welzheim',
description: "Generated by create next app", description: 'Logbuch für die Sternwarte Welzheim',
}; };
export default function RootLayout({ export default function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return ( return (
<html lang="en"> <html lang="de">
<body <body className="antialiased">{children}</body>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html> </html>
); );
} }

37
app/login/actions.ts Normal file
View File

@@ -0,0 +1,37 @@
'use server';
import { redirect } from 'next/navigation';
import { verifyCredentials } from '@/lib/auth';
import { createSession } from '@/lib/session';
export async function login(
_prevState: { error: string } | undefined,
formData: FormData
): Promise<{ error: string }> {
const kuerzel = (formData.get('username') as string)?.trim();
const password = formData.get('password') as string;
if (!kuerzel || !password) {
return { error: 'Bitte Kürzel und Passwort eingeben.' };
}
const result = await verifyCredentials(kuerzel, password);
if (!result || !result.valid) {
return { error: 'Ungültiges Kürzel oder Passwort.' };
}
await createSession({
kuerzel: result.beo.Kuerzel,
beoId: result.beo.ID,
beoName: result.beo.Name,
mustChangePassword: result.beo.MustChangePassword === 1,
isAuthenticated: true,
});
if (result.beo.MustChangePassword === 1) {
redirect('/change-password');
}
redirect('/');
}

110
app/login/page.tsx Normal file
View File

@@ -0,0 +1,110 @@
'use client';
import { useActionState, useState } from 'react';
import { login } from './actions';
import packageJson from '@/package.json';
export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined);
const [showPassword, setShowPassword] = useState(false);
const version = packageJson.version;
const buildDate =
process.env.NEXT_PUBLIC_BUILD_DATE ||
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
</div>
<div className="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
<form action={loginAction} className="space-y-5">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Kürzel
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="off"
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Kürzel"
disabled={isPending}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Passwort
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
autoComplete="new-password"
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Passwort"
disabled={isPending}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
</div>
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
<div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
mailto:rxf@gmx.de
</a>
</div>
<div className="text-right">
Version {version} {buildDate}
</div>
</footer>
</main>
</div>
);
}

View File

@@ -1,65 +1,16 @@
import Image from "next/image"; import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import MainClient from './MainClient';
export default async function HomePage() {
const session = await getSession();
if (!session) redirect('/login');
export default function Home() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <MainClient
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> kuerzel={session.kuerzel}
<Image beoId={session.beoId}
className="dark:invert" beoName={session.beoName}
src="/next.svg" />
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
); );
} }

42
claude/Logbuch.md Normal file
View File

@@ -0,0 +1,42 @@
Erstelle ein Logbuch für die Sternwarte in Welzheim.
+ Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Logbuch. Alle 4 Logbücher sind identisch gebaut.
+ Als Datenbank soll eine MYSQL verwendet werden
+ Als Sprache soll nextjs zum Einsatz kommen
+ Der Zugang zu der Webseite soll per User/Passwort gesichert werden
+ Der User soll folgende Einträge eingeben können
+ Kuppel (West, Ost ...)
+ Art der Führung. Auswahl aus folgenden Möglichkeiten:
+ Reguläre Führung
+ Sonderführung
+ BEO-Sitzung
+ Sonnenführung
+ Technischer Dienst
+ Beobachtung
+ Tag der offenen Tür
+ Sonstiges
Reguläre Führung ist der Default
+ Beginn der Führung (Datum und Uhrzeit, Datum ist vorausgefüllt mit dem aktuellen Datum)
+ Ende der Führung (wie Beginn)
+ Anzahl der Besucher
+ BEOs Hier ist der angemeldet User als 1. BEO vorbelegt, weitere können zugefügt werden
+ diese BEOs werden aus einer Tabelle in der MYSQL-DB gelesen
+ beobachtete Objekte
+ schon gespeicherte Objekte werden ebenfalls aus einer Tabelle in der MYSQL gelesen und zur Auswahl angeboten, eine Neueingabe ist natürlich möglich
+ ein Feld mit Bemerkungen; freier Text mit max. 500 Zeichen
+ die aktuellen Wetterdaten (werden aus aus dem Wetterarchiv ausgelesen, kann vorerst via MOCK simuliert werden)
+ Zusätzlich zur Eingabe kann die Liste der letzten Einträge (20 pro Seite) angezeigt werden als Tabelle
+ Umschaltung zwischen Eingabe und Liste über TABs
Besonderheit bei der Eingabe der beobachteten Objekte:
+ die Auswahlliste soll als ersten Eintrag 'Neu' haben
+ dann folgen die zuletzt eingetragenen Objekte in zeitlich absteigender Reihenfolge
User/Passwort-Eingabe
+ jeder User, der sich anmeldet, MUSS in der BEO-Tabelle in der DB vorhanden sein, verglichen wird sein Namenskürzel (das steht in der DB)
+ beim ersten mal meldet sich der User mit einem Default-Passwort an und wird aufgefordert, diese sofort zu ändern
+ falls er es nicht ändert, kann er nicht weiter machen
+ Das selbst gewählte Passwort wird dann in der BEO-Tabelle gespeichert und beim nächsten Login verwendet
Bitte stelle Rückfragen, falls etwas unklar ist.

View File

@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import type { BeoOption } from '@/types/logbuch';
interface Props {
selected: BeoOption[];
onChange: (beos: BeoOption[]) => void;
}
export default function BeoSelector({ selected, onChange }: Props) {
const [all, setAll] = useState<BeoOption[]>([]);
useEffect(() => {
fetch('/api/beos')
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
.then(setAll)
.catch(() => {});
}, []);
const selectedIds = new Set(selected.map((b) => b.ID));
const available = all.filter((b) => !selectedIds.has(b.ID));
function add(id: string) {
if (!id) return;
const beo = all.find((b) => b.ID === parseInt(id));
if (beo) onChange([...selected, beo]);
}
function remove(id: number) {
onChange(selected.filter((b) => b.ID !== id));
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{selected.map((b) => (
<span
key={b.ID}
className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-sm px-2 py-1 rounded-full"
>
{b.Kuerzel} {b.Name}
<button
type="button"
onClick={() => remove(b.ID)}
className="ml-1 text-blue-600 hover:text-red-600 font-bold leading-none"
aria-label={`${b.Kuerzel} entfernen`}
>
×
</button>
</span>
))}
</div>
{available.length > 0 && (
<select
className="px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
value=""
onChange={(e) => add(e.target.value)}
>
<option value="">+ BEO hinzufügen</option>
{available.map((b) => (
<option key={b.ID} value={b.ID}>
{b.Kuerzel} {b.Name}
</option>
))}
</select>
)}
</div>
);
}

270
components/LogbuchForm.tsx Normal file
View File

@@ -0,0 +1,270 @@
'use client';
import { useEffect, useState } from 'react';
import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch';
import { ARTEN } from '@/types/logbuch';
import BeoSelector from './BeoSelector';
import ObjektSelector from './ObjektSelector';
interface Props {
kuppel: Kuppel;
currentUserBeo: BeoOption;
editEntry?: LogbuchEintrag | null;
onSaved: () => void;
}
function toLocalDatetimeValue(isoOrDatetime: string): string {
if (!isoOrDatetime) return '';
return isoOrDatetime.slice(0, 16);
}
function nowLocalDatetime(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) {
const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('Reguläre Führung');
const [beginn, setBeginn] = useState(nowLocalDatetime());
const [ende, setEnde] = useState(nowLocalDatetime());
const [besucher, setBesucher] = useState(0);
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
const [bemerkungen, setBemerkungen] = useState('');
const [wetter, setWetter] = useState<Wetter | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
fetch('/api/wetter')
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then(setWetter)
.catch(() => {});
}, []);
useEffect(() => {
if (editEntry) {
setArtFuehrung(editEntry.ArtFuehrung);
setBeginn(toLocalDatetimeValue(editEntry.Beginn));
setEnde(toLocalDatetimeValue(editEntry.Ende));
setBesucher(editEntry.Besucher);
setBemerkungen(editEntry.Bemerkungen ?? '');
if (editEntry.WetterTemp !== null) {
setWetter({
temp: editEntry.WetterTemp ?? 0,
feuchte: editEntry.WetterFeuchte ?? 0,
druck: editEntry.WetterDruck ?? 0,
});
}
} else {
setArtFuehrung('Reguläre Führung');
setBeginn(nowLocalDatetime());
setEnde(nowLocalDatetime());
setBesucher(0);
setBeos([currentUserBeo]);
setObjekte([]);
setBemerkungen('');
}
}, [editEntry, currentUserBeo]);
useEffect(() => {
if (editEntry && editEntry.BEOs) {
fetch('/api/beos')
.then((r) => r.json())
.then((all: BeoOption[]) => {
const kuerzel = editEntry.BEOs.split(', ').map((k) => k.trim());
setBeos(all.filter((b) => kuerzel.includes(b.Kuerzel)));
})
.catch(() => {});
}
if (editEntry && editEntry.Objekte) {
const names = editEntry.Objekte.split(', ').map((n) => n.trim());
fetch('/api/objekte')
.then((r) => r.json())
.then((all: { ID: number; Name: string }[]) => {
const result: SelectedObjekt[] = names.map((name) => {
const found = all.find((o) => o.Name === name);
return { ID: found?.ID ?? null, Name: name };
});
setObjekte(result);
})
.catch(() => {});
}
}, [editEntry]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError('');
setSuccess(false);
const body = {
Kuppel: kuppel,
ArtFuehrung: artFuehrung,
Beginn: beginn,
Ende: ende,
Besucher: besucher,
beoIds: beos.map((b) => b.ID),
objekte,
Bemerkungen: bemerkungen,
Wetter: wetter,
};
const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch';
const method = editEntry ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
setSuccess(true);
if (!editEntry) {
setBeginn(nowLocalDatetime());
setEnde(nowLocalDatetime());
setBesucher(0);
setBeos([currentUserBeo]);
setObjekte([]);
setBemerkungen('');
}
onSaved();
} catch {
setError('Fehler beim Speichern. Bitte erneut versuchen.');
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Führung</label>
<select
value={artFuehrung}
onChange={(e) => setArtFuehrung(e.target.value as ArtFuehrung)}
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
>
{ARTEN.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kuppel</label>
<input
type="text"
value={kuppel}
readOnly
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg bg-gray-100 text-sm text-gray-600"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beginn</label>
<input
type="datetime-local"
value={beginn}
onChange={(e) => setBeginn(e.target.value)}
required
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ende</label>
<input
type="datetime-local"
value={ende}
onChange={(e) => setEnde(e.target.value)}
required
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Besucher</label>
<input
type="number"
value={besucher}
onChange={(e) => setBesucher(parseInt(e.target.value) || 0)}
min={0}
max={9999}
className="w-32 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">BEOs</label>
<BeoSelector selected={beos} onChange={setBeos} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beobachtete Objekte</label>
<ObjektSelector selected={objekte} onChange={setObjekte} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bemerkungen
<span className="ml-2 text-gray-400 font-normal text-xs">({bemerkungen.length}/500)</span>
</label>
<textarea
value={bemerkungen}
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
rows={3}
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none resize-y"
placeholder="Freier Text (max. 500 Zeichen)"
/>
</div>
{wetter && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Wetter (aktuell)</label>
<div className="flex gap-4 text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
<span>🌡 {wetter.temp} °C</span>
<span>💧 {wetter.feuchte} %</span>
<span>🌬 {wetter.druck} hPa</span>
</div>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{error}
</div>
)}
{success && !editEntry && (
<div className="bg-green-50 border border-green-300 text-green-700 px-3 py-2 rounded-lg text-sm">
Eintrag gespeichert.
</div>
)}
<div className="flex gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
>
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
</button>
{editEntry && (
<button
type="button"
onClick={onSaved}
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm"
>
Abbrechen
</button>
)}
</div>
</form>
);
}

121
components/LogbuchList.tsx Normal file
View File

@@ -0,0 +1,121 @@
'use client';
import { useEffect, useState } from 'react';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
interface Props {
kuppel: Kuppel;
refreshKey: number;
onEdit: (entry: LogbuchEintrag) => void;
}
function formatDateTime(dt: string): string {
if (!dt) return '';
const d = new Date(dt);
if (isNaN(d.getTime())) return dt;
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export default function LogbuchList({ kuppel, refreshKey, onEdit }: Props) {
const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
const [loading, setLoading] = useState(true);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
fetch(`/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=20`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => { setEntries(data); setLoading(false); })
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
}, [kuppel, refreshKey]);
async function confirmDelete(id: number) {
try {
const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
setEntries((prev) => prev.filter((e) => e.ID !== id));
} catch {
setError('Fehler beim Löschen.');
} finally {
setDeleteId(null);
}
}
if (loading) return <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>;
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
if (entries.length === 0) return <div className="text-gray-500 text-sm py-4">Keine Einträge vorhanden.</div>;
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 text-left">
<th className="px-3 py-2 border border-gray-300 whitespace-nowrap">Beginn</th>
<th className="px-3 py-2 border border-gray-300 whitespace-nowrap">Ende</th>
<th className="px-3 py-2 border border-gray-300">Art</th>
<th className="px-3 py-2 border border-gray-300 text-center">Besucher</th>
<th className="px-3 py-2 border border-gray-300">BEOs</th>
<th className="px-3 py-2 border border-gray-300">Objekte</th>
<th className="px-3 py-2 border border-gray-300">Bemerkungen</th>
<th className="px-3 py-2 border border-gray-300 text-center">Aktionen</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.ID} className="hover:bg-gray-50">
<td className="px-3 py-2 border border-gray-200 whitespace-nowrap">{formatDateTime(e.Beginn)}</td>
<td className="px-3 py-2 border border-gray-200 whitespace-nowrap">{formatDateTime(e.Ende)}</td>
<td className="px-3 py-2 border border-gray-200">{e.ArtFuehrung}</td>
<td className="px-3 py-2 border border-gray-200 text-center">{e.Besucher}</td>
<td className="px-3 py-2 border border-gray-200">{e.BEOs || '—'}</td>
<td className="px-3 py-2 border border-gray-200">{e.Objekte || '—'}</td>
<td className="px-3 py-2 border border-gray-200 max-w-xs">
<span className="line-clamp-2">{e.Bemerkungen || ''}</span>
</td>
<td className="px-3 py-2 border border-gray-200 text-center whitespace-nowrap">
<button
onClick={() => onEdit(e)}
className="text-blue-600 hover:text-blue-800 mr-3 text-xs font-medium"
>
Bearbeiten
</button>
<button
onClick={() => setDeleteId(e.ID)}
className="text-red-600 hover:text-red-800 text-xs font-medium"
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{deleteId !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
<h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3>
<p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteId(null)}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm"
>
Abbrechen
</button>
<button
onClick={() => confirmDelete(deleteId)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm"
>
Löschen
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
interface Props {
selected: SelectedObjekt[];
onChange: (objekte: SelectedObjekt[]) => void;
}
export default function ObjektSelector({ selected, onChange }: Props) {
const [all, setAll] = useState<ObjektOption[]>([]);
const [newName, setNewName] = useState('');
const [showNewInput, setShowNewInput] = useState(false);
useEffect(() => {
fetch('/api/objekte')
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
.then(setAll)
.catch(() => {});
}, []);
const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase()));
const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase()));
function add(value: string) {
if (value === 'neu') {
setShowNewInput(true);
return;
}
const obj = all.find((o) => o.ID === parseInt(value));
if (obj && !selectedNames.has(obj.Name.toLowerCase())) {
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
}
}
function addNew() {
const name = newName.trim();
if (!name || selectedNames.has(name.toLowerCase())) return;
onChange([...selected, { ID: null, Name: name }]);
setNewName('');
setShowNewInput(false);
}
function remove(name: string) {
onChange(selected.filter((o) => o.Name !== name));
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{selected.map((o) => (
<span
key={o.Name}
className="inline-flex items-center gap-1 bg-green-100 text-green-800 text-sm px-2 py-1 rounded-full"
>
{o.Name}
<button
type="button"
onClick={() => remove(o.Name)}
className="ml-1 text-green-600 hover:text-red-600 font-bold leading-none"
aria-label={`${o.Name} entfernen`}
>
×
</button>
</span>
))}
</div>
<select
className="px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
value=""
onChange={(e) => add(e.target.value)}
>
<option value="">+ Objekt hinzufügen</option>
<option value="neu"> Neues Objekt eingeben </option>
{available.map((o) => (
<option key={o.ID} value={o.ID}>
{o.Name}
</option>
))}
</select>
{showNewInput && (
<div className="flex gap-2">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
placeholder="Objektname eingeben"
className="flex-1 px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/>
<button
type="button"
onClick={addNew}
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700"
>
OK
</button>
<button
type="button"
onClick={() => { setShowNewInput(false); setNewName(''); }}
className="px-3 py-1.5 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
>
Abbrechen
</button>
</div>
)}
</div>
);
}

48
create_table.sql Normal file
View File

@@ -0,0 +1,48 @@
CREATE TABLE beos (
ID INT AUTO_INCREMENT PRIMARY KEY,
Kuerzel VARCHAR(10) NOT NULL UNIQUE,
Name VARCHAR(100) NOT NULL,
Passwort VARCHAR(255),
MustChangePassword TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE objekte (
ID INT AUTO_INCREMENT PRIMARY KEY,
Name VARCHAR(200) NOT NULL UNIQUE,
LastUsed TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE logbuch (
ID INT AUTO_INCREMENT PRIMARY KEY,
Kuppel ENUM('West','Ost','Süd','Pluto') NOT NULL DEFAULT 'West',
ArtFuehrung ENUM('Reguläre Führung','Sonderführung','BEO-Sitzung','Sonnenführung','Technischer Dienst','Beobachtung','Tag der offenen Tür','Sonstiges') NOT NULL DEFAULT 'Reguläre Führung',
Beginn DATETIME NOT NULL,
Ende DATETIME NOT NULL,
Besucher INT DEFAULT 0,
Bemerkungen TEXT,
WetterTemp DECIMAL(5,1),
WetterFeuchte DECIMAL(5,1),
WetterDruck DECIMAL(7,1),
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES beos(ID)
);
CREATE TABLE logbuch_beos (
ID INT AUTO_INCREMENT PRIMARY KEY,
LogbuchID INT NOT NULL,
BeoID INT NOT NULL,
FOREIGN KEY (LogbuchID) REFERENCES logbuch(ID) ON DELETE CASCADE,
FOREIGN KEY (BeoID) REFERENCES beos(ID)
);
CREATE TABLE logbuch_objekte (
ID INT AUTO_INCREMENT PRIMARY KEY,
LogbuchID INT NOT NULL,
ObjektID INT,
ObjektName VARCHAR(200) NOT NULL,
FOREIGN KEY (LogbuchID) REFERENCES logbuch(ID) ON DELETE CASCADE,
FOREIGN KEY (ObjektID) REFERENCES objekte(ID)
);

48
deploy.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
set -e
REGISTRY="docker.citysensor.de"
IMAGE_NAME="logbuch"
TAG="${1:-latest}"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "Logbuch Deploy Script"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
echo ">>> Richte Multiplatform Builder ein..."
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
fi
docker buildx use multiplatform-builder
echo ""
echo ">>> Baue Multiplatform Docker Image und pushe..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${FULL_IMAGE}" \
--push \
.
echo ""
echo "=========================================="
echo "Deploy erfolgreich abgeschlossen!"
echo "=========================================="
echo ""
echo "Auf dem Server ausführen:"
echo " docker pull ${FULL_IMAGE}"
echo " docker-compose -f docker-compose.prod.yml up -d"
echo ""

38
lib/auth.ts Normal file
View File

@@ -0,0 +1,38 @@
import bcrypt from 'bcryptjs';
import { query } from './db';
export interface Beo {
ID: number;
Kuerzel: string;
Name: string;
Passwort: string | null;
MustChangePassword: number;
}
export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
const rows = await query(
'SELECT ID, Kuerzel, Name, Passwort, MustChangePassword FROM beos WHERE Kuerzel = ?',
[kuerzel]
) as Beo[];
return rows[0] ?? null;
}
export async function verifyCredentials(
kuerzel: string,
password: string
): Promise<{ beo: Beo; valid: boolean } | null> {
const beo = await getBeoByKuerzel(kuerzel);
if (!beo) return null;
if (!beo.Passwort) {
const valid = password === 'logbuch123';
return { beo, valid };
}
const valid = await bcrypt.compare(password, beo.Passwort);
return { beo, valid };
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}

27
lib/db.ts Normal file
View File

@@ -0,0 +1,27 @@
import mysql from 'mysql2/promise';
import type { QueryResult } from 'mysql2/promise';
const dbConfig = {
host: process.env.DB_HOST || 'mydbase_mysql',
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME || 'logbuch',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
};
let pool: mysql.Pool | null = null;
export function getPool() {
if (!pool) {
pool = mysql.createPool(dbConfig);
}
return pool;
}
export async function query(sql: string, params?: (string | number | null)[]): Promise<QueryResult> {
const p = getPool();
const [rows] = await p.execute(sql, params || []);
return rows as QueryResult;
}

62
lib/session.ts Normal file
View File

@@ -0,0 +1,62 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session';
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000;
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
export interface SessionData {
kuerzel: string;
beoId: number;
beoName: string;
mustChangePassword: boolean;
isAuthenticated: boolean;
expiresAt: number;
}
async function encrypt(payload: SessionData): Promise<string> {
return new SignJWT(payload as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(key);
}
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
return payload as unknown as SessionData;
} catch {
return null;
}
}
export async function createSession(data: Omit<SessionData, 'expiresAt'>): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION;
const session: SessionData = { ...data, expiresAt };
const encrypted = await encrypt(session);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, encrypted, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!cookie?.value) return null;
const session = await decrypt(cookie.value);
if (!session || session.expiresAt < Date.now()) return null;
return session;
}
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}

View File

@@ -1,7 +1,24 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
],
},
];
},
}; };
export default nextConfig; export default nextConfig;

165
package-lock.json generated
View File

@@ -1,19 +1,23 @@
{ {
"name": "logbuch-next", "name": "logbuch",
"version": "0.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "logbuch-next", "name": "logbuch",
"version": "0.1.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.2.2",
"mysql2": "^3.22.3",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -1539,6 +1543,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1564,8 +1575,8 @@
"version": "20.19.39", "version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2423,6 +2434,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axe-core": { "node_modules/axe-core": {
"version": "4.11.3", "version": "4.11.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
@@ -2462,6 +2482,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.14", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
@@ -2802,6 +2831,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3657,6 +3695,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generator-function": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -3921,6 +3968,22 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4243,6 +4306,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4430,6 +4499,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4841,6 +4919,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4864,6 +4948,21 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4938,6 +5037,40 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz",
"integrity": "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -5636,6 +5769,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -5867,6 +6006,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6354,7 +6508,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "logbuch-next", "name": "logbuch",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -9,12 +9,16 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.2.2",
"mysql2": "^3.22.3",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

42
proxy.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session';
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/login') || pathname.startsWith('/_next') || pathname.startsWith('/favicon')) {
return NextResponse.next();
}
const cookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!cookie?.value) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const { payload } = await jwtVerify(cookie.value, key, { algorithms: ['HS256'] });
const mustChange = payload.mustChangePassword as boolean;
if (mustChange && pathname !== '/change-password') {
return NextResponse.redirect(new URL('/change-password', request.url));
}
if (!mustChange && pathname === '/change-password') {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

61
types/logbuch.ts Normal file
View File

@@ -0,0 +1,61 @@
export type Kuppel = 'West' | 'Ost' | 'Süd' | 'Pluto';
export type ArtFuehrung =
| 'Reguläre Führung'
| 'Sonderführung'
| 'BEO-Sitzung'
| 'Sonnenführung'
| 'Technischer Dienst'
| 'Beobachtung'
| 'Tag der offenen Tür'
| 'Sonstiges';
export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto'];
export const ARTEN: ArtFuehrung[] = [
'Reguläre Führung',
'Sonderführung',
'BEO-Sitzung',
'Sonnenführung',
'Technischer Dienst',
'Beobachtung',
'Tag der offenen Tür',
'Sonstiges',
];
export interface BeoOption {
ID: number;
Kuerzel: string;
Name: string;
}
export interface ObjektOption {
ID: number;
Name: string;
}
export interface SelectedObjekt {
ID: number | null;
Name: string;
}
export interface Wetter {
temp: number;
feuchte: number;
druck: number;
}
export interface LogbuchEintrag {
ID: number;
Kuppel: Kuppel;
ArtFuehrung: ArtFuehrung;
Beginn: string;
Ende: string;
Besucher: number;
Bemerkungen: string | null;
WetterTemp: number | null;
WetterFeuchte: number | null;
WetterDruck: number | null;
created_by: number | null;
created_at: string;
BEOs: string;
Objekte: string;
}