v1.6.0: Admin-Passwort-Reset, Login per Nachname, Default-PW-Sperre

This commit is contained in:
2026-05-11 12:20:44 +02:00
parent 4d84b8f718
commit 0ea960259c
12 changed files with 195 additions and 15 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
Die Remote-Datenbank auf logbuch.fuerst-.stuttgart.de wird zum lokalen entwickeln über einen SSH-Tunner erreicht:
~~~
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
~~~
Dieser ist vor dem Starten des Programme einmal einzurichten!!
+1 -1
View File
@@ -16,7 +16,7 @@ No test suite exists. Deploy via `./deploy.sh [tag]` — builds multiplatform Do
Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `logbuch123` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `welzheim` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement.
**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **utf8mb4** (collation `utf8mb4_unicode_ci`); connection pool uses `charset: 'utf8mb4'`.
+10 -1
View File
@@ -12,9 +12,10 @@ interface Props {
kuerzel: string;
beoId: number;
beoName: string;
role: string | null;
}
export default function MainClient({ kuerzel, beoId, beoName }: Props) {
export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik'>('eingabe');
const [refreshKey, setRefreshKey] = useState(0);
@@ -54,6 +55,14 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
Logbuch für {activeKuppel}-Kuppel
</h1>
<div className="flex items-center gap-2 shrink-0">
{role?.includes('admin') && (
<a
href="/admin"
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Admin
</a>
)}
<button
onClick={handleLogout}
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded-lg text-white"
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { useActionState } from 'react';
import { resetPassword } from './actions';
interface Props {
userId: number;
userName: string;
}
export default function ResetButton({ userId, userName }: Props) {
const [state, action, isPending] = useActionState(resetPassword, undefined);
return (
<div>
<form
action={action}
onSubmit={(e) => {
if (!confirm(`Passwort von „${userName}" wirklich zurücksetzen?`)) {
e.preventDefault();
}
}}
>
<input type="hidden" name="id" value={userId} />
<button
type="submit"
disabled={isPending}
className="px-3 py-1 text-xs font-medium bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200 disabled:opacity-50"
>
{isPending ? 'Bitte warten…' : 'Zurücksetzen'}
</button>
</form>
{state?.success && (
<p className="text-xs text-green-700 mt-1 max-w-xs">{state.success}</p>
)}
{state?.error && (
<p className="text-xs text-red-600 mt-1">{state.error}</p>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use server';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { query } from '@/lib/db';
export interface BeoUser {
id: number;
kürzel: string | null;
name: string;
vorname: string | null;
pw: string | null;
role: string | null;
}
export async function listUsers(): Promise<BeoUser[]> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/');
const rows = await query(
'SELECT id, `kürzel`, name, vorname, pw, role FROM beos ORDER BY name, vorname',
[]
) as BeoUser[];
return rows;
}
export async function resetPassword(
_prevState: { error?: string; success?: string } | undefined,
formData: FormData
): Promise<{ error?: string; success?: string }> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) {
return { error: 'Keine Berechtigung.' };
}
const idRaw = formData.get('id');
const id = Number(idRaw);
if (!id || isNaN(id)) {
return { error: 'Ungültige Benutzer-ID.' };
}
await query(
'UPDATE beos SET pw = NULL, MustChangePassword = 1 WHERE id = ?',
[id]
);
return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' };
}
+65
View File
@@ -0,0 +1,65 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { listUsers } from './actions';
import ResetButton from './ResetButton';
export default async function AdminPage() {
const session = await getSession();
if (!session) redirect('/login');
if (session.role === null || !session.role.includes('admin')) redirect('/');
const users = await listUsers();
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-[#EEF4FF]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
<Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Benutzerverwaltung</h2>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold">Kürzel</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold">Vorname</th>
<th className="text-left px-4 py-3 font-semibold">Rolle</th>
<th className="text-left px-4 py-3 font-semibold">Passwort</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user, idx) => (
<tr key={user.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 font-mono">{user.kürzel ?? '—'}</td>
<td className="px-4 py-3">{user.name}</td>
<td className="px-4 py-3">{user.vorname ?? '—'}</td>
<td className="px-4 py-3">{user.role ?? '—'}</td>
<td className="px-4 py-3">
{user.pw == null ? (
<span className="text-amber-600 font-medium">Standard</span>
) : (
<span className="text-green-700">gesetzt</span>
)}
</td>
<td className="px-4 py-3 text-right">
<ResetButton userId={user.id} userName={`${user.vorname ?? ''} ${user.name}`.trim()} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 text-xs text-gray-500">
&bdquo;Zur&uuml;cksetzen&ldquo; setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort &auml;ndern.
</p>
</main>
</div>
);
}
+4
View File
@@ -19,6 +19,10 @@ export async function changePassword(
return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' };
}
if (newPassword === 'welzheim') {
return { error: 'Das Standard-Passwort darf nicht als neues Passwort verwendet werden.' };
}
if (newPassword !== confirmPassword) {
return { error: 'Die Passwörter stimmen nicht überein.' };
}
+6 -6
View File
@@ -8,23 +8,23 @@ export async function login(
_prevState: { error: string } | undefined,
formData: FormData
): Promise<{ error: string }> {
const kuerzel = (formData.get('username') as string)?.trim();
const login = (formData.get('username') as string)?.trim();
const password = formData.get('password') as string;
if (!kuerzel || !password) {
return { error: 'Bitte Kürzel und Passwort eingeben.' };
if (!login || !password) {
return { error: 'Bitte Kürzel/Nachname und Passwort eingeben.' };
}
const result = await verifyCredentials(kuerzel, password);
const result = await verifyCredentials(login, password);
if (!result || !result.valid) {
return { error: 'Ungültiges Kürzel oder Passwort.' };
return { error: 'Ungültiger Benutzername oder Passwort.' };
}
const mustChange = result.beo.MustChangePassword === 1 || !result.beo.pw;
await createSession({
kuerzel: result.beo.kürzel ?? kuerzel,
kuerzel: result.beo.kürzel ?? login,
beoId: result.beo.id,
beoName: getBeoDisplayName(result.beo),
mustChangePassword: mustChange,
+2 -2
View File
@@ -27,7 +27,7 @@ export default function LoginPage() {
<form action={loginAction} className="space-y-5">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Kürzel
Kürzel oder Nachname
</label>
<input
id="username"
@@ -36,7 +36,7 @@ export default function LoginPage() {
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"
placeholder="Kürzel oder Nachname"
disabled={isPending}
/>
</div>
+1
View File
@@ -11,6 +11,7 @@ export default async function HomePage() {
kuerzel={session.kuerzel}
beoId={session.beoId}
beoName={session.beoName}
role={session.role ?? null}
/>
);
}
+15 -3
View File
@@ -19,15 +19,27 @@ export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
return rows[0] ?? null;
}
export async function getBeoByLogin(login: string): Promise<Beo | null> {
// First try exact Kürzel match, then case-insensitive Nachname match
const byKuerzel = await getBeoByKuerzel(login);
if (byKuerzel) return byKuerzel;
const rows = await query(
'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE LOWER(name) = LOWER(?)',
[login]
) as Beo[];
return rows[0] ?? null;
}
export async function verifyCredentials(
kuerzel: string,
login: string,
password: string
): Promise<{ beo: Beo; valid: boolean } | null> {
const beo = await getBeoByKuerzel(kuerzel);
const beo = await getBeoByLogin(login);
if (!beo) return null;
if (!beo.pw) {
const valid = password === 'logbuch123';
const valid = password === 'welzheim';
return { beo, valid };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "logbuch",
"version": "1.5.1",
"version": "1.6.0",
"private": true,
"scripts": {
"dev": "next dev",