feat: Login-Screen-Branding im Global Admin (Hintergrund + Logo)

Im Bereich Anpassungen (AdminCustomizePage) kann der Platform-Admin
nun den Login-Screen individuell gestalten:
- Hintergrundtyp: Farbverlauf, Einfarbig oder Hintergrundbild
- Farbverlauf: zwei Farbpicker (Von/Bis) mit Hex-Eingabe
- Hintergrundbild: Datei-Upload max. 2MB, Live-Vorschau
- Logo auf Login-Screen: wird automatisch aus dem Sidebar-Logo uebernommen

Backend: settings.controller.ts GET/POST /settings/branding um
loginBgType, loginBgColor1, loginBgColor2, loginBgImage erweitert.
LoginPage laedt Branding per oeffentlichem Endpoint (kein Auth), leitet
containerStyle per useMemo ab und zeigt Logo-Bild statt Hardcode-Text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 21:09:32 +01:00
parent b872b7e708
commit c333cbfa4b
5 changed files with 348 additions and 13 deletions

View file

@ -6,6 +6,38 @@
--- ---
### Aenderungen 2026-03-13 (16): Global Admin — Login-Screen-Branding (Hintergrund + Logo)
#### Backend (Core Service)
- `settings/settings.controller.ts``GET /settings/branding` + `POST /settings/branding` erweitert:
- 4 neue Felder: `loginBgType` ('gradient'|'solid'|'image'), `loginBgColor1`, `loginBgColor2`, `loginBgImage`
- `loginBgImage` Groeßen-Validierung: max. 2MB
- `loginBgType` Enum-Validierung: nur erlaubte Werte werden gespeichert
- Rueckwaertskompatibel: bestehende Redis-Daten ohne neue Felder liefern `null`-Defaults
#### Frontend
- `admin/AdminCustomizePage.tsx` — Neuer Card-Block "Login-Hintergrund":
- Typ-Toggle: Farbverlauf / Einfarbig / Hintergrundbild
- Farbverlauf: Zwei Farbpicker (Von/Bis) mit Hex-Eingabe
- Einfarbig: Ein Farbpicker mit Hex-Eingabe
- Hintergrundbild: Datei-Upload (max. 2MB, nur Bilder), Entfernen-Button
- Live-Vorschau: Mini-Login-Mockup (200x120px) mit aktuellem Hintergrund und Logo
- `auth/LoginPage.tsx` — Dynamisches Branding:
- `useQuery(['settings', 'branding'])` — oeffentlicher Endpoint, kein Auth noetig
- `useMemo``containerStyle`: leitet CSS `background` aus `loginBgType` ab
- Logo-Anzeige: zeigt `<img>` mit `branding.logo` statt hardcoded "INSIGHT"-Text
- `auth/LoginPage.module.css` — Neue Klasse `.logoImage` (max-width 180px, object-fit contain)
#### TypeScript
- `npx tsc --noEmit` in packages/frontend: 0 Fehler
- `npx tsc --noEmit` in packages/core-service: 0 Fehler
#### Deployment-Hinweis (Schritt 16)
- Rebuild + Restart: core-service (Backend) und frontend
- Kein Datenbankschema-Aenderung, keine Migration noetig (nur Redis)
---
### Aenderungen 2026-03-13 (15): Experten-Profil BulletEditor Fett/Kursiv/Unterstrichen + Aufgaben-Anzeige ### Aenderungen 2026-03-13 (15): Experten-Profil BulletEditor Fett/Kursiv/Unterstrichen + Aufgaben-Anzeige
#### Frontend #### Frontend

View file

