Redesign login UI, add theme toggle, refactor user update

- Redesigned login page with two-panel layout and branding
- Added HexaDistro logo and theme toggle to sidebar/header
- Refactored user update: roles via update endpoint, removed assignRole API/controller
- Extended UpdateUserRequest for username, role, password changes
- UserService.UpdateAsync syncs changes with AuthHex, with rollback
- Stock balances client dedupes by itemId-locationId
- Middleware matcher excludes static assets from auth checks
- Improved localStorage clearing for user context
- Added lucide-react for icons
- Minor UI/code cleanups for consistency and UX
This commit is contained in:
Kalana B. Thilakarathna
2026-10-19 02:20:14 +05:30
parent 4ef63420b7
commit 531b3a7fef
17 changed files with 300 additions and 94 deletions
+158 -43
View File
@@ -1,25 +1,29 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { Space_Grotesk } from 'next/font/google'
import { useAuth } from '@/contexts/auth-context'
import { getApiError } from '@/lib/api'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
const sg = Space_Grotesk({ subsets: ['latin'], weight: ['400', '500', '600', '700'] })
export default function LoginPage() {
const { login, user, loading } = useAuth()
const router = useRouter()
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [showPassword, setShowPassword] = useState(false)
useEffect(() => {
if (!loading && user) {
router.replace('/')
}
if (!loading && user) router.replace('/')
}, [user, loading, router])
async function handleSubmit(e: FormEvent) {
@@ -36,47 +40,158 @@ export default function LoginPage() {
}
return (
<div className="flex min-h-svh items-center justify-center bg-background">
<form onSubmit={handleSubmit} className="flex w-full max-w-sm flex-col gap-6 p-6">
<div className="flex flex-col gap-1 text-center">
<h1 className="text-2xl font-semibold text-foreground">Sign in</h1>
<p className="text-sm text-muted-foreground">Distribution ERP</p>
<div className={`${sg.className} min-h-svh flex flex-col md:flex-row`}>
{/* ── Left panel: image (hidden on mobile, 60% on desktop) ── */}
<div className="relative hidden md:flex md:w-[60%] shrink-0 flex-col">
<Image
src="/HexaDistribution.png"
alt="Distribution warehouse"
fill
sizes="60vw"
className="object-cover object-center"
priority
unoptimized
/>
{/* Logo overlay top-left */}
<div className="relative z-10 px-10 pt-9">
<div className="flex items-center gap-2.5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
alt="HexDive"
className="h-7 w-auto object-contain"
/>
<span className="font-bold text-[15px] tracking-wide text-black">
HexaDistro
</span>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="identifier">Email</Label>
<Input
id="identifier"
type="text"
autoComplete="username"
required
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
</div>
{/* Gradient at bottom */}
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.2) 40%, transparent 70%)' }}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" disabled={submitting} className="w-full">
{submitting ? 'Signing in…' : 'Sign in'}
</Button>
{/* Text content pinned to bottom-left */}
<div className="relative z-10 mt-auto px-12 pb-12">
<h1
className="font-bold leading-tight mb-3"
style={{ fontSize: 'clamp(28px, 3vw, 46px)', color: '#ffffff' }}
>
Your operations.<br />
<span
className="inline-block px-2.5 py-1 mt-1 rounded-sm"
style={{ backgroundColor: '#1A1033', color: '#ffffff' }}
>
Fully in control.
</span>
</h1>
<p style={{ color: 'rgba(255,255,255,0.72)', fontSize: 15, lineHeight: 1.65, maxWidth: 380 }}>
Distribution ERP built for teams that deliver right, every time.
</p>
</div>
</form>
</div>
{/* ── Right panel: login card ── */}
<div className="flex-1 bg-white flex flex-col items-center justify-center px-6 py-10 sm:px-10">
{/* Mobile-only logo */}
<div className="flex items-center gap-2.5 mb-8 md:hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
alt="HexDive"
className="h-7 w-auto object-contain"
/>
<span className="font-bold text-[14px] tracking-widest uppercase text-gray-900">
HexaDistro
</span>
</div>
<div
className="w-full rounded-2xl bg-white px-6 py-8 sm:px-10 sm:py-11"
style={{
maxWidth: 440,
boxShadow: '0 8px 40px rgba(0,0,0,0.12)',
}}
>
{/* Logo — desktop only inside card */}
<div className="hidden md:flex items-center gap-2.5 mb-8">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
alt="HexDive"
className="h-7 w-auto object-contain"
/>
<span className="font-bold text-[14px] tracking-widest uppercase text-gray-900">
HexaDistro
</span>
</div>
{/* Heading */}
<h1 className="text-3xl font-semibold text-gray-900 mb-1">Sign in</h1>
<p className="text-sm text-gray-500 mb-8">
Enter your credentials to access your account.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="identifier" className="text-gray-700">
Username or Email
</Label>
<Input
id="identifier"
type="text"
autoComplete="username"
required
value={identifier}
onChange={e => setIdentifier(e.target.value)}
className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-gray-700">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute inset-y-0 right-2.5 flex items-center text-gray-400 hover:text-gray-600"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" disabled={submitting} className="w-full mt-2">
{submitting ? 'Signing in…' : 'Sign In'}
</Button>
</form>
</div>
</div>
</div>
)
}