feat: add optional custom section links
This commit is contained in:
parent
db31146199
commit
18639de047
6 changed files with 149 additions and 4 deletions
|
|
@ -7,7 +7,7 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp
|
||||||
## Funktionsumfang
|
## Funktionsumfang
|
||||||
|
|
||||||
- Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes
|
- Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes
|
||||||
- Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion
|
- Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion mit bis zu drei CTA-Buttons
|
||||||
- Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist
|
- Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist
|
||||||
- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist
|
- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist
|
||||||
- Lokaler Bild-Upload über den Adminbereich
|
- Lokaler Bild-Upload über den Adminbereich
|
||||||
|
|
@ -27,6 +27,7 @@ Im Produktionsmodus gilt der Hostname als Trennlinie:
|
||||||
- Pfadbasierte Profilvorschauen stehen nur in der lokalen Entwicklungsumgebung zur Verfügung.
|
- Pfadbasierte Profilvorschauen stehen nur in der lokalen Entwicklungsumgebung zur Verfügung.
|
||||||
- Vollständige Profildaten sind ausschließlich über einen authentifizierten Admin-Endpunkt verfügbar.
|
- Vollständige Profildaten sind ausschließlich über einen authentifizierten Admin-Endpunkt verfügbar.
|
||||||
- Gemeinsam gepflegte Rechtstexte werden bei der öffentlichen Ausgabe von strukturiert bekannten Kennungen anderer Profile bereinigt. Die gespeicherten Originaltexte bleiben unverändert.
|
- Gemeinsam gepflegte Rechtstexte werden bei der öffentlichen Ausgabe von strukturiert bekannten Kennungen anderer Profile bereinigt. Die gespeicherten Originaltexte bleiben unverändert.
|
||||||
|
- Querverlinkungen entstehen nur durch ausdrücklich gepflegte Buttons oder Inhalte des jeweiligen Profils; es gibt keine automatische Verbindung zu anderen Profilen.
|
||||||
|
|
||||||
Der Reverse Proxy sollte zusätzlich nur die tatsächlich verwendeten Domains an den Container weiterleiten.
|
Der Reverse Proxy sollte zusätzlich nur die tatsächlich verwendeten Domains an den Container weiterleiten.
|
||||||
|
|
||||||
|
|
@ -149,6 +150,12 @@ Der Server:
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## Optionale Buttons im Zusatzabschnitt
|
||||||
|
|
||||||
|
Im zusätzlichen Textabschnitt eines Profils können bis zu drei CTA-Buttons gepflegt werden. Jeder Button benötigt eine Beschriftung und eine vollständige `http://`- oder `https://`-Adresse. Unvollständige Einträge werden nicht angezeigt, andere URL-Schemata werden serverseitig abgelehnt.
|
||||||
|
|
||||||
|
Die Buttons öffnen das Ziel in einem neuen Tab und werden automatisch mit den Akzentfarben des Profils gestaltet. Auf kleinen Bildschirmen stehen sie untereinander, auf größeren Bildschirmen nebeneinander. Damit können ausgewählte Pseudonyme bewusst miteinander verknüpft werden, ohne dass daraus eine automatische Verlinkung zu weiteren Profilen entsteht.
|
||||||
|
|
||||||
## Spotify und externe Dienste
|
## Spotify und externe Dienste
|
||||||
|
|
||||||
Spotify-Playlists werden sowohl in Buch- als auch in Projektdetails nach dem Zwei-Klick-Prinzip eingebunden. Beim Öffnen eines Details erscheint zunächst nur ein lokaler Platzhalter. Erst nach einem bewussten Klick auf „Spotify-Player laden“ wird das Spotify-`iframe` erzeugt und eine Verbindung zu Spotify hergestellt.
|
Spotify-Playlists werden sowohl in Buch- als auch in Projektdetails nach dem Zwei-Klick-Prinzip eingebunden. Beim Öffnen eines Details erscheint zunächst nur ein lokaler Platzhalter. Erst nach einem bewussten Klick auf „Spotify-Player laden“ wird das Spotify-`iframe` erzeugt und eine Verbindung zu Spotify hergestellt.
|
||||||
|
|
|
||||||
35
server.ts
35
server.ts
|
|
@ -5,7 +5,7 @@ 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";
|
||||||
import { AuthorData, AuthorProfile, PortfolioTheme, PublicAuthorData } from "./src/types.js";
|
import { AuthorData, AuthorProfile, CustomSectionLink, PortfolioTheme, PublicAuthorData } from "./src/types.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -486,6 +486,30 @@ function isolateLegalContent(content: string, activeKey: ProfileKey): string {
|
||||||
return isolated;
|
return isolated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!Array.isArray(value) || value.length > 3) return null;
|
||||||
|
|
||||||
|
const links: CustomSectionLink[] = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
if (!candidate || typeof candidate !== "object") return null;
|
||||||
|
const { label, url } = candidate as Record<string, unknown>;
|
||||||
|
if (typeof label !== "string" || typeof url !== "string" || label.length > 80 || url.length > 2048) return null;
|
||||||
|
const trimmedLabel = label.trim();
|
||||||
|
const trimmedUrl = url.trim();
|
||||||
|
if (!trimmedLabel && !trimmedUrl) continue;
|
||||||
|
if (!trimmedLabel || !trimmedUrl) continue;
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(trimmedUrl);
|
||||||
|
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") return null;
|
||||||
|
links.push({ label: trimmedLabel, url: parsedUrl.toString() });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
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];
|
||||||
|
|
@ -530,6 +554,7 @@ function seoMeta(req: express.Request, scriptNonce?: string): string {
|
||||||
app.get("/api/author-data", (req, res) => {
|
app.get("/api/author-data", (req, res) => {
|
||||||
const key = profileForRequest(req.hostname, req.path);
|
const key = profileForRequest(req.hostname, req.path);
|
||||||
const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key];
|
const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key];
|
||||||
|
publicProfile.customSectionLinks = normalizeCustomSectionLinks(publicProfile.customSectionLinks) || [];
|
||||||
const legalDocuments = (dbCache.legalDocuments || [])
|
const legalDocuments = (dbCache.legalDocuments || [])
|
||||||
.filter((document) => document.assignedProfiles.includes(key))
|
.filter((document) => document.assignedProfiles.includes(key))
|
||||||
.map((document) => ({
|
.map((document) => ({
|
||||||
|
|
@ -612,9 +637,15 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req,
|
||||||
res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
|
res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks);
|
||||||
|
if (customSectionLinks === null) {
|
||||||
|
res.status(400).json({ error: "Die optionalen Buttons enthalten eine ungültige URL oder überschreiten das Limit von drei Einträgen." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sanitizedProfileData = { ...profileData, customSectionLinks };
|
||||||
const nextData = await updateDatabase((current) => ({
|
const nextData = await updateDatabase((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[profileKey]: profileData,
|
[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." });
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,16 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCustomSectionLink = (index: number, field: "label" | "url", value: string) => {
|
||||||
|
if (!profile) return;
|
||||||
|
const links = Array.from({ length: 3 }, (_, linkIndex) => ({
|
||||||
|
label: profile.customSectionLinks?.[linkIndex]?.label || "",
|
||||||
|
url: profile.customSectionLinks?.[linkIndex]?.url || "",
|
||||||
|
}));
|
||||||
|
links[index] = { ...links[index], [field]: value };
|
||||||
|
updateProfileField("customSectionLinks", links);
|
||||||
|
};
|
||||||
|
|
||||||
// --- PROJECT ACTIONS ---
|
// --- PROJECT ACTIONS ---
|
||||||
const handleSaveProject = async () => {
|
const handleSaveProject = async () => {
|
||||||
if (!authorData || !profile) return;
|
if (!authorData || !profile) return;
|
||||||
|
|
@ -866,6 +876,37 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-sans"
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-sans"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3 md:col-span-2 pt-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-mono text-slate-400 uppercase">Optionale Buttons (maximal 3)</label>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1">Ein Button erscheint nur, wenn Beschriftung und eine vollständige HTTP(S)-Adresse eingetragen sind. Damit freigegebene Ziele werden öffentlich verlinkt.</p>
|
||||||
|
</div>
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div key={index} className="grid grid-cols-1 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-3 rounded-xl border border-slate-800 bg-slate-950/50 p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-mono text-slate-500 uppercase">Button {index + 1} – Beschriftung</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={80}
|
||||||
|
value={profile.customSectionLinks?.[index]?.label || ""}
|
||||||
|
onChange={(event) => updateCustomSectionLink(index, "label", event.target.value)}
|
||||||
|
placeholder="z.B. Zur anderen Autorin"
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-white outline-none focus:border-indigo-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-mono text-slate-500 uppercase">Ziel-URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={profile.customSectionLinks?.[index]?.url || ""}
|
||||||
|
onChange={(event) => updateCustomSectionLink(index, "url", event.target.value)}
|
||||||
|
placeholder="https://www.beispiel.de"
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-white outline-none focus:border-indigo-500 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,15 @@ interface PortfolioPageProps {
|
||||||
theme: PortfolioTheme;
|
theme: PortfolioTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeExternalUrl(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const themeDefaults: Record<PortfolioTheme, { accent: string; secondary: string; background: string; card: string; badge: string; subtitle: string }> = {
|
const themeDefaults: Record<PortfolioTheme, { accent: string; secondary: string; background: string; card: string; badge: string; subtitle: string }> = {
|
||||||
cosmic: { accent: "#06b6d4", secondary: "#a855f7", background: "#020617", card: "#0f172a", badge: "Geschichten & Welten", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" },
|
cosmic: { accent: "#06b6d4", secondary: "#a855f7", background: "#020617", card: "#0f172a", badge: "Geschichten & Welten", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" },
|
||||||
velvet: { accent: "#f97316", secondary: "#f59e0b", background: "#0a0a0a", card: "#171717", badge: "Literatur & Leidenschaft", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" },
|
velvet: { accent: "#f97316", secondary: "#f59e0b", background: "#0a0a0a", card: "#171717", badge: "Literatur & Leidenschaft", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" },
|
||||||
|
|
@ -35,6 +44,10 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
const cardBgColor = data.cardBgColor || defaults.card;
|
const cardBgColor = data.cardBgColor || defaults.card;
|
||||||
|
|
||||||
const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans";
|
const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans";
|
||||||
|
const customSectionLinks = (data.customSectionLinks || []).slice(0, 3).flatMap((link) => {
|
||||||
|
const url = safeExternalUrl(link.url);
|
||||||
|
return link.label.trim() && url ? [{ label: link.label.trim(), url }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -288,7 +301,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Section 4: Custom Highlight Section if present */}
|
{/* Section 4: Custom Highlight Section if present */}
|
||||||
{(data.customSectionTitle || data.customSectionContent) && (
|
{(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
|
||||||
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
||||||
<div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}>
|
<div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}>
|
||||||
CUSTOM_INFO_MODULE
|
CUSTOM_INFO_MODULE
|
||||||
|
|
@ -317,6 +330,27 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{customSectionLinks.length > 0 && (
|
||||||
|
<div className="mt-6 flex flex-col sm:flex-row sm:flex-wrap gap-3">
|
||||||
|
{customSectionLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={`${link.label}-${link.url}`}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-xl border px-5 py-3 text-sm font-semibold text-white transition-all hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
|
||||||
|
style={{
|
||||||
|
borderColor: `${accentColor}70`,
|
||||||
|
background: `linear-gradient(135deg, ${accentColor}35, ${secondaryColor}25)`,
|
||||||
|
boxShadow: `0 8px 24px ${accentColor}15`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ export interface Book {
|
||||||
spotlightBadge?: string; // Optional custom badge text (e.g. "Neuerscheinung", "Vorbestellbar")
|
spotlightBadge?: string; // Optional custom badge text (e.g. "Neuerscheinung", "Vorbestellbar")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomSectionLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthorProfile {
|
export interface AuthorProfile {
|
||||||
name: string;
|
name: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
|
|
@ -32,6 +37,7 @@ export interface AuthorProfile {
|
||||||
books: Book[];
|
books: Book[];
|
||||||
customSectionTitle?: string;
|
customSectionTitle?: string;
|
||||||
customSectionContent?: string;
|
customSectionContent?: string;
|
||||||
|
customSectionLinks?: CustomSectionLink[];
|
||||||
|
|
||||||
// Domain & Path Routing
|
// Domain & Path Routing
|
||||||
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"
|
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,29 @@ test("production hardening and public routing", async (t) => {
|
||||||
response = await fetch(`${baseUrl}/api/admin/author-data`);
|
response = await fetch(`${baseUrl}/api/admin/author-data`);
|
||||||
assert.equal(response.status, 401);
|
assert.equal(response.status, 401);
|
||||||
|
|
||||||
|
response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const adminData = await response.json();
|
||||||
|
adminData.erotica.customSectionLinks = [
|
||||||
|
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
|
||||||
|
];
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
|
||||||
|
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, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
|
||||||
const publicResponse = await getWithHost(`${baseUrl}/api/author-data?path=/clara`, "annieslone.de");
|
const publicResponse = await getWithHost(`${baseUrl}/api/author-data?path=/clara`, "annieslone.de");
|
||||||
assert.equal(publicResponse.status, 200);
|
assert.equal(publicResponse.status, 200);
|
||||||
const publicData = JSON.parse(publicResponse.body);
|
const publicData = JSON.parse(publicResponse.body);
|
||||||
|
|
@ -124,6 +147,9 @@ test("production hardening and public routing", async (t) => {
|
||||||
assert.equal(publicData.theme, "velvet");
|
assert.equal(publicData.theme, "velvet");
|
||||||
assert.equal(publicData.profile.customDomain, undefined);
|
assert.equal(publicData.profile.customDomain, undefined);
|
||||||
assert.equal(publicData.profile.customPath, undefined);
|
assert.equal(publicData.profile.customPath, undefined);
|
||||||
|
assert.deepEqual(publicData.profile.customSectionLinks, [
|
||||||
|
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
|
||||||
|
]);
|
||||||
assert.equal(publicResponse.body.includes("Clara Finch"), false);
|
assert.equal(publicResponse.body.includes("Clara Finch"), false);
|
||||||
assert.equal(publicResponse.body.includes("Renee Heart"), false);
|
assert.equal(publicResponse.body.includes("Renee Heart"), false);
|
||||||
assert.equal(publicResponse.body.includes("Daniel Hesse"), false);
|
assert.equal(publicResponse.body.includes("Daniel Hesse"), false);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue