feat(02-02): login page UI, header/sidebar auth wiring, i18n keys

- Create split-screen login page with branding left (#ffed00) and form right (D-01)
- Login form has username, password, and remember-me checkbox (D-02)
- Wire header user avatar with auth store: shows user initial, dropdown with role badge and logout
- Wire sidebar footer with auth store: shows user name, role, and initial
- Add auth, header role, and admin i18n keys to both de.json and en.json
- All new UI strings use t() function (no hardcoded text)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:34:43 +02:00
parent cdf4d60038
commit e7b2a70fc9
5 changed files with 459 additions and 43 deletions
+109 -19
View File
@@ -1,12 +1,58 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { fetchCurrentUser, logout } from '@/lib/auth-actions';
import { ThemeToggle } from '@/components/theme-toggle';
export function Header() {
const t = useTranslations();
const tHeader = useTranslations('header');
const setMobileOpen = useSidebarStore((s) => s.setMobileOpen);
const { user, setUser } = useAuthStore();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Fetch current user on mount to populate auth store
useEffect(() => {
if (!user) {
fetchCurrentUser().then((u) => {
if (u) {
setUser({
id: u.id,
username: u.username,
displayName: u.displayName,
role: u.role,
tenantId: u.tenantId,
});
}
});
}
}, [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 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">
@@ -41,32 +87,76 @@ export function Header() {
{/* Center: Breadcrumb / page title */}
<div className="flex-1 px-4 text-center">
<span className="text-sm text-muted-foreground truncate">
{t('header.breadcrumb.home')}
{tHeader('breadcrumb.home')}
</span>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
<ThemeToggle />
{/* User avatar placeholder - wired to auth in Phase 2 */}
<div
className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs text-muted-foreground"
aria-label={t('header.userMenu')}
>
<svg
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"
{/* User avatar dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
aria-label={tHeader('userMenu')}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{userInitial}
</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>
{/* Logout button */}
<div className="px-2 py-1">
<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
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>
@@ -2,11 +2,21 @@
import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { LocaleSwitcher } from '@/components/locale-switcher';
export function SidebarFooter() {
const t = useTranslations('sidebar');
const tHeader = useTranslations('header');
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
const user = useAuthStore((s) => s.user);
const userInitial = user
? (user.displayName ?? user.username).charAt(0).toUpperCase()
: '?';
const userName = user?.displayName ?? user?.username ?? t('userPlaceholder');
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
return (
<div className="border-t border-sidebar-border p-3 flex flex-col gap-2">
@@ -39,29 +49,22 @@ export function SidebarFooter() {
)}
</button>
{/* User info placeholder */}
{/* User info - wired to auth store */}
<div className="flex items-center gap-3 px-2 py-1.5">
<div className="h-7 w-7 shrink-0 rounded-full bg-muted flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<div className="h-7 w-7 shrink-0 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground">
{userInitial}
</div>
{!isCollapsed && (
<span className="text-sm text-sidebar-foreground truncate">
{t('userPlaceholder')}
</span>
<div className="flex flex-col overflow-hidden">
<span className="text-sm text-sidebar-foreground truncate">
{userName}
</span>
{user && (
<span className="text-xs text-muted-foreground truncate">
{roleLabel}
</span>
)}
</div>
)}
</div>
</div>