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
+193
View File
@@ -0,0 +1,193 @@
'use client';
import { useState, useTransition } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { login } from '@/lib/auth-actions';
/**
* Split-screen login page (D-01).
* Left: Tessera branding with primary yellow (#ffed00) background.
* Right: Login form on neutral background.
* No sidebar or header (D-04, handled by (auth) layout).
*/
export default function LoginPage() {
const t = useTranslations('auth');
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const formData = new FormData(e.currentTarget);
startTransition(async () => {
const result = await login(formData);
if (result.success) {
router.push('/');
router.refresh();
} else {
setError(result.error ?? 'invalidCredentials');
}
});
}
return (
<div className="flex min-h-screen">
{/* Left: Branding panel (hidden on mobile, visible on md+) */}
<div className="hidden md:flex md:flex-1 items-center justify-center bg-[#ffed00]">
<div className="flex flex-col items-center gap-6 px-8 text-center">
{/* Tessera logo / text */}
<h1 className="text-6xl font-extrabold text-gray-900 tracking-tight">
Tessera
</h1>
<p className="text-xl text-gray-800 max-w-md leading-relaxed">
{t('branding.tagline')}
</p>
{/* Abstract grid icon representing modules */}
<div className="mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="80"
height="80"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-800 opacity-50"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
</div>
</div>
</div>
{/* Right: Login form */}
<div className="flex flex-1 items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-sm space-y-8">
{/* Mobile-only branding */}
<div className="text-center md:hidden">
<h1 className="text-3xl font-extrabold text-primary tracking-tight">
Tessera
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{t('branding.tagline')}
</p>
</div>
{/* Login heading */}
<div className="text-center">
<h2 className="text-2xl font-bold text-foreground">
{t('login')}
</h2>
</div>
{/* Error message */}
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{t(`error.${error}`)}
</div>
)}
{/* Login form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Username */}
<div className="space-y-2">
<label
htmlFor="username"
className="text-sm font-medium text-foreground"
>
{t('username')}
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="username"
autoFocus
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('username')}
/>
</div>
{/* Password */}
<div className="space-y-2">
<label
htmlFor="password"
className="text-sm font-medium text-foreground"
>
{t('password')}
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('password')}
/>
</div>
{/* Remember me (D-02) */}
<div className="flex items-center gap-2">
<input
id="rememberMe"
name="rememberMe"
type="checkbox"
defaultChecked
className="h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<label
htmlFor="rememberMe"
className="text-sm text-muted-foreground"
>
{t('rememberMe')}
</label>
</div>
{/* Submit button */}
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isPending ? (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
t('submit')
)}
</button>
</form>
</div>
</div>
</div>
);
}
+109 -19
View File
@@ -1,12 +1,58 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store'; 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'; import { ThemeToggle } from '@/components/theme-toggle';
export function Header() { export function Header() {
const t = useTranslations(); const t = useTranslations();
const tHeader = useTranslations('header');
const setMobileOpen = useSidebarStore((s) => s.setMobileOpen); 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 ( 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"> <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 */} {/* Center: Breadcrumb / page title */}
<div className="flex-1 px-4 text-center"> <div className="flex-1 px-4 text-center">
<span className="text-sm text-muted-foreground truncate"> <span className="text-sm text-muted-foreground truncate">
{t('header.breadcrumb.home')} {tHeader('breadcrumb.home')}
</span> </span>
</div> </div>
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
{/* User avatar placeholder - wired to auth in Phase 2 */}
<div {/* User avatar dropdown */}
className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs text-muted-foreground" <div className="relative" ref={dropdownRef}>
aria-label={t('header.userMenu')} <button
> onClick={() => setDropdownOpen(!dropdownOpen)}
<svg 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"
xmlns="http://www.w3.org/2000/svg" aria-label={tHeader('userMenu')}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
> >
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> {userInitial}
<circle cx="12" cy="7" r="4" /> </button>
</svg>
{/* 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>
</div> </div>
</header> </header>
@@ -2,11 +2,21 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { LocaleSwitcher } from '@/components/locale-switcher'; import { LocaleSwitcher } from '@/components/locale-switcher';
export function SidebarFooter() { export function SidebarFooter() {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const tHeader = useTranslations('header');
const isCollapsed = useSidebarStore((s) => s.isCollapsed); 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 ( return (
<div className="border-t border-sidebar-border p-3 flex flex-col gap-2"> <div className="border-t border-sidebar-border p-3 flex flex-col gap-2">
@@ -39,29 +49,22 @@ export function SidebarFooter() {
)} )}
</button> </button>
{/* User info placeholder */} {/* User info - wired to auth store */}
<div className="flex items-center gap-3 px-2 py-1.5"> <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"> <div className="h-7 w-7 shrink-0 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground">
<svg {userInitial}
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> </div>
{!isCollapsed && ( {!isCollapsed && (
<span className="text-sm text-sidebar-foreground truncate"> <div className="flex flex-col overflow-hidden">
{t('userPlaceholder')} <span className="text-sm text-sidebar-foreground truncate">
</span> {userName}
</span>
{user && (
<span className="text-xs text-muted-foreground truncate">
{roleLabel}
</span>
)}
</div>
)} )}
</div> </div>
</div> </div>
+67 -2
View File
@@ -4,13 +4,44 @@
"loading": "Laden...", "loading": "Laden...",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"menu": "Menu" "menu": "Menu",
"confirm": "Bestaetigen",
"delete": "Loeschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"close": "Schliessen",
"yes": "Ja",
"no": "Nein",
"active": "Aktiv",
"inactive": "Inaktiv",
"actions": "Aktionen",
"accessDenied": "Zugriff verweigert"
},
"auth": {
"login": "Anmelden",
"username": "Benutzername",
"password": "Passwort",
"rememberMe": "Angemeldet bleiben",
"submit": "Anmelden",
"error": {
"invalidCredentials": "Benutzername oder Passwort ungueltig",
"networkError": "Verbindungsfehler. Bitte versuchen Sie es erneut."
},
"branding": {
"tagline": "Modulare Workflow-Plattform fuer Ihr Unternehmen"
}
}, },
"header": { "header": {
"search": "Suchen...", "search": "Suchen...",
"userMenu": "Benutzermenu", "userMenu": "Benutzermenu",
"breadcrumb": { "breadcrumb": {
"home": "Startseite" "home": "Startseite"
},
"logout": "Abmelden",
"role": {
"SUPER_ADMIN": "Super-Admin",
"ADMIN": "Admin",
"USER": "Benutzer"
} }
}, },
"sidebar": { "sidebar": {
@@ -23,13 +54,47 @@
"categories": { "categories": {
"label": "Kategorien", "label": "Kategorien",
"empty": "Keine Module" "empty": "Keine Module"
} },
"admin": "Verwaltung",
"users": "Benutzer",
"tenants": "Mandanten"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"empty": "Keine Widgets aktiv", "empty": "Keine Widgets aktiv",
"addWidget": "Widget hinzufuegen" "addWidget": "Widget hinzufuegen"
}, },
"admin": {
"users": {
"title": "Benutzerverwaltung",
"create": "Benutzer erstellen",
"edit": "Benutzer bearbeiten",
"delete": "Benutzer loeschen",
"deleteConfirm": "Moechten Sie diesen Benutzer wirklich loeschen?",
"name": "Name",
"username": "Benutzername",
"email": "E-Mail",
"displayName": "Anzeigename",
"role": "Rolle",
"status": "Status",
"actions": "Aktionen",
"password": "Passwort",
"noUsers": "Keine Benutzer gefunden"
},
"tenants": {
"title": "Mandantenverwaltung",
"create": "Mandant erstellen",
"edit": "Mandant bearbeiten",
"name": "Name",
"slug": "Slug",
"status": "Status",
"userCount": "Benutzer",
"actions": "Aktionen",
"noTenants": "Keine Mandanten gefunden",
"deleteConfirm": "Moechten Sie diesen Mandanten wirklich loeschen?",
"cannotDeleteActive": "Mandanten mit aktiven Benutzern koennen nicht geloescht werden"
}
},
"theme": { "theme": {
"light": "Hell", "light": "Hell",
"dark": "Dunkel", "dark": "Dunkel",
+67 -2
View File
@@ -4,13 +4,44 @@
"loading": "Loading...", "loading": "Loading...",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"menu": "Menu" "menu": "Menu",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"close": "Close",
"yes": "Yes",
"no": "No",
"active": "Active",
"inactive": "Inactive",
"actions": "Actions",
"accessDenied": "Access denied"
},
"auth": {
"login": "Sign In",
"username": "Username",
"password": "Password",
"rememberMe": "Remember me",
"submit": "Sign In",
"error": {
"invalidCredentials": "Invalid username or password",
"networkError": "Connection error. Please try again."
},
"branding": {
"tagline": "Modular workflow platform for your organization"
}
}, },
"header": { "header": {
"search": "Search...", "search": "Search...",
"userMenu": "User menu", "userMenu": "User menu",
"breadcrumb": { "breadcrumb": {
"home": "Home" "home": "Home"
},
"logout": "Sign Out",
"role": {
"SUPER_ADMIN": "Super Admin",
"ADMIN": "Admin",
"USER": "User"
} }
}, },
"sidebar": { "sidebar": {
@@ -23,13 +54,47 @@
"categories": { "categories": {
"label": "Categories", "label": "Categories",
"empty": "No modules" "empty": "No modules"
} },
"admin": "Administration",
"users": "Users",
"tenants": "Tenants"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"empty": "No active widgets", "empty": "No active widgets",
"addWidget": "Add widget" "addWidget": "Add widget"
}, },
"admin": {
"users": {
"title": "User Management",
"create": "Create User",
"edit": "Edit User",
"delete": "Delete User",
"deleteConfirm": "Are you sure you want to delete this user?",
"name": "Name",
"username": "Username",
"email": "Email",
"displayName": "Display Name",
"role": "Role",
"status": "Status",
"actions": "Actions",
"password": "Password",
"noUsers": "No users found"
},
"tenants": {
"title": "Tenant Management",
"create": "Create Tenant",
"edit": "Edit Tenant",
"name": "Name",
"slug": "Slug",
"status": "Status",
"userCount": "Users",
"actions": "Actions",
"noTenants": "No tenants found",
"deleteConfirm": "Are you sure you want to delete this tenant?",
"cannotDeleteActive": "Cannot delete tenants with active users"
}
},
"theme": { "theme": {
"light": "Light", "light": "Light",
"dark": "Dark", "dark": "Dark",