7416a925a6
- Kopfzeile bekommt Einhaengepunkt header-center-slot; auf / entfaellt der Text Startseite - DashboardTabs rendert per createPortal dorthin, eigene Zeile ueber dem Raster entfaellt - eingelassene Spur mit erhabenem aktivem Reiter, waagrecht scrollbar mit weicher Randausblendung - role=tablist/tab, Pfeiltasten/Pos1/Ende, sichtbarer Fokusring, aktiver Reiter wird ins Bild gescrollt - Plus-Knopf ausserhalb der Spur, Loeschdialog am Dokumentkoerper, Ziehhinweis als sr-only/Tooltip Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { useTranslations } from 'next-intl';
|
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
|
import { fetchSessionState, logout } from '@/lib/auth-actions';
|
|
import { buildNextParam } from '@/lib/safe-next';
|
|
import { ThemeToggle } from '@/components/theme-toggle';
|
|
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
|
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
|
import { usePathname } from 'next/navigation';
|
|
|
|
export function Header() {
|
|
const t = useTranslations();
|
|
const tHeader = useTranslations('header');
|
|
const setMobileOpen = useSidebarStore((s) => s.setMobileOpen);
|
|
const { user, setUser, avatarVersion } = useAuthStore();
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const pathname = usePathname();
|
|
|
|
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
|
|
|
// Redirect-Sperre gegen doppelte Navigation (StrictMode-Doppeleffekt,
|
|
// Effekt-Wiederholungen) — quick-260917-gyd.
|
|
const redirectedRef = useRef(false);
|
|
|
|
// Sitzungswaechter (quick-260917-gyd): Der Header ist der EINZIGE
|
|
// Waechter, weil er auf jeder Portalseite genau einmal gerendert wird
|
|
// (AppShell im (portal)-Layout); das (auth)-Layout hat keinen Header,
|
|
// /login ist oeffentlich (middleware.ts publicRoutes) — Seitenleiste
|
|
// und Widget-Aufrufe brauchen deshalb keinen eigenen Umbau, ein
|
|
// Doppel-Redirect ist ausgeschlossen. `authenticated` fuellt den Store,
|
|
// `unauthenticated` (tote Sitzung, Cookie bereits serverseitig
|
|
// geloescht) leitet per Vollnavigation zur Anmeldeseite mit `next` auf
|
|
// die aktuelle Seite, `unavailable` (API-Ausfall) bleibt still.
|
|
useEffect(() => {
|
|
if (user) {
|
|
return;
|
|
}
|
|
fetchSessionState().then((state) => {
|
|
if (state.status === 'authenticated') {
|
|
const u = state.user;
|
|
setUser({
|
|
id: u.id,
|
|
username: u.username,
|
|
displayName: u.displayName,
|
|
role: u.role,
|
|
tenantId: u.tenantId,
|
|
hasAvatar: u.hasAvatar,
|
|
accentColor: u.accentColor,
|
|
});
|
|
} else if (state.status === 'unauthenticated') {
|
|
if (redirectedRef.current) {
|
|
return;
|
|
}
|
|
redirectedRef.current = true;
|
|
const next = buildNextParam(
|
|
window.location.pathname,
|
|
window.location.search,
|
|
);
|
|
window.location.href = next
|
|
? `/login?next=${encodeURIComponent(next)}`
|
|
: '/login';
|
|
}
|
|
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
|
|
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
|
|
});
|
|
}, [user, setUser]);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(event.target as Node)
|
|
) {
|
|
setDropdownOpen(false);
|
|
}
|
|
}
|
|
if (dropdownOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
}
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [dropdownOpen]);
|
|
|
|
const [avatarError, setAvatarError] = useState(false);
|
|
|
|
const userInitial = user
|
|
? (user.displayName ?? user.username).charAt(0).toUpperCase()
|
|
: '?';
|
|
|
|
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
|
|
{/* Left: Logo + Mobile hamburger */}
|
|
<div className="flex shrink-0 items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMobileOpen(true)}
|
|
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors md:hidden"
|
|
aria-label={t('common.menu')}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</button>
|
|
<TesseraLogo
|
|
variant="horizontal"
|
|
size={28}
|
|
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
|
|
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
|
|
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
|
|
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
|
|
wordmarkClassName="dark:text-primary"
|
|
/>
|
|
</div>
|
|
|
|
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x).
|
|
`DashboardTabs` rendert per Portal hierher; der Punkt muss deshalb
|
|
im React-Baum der Kopfzeile LEER bleiben. Auf der Startseite
|
|
entfaellt der Text „Startseite“, auf allen anderen Seiten bleibt
|
|
er wie bisher. `min-w-0` laesst die Mitte schrumpfen, damit Logo
|
|
und Aktionen rechts auch bei schmalem Fenster Platz behalten. */}
|
|
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
|
|
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
|
|
{pathname !== '/' && (
|
|
<span className="text-sm text-muted-foreground truncate">
|
|
{tHeader('breadcrumb.home')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Actions */}
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<BugReportButton />
|
|
<ThemeToggle />
|
|
|
|
{/* User avatar dropdown */}
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
className="h-8 w-8 rounded-full overflow-hidden bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
|
aria-label={tHeader('userMenu')}
|
|
>
|
|
{user?.hasAvatar && !avatarError ? (
|
|
<img
|
|
src={`/api-proxy/users/me/avatar?v=${avatarVersion}`}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="h-8 w-8 rounded-full object-cover"
|
|
onError={() => setAvatarError(true)}
|
|
/>
|
|
) : (
|
|
<span>{userInitial}</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Dropdown menu */}
|
|
{dropdownOpen && (
|
|
<div className="absolute right-0 top-full mt-2 w-56 rounded-md border border-border bg-card shadow-lg py-2 z-50">
|
|
{/* User info */}
|
|
<div className="px-4 py-2 border-b border-border">
|
|
<p className="text-sm font-medium text-foreground truncate">
|
|
{user?.displayName ?? user?.username ?? '...'}
|
|
</p>
|
|
{user && (
|
|
<span
|
|
className={`inline-block mt-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
user.role === 'SUPER_ADMIN'
|
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
|
: user.role === 'ADMIN'
|
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{roleLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Settings link */}
|
|
<div className="px-2 py-1">
|
|
<Link
|
|
href="/settings"
|
|
onClick={() => setDropdownOpen(false)}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
</svg>
|
|
{tHeader('settings')}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Administration link */}
|
|
{isAdmin && (
|
|
<div className="px-2 py-1">
|
|
<Link
|
|
href="/admin/users"
|
|
onClick={() => setDropdownOpen(false)}
|
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${
|
|
pathname.startsWith('/admin')
|
|
? 'bg-muted text-foreground font-medium'
|
|
: 'text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
</svg>
|
|
{tHeader('admin.label')}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Logout button */}
|
|
<div className="border-t border-border my-1" />
|
|
<div className="px-2 py-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDropdownOpen(false);
|
|
logout();
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
<polyline points="16 17 21 12 16 7" />
|
|
<line x1="21" y1="12" x2="9" y2="12" />
|
|
</svg>
|
|
{tHeader('logout')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|