Merge pull request #14 from Dada1981/codex/persistence-seo-hardening

feat: secure admin authentication with sessions
This commit is contained in:
Dada1981 2026-08-15 00:39:50 +02:00 committed by GitHub
commit 463f98d078
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 250 additions and 80 deletions

View file

@ -3,10 +3,16 @@
# Users configure this via the Secrets panel in the AI Studio UI. # Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY" GEMINI_API_KEY="MY_GEMINI_API_KEY"
# Required in production. There is a development fallback, but it must not be # Required in production (at least 12 characters).
# used for an internet-facing deployment.
ADMIN_PASSWORD="CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD" ADMIN_PASSWORD="CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD"
# Required in production (at least 32 random characters). This is separate
# from the password and signs the short-lived admin session cookie.
SESSION_SECRET="CHANGE_ME_TO_AN_INDEPENDENT_LONG_RANDOM_SECRET"
# Set to 1 when exactly one trusted reverse proxy sits in front of Express.
# TRUST_PROXY="1"
# Optional location for database.json, uploads and migration backups. # Optional location for database.json, uploads and migration backups.
# DATA_DIR="./data" # DATA_DIR="./data"

View file

@ -33,7 +33,6 @@ COPY --from=builder /app/dist ./dist
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV ADMIN_PASSWORD=autor2026
# Execute standalone node production entrypoint # Execute standalone node production entrypoint
CMD ["node", "dist/server.cjs"] CMD ["node", "dist/server.cjs"]

View file

@ -34,16 +34,15 @@ Stellen Sie sicher, dass sich folgende Dateien im gleichen Ordner auf Ihrem Serv
- Der gesamte Code-Ordner - Der gesamte Code-Ordner
### 2. Konfiguration anpassen ### 2. Konfiguration anpassen
Öffnen Sie die `docker-compose.yml` auf Ihrem Server und passen Sie folgende Umgebungsvariablen an: Kopieren Sie `.env.example` nach `.env` und tragen Sie dort die Geheimnisse ein. Die `.env`-Datei wird nicht eingecheckt:
```yaml ```dotenv
environment: ADMIN_PASSWORD=IhrSicheresLieblingsPasswort123
- NODE_ENV=production SESSION_SECRET=EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen
# Das Passwort für Ihren Administrationsbereich (/admin): GEMINI_API_KEY=Ihr_Gemini_API_Schluessel
- ADMIN_PASSWORD=IhrSicheresLieblingsPasswort123
# (Optional) Für den 21. Jahrhundert Schreibassistenten (Gemini 3.5 Flash)
- GEMINI_API_KEY=Ihr_Gemini_API_Schluessel
``` ```
Der Admin-Login erzeugt eine auf 24 Stunden begrenzte, serverseitige Sitzung in einem `HttpOnly`-, `Secure`- und `SameSite=Strict`-Cookie. Ein Container-Neustart beendet aktive Sitzungen. Das Passwort selbst wird nicht im Browser gespeichert. Ohne `ADMIN_PASSWORD` und `SESSION_SECRET` startet die Anwendung im Produktionsmodus bewusst nicht.
### 3. Container starten ### 3. Container starten
Führen Sie im entsprechenden Verzeichnis folgenden Befehl aus: Führen Sie im entsprechenden Verzeichnis folgenden Befehl aus:
```bash ```bash
@ -54,6 +53,8 @@ Die Anwendung baut das Image und startet die Autoren-Zentrale im Hintergrund. Si
#### Reverse Proxy Tipp: #### Reverse Proxy Tipp:
Sie können ganz hervorragend einen Reverse Proxy wie **Nginx Proxy Manager**, **Traefik** oder **Caddy** davorhängen, um SSL-Zertifikate (Let's Encrypt) zuzuweisen und Ihre Domain auf den Container-Port `3000` umzuleiten. Sie können ganz hervorragend einen Reverse Proxy wie **Nginx Proxy Manager**, **Traefik** oder **Caddy** davorhängen, um SSL-Zertifikate (Let's Encrypt) zuzuweisen und Ihre Domain auf den Container-Port `3000` umzuleiten.
Wenn genau ein vertrauenswürdiger Reverse Proxy vor dem Container sitzt und Port 3000 nicht direkt aus dem Internet erreichbar ist, setzen Sie zusätzlich `TRUST_PROXY=1` in `.env`. So verwendet das Login-Limit die ursprüngliche Client-IP. Bei direkter Veröffentlichung des Containerports darf diese Option nicht aktiviert werden.
--- ---
## 💾 Manuelle Installation ohne Docker (Alternativ) ## 💾 Manuelle Installation ohne Docker (Alternativ)
@ -73,6 +74,7 @@ Sollten Sie die Software direkt auf Ihrem Server (ohne Docker) starten wollen:
Erstellen Sie eine `.env`-Datei oder exportieren Sie diese im Terminal: Erstellen Sie eine `.env`-Datei oder exportieren Sie diese im Terminal:
```bash ```bash
export ADMIN_PASSWORD="IhrSicheresPasswort" export ADMIN_PASSWORD="IhrSicheresPasswort"
export SESSION_SECRET="EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen"
export GEMINI_API_KEY="Ihr_Gemini_API_Schlüssel" export GEMINI_API_KEY="Ihr_Gemini_API_Schlüssel"
``` ```
5. **Starten:** 5. **Starten:**

