diff --git a/Distribution/Features/Store/StoreController.cs b/Distribution/Features/Store/StoreController.cs index b94820e..a718e10 100644 --- a/Distribution/Features/Store/StoreController.cs +++ b/Distribution/Features/Store/StoreController.cs @@ -26,8 +26,8 @@ public class StoreController( public async Task 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); } diff --git a/Distribution/Features/User/UserController.cs b/Distribution/Features/User/UserController.cs index 09db811..207c83d 100644 --- a/Distribution/Features/User/UserController.cs +++ b/Distribution/Features/User/UserController.cs @@ -31,13 +31,6 @@ public class UserController(UserService service) : ControllerBase return result is null ? NotFound() : Ok(result); } - [HttpPut("{id:guid}/role")] - public async Task 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 AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct) { diff --git a/Distribution/Features/User/UserDTOs.cs b/Distribution/Features/User/UserDTOs.cs index 98c4552..513fe1d 100644 --- a/Distribution/Features/User/UserDTOs.cs +++ b/Distribution/Features/User/UserDTOs.cs @@ -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); diff --git a/Distribution/Features/User/UserService.cs b/Distribution/Features/User/UserService.cs index 554f05e..91e24e3 100644 --- a/Distribution/Features/User/UserService.cs +++ b/Distribution/Features/User/UserService.cs @@ -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 { ["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 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 AssignScopeAsync(Guid id, AssignUserScopeRequest request, CancellationToken ct = default) diff --git a/Distribution/appsettings.json b/Distribution/appsettings.json index 8b0fd98..58ac152 100644 --- a/Distribution/appsettings.json +++ b/Distribution/appsettings.json @@ -21,7 +21,7 @@ }, "AuthHex": { - "BaseUrl": "http://localhost:6703" + "BaseUrl": "http://localhost:6705" }, "StoreManagement": { "UnverifiedKycOrderThreshold": 0 diff --git a/distribution_fe/app/(auth)/login/page.tsx b/distribution_fe/app/(auth)/login/page.tsx index a89d869..621ac17 100644 --- a/distribution_fe/app/(auth)/login/page.tsx +++ b/distribution_fe/app/(auth)/login/page.tsx @@ -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(null) + const [password, setPassword] = useState('') + const [error, setError] = useState(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 ( -
-
-
-

Sign in

-

Distribution ERP

+
+ + {/* ── Left panel: image (hidden on mobile, 60% on desktop) ── */} +
+ Distribution warehouse + + {/* Logo overlay top-left */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + HexDive + + HexaDistro + +
-
-
- - setIdentifier(e.target.value)} - /> -
+ {/* Gradient at bottom */} +
-
- - setPassword(e.target.value)} - /> -
- - {error && ( -

{error}

- )} - - + {/* Text content pinned to bottom-left */} +
+

+ Your operations.
+ + Fully in control. + +

+

+ Distribution ERP built for teams that deliver right, every time. +

- +
+ + {/* ── Right panel: login card ── */} +
+ + {/* Mobile-only logo */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + HexDive + + HexaDistro + +
+ +
+ {/* Logo — desktop only inside card */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + HexDive + + HexaDistro + +
+ + {/* Heading */} +

Sign in

+

+ Enter your credentials to access your account. +

+ +
+ +
+ + setIdentifier(e.target.value)} + className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400" + /> +
+ +
+ +
+ setPassword(e.target.value)} + className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400 pr-10" + /> + +
+
+ + {error && ( +

{error}

+ )} + + + +
+
+
+
) } diff --git a/distribution_fe/app/(dashboard)/layout.tsx b/distribution_fe/app/(dashboard)/layout.tsx index 308eafc..a70e37d 100644 --- a/distribution_fe/app/(dashboard)/layout.tsx +++ b/distribution_fe/app/(dashboard)/layout.tsx @@ -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({
+
+ +
{children} diff --git a/distribution_fe/app/(dashboard)/stock/_components/stock-balances-client.tsx b/distribution_fe/app/(dashboard)/stock/_components/stock-balances-client.tsx index 7249045..021c4bc 100644 --- a/distribution_fe/app/(dashboard)/stock/_components/stock-balances-client.tsx +++ b/distribution_fe/app/(dashboard)/stock/_components/stock-balances-client.tsx @@ -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() + 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' }) diff --git a/distribution_fe/app/(dashboard)/users/_components/user-sheet.tsx b/distribution_fe/app/(dashboard)/users/_components/user-sheet.tsx index c022a1f..fd29b91 100644 --- a/distribution_fe/app/(dashboard)/users/_components/user-sheet.tsx +++ b/distribution_fe/app/(dashboard)/users/_components/user-sheet.tsx @@ -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() diff --git a/distribution_fe/components/layout/app-sidebar.tsx b/distribution_fe/components/layout/app-sidebar.tsx index b8e30f9..35ac78a 100644 --- a/distribution_fe/components/layout/app-sidebar.tsx +++ b/distribution_fe/components/layout/app-sidebar.tsx @@ -81,11 +81,14 @@ export function AppSidebar() {
-
- D -
+ {/* eslint-disable-next-line @next/next/no-img-element */} + HexDive - Distribution + HexaDistro
@@ -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 ( {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 ( diff --git a/distribution_fe/components/layout/theme-toggle.tsx b/distribution_fe/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..a2d1b4d --- /dev/null +++ b/distribution_fe/components/layout/theme-toggle.tsx @@ -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 ( + + ) +} diff --git a/distribution_fe/contexts/auth-context.tsx b/distribution_fe/contexts/auth-context.tsx index 774f515..6a8ff82 100644 --- a/distribution_fe/contexts/auth-context.tsx +++ b/distribution_fe/contexts/auth-context.tsx @@ -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 { 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') diff --git a/distribution_fe/lib/api/users.ts b/distribution_fe/lib/api/users.ts index 81580eb..ce67c3f 100644 --- a/distribution_fe/lib/api/users.ts +++ b/distribution_fe/lib/api/users.ts @@ -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(`/users/${id}`, body).then(r => r.data), - assignRole: (id: string, roleId: string) => - api.put(`/users/${id}/role`, { roleId }).then(r => r.data), - deactivate: (id: string) => api.delete(`/users/${id}`), } diff --git a/distribution_fe/middleware.ts b/distribution_fe/middleware.ts index 808e69c..a31dbfa 100644 --- a/distribution_fe/middleware.ts +++ b/distribution_fe/middleware.ts @@ -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)$).*)'], } diff --git a/distribution_fe/package-lock.json b/distribution_fe/package-lock.json index e30f963..70cb4c4 100644 --- a/distribution_fe/package-lock.json +++ b/distribution_fe/package-lock.json @@ -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", diff --git a/distribution_fe/package.json b/distribution_fe/package.json index 4dddcdc..d40c022 100644 --- a/distribution_fe/package.json +++ b/distribution_fe/package.json @@ -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", diff --git a/distribution_fe/public/HexaDistribution.png b/distribution_fe/public/HexaDistribution.png new file mode 100644 index 0000000..e517208 Binary files /dev/null and b/distribution_fe/public/HexaDistribution.png differ