Codex/admin quality hardening #17
8 changed files with 419 additions and 247 deletions
|
|
@ -115,10 +115,12 @@ Das Admin-Passwort wird nur beim Login übertragen und weder als Bearer-Token zu
|
||||||
- Sitzungsdauer: 24 Stunden
|
- Sitzungsdauer: 24 Stunden
|
||||||
- Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure`
|
- Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure`
|
||||||
- Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart
|
- Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart
|
||||||
|
- Abgelaufene Sitzungen führen im Adminbereich kontrolliert zurück zur Anmeldung
|
||||||
- Schreibende Admin-Anfragen werden auf gleiche Herkunft geprüft
|
- Schreibende Admin-Anfragen werden auf gleiche Herkunft geprüft
|
||||||
- Fehlgeschlagene Logins werden pro Client-IP begrenzt
|
- Fehlgeschlagene Logins werden pro Client-IP begrenzt
|
||||||
- Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits
|
- Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits
|
||||||
- Sicherheitsheader und eine Content Security Policy werden serverseitig gesetzt
|
- Sicherheitsheader und eine Content Security Policy werden serverseitig gesetzt
|
||||||
|
- Profil- und Rechtstextänderungen verwenden eine Revision; parallele Änderungen werden mit einem Konflikthinweis abgelehnt statt überschrieben
|
||||||
|
|
||||||
Ein vergessenes Admin-Passwort wird über die Betriebsumgebung geändert; es gibt keine öffentliche Passwort-zurücksetzen-Funktion.
|
Ein vergessenes Admin-Passwort wird über die Betriebsumgebung geändert; es gibt keine öffentliche Passwort-zurücksetzen-Funktion.
|
||||||
|
|
||||||
|
|
@ -147,6 +149,7 @@ Der Server:
|
||||||
- erzeugt zufällige, nicht überschreibbare Dateinamen,
|
- erzeugt zufällige, nicht überschreibbare Dateinamen,
|
||||||
- begrenzt Uploadversuche pro Admin-Sitzung und Stunde,
|
- begrenzt Uploadversuche pro Admin-Sitzung und Stunde,
|
||||||
- liefert Uploads mit `nosniff` und langfristigen Cache-Headern aus.
|
- liefert Uploads mit `nosniff` und langfristigen Cache-Headern aus.
|
||||||
|
- erlaubt das Löschen unbenutzter Bilder in der Server-Mediathek, schützt aber Bilder, die noch in einem Profil referenziert werden.
|
||||||
|
|
||||||
Die Bilder können für Avatare, Banner, Buchcover und aktuelle Projekte verwendet werden. Leere Bildfelder erzeugen keinen Request zu einem externen Standardbild.
|
Die Bilder können für Avatare, Banner, Buchcover und aktuelle Projekte verwendet werden. Leere Bildfelder erzeugen keinen Request zu einem externen Standardbild.
|
||||||
|
|
||||||
|
|
@ -193,7 +196,9 @@ Die Integrationstests verwenden ein temporäres Datenverzeichnis und einen kurzl
|
||||||
- Origin-Schutz und Login-Limit,
|
- Origin-Schutz und Login-Limit,
|
||||||
- exaktes Domain-Routing und öffentliche Profilisolation,
|
- exaktes Domain-Routing und öffentliche Profilisolation,
|
||||||
- Schutz des vollständigen Admin-Datenendpunkts,
|
- Schutz des vollständigen Admin-Datenendpunkts,
|
||||||
|
- Revisionskonflikte und serverseitige Inhaltsvalidierung,
|
||||||
- gültige und manipulierte Bild-Uploads,
|
- gültige und manipulierte Bild-Uploads,
|
||||||
|
- Löschung unbenutzter sowie Schutz referenzierter Uploads,
|
||||||
- wesentliche Sicherheitsheader.
|
- wesentliche Sicherheitsheader.
|
||||||
|
|
||||||
## Hinweise zur Aktualisierung
|
## Hinweise zur Aktualisierung
|
||||||
|
|
|
||||||
144
server.ts
144
server.ts
|
|
@ -54,6 +54,8 @@ interface RateRecord {
|
||||||
resetAt: number;
|
resetAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RevisionConflictError extends Error {}
|
||||||
|
|
||||||
const sessions = new Map<string, SessionRecord>();
|
const sessions = new Map<string, SessionRecord>();
|
||||||
const loginFailures = new Map<string, RateRecord>();
|
const loginFailures = new Map<string, RateRecord>();
|
||||||
const geminiRequests = new Map<string, RateRecord>();
|
const geminiRequests = new Map<string, RateRecord>();
|
||||||
|
|
@ -510,6 +512,77 @@ function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSafeContentUrl(value: unknown, allowSpotifyId = false): boolean {
|
||||||
|
if (value === undefined || value === "") return true;
|
||||||
|
if (typeof value !== "string" || value.length > 2048) return false;
|
||||||
|
if (allowSpotifyId && /^[a-zA-Z0-9]+$/.test(value)) return true;
|
||||||
|
if (value.startsWith("/uploads/")) return SAFE_UPLOAD_NAME.test(path.basename(value));
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "https:" || url.protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProfile(profile: unknown): string | null {
|
||||||
|
if (!profile || typeof profile !== "object") return "Profildaten fehlen.";
|
||||||
|
const value = profile as AuthorProfile;
|
||||||
|
if (!Array.isArray(value.books) || value.books.length > 500) return "Die Bücherliste ist ungültig oder zu groß.";
|
||||||
|
if (!Array.isArray(value.projects) || value.projects.length > 200) return "Die Projektliste ist ungültig oder zu groß.";
|
||||||
|
const requiredStrings: Array<[unknown, string, number]> = [
|
||||||
|
[value.name, "Name", 200], [value.bio, "Biografie", 50_000],
|
||||||
|
[value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500],
|
||||||
|
];
|
||||||
|
for (const [field, label, maxLength] of requiredStrings) {
|
||||||
|
if (typeof field !== "string" || field.length > maxLength) return `${label} ist ungültig oder zu lang.`;
|
||||||
|
}
|
||||||
|
if (!isSafeContentUrl(value.avatarUrl) || !isSafeContentUrl(value.heroBannerUrl) || !isSafeContentUrl(value.socialImageUrl)) {
|
||||||
|
return "Mindestens eine Bild-URL ist ungültig.";
|
||||||
|
}
|
||||||
|
if (value.fontFamily && !["sans", "serif", "mono"].includes(value.fontFamily)) return "Die Schriftart ist ungültig.";
|
||||||
|
for (const project of value.projects) {
|
||||||
|
if (!project || typeof project.id !== "string" || typeof project.title !== "string" || project.title.length > 500 ||
|
||||||
|
typeof project.description !== "string" || project.description.length > 100_000 ||
|
||||||
|
!Number.isFinite(project.progress) || project.progress < 0 || project.progress > 100 ||
|
||||||
|
!isSafeContentUrl(project.imageUrl) || !isSafeContentUrl(project.spotifyPlaylistId, true)) {
|
||||||
|
return "Mindestens ein Projekt enthält ungültige Werte.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const book of value.books) {
|
||||||
|
if (!book || typeof book.id !== "string" || typeof book.title !== "string" || book.title.length > 500 ||
|
||||||
|
typeof book.description !== "string" || book.description.length > 100_000 ||
|
||||||
|
!isSafeContentUrl(book.coverUrl) || !isSafeContentUrl(book.buyLink) || !isSafeContentUrl(book.spotifyPlaylistId, true) ||
|
||||||
|
(book.genres !== undefined && (!Array.isArray(book.genres) || book.genres.length > 20 || book.genres.some((genre) => typeof genre !== "string" || genre.length > 100)))) {
|
||||||
|
return "Mindestens ein Buch enthält ungültige Werte.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLegalDocuments(value: unknown): value is AuthorData["legalDocuments"] {
|
||||||
|
return Array.isArray(value) && value.length <= 100 && value.every((document) =>
|
||||||
|
document && typeof document.id === "string" && document.id.length <= 200 &&
|
||||||
|
typeof document.title === "string" && document.title.length <= 500 &&
|
||||||
|
["impressum", "privacy", "custom"].includes(document.type) &&
|
||||||
|
typeof document.content === "string" && document.content.length <= 200_000 &&
|
||||||
|
Array.isArray(document.assignedProfiles) && document.assignedProfiles.length <= profileKeys.length &&
|
||||||
|
document.assignedProfiles.every((key: unknown) => profileKeys.includes(key as ProfileKey))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRevisionConflict(expectedRevision: unknown): boolean {
|
||||||
|
return !Number.isInteger(expectedRevision) || expectedRevision !== (dbCache.revision ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUploadReferenced(url: string): boolean {
|
||||||
|
return profileKeys.some((key) => {
|
||||||
|
const profile = dbCache[key];
|
||||||
|
return profile.avatarUrl === url || profile.heroBannerUrl === url || profile.socialImageUrl === url ||
|
||||||
|
profile.books.some((book) => book.coverUrl === url) || profile.projects.some((project) => project.imageUrl === url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function seoMeta(req: express.Request, scriptNonce?: string): 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];
|
||||||
|
|
@ -626,15 +699,20 @@ app.post("/api/admin/logout", verifySameOrigin, (req, res) => {
|
||||||
|
|
||||||
// 3. Save modified profile configurations (About, Projects, Books)
|
// 3. Save modified profile configurations (About, Projects, Books)
|
||||||
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
|
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
const { profileKey, profileData } = req.body;
|
const { profileKey, profileData, expectedRevision } = 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." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) {
|
if (hasRevisionConflict(expectedRevision)) {
|
||||||
res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validationError = validateProfile(profileData);
|
||||||
|
if (validationError) {
|
||||||
|
res.status(400).json({ error: validationError });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks);
|
const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks);
|
||||||
|
|
@ -643,13 +721,16 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedProfileData = { ...profileData, customSectionLinks };
|
const sanitizedProfileData = { ...profileData, customSectionLinks };
|
||||||
const nextData = await updateDatabase((current) => ({
|
const nextData = await updateDatabase((current) => {
|
||||||
...current,
|
if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError();
|
||||||
[profileKey]: sanitizedProfileData,
|
return { ...current, [profileKey]: sanitizedProfileData, revision: (current.revision ?? 0) + 1 };
|
||||||
revision: (current.revision ?? 0) + 1,
|
});
|
||||||
}));
|
|
||||||
res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });
|
res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof RevisionConflictError) {
|
||||||
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Failed to write to database.json:", err);
|
console.error("Failed to write to database.json:", err);
|
||||||
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." });
|
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." });
|
||||||
}
|
}
|
||||||
|
|
@ -657,20 +738,27 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req,
|
||||||
|
|
||||||
// 3b. Save legal documents (Impressum & Datenschutzerklärung)
|
// 3b. Save legal documents (Impressum & Datenschutzerklärung)
|
||||||
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
|
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
const { legalDocuments } = req.body;
|
const { legalDocuments, expectedRevision } = req.body;
|
||||||
if (!Array.isArray(legalDocuments)) {
|
if (hasRevisionConflict(expectedRevision)) {
|
||||||
res.status(400).json({ error: "legalDocuments muss ein Array sein." });
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateLegalDocuments(legalDocuments)) {
|
||||||
|
res.status(400).json({ error: "Die rechtlichen Dokumente besitzen nicht die erwartete Struktur oder überschreiten zulässige Längen." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextData = await updateDatabase((current) => ({
|
const nextData = await updateDatabase((current) => {
|
||||||
...current,
|
if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError();
|
||||||
legalDocuments,
|
return { ...current, legalDocuments, revision: (current.revision ?? 0) + 1 };
|
||||||
revision: (current.revision ?? 0) + 1,
|
});
|
||||||
}));
|
|
||||||
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
|
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof RevisionConflictError) {
|
||||||
|
res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Failed to write legal documents to database.json:", err);
|
console.error("Failed to write legal documents to database.json:", err);
|
||||||
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." });
|
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." });
|
||||||
}
|
}
|
||||||
|
|
@ -798,6 +886,30 @@ app.get("/api/admin/list-uploads", verifySession, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/uploads/:name", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
|
const name = req.params.name;
|
||||||
|
if (!SAFE_UPLOAD_NAME.test(name) || path.basename(name) !== name) {
|
||||||
|
res.status(400).json({ error: "Ungültiger Dateiname." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `/uploads/${name}`;
|
||||||
|
if (isUploadReferenced(url)) {
|
||||||
|
res.status(409).json({ error: "Das Bild wird noch in mindestens einem Profil verwendet und kann nicht gelöscht werden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.unlink(path.join(DATA_DIR, "uploads", name));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "ENOENT") {
|
||||||
|
res.status(404).json({ error: "Die Datei wurde nicht gefunden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Failed to delete upload:", error);
|
||||||
|
res.status(500).json({ error: "Die Datei konnte nicht gelöscht werden." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Configure Vite middleware or static serve
|
// Configure Vite middleware or static serve
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
// Serve the dynamic uploads directory statically
|
// Serve the dynamic uploads directory statically
|
||||||
|
|
|
||||||
41
src/api/adminApi.ts
Normal file
41
src/api/adminApi.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { AuthorData, AuthorProfile, LegalDocument } from "../types";
|
||||||
|
|
||||||
|
export class AdminApiError extends Error {
|
||||||
|
constructor(message: string, public readonly status: number) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
const payload = await response.json().catch(() => ({})) as { error?: string };
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 && url !== "/api/admin/login") window.dispatchEvent(new Event("admin-session-expired"));
|
||||||
|
throw new AdminApiError(payload.error || `Anfrage fehlgeschlagen (HTTP ${response.status}).`, response.status);
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonPost = (body: unknown): RequestInit => ({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
session: () => request<{ success: true }>("/api/admin/session"),
|
||||||
|
login: (password: string) => request<{ success: true }>("/api/admin/login", jsonPost({ password })),
|
||||||
|
logout: () => request<{ success: true }>("/api/admin/logout", { method: "POST" }),
|
||||||
|
loadData: () => request<AuthorData>("/api/admin/author-data"),
|
||||||
|
saveProfile: (profileKey: "scifi" | "erotica" | "clara" | "renee", profileData: AuthorProfile, expectedRevision: number) =>
|
||||||
|
request<{ success: true; revision: number }>("/api/admin/save-profile", jsonPost({ profileKey, profileData, expectedRevision })),
|
||||||
|
saveLegal: (legalDocuments: LegalDocument[], expectedRevision: number) =>
|
||||||
|
request<{ success: true; revision: number }>("/api/admin/save-legal", jsonPost({ legalDocuments, expectedRevision })),
|
||||||
|
generateBlurb: (input: { title: string; genre: string; ideas: string; tone: string }) =>
|
||||||
|
request<{ success: true; text: string }>("/api/admin/generate-blurb", jsonPost(input)),
|
||||||
|
listUploads: () => request<{ success: true; files: Array<{ name: string; url: string }> }>("/api/admin/list-uploads"),
|
||||||
|
upload: (fileName: string, base64Data: string) =>
|
||||||
|
request<{ success: true; url: string }>("/api/admin/upload-file", jsonPost({ fileName, base64Data })),
|
||||||
|
deleteUpload: (name: string) =>
|
||||||
|
request<{ success: true }>(`/api/admin/uploads/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
||||||
|
};
|
||||||
72
src/components/AccessibleModal.tsx
Normal file
72
src/components/AccessibleModal.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AccessibleModalProps {
|
||||||
|
titleId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
maxWidthClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusableSelector = [
|
||||||
|
"a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])",
|
||||||
|
"textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])",
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
export default function AccessibleModal({ titleId, onClose, children, maxWidthClass = "sm:max-w-4xl" }: AccessibleModalProps) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
const firstFocusable = dialog?.querySelector<HTMLElement>(focusableSelector);
|
||||||
|
(firstFocusable || dialog)?.focus();
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab" || !dialog) return;
|
||||||
|
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector));
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialog.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
previousFocus?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
||||||
|
<button type="button" className="fixed inset-0 bg-slate-950/80 backdrop-blur-md" onClick={onClose} aria-label="Dialog schließen" />
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full ${maxWidthClass} p-6 md:p-8 space-y-6`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import ImagePicker from "./ImagePicker";
|
import ImagePicker from "./ImagePicker";
|
||||||
import AdminLogin from "./admin/AdminLogin";
|
import AdminLogin from "./admin/AdminLogin";
|
||||||
|
import { adminApi, AdminApiError } from "../api/adminApi";
|
||||||
|
|
||||||
interface AdminPanelProps {
|
interface AdminPanelProps {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
|
@ -19,6 +20,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<{ message: string; tone: "success" | "error" } | null>(null);
|
||||||
|
|
||||||
// Author Data State
|
// Author Data State
|
||||||
const [authorData, setAuthorData] = useState<AuthorData | null>(null);
|
const [authorData, setAuthorData] = useState<AuthorData | null>(null);
|
||||||
|
|
@ -57,11 +59,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const restoreSession = async () => {
|
const restoreSession = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/session");
|
await adminApi.session();
|
||||||
if (response.ok) {
|
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
await fetchAuthorData();
|
await fetchAuthorData();
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Admin session check failed:", err);
|
console.error("Admin session check failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -71,11 +71,20 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
restoreSession();
|
restoreSession();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleExpiredSession = () => {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
setAuthorData(null);
|
||||||
|
setError("Die Admin-Sitzung ist abgelaufen. Bitte erneut anmelden.");
|
||||||
|
};
|
||||||
|
window.addEventListener("admin-session-expired", handleExpiredSession);
|
||||||
|
return () => window.removeEventListener("admin-session-expired", handleExpiredSession);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchAuthorData = async () => {
|
const fetchAuthorData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/author-data");
|
const data = await adminApi.loadData();
|
||||||
const data = await res.json();
|
|
||||||
setAuthorData(data);
|
setAuthorData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching author data:", err);
|
console.error("Error fetching author data:", err);
|
||||||
|
|
@ -89,21 +98,12 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/login", {
|
await adminApi.login(password);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
await fetchAuthorData();
|
await fetchAuthorData();
|
||||||
} else {
|
|
||||||
setError(data.error || "Ungültiges Passwort.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Verbindung zum Server fehlgeschlagen.");
|
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +111,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
const handleLogoutLocal = async () => {
|
const handleLogoutLocal = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/admin/logout", { method: "POST" });
|
await adminApi.logout();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Admin logout failed:", err);
|
console.error("Admin logout failed:", err);
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +120,28 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
onLogout();
|
onLogout();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistProfile = async (profileKey: "scifi" | "erotica" | "clara" | "renee", profileData: AuthorProfile) => {
|
||||||
|
if (!authorData) throw new Error("Keine Profildaten geladen.");
|
||||||
|
const result = await adminApi.saveProfile(profileKey, profileData, authorData.revision ?? 0);
|
||||||
|
setAuthorData((current) => current ? { ...current, [profileKey]: profileData, revision: result.revision } : current);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistLegalDocuments = async (legalDocuments: LegalDocument[]) => {
|
||||||
|
if (!authorData) throw new Error("Keine Profildaten geladen.");
|
||||||
|
const result = await adminApi.saveLegal(legalDocuments, authorData.revision ?? 0);
|
||||||
|
setAuthorData((current) => current ? { ...current, legalDocuments, revision: result.revision } : current);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNotice = (message: string, tone: "success" | "error" = "error") => setNotice({ message, tone });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) return;
|
||||||
|
const timeout = window.setTimeout(() => setNotice(null), 5000);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
// Profile save handler
|
// Profile save handler
|
||||||
const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => {
|
const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => {
|
||||||
if (!authorData) return;
|
if (!authorData) return;
|
||||||
|
|
@ -127,26 +149,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(profileKey, authorData[profileKey]);
|
||||||
method: "POST",
|
showNotice("Profilinformationen erfolgreich gespeichert!", "success");
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
profileKey,
|
|
||||||
profileData: authorData[profileKey]
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resData = await res.json();
|
|
||||||
if (res.ok && resData.success) {
|
|
||||||
alert("Profilinformationen erfolgreich gespeichert!");
|
|
||||||
fetchAuthorData();
|
|
||||||
} else {
|
|
||||||
setError(resData.error || "Profile konnte nicht gespeichert werden.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Fehler beim Senden der Profildaten.");
|
setError(err instanceof AdminApiError ? err.message : "Fehler beim Senden der Profildaten.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -198,27 +204,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedProfile = { ...profile, projects: updatedProjects };
|
const updatedProfile = { ...profile, projects: updatedProjects };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
setEditingProjectId(null);
|
setEditingProjectId(null);
|
||||||
setProjectForm({});
|
setProjectForm({});
|
||||||
} else {
|
|
||||||
alert("Fehler beim Speichern des Projekts.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -229,24 +222,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
const updatedProjects = profile.projects.filter(p => p.id !== projId);
|
const updatedProjects = profile.projects.filter(p => p.id !== projId);
|
||||||
const updatedProfile = { ...profile, projects: updatedProjects };
|
const updatedProfile = { ...profile, projects: updatedProjects };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Löschen.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -292,26 +272,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedProfile = { ...profile, books: updatedBooks };
|
const updatedProfile = { ...profile, books: updatedBooks };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
setEditingBookId(null);
|
setEditingBookId(null);
|
||||||
setBookForm({});
|
setBookForm({});
|
||||||
} else {
|
|
||||||
alert("Fehler beim Speichern des Buches.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -322,24 +289,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
const updatedBooks = profile.books.filter(b => b.id !== bookId);
|
const updatedBooks = profile.books.filter(b => b.id !== bookId);
|
||||||
const updatedProfile = { ...profile, books: updatedBooks };
|
const updatedProfile = { ...profile, books: updatedBooks };
|
||||||
const nextData = { ...authorData, [activeProfile]: updatedProfile };
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-profile", {
|
await persistProfile(activeProfile, updatedProfile);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData(nextData);
|
|
||||||
} else {
|
|
||||||
alert("Fehler beim Löschen des Buches.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Netzwerkfehler.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -348,36 +302,22 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
// --- GEMINI SYNOPSIS GENERATOR ---
|
// --- GEMINI SYNOPSIS GENERATOR ---
|
||||||
const handleGenerateAISynopsis = async () => {
|
const handleGenerateAISynopsis = async () => {
|
||||||
if (!aiTitle.trim()) {
|
if (!aiTitle.trim()) {
|
||||||
alert("Bitte geben Sie einen Buchtitel für den KI-Assistenten an.");
|
showNotice("Bitte geben Sie einen Buchtitel für den KI-Assistenten an.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAiLoading(true);
|
setAiLoading(true);
|
||||||
setAiResult("");
|
setAiResult("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/generate-blurb", {
|
const data = await adminApi.generateBlurb({
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: aiTitle,
|
title: aiTitle,
|
||||||
genre: aiGenre || activeProfile === "scifi" ? "Science-Fiction" : "Sinnliche Literatur",
|
genre: aiGenre || (activeProfile === "scifi" ? "Science-Fiction" : "Literatur"),
|
||||||
ideas: aiIdeas,
|
ideas: aiIdeas,
|
||||||
tone: aiTone
|
tone: aiTone,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.text) {
|
|
||||||
setAiResult(data.text);
|
setAiResult(data.text);
|
||||||
setBookForm({
|
setBookForm({ ...bookForm, description: data.text });
|
||||||
...bookForm,
|
} catch (err) {
|
||||||
description: data.text
|
showNotice(err instanceof AdminApiError ? err.message : "Fehler bei der Verbindung zur KI-Schnittstelle.");
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert(data.error || "Generierung fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert("Fehler bei der Verbindung zur KI-Schnittstelle.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setAiLoading(false);
|
setAiLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -418,22 +358,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-legal", {
|
await persistLegalDocuments(updatedDocs);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
|
|
||||||
setEditingLegalId(null);
|
setEditingLegalId(null);
|
||||||
setLegalForm({});
|
setLegalForm({});
|
||||||
} else {
|
} catch (err) {
|
||||||
alert("Fehler beim Speichern des Dokuments.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Speichern.");
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert("Netzwerkfehler beim Speichern.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -444,24 +373,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId);
|
const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/save-legal", {
|
await persistLegalDocuments(updatedDocs);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
|
|
||||||
if (editingLegalId === docId) {
|
if (editingLegalId === docId) {
|
||||||
setEditingLegalId(null);
|
setEditingLegalId(null);
|
||||||
setLegalForm({});
|
setLegalForm({});
|
||||||
}
|
}
|
||||||
} else {
|
} catch (err) {
|
||||||
alert("Fehler beim Löschen des Dokuments.");
|
showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Löschen.");
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert("Netzwerkfehler beim Löschen.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -510,6 +428,15 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-200">
|
<div className="min-h-screen bg-slate-950 text-slate-200">
|
||||||
|
{notice && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={`fixed right-4 top-4 z-[70] max-w-sm rounded-xl border px-4 py-3 text-sm shadow-2xl ${notice.tone === "success" ? "border-emerald-700 bg-emerald-950 text-emerald-100" : "border-rose-800 bg-rose-950 text-rose-100"}`}
|
||||||
|
>
|
||||||
|
{notice.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top Navbar */}
|
{/* Top Navbar */}
|
||||||
<nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md">
|
<nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle } from "lucide-react";
|
import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle, Trash2 } from "lucide-react";
|
||||||
|
import { adminApi, AdminApiError } from "../api/adminApi";
|
||||||
|
|
||||||
interface ImagePickerProps {
|
interface ImagePickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -39,18 +40,10 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
setLoadingFiles(true);
|
setLoadingFiles(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/list-uploads");
|
const data = await adminApi.listUploads();
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Fehler beim Laden der Serverdateien");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
setServerFiles(data.files || []);
|
setServerFiles(data.files || []);
|
||||||
} else {
|
} catch (err) {
|
||||||
setError(data.error || "Laden der Dateien fehlgeschlagen.");
|
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError("Verbindung zum Server fehlgeschlagen.");
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingFiles(false);
|
setLoadingFiles(false);
|
||||||
|
|
@ -78,31 +71,11 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
reader.onloadend = async () => {
|
reader.onloadend = async () => {
|
||||||
const base64Data = reader.result as string;
|
const base64Data = reader.result as string;
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/upload-file", {
|
const data = await adminApi.upload(file.name, base64Data);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileName: file.name,
|
|
||||||
base64Data: base64Data,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const responseData = await response.json().catch(() => null);
|
|
||||||
throw new Error(responseData?.error || "Serverfehler beim Upload.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
onChange(data.url);
|
onChange(data.url);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} else {
|
} catch (err) {
|
||||||
setError(data.error || "Upload fehlgeschlagen.");
|
setError(err instanceof AdminApiError ? err.message : "Upload fehlgeschlagen.");
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError("Upload-Fehler: " + err.message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -156,6 +129,18 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteServerFile = async (file: ServerFile) => {
|
||||||
|
if (!confirm(`Bild „${file.name}“ wirklich dauerhaft löschen?`)) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUpload(file.name);
|
||||||
|
setServerFiles((files) => files.filter((candidate) => candidate.name !== file.name));
|
||||||
|
if (value === file.url) onChange("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof AdminApiError ? err.message : "Die Datei konnte nicht gelöscht werden.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -330,22 +315,22 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
{serverFiles.map((file) => {
|
{serverFiles.map((file) => {
|
||||||
const isSelected = value === file.url;
|
const isSelected = value === file.url;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={file.name}
|
key={file.name}
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectServerFile(file.url)}
|
|
||||||
className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${
|
className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-indigo-500 ring-2 ring-indigo-500/20"
|
? "border-indigo-500 ring-2 ring-indigo-500/20"
|
||||||
: "border-slate-800 hover:border-slate-700"
|
: "border-slate-800 hover:border-slate-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<button type="button" onClick={() => handleSelectServerFile(file.url)} className="absolute inset-0 z-0 w-full h-full">
|
||||||
<img
|
<img
|
||||||
src={file.url}
|
src={file.url}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
{/* Overlay filename on hover */}
|
{/* Overlay filename on hover */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p>
|
<p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p>
|
||||||
|
|
@ -356,7 +341,16 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteServerFile(file)}
|
||||||
|
className="absolute top-1.5 left-1.5 z-20 rounded-md bg-black/75 p-1.5 text-slate-300 opacity-0 transition-opacity hover:bg-rose-900 hover:text-white group-hover:opacity-100 focus:opacity-100"
|
||||||
|
aria-label={`${file.name} löschen`}
|
||||||
|
title="Bild löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import SpotlightSection from "./SpotlightSection";
|
||||||
import LegalModal from "./LegalModal";
|
import LegalModal from "./LegalModal";
|
||||||
import ProjectDetailMedia from "./ProjectDetailMedia";
|
import ProjectDetailMedia from "./ProjectDetailMedia";
|
||||||
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
||||||
|
import AccessibleModal from "./AccessibleModal";
|
||||||
|
|
||||||
interface PortfolioPageProps {
|
interface PortfolioPageProps {
|
||||||
data: AuthorProfile;
|
data: AuthorProfile;
|
||||||
|
|
@ -358,15 +359,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
|
|
||||||
{/* Book details modular interface modal */}
|
{/* Book details modular interface modal */}
|
||||||
{selectedBook && (
|
{selectedBook && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
<AccessibleModal titleId="book-detail-title" onClose={() => setSelectedBook(null)}>
|
||||||
{/* Overlay background */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
|
|
||||||
onClick={() => setSelectedBook(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
|
||||||
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
|
|
||||||
|
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -415,7 +408,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-3xl font-extrabold text-white">
|
<h3 id="book-detail-title" className="text-3xl font-extrabold text-white">
|
||||||
{selectedBook.title}
|
{selectedBook.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|
@ -441,22 +434,12 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</AccessibleModal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project details modular modal */}
|
{/* Project details modular modal */}
|
||||||
{selectedProject && (
|
{selectedProject && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
<AccessibleModal titleId="project-detail-title" onClose={() => setSelectedProject(null)} maxWidthClass="sm:max-w-5xl">
|
||||||
{/* Overlay background */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
|
|
||||||
onClick={() => setSelectedProject(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
|
||||||
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-5xl p-6 md:p-8 space-y-6">
|
|
||||||
|
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -476,7 +459,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-3xl font-extrabold text-white">
|
<h3 id="project-detail-title" className="text-3xl font-extrabold text-white">
|
||||||
{selectedProject.title}
|
{selectedProject.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|
@ -514,9 +497,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</AccessibleModal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Styled Footer */}
|
{/* Styled Footer */}
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,17 @@ test("production hardening and public routing", async (t) => {
|
||||||
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica }),
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: adminData.revision }),
|
||||||
});
|
});
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
|
const firstSave = await response.json();
|
||||||
|
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: adminData.revision }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 409);
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -135,6 +143,7 @@ test("production hardening and public routing", async (t) => {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
profileKey: "erotica",
|
profileKey: "erotica",
|
||||||
profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
|
profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
|
||||||
|
expectedRevision: firstSave.revision,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
assert.equal(response.status, 400);
|
assert.equal(response.status, 400);
|
||||||
|
|
@ -184,6 +193,37 @@ test("production hardening and public routing", async (t) => {
|
||||||
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
||||||
assert.match(response.headers.get("cache-control") || "", /immutable/);
|
assert.match(response.headers.get("cache-control") || "", /immutable/);
|
||||||
|
|
||||||
|
adminData.erotica.avatarUrl = uploaded.url;
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: firstSave.revision }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const referencedSave = await response.json();
|
||||||
|
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { origin: baseUrl, cookie },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 409);
|
||||||
|
|
||||||
|
adminData.erotica.avatarUrl = "";
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: referencedSave.revision }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { origin: baseUrl, cookie },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
response = await fetch(`${baseUrl}${uploaded.url}`);
|
||||||
|
assert.equal(response.status, 404);
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
|
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
|
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue