Merge pull request #372 from Luca-Timo/beta
feat(cms): add external URL toggle for imprint and privacy pages
This commit is contained in:
@@ -464,14 +464,30 @@ async function ensureGlobalCategories() {
|
|||||||
table.text('content_en');
|
table.text('content_en');
|
||||||
table.text('content_de');
|
table.text('content_de');
|
||||||
table.string('logo_url').nullable();
|
table.string('logo_url').nullable();
|
||||||
|
table.boolean('use_external_url').notNullable().defaultTo(false);
|
||||||
|
table.string('external_url').nullable();
|
||||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||||
});
|
});
|
||||||
} else if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) {
|
} else {
|
||||||
// Online migration for existing deployments — see issue #324, per-page
|
if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) {
|
||||||
// logo override for admin-customisable error pages.
|
// Online migration for existing deployments — see issue #324, per-page
|
||||||
await db.schema.alterTable('cms_pages', (table) => {
|
// logo override for admin-customisable error pages.
|
||||||
table.string('logo_url').nullable();
|
await db.schema.alterTable('cms_pages', (table) => {
|
||||||
});
|
table.string('logo_url').nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(await db.schema.hasColumn('cms_pages', 'use_external_url'))) {
|
||||||
|
// Per-page toggle to redirect visitors to an external imprint /
|
||||||
|
// privacy-policy URL instead of rendering the internal CMS content.
|
||||||
|
await db.schema.alterTable('cms_pages', (table) => {
|
||||||
|
table.boolean('use_external_url').notNullable().defaultTo(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(await db.schema.hasColumn('cms_pages', 'external_url'))) {
|
||||||
|
await db.schema.alterTable('cms_pages', (table) => {
|
||||||
|
table.string('external_url').nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
|
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
|||||||
body('title_de').optional().isString(),
|
body('title_de').optional().isString(),
|
||||||
body('content_en').optional().isString(),
|
body('content_en').optional().isString(),
|
||||||
body('content_de').optional().isString(),
|
body('content_de').optional().isString(),
|
||||||
body('logo_url').optional({ nullable: true }).isString()
|
body('logo_url').optional({ nullable: true }).isString(),
|
||||||
|
body('use_external_url').optional().isBoolean(),
|
||||||
|
body('external_url').optional({ nullable: true }).isString()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -80,7 +82,26 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { slug } = req.params;
|
const { slug } = req.params;
|
||||||
const { title_en, title_de, content_en, content_de, logo_url } = req.body;
|
const { title_en, title_de, content_en, content_de, logo_url, use_external_url, external_url } = req.body;
|
||||||
|
|
||||||
|
// When the external-URL toggle is on, the URL must parse and use https://.
|
||||||
|
// express-validator's isURL() is too permissive (allows http:, ftp:, etc.) —
|
||||||
|
// an explicit protocol check is the security-relevant gate.
|
||||||
|
if (use_external_url === true) {
|
||||||
|
const candidate = typeof external_url === 'string' ? external_url.trim() : '';
|
||||||
|
if (!candidate) {
|
||||||
|
return res.status(400).json({ error: 'external_url is required when use_external_url is true' });
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(candidate);
|
||||||
|
} catch (_err) {
|
||||||
|
return res.status(400).json({ error: 'external_url must be a valid URL' });
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'https:') {
|
||||||
|
return res.status(400).json({ error: 'external_url must use https://' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const page = await db('cms_pages').where('slug', slug).first();
|
const page = await db('cms_pages').where('slug', slug).first();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -99,6 +120,13 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
|||||||
if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) {
|
if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) {
|
||||||
updateFields.logo_url = logo_url || null;
|
updateFields.logo_url = logo_url || null;
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(req.body, 'use_external_url')) {
|
||||||
|
updateFields.use_external_url = !!use_external_url;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(req.body, 'external_url')) {
|
||||||
|
const trimmed = typeof external_url === 'string' ? external_url.trim() : '';
|
||||||
|
updateFields.external_url = trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
await db('cms_pages').where('slug', slug).update(updateFields);
|
await db('cms_pages').where('slug', slug).update(updateFields);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ router.get('/pages/:slug', async (req, res) => {
|
|||||||
// Per-page logo override (#324). Null means "fall back to global
|
// Per-page logo override (#324). Null means "fall back to global
|
||||||
// branding logo" — the consumer decides.
|
// branding logo" — the consumer decides.
|
||||||
logo_url: page.logo_url || null,
|
logo_url: page.logo_url || null,
|
||||||
|
// Per-page external-URL override. When use_external_url is true and
|
||||||
|
// external_url is set, consumers should redirect / link out instead
|
||||||
|
// of rendering the internal title/content.
|
||||||
|
use_external_url: !!page.use_external_url,
|
||||||
|
external_url: page.external_url || null,
|
||||||
updated_at: page.updated_at
|
updated_at: page.updated_at
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
|
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -9,6 +10,7 @@ import { DynamicFavicon } from '../common/DynamicFavicon';
|
|||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
import { cmsService, type PublicCMSPage } from '../../services/cms.service';
|
||||||
import type { HeaderStyleType } from '../../types/theme.types';
|
import type { HeaderStyleType } from '../../types/theme.types';
|
||||||
|
|
||||||
interface GalleryLayoutProps {
|
interface GalleryLayoutProps {
|
||||||
@@ -62,6 +64,22 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const guestIdentity = useGuestIdentityOptional();
|
const guestIdentity = useGuestIdentityOptional();
|
||||||
|
|
||||||
|
// Footer legal-link config. Cached aggressively because the toggle state
|
||||||
|
// changes rarely and the gallery footer renders on every page view.
|
||||||
|
// Failures fall back to the internal /impressum and /datenschutz routes.
|
||||||
|
const { data: impressumPage } = useQuery<PublicCMSPage>({
|
||||||
|
queryKey: ['public-cms', 'impressum'],
|
||||||
|
queryFn: () => cmsService.getPublicPage('impressum'),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const { data: datenschutzPage } = useQuery<PublicCMSPage>({
|
||||||
|
queryKey: ['public-cms', 'datenschutz'],
|
||||||
|
queryFn: () => cmsService.getPublicPage('datenschutz'),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
||||||
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
||||||
const isHeroHeader = headerStyle === 'hero';
|
const isHeroHeader = headerStyle === 'hero';
|
||||||
@@ -592,19 +610,41 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
{/* Legal Links */}
|
{/* Legal Links */}
|
||||||
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
|
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
|
||||||
<Link
|
{impressumPage?.use_external_url && impressumPage.external_url ? (
|
||||||
to="/impressum"
|
<a
|
||||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
href={impressumPage.external_url}
|
||||||
>
|
target="_blank"
|
||||||
{t('legal.impressum')}
|
rel="noopener noreferrer"
|
||||||
</Link>
|
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||||
|
>
|
||||||
|
{t('legal.impressum')}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/impressum"
|
||||||
|
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||||
|
>
|
||||||
|
{t('legal.impressum')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<span className="text-xs text-muted-theme">|</span>
|
<span className="text-xs text-muted-theme">|</span>
|
||||||
<Link
|
{datenschutzPage?.use_external_url && datenschutzPage.external_url ? (
|
||||||
to="/datenschutz"
|
<a
|
||||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
href={datenschutzPage.external_url}
|
||||||
>
|
target="_blank"
|
||||||
{t('legal.datenschutz')}
|
rel="noopener noreferrer"
|
||||||
</Link>
|
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||||
|
>
|
||||||
|
{t('legal.datenschutz')}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/datenschutz"
|
||||||
|
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||||
|
>
|
||||||
|
{t('legal.datenschutz')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{guestIdentity?.identity && (
|
{guestIdentity?.identity && (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-muted-theme">|</span>
|
<span className="text-xs text-muted-theme">|</span>
|
||||||
|
|||||||
@@ -2210,7 +2210,13 @@
|
|||||||
"lastUpdated": "Zuletzt aktualisiert:",
|
"lastUpdated": "Zuletzt aktualisiert:",
|
||||||
"impressum": "Impressum",
|
"impressum": "Impressum",
|
||||||
"datenschutz": "Datenschutzerklärung",
|
"datenschutz": "Datenschutzerklärung",
|
||||||
"pageUpdated": "Seite erfolgreich aktualisiert"
|
"pageUpdated": "Seite erfolgreich aktualisiert",
|
||||||
|
"useExternalUrl": "Externe URL verwenden",
|
||||||
|
"useExternalUrlHelp": "Besucher werden auf eine externe Seite weitergeleitet, anstatt die internen Inhalte anzuzeigen. Titel und Inhalt bleiben als Fallback gespeichert.",
|
||||||
|
"externalUrl": "Externe URL",
|
||||||
|
"externalUrlPlaceholder": "https://example.com/impressum",
|
||||||
|
"externalUrlInvalid": "Muss eine gültige https://-URL sein",
|
||||||
|
"externalUrlActive": "Externe URL ist aktiv — interne Inhalte bleiben gespeichert, werden Besuchern aber nicht angezeigt."
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"eventNameRequired": "Veranstaltungsname ist erforderlich",
|
"eventNameRequired": "Veranstaltungsname ist erforderlich",
|
||||||
|
|||||||
@@ -1759,7 +1759,13 @@
|
|||||||
"lastUpdated": "Last updated:",
|
"lastUpdated": "Last updated:",
|
||||||
"impressum": "Legal Notice",
|
"impressum": "Legal Notice",
|
||||||
"datenschutz": "Privacy Policy",
|
"datenschutz": "Privacy Policy",
|
||||||
"pageUpdated": "Page updated successfully"
|
"pageUpdated": "Page updated successfully",
|
||||||
|
"useExternalUrl": "Use external URL",
|
||||||
|
"useExternalUrlHelp": "Redirect visitors to an external page instead of showing the internal content. The internal title and content stay saved as a fallback.",
|
||||||
|
"externalUrl": "External URL",
|
||||||
|
"externalUrlPlaceholder": "https://example.com/impressum",
|
||||||
|
"externalUrlInvalid": "Must be a valid https:// URL",
|
||||||
|
"externalUrlActive": "External URL is active — internal content is preserved but not shown to visitors."
|
||||||
},
|
},
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
"title": "Event Types",
|
"title": "Event Types",
|
||||||
|
|||||||
@@ -182,6 +182,31 @@ export const CMSPage: React.FC = () => {
|
|||||||
setHasUnsavedChanges(true);
|
setHasUnsavedChanges(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUseExternalUrlChange = (val: boolean) => {
|
||||||
|
setEditForm(prev => ({ ...prev, use_external_url: val }));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExternalUrlChange = (val: string) => {
|
||||||
|
setEditForm(prev => ({ ...prev, external_url: val }));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline URL validation. Empty input while typing is not an error
|
||||||
|
// (avoid flagging mid-edit). Backend re-validates on save regardless.
|
||||||
|
const externalUrlError = useMemo(() => {
|
||||||
|
if (!editForm.use_external_url) return null;
|
||||||
|
const v = (editForm.external_url || '').trim();
|
||||||
|
if (!v) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(v);
|
||||||
|
if (u.protocol !== 'https:') return t('cms.externalUrlInvalid');
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return t('cms.externalUrlInvalid');
|
||||||
|
}
|
||||||
|
}, [editForm.use_external_url, editForm.external_url, t]);
|
||||||
|
|
||||||
// Per-page logo upload (#324). Only meaningful for the customisable
|
// Per-page logo upload (#324). Only meaningful for the customisable
|
||||||
// error pages right now, but harmless if exposed for any slug.
|
// error pages right now, but harmless if exposed for any slug.
|
||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -607,6 +632,41 @@ export const CMSPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* External URL override */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 bg-neutral-50 dark:bg-neutral-800/40">
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
checked={!!editForm.use_external_url}
|
||||||
|
onChange={(e) => handleUseExternalUrlChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('cms.useExternalUrl')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
{t('cms.useExternalUrlHelp')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{editForm.use_external_url && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
label={t('cms.externalUrl')}
|
||||||
|
value={editForm.external_url || ''}
|
||||||
|
onChange={(e) => handleExternalUrlChange(e.target.value)}
|
||||||
|
placeholder={t('cms.externalUrlPlaceholder')}
|
||||||
|
error={externalUrlError || undefined}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||||
|
{t('cms.externalUrlActive')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
|||||||
@@ -44,7 +44,17 @@ export const LegalPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [page?.title]);
|
}, [page?.title]);
|
||||||
|
|
||||||
if (isLoading) {
|
// External-URL override: full-page redirect so the visitor lands on the
|
||||||
|
// operator's own canonical legal page. Use replace() so the back button
|
||||||
|
// returns to the gallery instead of looping back through the redirect.
|
||||||
|
const willRedirect = !!(page?.use_external_url && page?.external_url);
|
||||||
|
useEffect(() => {
|
||||||
|
if (willRedirect && page?.external_url) {
|
||||||
|
window.location.replace(page.external_url);
|
||||||
|
}
|
||||||
|
}, [willRedirect, page?.external_url]);
|
||||||
|
|
||||||
|
if (isLoading || willRedirect) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||||
<Loading size="lg" text="Loading..." />
|
<Loading size="lg" text="Loading..." />
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface CMSPage {
|
|||||||
content_en: string;
|
content_en: string;
|
||||||
content_de: string;
|
content_de: string;
|
||||||
logo_url: string | null;
|
logo_url: string | null;
|
||||||
|
use_external_url: boolean;
|
||||||
|
external_url: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ export interface PublicCMSPage {
|
|||||||
content: string;
|
content: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
logo_url: string | null;
|
logo_url: string | null;
|
||||||
|
use_external_url: boolean;
|
||||||
|
external_url: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user