@ -138,7 +138,7 @@ export class SettingsController {
/** /**
* GET /api/v1/settings/branding * GET /api/v1/settings/branding
* Branding-Einstellungen lesen (Logo, Sidebar-Farbe etc.). * Branding-Einstellungen lesen (Logo, Sidebar-Farbe, Login-Hintergrund etc.).
*/ */
@Get('branding') @Get('branding')
@ApiOperation({ summary: 'Branding-Einstellungen lesen' }) @ApiOperation({ summary: 'Branding-Einstellungen lesen' })
@ -147,21 +147,36 @@ export class SettingsController {
sidebarColor: string | null; sidebarColor: string | null;
logoWidth: number | null; logoWidth: number | null;
sidebarWidth: number | null; sidebarWidth: number | null;
loginBgType: 'gradient' | 'solid' | 'image' | null;
loginBgColor1: string | null;
loginBgColor2: string | null;
loginBgImage: string | null;
}> { }> {
const raw = await this.redis.get(BRANDING_LOGO_KEY); const raw = await this.redis.get(BRANDING_LOGO_KEY);
if (!raw) return { logo: null, sidebarColor: null, logoWidth: null, sidebarWidth: null }; const empty = {
logo: null, sidebarColor: null, logoWidth: null, sidebarWidth: null,
loginBgType: null, loginBgColor1: null, loginBgColor2: null, loginBgImage: null,
};
if (!raw) return empty;
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
const loginBgType = ['gradient', 'solid', 'image'].includes(data.loginBgType)
? (data.loginBgType as 'gradient' | 'solid' | 'image')
: null;
return { return {
logo: data.logo || null, logo: data.logo || null,
sidebarColor: data.sidebarColor || null, sidebarColor: data.sidebarColor || null,
logoWidth: typeof data.logoWidth === 'number' ? data.logoWidth : null, logoWidth: typeof data.logoWidth === 'number' ? data.logoWidth : null,
sidebarWidth: typeof data.sidebarWidth === 'number' ? data.sidebarWidth : null, sidebarWidth: typeof data.sidebarWidth === 'number' ? data.sidebarWidth : null,
loginBgType,
loginBgColor1: data.loginBgColor1 || null,
loginBgColor2: data.loginBgColor2 || null,
loginBgImage: data.loginBgImage || null,
}; };
} catch { } catch {
// Legacy: nur Logo als String // Legacy: nur Logo als String
return { logo: raw, sidebarColor: null, logoWidth: null, sidebarWidth: null }; return { ...empty, logo: raw };
} }
} }
@ -179,12 +194,25 @@ export class SettingsController {
sidebarColor?: string | null; sidebarColor?: string | null;
logoWidth?: number | null; logoWidth?: number | null;
sidebarWidth?: number | null; sidebarWidth?: number | null;
loginBgType?: string | null;
loginBgColor1?: string | null;
loginBgColor2?: string | null;
loginBgImage?: string | null;
}, },
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
if (body.logo && body.logo.length > 500_000) { if (body.logo && body.logo.length > 500_000) {
throw new BadRequestException('Logo darf maximal 500KB gross sein'); throw new BadRequestException('Logo darf maximal 500KB gross sein');
} }
if (body.loginBgImage && body.loginBgImage.length > 2_000_000) {
throw new BadRequestException('Hintergrundbild darf maximal 2MB gross sein');
}
const validBgTypes = ['gradient', 'solid', 'image'];
const loginBgType = body.loginBgType && validBgTypes.includes(body.loginBgType)
? body.loginBgType
: null;
// Werte-Grenzen // Werte-Grenzen
const logoWidth = typeof body.logoWidth === 'number' const logoWidth = typeof body.logoWidth === 'number'
? Math.min(Math.max(Math.round(body.logoWidth), 40), 240) ? Math.min(Math.max(Math.round(body.logoWidth), 40), 240)
@ -194,10 +222,14 @@ export class SettingsController {
: null; : null;
const data = { const data = {
logo: body.logo || null, logo: body.logo || null,
sidebarColor: body.sidebarColor || null, sidebarColor: body.sidebarColor || null,
logoWidth, logoWidth,
sidebarWidth, sidebarWidth,
loginBgType,
loginBgColor1: body.loginBgColor1 || null,
loginBgColor2: body.loginBgColor2 || null,
loginBgImage: body.loginBgImage || null,
}; };
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data)); await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));