View file

@ -13,7 +13,9 @@ services:
- ./data:/app/data - ./data:/app/data
environment: environment:
- NODE_ENV=production - NODE_ENV=production
# Ändern Sie das Passwort für Ihren Administrationsbereich: - TRUST_PROXY=${TRUST_PROXY:-}
- ADMIN_PASSWORD=IhrSicheresPasswort2026 # Werte werden aus der nicht eingecheckten .env-Datei gelesen.
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD muss in .env gesetzt sein}
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
# (Optional) Für den 21. Jahrhundert KI-Klappentextassistenten (Gemini 3.5 Flash) # (Optional) Für den 21. Jahrhundert KI-Klappentextassistenten (Gemini 3.5 Flash)
- GEMINI_API_KEY=Ihr_Gemini_API_Schluessel - GEMINI_API_KEY=${GEMINI_API_KEY:-}

230
server.ts
View file

@ -1,6 +1,7 @@
import express from "express"; import express from "express";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { GoogleGenAI } from "@google/genai"; import { GoogleGenAI } from "@google/genai";
import { defaultAuthorData } from "./src/defaultData.js"; import { defaultAuthorData } from "./src/defaultData.js";
@ -33,6 +34,32 @@ const LEGACY_DEFAULT_IMAGE_URLS = new Set([
]); ]);
let writeQueue: Promise<void> = Promise.resolve(); let writeQueue: Promise<void> = Promise.resolve();
let serverReady = false; let serverReady = false;
const SESSION_COOKIE = "author_session";
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_MAX_FAILURES = 5;
const GEMINI_WINDOW_MS = 60 * 60 * 1000;
const GEMINI_MAX_REQUESTS = 20;
interface SessionRecord {
expiresAt: number;
}
interface RateRecord {
count: number;
resetAt: number;
}
const sessions = new Map<string, SessionRecord>();
const loginFailures = new Map<string, RateRecord>();
const geminiRequests = new Map<string, RateRecord>();
setInterval(() => {
const now = Date.now();
for (const [key, value] of sessions) if (value.expiresAt <= now) sessions.delete(key);
for (const [key, value] of loginFailures) if (value.resetAt <= now) loginFailures.delete(key);
for (const [key, value] of geminiRequests) if (value.resetAt <= now) geminiRequests.delete(key);
}, 60 * 60 * 1000).unref();
const profileKeys = ["scifi", "erotica", "clara", "renee"] as const; const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
type ProfileKey = typeof profileKeys[number]; type ProfileKey = typeof profileKeys[number];
@ -192,6 +219,34 @@ async function initDatabase(): Promise<AuthorData> {
let dbCache: AuthorData; let dbCache: AuthorData;
// Configure middleware // Configure middleware
if (process.env.TRUST_PROXY === "1") app.set("trust proxy", 1);
app.disable("x-powered-by");
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
if (process.env.NODE_ENV === "production") {
const scriptNonce = randomBytes(18).toString("base64url");
res.locals.scriptNonce = scriptNonce;
res.setHeader("Content-Security-Policy", [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
`script-src 'self' 'nonce-${scriptNonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src https://open.spotify.com",
].join("; "));
}
next();
});
app.use(express.json({ limit: "10mb" })); app.use(express.json({ limit: "10mb" }));
// Initialize Google GenAI if API key exists // Initialize Google GenAI if API key exists
@ -208,28 +263,108 @@ const getGeminiClient = () => {
}); });
}; };
// Admin authentication password helper function getAdminPassword(): string {
// In production or self-hosted, they set ADMIN_PASSWORD in environment or docker-compose. return process.env.ADMIN_PASSWORD || "dev-only-autor2026";
// Default fallback is "autor2026" }
const getAdminPassword = () => {
return process.env.ADMIN_PASSWORD || "autor2026";
};
// Authorization verification middleware function getSessionSecret(): string {
const verifyToken = (req: express.Request, res: express.Response, next: express.NextFunction) => { return process.env.SESSION_SECRET || "dev-only-session-secret-change-me";
const authHeader = req.headers.authorization; }
if (!authHeader) {
res.status(401).json({ error: "Kein Autorisierungs-Token bereitgestellt." }); function validateProductionSecrets(): void {
if (process.env.NODE_ENV !== "production") return;
const missing = ["ADMIN_PASSWORD", "SESSION_SECRET"].filter((name) => !process.env[name]?.trim());
if (missing.length > 0) throw new Error(`Fehlende Produktionskonfiguration: ${missing.join(", ")}`);
if ((process.env.ADMIN_PASSWORD?.length || 0) < 12) throw new Error("ADMIN_PASSWORD muss in Produktion mindestens 12 Zeichen lang sein.");
if ((process.env.SESSION_SECRET?.length || 0) < 32) throw new Error("SESSION_SECRET muss in Produktion mindestens 32 Zeichen lang sein.");
}
function safeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function parseCookies(req: express.Request): Record<string, string> {
return Object.fromEntries((req.headers.cookie || "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
const separator = part.indexOf("=");
if (separator < 0) return [part, ""];
return [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))];
}));
}
function sessionSignature(sessionId: string): string {
return createHmac("sha256", getSessionSecret()).update(sessionId).digest("base64url");
}
function sessionCookieValue(sessionId: string): string {
return `${sessionId}.${sessionSignature(sessionId)}`;
}
function readSessionId(req: express.Request): string | null {
const value = parseCookies(req)[SESSION_COOKIE];
if (!value) return null;
const separator = value.lastIndexOf(".");
if (separator < 1) return null;
const sessionId = value.slice(0, separator);
const signature = value.slice(separator + 1);
if (!safeEqual(signature, sessionSignature(sessionId))) return null;
const record = sessions.get(sessionId);
if (!record || record.expiresAt <= Date.now()) {
sessions.delete(sessionId);
return null;
}
return sessionId;
}
function setSessionCookie(res: express.Response, sessionId: string): void {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
res.append("Set-Cookie", `${SESSION_COOKIE}=${encodeURIComponent(sessionCookieValue(sessionId))}; Path=/api/admin; Max-Age=${SESSION_DURATION_MS / 1000}; HttpOnly; SameSite=Strict${secure}`);
}
function clearSessionCookie(res: express.Response): void {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
res.append("Set-Cookie", `${SESSION_COOKIE}=; Path=/api/admin; Max-Age=0; HttpOnly; SameSite=Strict${secure}`);
}
function verifySession(req: express.Request, res: express.Response, next: express.NextFunction): void {
const sessionId = readSessionId(req);
if (!sessionId) {
clearSessionCookie(res);
res.status(401).json({ error: "Keine gültige Admin-Sitzung vorhanden." });
return; return;
} }
const token = authHeader.replace("Bearer ", ""); res.locals.sessionId = sessionId;
// To keep session simple, secure, and self-hosted, our auth token is just the password itself or adminPassword next();
if (token === getAdminPassword()) { }
function verifySameOrigin(req: express.Request, res: express.Response, next: express.NextFunction): void {
const origin = req.get("origin");
if (!origin) {
if (process.env.NODE_ENV === "production") {
res.status(403).json({ error: "Fehlender Origin-Header." });
return;
}
next(); next();
} else { return;
res.status(403).json({ error: "Ungültiges Passwort oder Sitzungstoken." });
} }
}; try {
if (new URL(origin).host !== req.get("host")) throw new Error("origin mismatch");
next();
} catch {
res.status(403).json({ error: "Anfrage von einer fremden Herkunft abgelehnt." });
}
}
function rateRecord(map: Map<string, RateRecord>, key: string, windowMs: number): RateRecord {
const existing = map.get(key);
if (!existing || existing.resetAt <= Date.now()) {
const fresh = { count: 0, resetAt: Date.now() + windowMs };
map.set(key, fresh);
return fresh;
}
return existing;
}
function cleanDomain(value: string): string { function cleanDomain(value: string): string {
return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]; return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
@ -281,7 +416,7 @@ function absoluteUrl(value: string | undefined, origin: string): string | undefi
} }
} }
function seoMeta(req: express.Request): string { function seoMeta(req: express.Request, scriptNonce?: string): string {
const key = profileForRequest(req.hostname, req.path); const key = profileForRequest(req.hostname, req.path);
const profile = dbCache[key]; const profile = dbCache[key];
const forwardedProtocol = req.header("x-forwarded-proto")?.split(",")[0].trim(); const forwardedProtocol = req.header("x-forwarded-proto")?.split(",")[0].trim();
@ -315,7 +450,7 @@ function seoMeta(req: express.Request): string {
`<meta property="og:url" content="${escapeHtml(canonical)}" />`, `<meta property="og:url" content="${escapeHtml(canonical)}" />`,
image ? `<meta property="og:image" content="${escapeHtml(image)}" />` : "", image ? `<meta property="og:image" content="${escapeHtml(image)}" />` : "",
`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}" />`, `<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}" />`,
`<script type="application/ld+json">${structuredData}</script>`, `<script${scriptNonce ? ` nonce="${scriptNonce}"` : ""} type="application/ld+json">${structuredData}</script>`,
].filter(Boolean).join("\n "); ].filter(Boolean).join("\n ");
} }
@ -338,24 +473,46 @@ app.get("/health/ready", (_req, res) => {
res.json({ status: "ok", revision: dbCache.revision ?? 0 }); res.json({ status: "ok", revision: dbCache.revision ?? 0 });
}); });
// 2. Manage Admin Login // 2. Manage short-lived admin sessions
app.post("/api/admin/login", (req, res) => { app.get("/api/admin/session", verifySession, (_req, res) => {
res.json({ success: true });
});
app.post("/api/admin/login", verifySameOrigin, (req, res) => {
const rate = rateRecord(loginFailures, req.ip || "unknown", LOGIN_WINDOW_MS);
if (rate.count >= LOGIN_MAX_FAILURES) {
res.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000));
res.status(429).json({ error: "Zu viele fehlgeschlagene Anmeldeversuche. Bitte später erneut versuchen." });
return;
}
const { password } = req.body; const { password } = req.body;
if (!password) { if (!password || typeof password !== "string") {
res.status(400).json({ error: "Passwort ist erforderlich." }); res.status(400).json({ error: "Passwort ist erforderlich." });
return; return;
} }
if (password === getAdminPassword()) { if (!safeEqual(password, getAdminPassword())) {
// Return the token which client stores in localStorage rate.count += 1;
res.json({ success: true, token: getAdminPassword() });
} else {
res.status(401).json({ error: "Ungültiges Passwort. Bitte versuchen Sie es erneut." }); res.status(401).json({ error: "Ungültiges Passwort. Bitte versuchen Sie es erneut." });
return;
} }
loginFailures.delete(req.ip || "unknown");
const sessionId = randomBytes(32).toString("base64url");
sessions.set(sessionId, { expiresAt: Date.now() + SESSION_DURATION_MS });
setSessionCookie(res, sessionId);
res.json({ success: true, expiresInSeconds: SESSION_DURATION_MS / 1000 });
});
app.post("/api/admin/logout", verifySameOrigin, (req, res) => {
const sessionId = readSessionId(req);
if (sessionId) sessions.delete(sessionId);
clearSessionCookie(res);
res.json({ success: true });
}); });
// 3. Save modified profile configurations (About, Projects, Books) // 3. Save modified profile configurations (About, Projects, Books)
app.post("/api/admin/save-profile", verifyToken, async (req, res) => { app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
const { profileKey, profileData } = req.body; const { profileKey, profileData } = req.body;
if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") { if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") {
res.status(400).json({ error: "Ungültiger Profilschlüssel." }); res.status(400).json({ error: "Ungültiger Profilschlüssel." });
@ -380,7 +537,7 @@ app.post("/api/admin/save-profile", verifyToken, async (req, res) => {
}); });
// 3b. Save legal documents (Impressum & Datenschutzerklärung) // 3b. Save legal documents (Impressum & Datenschutzerklärung)
app.post("/api/admin/save-legal", verifyToken, async (req, res) => { app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
const { legalDocuments } = req.body; const { legalDocuments } = req.body;
if (!Array.isArray(legalDocuments)) { if (!Array.isArray(legalDocuments)) {
res.status(400).json({ error: "legalDocuments muss ein Array sein." }); res.status(400).json({ error: "legalDocuments muss ein Array sein." });
@ -401,7 +558,15 @@ app.post("/api/admin/save-legal", verifyToken, async (req, res) => {
}); });
// 4. Creative AI Blurb Assistant for book blurb updates // 4. Creative AI Blurb Assistant for book blurb updates
app.post("/api/admin/generate-blurb", verifyToken, async (req, res) => { app.post("/api/admin/generate-blurb", verifySession, verifySameOrigin, async (req, res) => {
const sessionId = res.locals.sessionId as string;
const rate = rateRecord(geminiRequests, sessionId, GEMINI_WINDOW_MS);
if (rate.count >= GEMINI_MAX_REQUESTS) {
res.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000));
res.status(429).json({ error: "Das stündliche Limit des Schreibassistenten ist erreicht." });
return;
}
rate.count += 1;
const { title, genre, ideas, tone } = req.body; const { title, genre, ideas, tone } = req.body;
const ai = getGeminiClient(); const ai = getGeminiClient();
@ -436,7 +601,7 @@ Die Synopsis soll neugierig machen, stark die visuelle Stimmung rüberbringen un
}); });
// 5. Upload a file via base64 // 5. Upload a file via base64
app.post("/api/admin/upload-file", verifyToken, async (req, res) => { app.post("/api/admin/upload-file", verifySession, verifySameOrigin, async (req, res) => {
const { fileName, base64Data } = req.body; const { fileName, base64Data } = req.body;
if (!fileName || !base64Data) { if (!fileName || !base64Data) {
res.status(400).json({ error: "Dateiname und Base64-Daten sind erforderlich." }); res.status(400).json({ error: "Dateiname und Base64-Daten sind erforderlich." });
@ -461,7 +626,7 @@ app.post("/api/admin/upload-file", verifyToken, async (req, res) => {
}); });
// 6. List uploaded files // 6. List uploaded files
app.get("/api/admin/list-uploads", verifyToken, async (req, res) => { app.get("/api/admin/list-uploads", verifySession, async (req, res) => {
try { try {
const uploadsDir = path.join(DATA_DIR, "uploads"); const uploadsDir = path.join(DATA_DIR, "uploads");
await fs.mkdir(uploadsDir, { recursive: true }); await fs.mkdir(uploadsDir, { recursive: true });
@ -513,7 +678,7 @@ async function startServer() {
app.use(express.static(distPath, { index: false })); app.use(express.static(distPath, { index: false }));
app.get("*", (req: express.Request, res: express.Response) => { app.get("*", (req: express.Request, res: express.Response) => {
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req)); const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req, res.locals.scriptNonce));
if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive"); if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
res.type("html").send(html); res.type("html").send(html);
}); });
@ -537,6 +702,7 @@ async function startServer() {
} }
async function main() { async function main() {
validateProductionSecrets();
dbCache = await initDatabase(); dbCache = await initDatabase();
await startServer(); await startServer();
} }

View file

@ -16,7 +16,6 @@ interface AdminPanelProps {
export default function AdminPanel({ onLogout }: AdminPanelProps) { export default function AdminPanel({ onLogout }: AdminPanelProps) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [token, setToken] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -50,14 +49,25 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const [aiLoading, setAiLoading] = useState(false); const [aiLoading, setAiLoading] = useState(false);
const [aiResult, setAiResult] = useState(""); const [aiResult, setAiResult] = useState("");
// Check login state on component mount // Restore a valid server-side session on component mount.
useEffect(() => { useEffect(() => {
const storedToken = localStorage.getItem("author_admin_token"); // Remove the legacy value that used to contain the admin password.
if (storedToken) { localStorage.removeItem("author_admin_token");
setToken(storedToken); const restoreSession = async () => {
setIsLoggedIn(true); setLoading(true);
fetchAuthorData(); try {
} const response = await fetch("/api/admin/session");
if (response.ok) {
setIsLoggedIn(true);
await fetchAuthorData();
}
} catch (err) {
console.error("Admin session check failed:", err);
} finally {
setLoading(false);
}
};
restoreSession();
}, []); }, []);
const fetchAuthorData = async () => { const fetchAuthorData = async () => {
@ -85,10 +95,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
}); });
const data = await res.json(); const data = await res.json();
if (res.ok && data.success) { if (res.ok && data.success) {
localStorage.setItem("author_admin_token", data.token); setPassword("");
setToken(data.token);
setIsLoggedIn(true); setIsLoggedIn(true);
fetchAuthorData(); await fetchAuthorData();
} else { } else {
setError(data.error || "Ungültiges Passwort."); setError(data.error || "Ungültiges Passwort.");
} }
@ -99,10 +108,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
} }
}; };
const handleLogoutLocal = () => { const handleLogoutLocal = async () => {
localStorage.removeItem("author_admin_token"); try {
await fetch("/api/admin/logout", { method: "POST" });
} catch (err) {
console.error("Admin logout failed:", err);
}
setIsLoggedIn(false); setIsLoggedIn(false);
setToken(""); setAuthorData(null);
onLogout(); onLogout();
}; };
@ -117,7 +130,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
profileKey, profileKey,
@ -184,7 +196,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
}); });
@ -215,7 +226,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
}); });
@ -279,7 +289,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
}); });
@ -310,7 +319,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
}); });
@ -339,7 +347,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
title: aiTitle, title: aiTitle,
@ -404,7 +411,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ legalDocuments: updatedDocs }) body: JSON.stringify({ legalDocuments: updatedDocs })
}); });
@ -431,7 +437,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}, },
body: JSON.stringify({ legalDocuments: updatedDocs }) body: JSON.stringify({ legalDocuments: updatedDocs })
}); });
@ -708,7 +713,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<ImagePicker <ImagePicker
value={profile.avatarUrl} value={profile.avatarUrl}
onChange={(url) => updateProfileField("avatarUrl", url)} onChange={(url) => updateProfileField("avatarUrl", url)}
token={token}
label="Autorenfoto / Avatar" label="Autorenfoto / Avatar"
/> />
</div> </div>
@ -1268,7 +1272,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<ImagePicker <ImagePicker
value={profile.heroBannerUrl || ""} value={profile.heroBannerUrl || ""}
onChange={(url) => updateProfileField("heroBannerUrl", url)} onChange={(url) => updateProfileField("heroBannerUrl", url)}
token={token}
label="Hero Banner-Bild" label="Hero Banner-Bild"
/> />
<p className="text-[10px] text-slate-500">Wenn angegeben, wird dieses Bild als stimmungsvoller Hintergrund im oberen Bereich der Webseite hinterlegt.</p> <p className="text-[10px] text-slate-500">Wenn angegeben, wird dieses Bild als stimmungsvoller Hintergrund im oberen Bereich der Webseite hinterlegt.</p>
@ -1400,7 +1403,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<ImagePicker <ImagePicker
value={projectForm.imageUrl || ""} value={projectForm.imageUrl || ""}
onChange={(url) => setProjectForm({ ...projectForm, imageUrl: url })} onChange={(url) => setProjectForm({ ...projectForm, imageUrl: url })}
token={token}
label="Projekt-Detailbild" label="Projekt-Detailbild"
/> />
</div> </div>
@ -1596,7 +1598,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
<ImagePicker <ImagePicker
value={bookForm.coverUrl || ""} value={bookForm.coverUrl || ""}
onChange={(url) => setBookForm({ ...bookForm, coverUrl: url })} onChange={(url) => setBookForm({ ...bookForm, coverUrl: url })}
token={token}
label="Buch-Cover" label="Buch-Cover"
/> />
</div> </div>

View file

@ -4,7 +4,6 @@ import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, Folder
interface ImagePickerProps { interface ImagePickerProps {
value: string; value: string;
onChange: (url: string) => void; onChange: (url: string) => void;
token: string;
label: string; label: string;
} }
@ -13,7 +12,7 @@ interface ServerFile {
url: string; url: string;
} }
export default function ImagePicker({ value, onChange, token, label }: ImagePickerProps) { export default function ImagePicker({ value, onChange, label }: ImagePickerProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"upload" | "server" | "url">("upload"); const [activeTab, setActiveTab] = useState<"upload" | "server" | "url">("upload");
const [manualUrl, setManualUrl] = useState(value || ""); const [manualUrl, setManualUrl] = useState(value || "");
@ -40,11 +39,7 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
setLoadingFiles(true); setLoadingFiles(true);
setError(""); setError("");
try { try {
const response = await fetch("/api/admin/list-uploads", { const response = await fetch("/api/admin/list-uploads");
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) { if (!response.ok) {
throw new Error("Fehler beim Laden der Serverdateien"); throw new Error("Fehler beim Laden der Serverdateien");
} }
@ -82,7 +77,6 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
fileName: file.name, fileName: file.name,