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
@@ -26,8 +26,8 @@ public class StoreController(
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
return StatusCode(403);
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
// return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
@@ -31,13 +31,6 @@ public class UserController(UserService service) : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/role")]
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
{
var result = await service.AssignRoleAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/scope")]
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
{
+6 -1
View File
@@ -9,7 +9,12 @@ public record CreateUserRequest(
Guid RoleId,
Guid? ReportsToUserId);
public record UpdateUserRequest(string FullName);
public record UpdateUserRequest(
string FullName,
string? Username = null,
Guid? RoleId = null,
string? CurrentPassword = null,
string? NewPassword = null);
public record AssignUserRoleRequest(Guid RoleId);
+44 -17
View File
@@ -44,7 +44,7 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
email = request.Username,
fullname = request.FullName,
password = request.Password,
roleId = request.RoleId,
roleId = request.RoleId.ToString(),
userTypeId = "00000001-0000-0000-0000-200000000000",
sendCredentialsEmail = false
}, reference: user.Id.ToString(), ct: ct);
@@ -69,12 +69,52 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
{
var user = await repo.GetByIdAsync(id);
if (user is null || !user.IsActive) return null;
user.FullName = request.FullName;
if (!string.IsNullOrWhiteSpace(request.Username))
user.Username = request.Username;
if (request.RoleId.HasValue)
user.FK_RoleId = request.RoleId.Value;
user.UpdatedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(user);
var hasUsernameChange = !string.IsNullOrWhiteSpace(request.Username);
var hasRoleChange = request.RoleId.HasValue;
if (hasUsernameChange || hasRoleChange)
{
var payload = new Dictionary<string, object> { ["userId"] = user.Id.ToString() };
if (hasUsernameChange) payload["userName"] = request.Username!;
if (hasRoleChange) payload["roleCode"] = request.RoleId!.Value.ToString();
var authResult = await authHex.CallAsync("UpdateUser", payload, reference: user.Id.ToString(), ct: ct);
if (!authResult.Success)
{
await uow.RollbackAsync(ct);
throw new InvalidOperationException($"AuthHex update failed: {authResult.Message}");
}
}
if (!string.IsNullOrWhiteSpace(request.CurrentPassword) && !string.IsNullOrWhiteSpace(request.NewPassword))
{
var authResult = await authHex.CallAsync("ChangeUserPassword", new
{
userId = user.Id.ToString(),
currentPassword = request.CurrentPassword,
newPassword = request.NewPassword
}, reference: user.Id.ToString(), ct: ct);
if (!authResult.Success)
{
await uow.RollbackAsync(ct);
throw new InvalidOperationException($"AuthHex password change failed: {authResult.Message}");
}
}
await uow.CommitAsync(ct);
}
catch
@@ -87,22 +127,9 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
public async Task<UserResponse?> AssignRoleAsync(Guid id, AssignUserRoleRequest request, CancellationToken ct = default)
{
var user = await repo.GetByIdAsync(id);
if (user is null || !user.IsActive) return null;
user.FK_RoleId = request.RoleId;
user.UpdatedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(user);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(user);
return await UpdateAsync(id, new UpdateUserRequest(
FullName: (await repo.GetByIdAsync(id))?.FullName ?? string.Empty,
RoleId: request.RoleId), ct);
}
public async Task<bool> AssignScopeAsync(Guid id, AssignUserScopeRequest request, CancellationToken ct = default)
+1 -1
View File
@@ -21,7 +21,7 @@
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": "http://localhost:6705"
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
+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>
)
}
@@ -1,6 +1,7 @@
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { AppSidebar } from '@/components/layout/app-sidebar'
import { BottomNav } from '@/components/layout/bottom-nav'
import { ThemeToggle } from '@/components/layout/theme-toggle'
export default function DashboardLayout({
children,
@@ -13,6 +14,9 @@ export default function DashboardLayout({
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
<SidebarTrigger className="-ml-1 hidden md:flex" />
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
{children}
@@ -45,7 +45,15 @@ export function StockBalancesClient() {
setLoading(true)
try {
const res = await stockApi.balances({ page: p, pageSize: ps })
setData(getPaginatedItems(res))
const items = getPaginatedItems(res)
const seen = new Set<string>()
const deduped = items.filter(r => {
const k = `${r.itemId}-${r.locationId}`
if (seen.has(k)) return false
seen.add(k)
return true
})
setData(deduped)
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load stock balances', description: getApiError(err), type: 'error' })
@@ -83,10 +83,10 @@ export function UserSheet({ open, onClose, user, roles, users, onSuccess }: User
async function onEditSubmit(values: EditValues) {
try {
await usersApi.update(user!.id, { fullName: values.fullName })
if (values.roleId !== user!.roleId) {
await usersApi.assignRole(user!.id, values.roleId)
}
await usersApi.update(user!.id, {
fullName: values.fullName,
roleId: values.roleId !== user!.roleId ? values.roleId : undefined,
})
toast.add({ title: 'User updated', type: 'success' })
onSuccess()
onClose()
@@ -81,11 +81,14 @@ export function AppSidebar() {
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
D
</div>
{/* 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 shrink-0"
/>
<span className="truncate font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
Distribution
HexaDistro
</span>
</div>
</SidebarHeader>
@@ -114,11 +117,15 @@ export function AppSidebar() {
const visibleItems = group.items.filter((item) =>
canSee(item, permissions),
)
const groupActive = visibleItems.some(
(item) =>
const groupActive = visibleItems.some((item) => {
const hasSiblingUnder = visibleItems.some(
s => s.href !== item.href && s.href.startsWith(item.href + '/')
)
return (
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href)),
)
(!hasSiblingUnder && item.href !== '/' && pathname.startsWith(item.href + '/'))
)
})
return (
<Collapsible
@@ -150,10 +157,14 @@ export function AppSidebar() {
<CollapsibleContent>
<SidebarMenuSub>
{visibleItems.map((item) => {
const hasSiblingUnder = visibleItems.some(
s => s.href !== item.href && s.href.startsWith(item.href + '/')
)
const isActive =
pathname === item.href ||
(item.href !== '/' &&
pathname.startsWith(item.href))
(!hasSiblingUnder &&
item.href !== '/' &&
pathname.startsWith(item.href + '/'))
return (
<Tooltip key={item.href}>
<SidebarMenuSubItem>
@@ -0,0 +1,24 @@
'use client'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import { Sun, Moon } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
aria-label="Toggle theme"
>
{mounted && (resolvedTheme === 'dark' ? <Sun size={16} /> : <Moon size={16} />)}
</Button>
)
}
+5 -4
View File
@@ -22,6 +22,7 @@ interface AuthContextValue {
}
const PERMISSIONS_KEY = 'app:permissions'
const USER_STORAGE_KEYS = [PERMISSIONS_KEY, 'distribution-dashboard-widgets-v3']
function savePermissions(perms: string[]): void {
try {
@@ -37,9 +38,9 @@ function readPermissions(): Set<string> {
return new Set()
}
function clearPermissions(): void {
function clearUserStorage(): void {
try {
localStorage.removeItem(PERMISSIONS_KEY)
USER_STORAGE_KEYS.forEach(k => localStorage.removeItem(k))
} catch {}
}
@@ -71,7 +72,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
return registerUnauthorizedHandler(() => {
clearPermissions()
clearUserStorage()
setUser(null)
setPermissions(new Set())
router.push('/login')
@@ -95,7 +96,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
await api.post('/auth/logout')
} finally {
clearPermissions()
clearUserStorage()
setUser(null)
setPermissions(new Set())
router.push('/login')
+4 -3
View File
@@ -13,6 +13,10 @@ export interface CreateUserBody {
export interface UpdateUserBody {
fullName: string
username?: string
roleId?: string
currentPassword?: string
newPassword?: string
}
export const users = {
@@ -28,9 +32,6 @@ export const users = {
update: (id: string, body: UpdateUserBody) =>
api.put<UserResponse>(`/users/${id}`, body).then(r => r.data),
assignRole: (id: string, roleId: string) =>
api.put<UserResponse>(`/users/${id}/role`, { roleId }).then(r => r.data),
deactivate: (id: string) =>
api.delete(`/users/${id}`),
}
+1 -1
View File
@@ -19,5 +19,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf)$).*)'],
}
+16
View File
@@ -21,6 +21,7 @@
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.9",
"leaflet": "^1.9.4",
"lucide-react": "^1.59.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
@@ -8073,6 +8074,21 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.59.0.tgz",
"integrity": "sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -25,6 +25,7 @@
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.9",
"leaflet": "^1.9.4",
"lucide-react": "^1.59.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB