Add first-time setup wizard (/setup)
Passwortgeschuetzter Assistent fuer die Ersteinrichtung auf einem neuen Server: Datenbank-Zugangsdaten testen & speichern, Datenbankstruktur anlegen, ersten Admin-Account erstellen. Sperrt sich danach dauerhaft selbst, sobald ein Admin-Account existiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f3ab7d0816
commit
fe6b463135
8 changed files with 631 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -23,6 +23,10 @@
|
||||||
# unterscheidet sich zwischen Dev-/Live-Umgebung und gehört daher nicht ins Repo)
|
# unterscheidet sich zwischen Dev-/Live-Umgebung und gehört daher nicht ins Repo)
|
||||||
/hifi/api/config/db.php
|
/hifi/api/config/db.php
|
||||||
|
|
||||||
|
# Einmalpasswort für den Ersteinrichtungs-Assistenten (siehe setup.php.example) -
|
||||||
|
# pro Umgebung unterschiedlich und darf nicht öffentlich einsehbar sein
|
||||||
|
/hifi/api/config/setup.php
|
||||||
|
|
||||||
# Claude-Code-Tooling (Skills/lokale Einstellungen) - gehört nicht zum Website-Projekt
|
# Claude-Code-Tooling (Skills/lokale Einstellungen) - gehört nicht zum Website-Projekt
|
||||||
/hifi/.claude/
|
/hifi/.claude/
|
||||||
/hifi/.agents/
|
/hifi/.agents/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import ScrollToTop from './components/ScrollToTop.jsx';
|
import ScrollToTop from './components/ScrollToTop.jsx';
|
||||||
|
import SetupGate from './components/SetupGate.jsx';
|
||||||
import PublicLayout from './components/PublicLayout.jsx';
|
import PublicLayout from './components/PublicLayout.jsx';
|
||||||
import AdminLayout from './components/AdminLayout.jsx';
|
import AdminLayout from './components/AdminLayout.jsx';
|
||||||
import ProtectedRoute from './components/ProtectedRoute.jsx';
|
import ProtectedRoute from './components/ProtectedRoute.jsx';
|
||||||
|
|
@ -14,6 +15,7 @@ import ContactPage from './pages/public/ContactPage.jsx';
|
||||||
import Impressum from './pages/public/Impressum.jsx';
|
import Impressum from './pages/public/Impressum.jsx';
|
||||||
import Datenschutz from './pages/public/Datenschutz.jsx';
|
import Datenschutz from './pages/public/Datenschutz.jsx';
|
||||||
import AGB from './pages/public/AGB.jsx';
|
import AGB from './pages/public/AGB.jsx';
|
||||||
|
import SetupWizard from './pages/public/SetupWizard.jsx';
|
||||||
|
|
||||||
import Login from './pages/admin/Login.jsx';
|
import Login from './pages/admin/Login.jsx';
|
||||||
import Dashboard from './pages/admin/Dashboard.jsx';
|
import Dashboard from './pages/admin/Dashboard.jsx';
|
||||||
|
|
@ -37,7 +39,10 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
|
<SetupGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/setup" element={<SetupWizard />} />
|
||||||
|
|
||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/fahrzeuge" element={<VehicleSelect />} />
|
<Route path="/fahrzeuge" element={<VehicleSelect />} />
|
||||||
|
|
@ -78,6 +83,7 @@ export default function App() {
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</SetupGate>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
hifi-src/src/components/SetupGate.jsx
Normal file
47
hifi-src/src/components/SetupGate.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client.js';
|
||||||
|
|
||||||
|
// Prüft bei jedem Seitenaufruf, ob die Ersteinrichtung (Datenbank, Struktur, erster
|
||||||
|
// Admin-Account) schon abgeschlossen ist. Falls nicht, wird - egal welche URL
|
||||||
|
// aufgerufen wurde - auf /setup umgeleitet. Ist die Einrichtung bereits
|
||||||
|
// abgeschlossen, kommt man umgekehrt nicht mehr auf /setup rein.
|
||||||
|
//
|
||||||
|
// Der Status wird zusammen mit dem Pfad gespeichert, für den er ermittelt wurde:
|
||||||
|
// direkt nach einem Pfadwechsel ist der zuletzt bekannte Stand noch der des ALTEN
|
||||||
|
// Pfads. Würde man den sofort auswerten, könnte das kurzzeitig zu einer falschen
|
||||||
|
// Umleitung führen (z.B. zurück auf /setup, obwohl man gerade fertig eingerichtet
|
||||||
|
// hat und eigentlich zu /admin/login navigiert). Deshalb wird erst wieder
|
||||||
|
// entschieden, wenn der Stand nachweislich für den aktuellen Pfad frisch ist.
|
||||||
|
export default function SetupGate({ children }) {
|
||||||
|
const [state, setState] = useState({ pathname: null, needsSetup: null });
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api
|
||||||
|
.get('/setup/status')
|
||||||
|
.then((status) => {
|
||||||
|
if (!cancelled) setState({ pathname: location.pathname, needsSetup: status.needs_setup });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState({ pathname: location.pathname, needsSetup: false });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
if (state.pathname !== location.pathname || state.needsSetup === null) {
|
||||||
|
return <div className="min-h-screen bg-white dark:bg-slate-950" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.needsSetup && location.pathname !== '/setup') {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
if (!state.needsSetup && location.pathname === '/setup') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
341
hifi-src/src/pages/public/SetupWizard.jsx
Normal file
341
hifi-src/src/pages/public/SetupWizard.jsx
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '../../api/client.js';
|
||||||
|
import usePageMeta from '../../hooks/usePageMeta.js';
|
||||||
|
import logo from '../../assets/logo.png';
|
||||||
|
|
||||||
|
const emptyDbForm = { host: '', name: '', user: '', password: '', charset: 'utf8mb4' };
|
||||||
|
const emptyAdminForm = { username: '', password: '', password_confirmation: '' };
|
||||||
|
|
||||||
|
export default function SetupWizard() {
|
||||||
|
usePageMeta({ title: 'Ersteinrichtung', path: '/setup' });
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState('loading');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [dbForm, setDbForm] = useState(emptyDbForm);
|
||||||
|
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
||||||
|
const [migrateActions, setMigrateActions] = useState(null);
|
||||||
|
|
||||||
|
// Wird NACH erfolgreicher Passwort-Prüfung (und nach jedem Schritt) aufgerufen,
|
||||||
|
// um zu bestimmen, bei welchem Schritt der Assistent weitermachen soll.
|
||||||
|
const loadStepStatus = () =>
|
||||||
|
api.get('/setup/status').then((status) => {
|
||||||
|
if (!status.needs_setup) {
|
||||||
|
setPhase('locked');
|
||||||
|
} else if (status.steps.admin.ok) {
|
||||||
|
setPhase('done');
|
||||||
|
} else if (!status.steps.schema.ok) {
|
||||||
|
setPhase(status.steps.database.ok ? 'schema' : 'database');
|
||||||
|
} else {
|
||||||
|
setPhase('admin');
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialer Check beim Laden der Seite: nur ermitteln, ob überhaupt noch eine
|
||||||
|
// Einrichtung nötig ist (kein Passwort erforderlich) - Details gibt's erst danach.
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get('/setup/status')
|
||||||
|
.then((status) => setPhase(status.needs_setup ? 'password' : 'locked'))
|
||||||
|
.catch(() => setPhase('locked'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePassword = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.post('/setup/verify-password', { password });
|
||||||
|
await loadStepStatus();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDatabase = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.post('/setup/database', dbForm);
|
||||||
|
await loadStepStatus();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMigrate = async () => {
|
||||||
|
setError('');
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await api.post('/setup/migrate', {});
|
||||||
|
setMigrateActions(result.actions);
|
||||||
|
if (result.status.steps.schema.ok) {
|
||||||
|
setPhase('admin');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdmin = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (adminForm.password !== adminForm.password_confirmation) {
|
||||||
|
setError('Die Passwörter stimmen nicht überein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.post('/setup/admin', adminForm);
|
||||||
|
setPhase('done');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900';
|
||||||
|
const labelClass = 'mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300';
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ key: 'database', label: 'Datenbank' },
|
||||||
|
{ key: 'schema', label: 'Struktur' },
|
||||||
|
{ key: 'admin', label: 'Admin-Account' },
|
||||||
|
];
|
||||||
|
const currentStepIndex = steps.findIndex((s) => s.key === phase);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-slate-50 px-4 py-12 dark:bg-slate-950">
|
||||||
|
<img src={logo} alt="HifiPlanet" className="h-16 w-auto sm:h-20" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
{phase === 'loading' && <p className="text-center text-sm text-slate-400">Lädt…</p>}
|
||||||
|
|
||||||
|
{phase === 'locked' && (
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Bereits eingerichtet</h1>
|
||||||
|
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Diese Seite wurde bereits vollständig eingerichtet. Der Assistent ist deshalb gesperrt.
|
||||||
|
</p>
|
||||||
|
<Link to="/admin/login" className="text-sm font-medium text-brand-600 hover:underline dark:text-brand-400">
|
||||||
|
Zum Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{['database', 'schema', 'admin'].includes(phase) && (
|
||||||
|
<div className="mb-6 flex items-center justify-center gap-2">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={s.key} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold ${
|
||||||
|
i < currentStepIndex
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: i === currentStepIndex
|
||||||
|
? 'bg-brand-500 text-white'
|
||||||
|
: 'bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < currentStepIndex ? '✓' : i + 1}
|
||||||
|
</span>
|
||||||
|
{i < steps.length - 1 && <span className="h-px w-6 bg-slate-300 dark:bg-slate-700" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'password' && (
|
||||||
|
<form onSubmit={handlePassword}>
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Ersteinrichtung</h1>
|
||||||
|
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Gib das Einmalpasswort ein, das in <code>config/setup.php</code> hinterlegt ist.
|
||||||
|
</p>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className={labelClass}>Einmalpasswort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? 'Prüfe…' : 'Weiter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'database' && (
|
||||||
|
<form onSubmit={handleDatabase}>
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Datenbank-Zugangsdaten</h1>
|
||||||
|
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Trage die Zugangsdaten der Datenbank auf diesem Server ein. Die Verbindung wird vor dem Speichern
|
||||||
|
getestet.
|
||||||
|
</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className={labelClass}>Host *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={dbForm.host}
|
||||||
|
onChange={(e) => setDbForm({ ...dbForm, host: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className={labelClass}>Datenbankname *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={dbForm.name}
|
||||||
|
onChange={(e) => setDbForm({ ...dbForm, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className={labelClass}>Benutzer *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={dbForm.user}
|
||||||
|
onChange={(e) => setDbForm({ ...dbForm, user: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className={labelClass}>Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={dbForm.password}
|
||||||
|
onChange={(e) => setDbForm({ ...dbForm, password: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className={labelClass}>Zeichensatz</label>
|
||||||
|
<input
|
||||||
|
value={dbForm.charset}
|
||||||
|
onChange={(e) => setDbForm({ ...dbForm, charset: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? 'Teste Verbindung…' : 'Verbindung testen & speichern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'schema' && (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Datenbankstruktur</h1>
|
||||||
|
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Die Datenbankverbindung funktioniert. Jetzt legen wir die benötigten Tabellen an.
|
||||||
|
</p>
|
||||||
|
{migrateActions && (
|
||||||
|
<ul className="mb-4 space-y-1 rounded-md bg-slate-50 p-3 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
{migrateActions.map((a, i) => (
|
||||||
|
<li key={i}>{a}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMigrate}
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? 'Richte ein…' : 'Datenbankstruktur einrichten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'admin' && (
|
||||||
|
<form onSubmit={handleAdmin}>
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Erster Admin-Account</h1>
|
||||||
|
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Lege den ersten Super-Admin-Account an, mit dem du dich anschließend einloggen kannst.
|
||||||
|
</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className={labelClass}>Benutzername</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
value={adminForm.username}
|
||||||
|
onChange={(e) => setAdminForm({ ...adminForm, username: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className={labelClass}>Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={adminForm.password}
|
||||||
|
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className={labelClass}>Passwort wiederholen</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={adminForm.password_confirmation}
|
||||||
|
onChange={(e) => setAdminForm({ ...adminForm, password_confirmation: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? 'Lege an…' : 'Admin-Account anlegen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'done' && (
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Fertig!</h1>
|
||||||
|
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Die Ersteinrichtung ist abgeschlossen. Du kannst dich jetzt mit deinem neuen Admin-Account anmelden.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/admin/login"
|
||||||
|
className="inline-block w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
Zum Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,9 @@ return [
|
||||||
// Ausgelagert, damit das Admin-Panel (Einstellungen -> Datenbank) diese Datei
|
// Ausgelagert, damit das Admin-Panel (Einstellungen -> Datenbank) diese Datei
|
||||||
// gezielt neu schreiben kann, ohne den Rest dieser Konfiguration anzufassen.
|
// gezielt neu schreiben kann, ohne den Rest dieser Konfiguration anzufassen.
|
||||||
'db' => require __DIR__ . '/db.php',
|
'db' => require __DIR__ . '/db.php',
|
||||||
|
// Einmalpasswort für den Ersteinrichtungs-Assistenten unter /setup (siehe setup.php.example).
|
||||||
|
// Liegt in einer eigenen, nicht versionierten Datei, genau wie db.php.
|
||||||
|
'setup_password' => is_file(__DIR__ . '/setup.php') ? require __DIR__ . '/setup.php' : null,
|
||||||
'mail' => [
|
'mail' => [
|
||||||
// Vom Betreiber auszufüllen (z.B. eigenes Postfach oder Transactional-Mail-Anbieter).
|
// Vom Betreiber auszufüllen (z.B. eigenes Postfach oder Transactional-Mail-Anbieter).
|
||||||
// Solange 'host' leer ist, wird der Mailversand übersprungen (Anfrage wird trotzdem in der DB gespeichert).
|
// Solange 'host' leer ist, wird der Mailversand übersprungen (Anfrage wird trotzdem in der DB gespeichert).
|
||||||
|
|
|
||||||
11
hifi/api/config/setup.php.example
Normal file
11
hifi/api/config/setup.php.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// Kopiere diese Datei zu setup.php und trage ein eigenes, nur dir bekanntes
|
||||||
|
// Einmalpasswort ein - am besten BEVOR du die Seite auf einen neuen Server hochlädst.
|
||||||
|
// Damit kannst du beim allerersten Aufruf der Seite die Ersteinrichtung
|
||||||
|
// (Datenbank-Zugangsdaten, Datenbankstruktur, erster Admin-Account) über einen
|
||||||
|
// geführten Assistenten unter /setup durchführen.
|
||||||
|
//
|
||||||
|
// Sobald der erste Admin-Account angelegt wurde, sperrt sich der Assistent
|
||||||
|
// automatisch selbst - das Passwort funktioniert dann nicht mehr.
|
||||||
|
return 'CHANGE-ME-BEFORE-UPLOAD';
|
||||||
|
|
@ -17,6 +17,7 @@ use App\Controllers\ProductController;
|
||||||
use App\Controllers\SchemaController;
|
use App\Controllers\SchemaController;
|
||||||
use App\Controllers\ServiceController;
|
use App\Controllers\ServiceController;
|
||||||
use App\Controllers\SettingsController;
|
use App\Controllers\SettingsController;
|
||||||
|
use App\Controllers\SetupController;
|
||||||
use App\Controllers\TwoFactorController;
|
use App\Controllers\TwoFactorController;
|
||||||
use App\Controllers\UploadController;
|
use App\Controllers\UploadController;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
@ -149,6 +150,15 @@ $router->post('/settings/database', $perm('settings.manage', fn($p) => DatabaseC
|
||||||
$router->get('/maintenance', fn($p) => MaintenanceController::status());
|
$router->get('/maintenance', fn($p) => MaintenanceController::status());
|
||||||
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));
|
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));
|
||||||
|
|
||||||
|
// Ersteinrichtungs-Assistent (/setup) - bewusst ohne Login, da es ja noch keinen
|
||||||
|
// Admin gibt. Jeder schreibende Endpunkt prüft selbst, ob wirklich noch kein
|
||||||
|
// Admin-Account existiert (siehe SetupController::requireUnlocked).
|
||||||
|
$router->get('/setup/status', fn($p) => SetupController::status());
|
||||||
|
$router->post('/setup/verify-password', fn($p) => SetupController::verifyPassword());
|
||||||
|
$router->post('/setup/database', fn($p) => SetupController::saveDatabase());
|
||||||
|
$router->post('/setup/migrate', fn($p) => SetupController::migrateSchema());
|
||||||
|
$router->post('/setup/admin', fn($p) => SetupController::createAdmin());
|
||||||
|
|
||||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
$apiBase = $config['app']['base_path'] . '/api';
|
$apiBase = $config['app']['base_path'] . '/api';
|
||||||
$route = '/' . ltrim(substr($path, strlen($apiBase)), '/');
|
$route = '/' . ltrim(substr($path, strlen($apiBase)), '/');
|
||||||
|
|
|
||||||
209
hifi/api/src/Controllers/SetupController.php
Normal file
209
hifi/api/src/Controllers/SetupController.php
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Support\Http;
|
||||||
|
use App\Support\Schema;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class SetupController
|
||||||
|
{
|
||||||
|
// Öffentliche Endpunkte (es gibt ja noch keinen Admin) - daher prüft jede
|
||||||
|
// schreibende Aktion zusätzlich selbst per requireUnlocked(), ob wirklich noch
|
||||||
|
// kein Admin-Account existiert. Sobald einer existiert, sperrt sich der
|
||||||
|
// komplette Assistent dauerhaft selbst, unabhängig vom Einmalpasswort.
|
||||||
|
|
||||||
|
private static function configuredPassword(): ?string
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/config.php';
|
||||||
|
$pw = $config['setup_password'] ?? null;
|
||||||
|
return ($pw !== null && $pw !== '') ? $pw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tryConnect(?array $overrideConfig = null): ?PDO
|
||||||
|
{
|
||||||
|
$db = $overrideConfig ?? require __DIR__ . '/../../config/db.php';
|
||||||
|
try {
|
||||||
|
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset=" . ($db['charset'] ?? 'utf8mb4');
|
||||||
|
return new PDO($dsn, $db['user'], $db['pass'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private static function existingTables(PDO $db): array
|
||||||
|
{
|
||||||
|
$stmt = $db->query('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE()');
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function schemaOk(PDO $db): bool
|
||||||
|
{
|
||||||
|
$existingTables = self::existingTables($db);
|
||||||
|
foreach (array_keys(Schema::CREATE_STATEMENTS) as $table) {
|
||||||
|
if (!in_array($table, $existingTables, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ermittelt den Fortschritt, ohne dass irgendwo eine Exception nach außen dringt. */
|
||||||
|
private static function buildStatus(): array
|
||||||
|
{
|
||||||
|
$db = self::tryConnect();
|
||||||
|
$databaseOk = $db !== null;
|
||||||
|
$schemaOk = $databaseOk && self::schemaOk($db);
|
||||||
|
|
||||||
|
$adminOk = false;
|
||||||
|
if ($schemaOk) {
|
||||||
|
try {
|
||||||
|
$adminOk = ((int) $db->query('SELECT COUNT(*) FROM admin_users')->fetchColumn()) > 0;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$adminOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'needs_setup' => !$adminOk,
|
||||||
|
'has_setup_password' => self::configuredPassword() !== null,
|
||||||
|
'steps' => [
|
||||||
|
'database' => ['ok' => $databaseOk],
|
||||||
|
'schema' => ['ok' => $schemaOk],
|
||||||
|
'admin' => ['ok' => $adminOk],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function status(): void
|
||||||
|
{
|
||||||
|
Http::send(self::buildStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function requireUnlocked(): array
|
||||||
|
{
|
||||||
|
$status = self::buildStatus();
|
||||||
|
if (!$status['needs_setup']) {
|
||||||
|
Http::error('Die Ersteinrichtung ist bereits abgeschlossen.', 403);
|
||||||
|
}
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verifyPassword(): void
|
||||||
|
{
|
||||||
|
self::requireUnlocked();
|
||||||
|
|
||||||
|
$configured = self::configuredPassword();
|
||||||
|
$body = Http::jsonBody();
|
||||||
|
$given = (string) ($body['password'] ?? '');
|
||||||
|
|
||||||
|
if ($configured === null || $given === '' || !hash_equals($configured, $given)) {
|
||||||
|
Http::error('Einmalpasswort falsch oder nicht konfiguriert.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['setup_verified'] = true;
|
||||||
|
Http::send(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function requireVerified(): void
|
||||||
|
{
|
||||||
|
self::requireUnlocked();
|
||||||
|
if (empty($_SESSION['setup_verified'])) {
|
||||||
|
Http::error('Bitte zuerst das Einmalpasswort bestätigen.', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function saveDatabase(): void
|
||||||
|
{
|
||||||
|
self::requireVerified();
|
||||||
|
|
||||||
|
$body = Http::jsonBody();
|
||||||
|
$host = trim($body['host'] ?? '');
|
||||||
|
$name = trim($body['name'] ?? '');
|
||||||
|
$user = trim($body['user'] ?? '');
|
||||||
|
$pass = (string) ($body['password'] ?? '');
|
||||||
|
$charset = trim($body['charset'] ?? '') ?: 'utf8mb4';
|
||||||
|
|
||||||
|
if ($host === '' || $name === '' || $user === '') {
|
||||||
|
Http::error('Host, Datenbankname und Benutzer sind erforderlich', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newConfig = ['host' => $host, 'name' => $name, 'user' => $user, 'pass' => $pass, 'charset' => $charset];
|
||||||
|
|
||||||
|
if (self::tryConnect($newConfig) === null) {
|
||||||
|
Http::error('Verbindung mit diesen Zugangsdaten fehlgeschlagen.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$php = "<?php\n\n// Wird über das Admin-Panel (Einstellungen -> Datenbank) verwaltet.\n"
|
||||||
|
. "// Diese Datei wird bei Änderungen automatisch neu geschrieben.\n"
|
||||||
|
. "return " . var_export($newConfig, true) . ";\n";
|
||||||
|
|
||||||
|
if (@file_put_contents(__DIR__ . '/../../config/db.php', $php) === false) {
|
||||||
|
Http::error('Verbindung erfolgreich getestet, aber die Konfigurationsdatei konnte nicht geschrieben werden.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::send(['ok' => true, 'status' => self::buildStatus()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function migrateSchema(): void
|
||||||
|
{
|
||||||
|
self::requireVerified();
|
||||||
|
|
||||||
|
$db = self::tryConnect();
|
||||||
|
if ($db === null) {
|
||||||
|
Http::error('Datenbankverbindung nicht möglich.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingTables = self::existingTables($db);
|
||||||
|
$actions = [];
|
||||||
|
foreach (Schema::CREATE_STATEMENTS as $table => $createSql) {
|
||||||
|
if (!in_array($table, $existingTables, true)) {
|
||||||
|
try {
|
||||||
|
$db->exec($createSql);
|
||||||
|
$actions[] = "Tabelle `$table` angelegt";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$actions[] = "FEHLER bei `$table`: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::send(['ok' => true, 'actions' => $actions, 'status' => self::buildStatus()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createAdmin(): void
|
||||||
|
{
|
||||||
|
self::requireVerified();
|
||||||
|
$status = self::buildStatus();
|
||||||
|
|
||||||
|
if (!$status['steps']['schema']['ok']) {
|
||||||
|
Http::error('Bitte zuerst die Datenbankstruktur einrichten.', 422);
|
||||||
|
}
|
||||||
|
if ($status['steps']['admin']['ok']) {
|
||||||
|
Http::error('Es existiert bereits ein Admin-Account.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = Http::jsonBody();
|
||||||
|
$username = trim($body['username'] ?? '');
|
||||||
|
$password = (string) ($body['password'] ?? '');
|
||||||
|
|
||||||
|
if ($username === '') {
|
||||||
|
Http::error('Benutzername erforderlich', 422);
|
||||||
|
}
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
Http::error('Das Passwort muss mindestens 8 Zeichen lang sein', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = self::tryConnect();
|
||||||
|
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
$stmt = $db->prepare('INSERT INTO admin_users (username, password_hash, is_super_admin) VALUES (?, ?, 1)');
|
||||||
|
$stmt->execute([$username, $hash]);
|
||||||
|
|
||||||
|
unset($_SESSION['setup_verified']);
|
||||||
|
|
||||||
|
Http::send(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue