v1.6.0: Admin-Passwort-Reset, Login per Nachname, Default-PW-Sperre
This commit is contained in:
+1
-1
@@ -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!!
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
@@ -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">
|
||||
„Zurücksetzen“ setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort ändern.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -11,6 +11,7 @@ export default async function HomePage() {
|
||||
kuerzel={session.kuerzel}
|
||||
beoId={session.beoId}
|
||||
beoName={session.beoName}
|
||||
role={session.role ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-3
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "logbuch",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user