View file

@ -7,6 +7,10 @@ interface BrandingData {
sidebarColor: string | null; sidebarColor: string | null;
logoWidth: number | null; logoWidth: number | null;
sidebarWidth: number | null; sidebarWidth: number | null;
loginBgType: 'gradient' | 'solid' | 'image' | null;
loginBgColor1: string | null;
loginBgColor2: string | null;
loginBgImage: string | null;
} }
const SIDEBAR_PRESETS = [ const SIDEBAR_PRESETS = [
@ -63,10 +67,15 @@ const labelStyle: React.CSSProperties = {
export function AdminCustomizePage() { export function AdminCustomizePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const loginBgFileInputRef = useRef<HTMLInputElement>(null);
const [logo, setLogo] = useState<string | null>(null); const [logo, setLogo] = useState<string | null>(null);
const [sidebarColor, setSidebarColor] = useState<string>('#1e293b'); const [sidebarColor, setSidebarColor] = useState<string>('#1e293b');
const [logoWidth, setLogoWidth] = useState<number>(160); const [logoWidth, setLogoWidth] = useState<number>(160);
const [sidebarWidth, setSidebarWidth] = useState<number>(240); const [sidebarWidth, setSidebarWidth] = useState<number>(240);
const [loginBgType, setLoginBgType] = useState<'gradient' | 'solid' | 'image'>('gradient');
const [loginBgColor1, setLoginBgColor1] = useState<string>('#1a56db');
const [loginBgColor2, setLoginBgColor2] = useState<string>('#1e3a5f');
const [loginBgImage, setLoginBgImage] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
@ -84,6 +93,10 @@ export function AdminCustomizePage() {
setSidebarColor(data.sidebarColor || '#1e293b'); setSidebarColor(data.sidebarColor || '#1e293b');
setLogoWidth(data.logoWidth ?? 160); setLogoWidth(data.logoWidth ?? 160);
setSidebarWidth(data.sidebarWidth ?? 240); setSidebarWidth(data.sidebarWidth ?? 240);
setLoginBgType(data.loginBgType || 'gradient');
setLoginBgColor1(data.loginBgColor1 || '#1a56db');
setLoginBgColor2(data.loginBgColor2 || '#1e3a5f');
setLoginBgImage(data.loginBgImage || null);
setHasChanges(false); setHasChanges(false);
} }
}, [data]); }, [data]);
@ -94,6 +107,10 @@ export function AdminCustomizePage() {
sidebarColor: string; sidebarColor: string;
logoWidth: number; logoWidth: number;
sidebarWidth: number; sidebarWidth: number;
loginBgType: string;
loginBgColor1: string;
loginBgColor2: string;
loginBgImage: string | null;
}) => { }) => {
const res = await api.post('/settings/branding', branding); const res = await api.post('/settings/branding', branding);
return res.data; return res.data;
@ -137,9 +154,44 @@ export function AdminCustomizePage() {
}; };
const handleSave = () => { const handleSave = () => {
saveMutation.mutate({ logo, sidebarColor, logoWidth, sidebarWidth }); saveMutation.mutate({ logo, sidebarColor, logoWidth, sidebarWidth, loginBgType, loginBgColor1, loginBgColor2, loginBgImage });
}; };
const handleLoginBgFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Bitte nur Bilddateien hochladen');
return;
}
if (file.size > 2_000_000) {
alert('Datei darf maximal 2MB gross sein');
return;
}
const reader = new FileReader();
reader.onload = () => {
setLoginBgImage(reader.result as string);
setHasChanges(true);
};
reader.readAsDataURL(file);
};
const loginBgPreviewStyle: React.CSSProperties = (() => {
if (loginBgType === 'gradient') {
return { background: `linear-gradient(135deg, ${loginBgColor1} 0%, ${loginBgColor2} 100%)` };
}
if (loginBgType === 'solid') {
return { background: loginBgColor1 };
}
if (loginBgType === 'image' && loginBgImage) {
return { background: `url(${loginBgImage}) center/cover no-repeat` };
}
return { background: `linear-gradient(135deg, ${loginBgColor1} 0%, ${loginBgColor2} 100%)` };
})();
return ( return (
<div> <div>
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
@ -511,6 +563,176 @@ export function AdminCustomizePage() {
</div> </div>
</div> </div>
{/* Login-Hintergrund */}
<div style={cardStyle}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.5rem' }}>
Login-Hintergrund
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: '1.25rem' }}>
Hintergrund des Login-Screens. Das Plattform-Logo (oben) wird ebenfalls auf dem Login-Screen angezeigt.
</p>
{/* Typ-Toggle */}
<div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Hintergrundtyp</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{(['gradient', 'solid', 'image'] as const).map((type) => (
<button
key={type}
onClick={() => { setLoginBgType(type); setHasChanges(true); }}
style={{
padding: '0.375rem 0.875rem',
fontSize: '0.8125rem',
border: loginBgType === type ? '2px solid var(--color-primary)' : '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: loginBgType === type ? 'var(--color-primary-light)' : 'none',
color: 'var(--color-text)',
cursor: 'pointer',
fontWeight: loginBgType === type ? 600 : 400,
}}
>
{type === 'gradient' ? 'Farbverlauf' : type === 'solid' ? 'Einfarbig' : 'Hintergrundbild'}
</button>
))}
</div>
</div>
{/* Farbverlauf */}
{loginBgType === 'gradient' && (
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
<div>
<label style={labelStyle}>Von</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="color"
value={loginBgColor1}
onChange={(e) => { setLoginBgColor1(e.target.value); setHasChanges(true); }}
style={{ width: 40, height: 36, padding: 0, border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer' }}
/>
<input
type="text"
value={loginBgColor1}
onChange={(e) => { setLoginBgColor1(e.target.value); setHasChanges(true); }}
style={{ padding: '0.375rem 0.5rem', fontSize: '0.875rem', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg-card)', color: 'var(--color-text)', width: 100, fontFamily: 'monospace' }}
/>
</div>
</div>
<div>
<label style={labelStyle}>Bis</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="color"
value={loginBgColor2}
onChange={(e) => { setLoginBgColor2(e.target.value); setHasChanges(true); }}
style={{ width: 40, height: 36, padding: 0, border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer' }}
/>
<input
type="text"
value={loginBgColor2}
onChange={(e) => { setLoginBgColor2(e.target.value); setHasChanges(true); }}
style={{ padding: '0.375rem 0.5rem', fontSize: '0.875rem', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg-card)', color: 'var(--color-text)', width: 100, fontFamily: 'monospace' }}
/>
</div>
</div>
</div>
)}
{/* Einfarbig */}
{loginBgType === 'solid' && (
<div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Farbe</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="color"
value={loginBgColor1}
onChange={(e) => { setLoginBgColor1(e.target.value); setHasChanges(true); }}
style={{ width: 40, height: 40, padding: 0, border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer' }}
/>
<input
type="text"
value={loginBgColor1}
onChange={(e) => { setLoginBgColor1(e.target.value); setHasChanges(true); }}
style={{ padding: '0.5rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg-card)', color: 'var(--color-text)', width: 120, fontFamily: 'monospace' }}
/>
</div>
</div>
)}
{/* Hintergrundbild */}
{loginBgType === 'image' && (
<div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Hintergrundbild</label>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: '0.75rem' }}>
Empfohlen: JPG oder PNG, min. 1920×1080px, max. 2MB.
</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
ref={loginBgFileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleLoginBgFileSelect}
/>
<button style={btnSecondary} onClick={() => loginBgFileInputRef.current?.click()}>
Bild hochladen
</button>
{loginBgImage && (
<button
style={{ ...btnSecondary, color: 'var(--color-error)', borderColor: 'var(--color-error)' }}
onClick={() => { setLoginBgImage(null); setHasChanges(true); }}
>
Entfernen
</button>
)}
{loginBgImage && (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-success)' }}> Bild geladen</span>
)}
</div>
</div>
)}
{/* Vorschau */}
<div>
<label style={labelStyle}>Vorschau</label>
<div
style={{
...loginBgPreviewStyle,
width: 200,
height: 120,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Mini-Login-Card */}
<div
style={{
width: 80,
height: 72,
background: 'rgba(255,255,255,0.95)',
borderRadius: 6,
padding: '0.5rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
}}
>
{logo ? (
<img src={logo} alt="" style={{ maxWidth: 40, maxHeight: 14, objectFit: 'contain' }} />
) : (
<div style={{ fontSize: '0.5rem', fontWeight: 700, color: 'var(--color-primary)', letterSpacing: 1 }}>INSIGHT</div>
)}
<div style={{ width: '100%', height: 8, background: '#e5e7eb', borderRadius: 2 }} />
<div style={{ width: '100%', height: 8, background: '#e5e7eb', borderRadius: 2 }} />
<div style={{ width: '100%', height: 10, background: 'var(--color-primary)', borderRadius: 2, opacity: 0.8 }} />
</div>
</div>
</div>
</div>
{/* Speichern */} {/* Speichern */}
<div <div
style={{ style={{

View file

@ -33,6 +33,14 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.logoImage {
max-width: 180px;
max-height: 64px;
object-fit: contain;
display: block;
margin: 0 auto;
}
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -1,10 +1,18 @@
import { useState, useEffect, type FormEvent } from 'react'; import { useState, useEffect, useMemo, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '../api/client'; import api from '../api/client';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import styles from './LoginPage.module.css'; import styles from './LoginPage.module.css';
interface BrandingData {
logo: string | null;
loginBgType: 'gradient' | 'solid' | 'image' | null;
loginBgColor1: string | null;
loginBgColor2: string | null;
loginBgImage: string | null;
}
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -25,6 +33,35 @@ export function LoginPage() {
} }
}, [searchParams]); }, [searchParams]);
// Branding laden (kein Auth-Guard auf diesem Endpoint)
const { data: branding } = useQuery<BrandingData>({
queryKey: ['settings', 'branding'],
queryFn: async () => {
const res = await api.get<BrandingData>('/settings/branding');
return res.data;
},
staleTime: 5 * 60 * 1000,
retry: false,
});
// Dynamischer Hintergrund basierend auf Branding
const containerStyle = useMemo((): React.CSSProperties => {
if (!branding) return {};
const type = branding.loginBgType || 'gradient';
if (type === 'gradient') {
const c1 = branding.loginBgColor1 || '#1a56db';
const c2 = branding.loginBgColor2 || '#1e3a5f';
return { background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)` };
}
if (type === 'solid') {
return { background: branding.loginBgColor1 || '#1a56db' };
}
if (type === 'image' && branding.loginBgImage) {
return { background: `url(${branding.loginBgImage}) center/cover no-repeat` };
}
return {};
}, [branding]);
// SSO-Status abfragen: Ist Microsoft SSO konfiguriert? // SSO-Status abfragen: Ist Microsoft SSO konfiguriert?
const { data: ssoStatus } = useQuery<{ microsoft: boolean }>({ const { data: ssoStatus } = useQuery<{ microsoft: boolean }>({
queryKey: ['sso', 'status'], queryKey: ['sso', 'status'],
@ -70,10 +107,14 @@ export function LoginPage() {
}; };
return ( return (
<div className={styles.container}> <div className={styles.container} style={containerStyle}>
<div className={styles.card}> <div className={styles.card}>
<div className={styles.logo}> <div className={styles.logo}>
<h1>INSIGHT</h1> {branding?.logo ? (
<img src={branding.logo} alt="Logo" className={styles.logoImage} />
) : (
<h1>INSIGHT</h1>
)}
<p>Business Platform</p> <p>Business Platform</p>
</div> </div>