From 9b04d00b3a7bbf2d2a07cb70200114b8b535c3d6 Mon Sep 17 00:00:00 2001 From: mxllv <80990476+mxllv@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:16:29 +0100 Subject: [PATCH] feat: validated frontend env, user invite and active routes, paginated department drawer, sortable assets table Closes #1628 Closes #1629 Closes #1630 Closes #1631 --- .github/workflows/CI.yaml | 2 +- README.md | 11 + .../src/assets/dto/list-assets-query.dto.ts | 2 + backend/src/users/dto/invite-user.dto.ts | 44 ++++ backend/src/users/users.controller.ts | 30 +++ backend/src/users/users.service.ts | 42 +++- frontend/__tests__/department-drawer.test.tsx | 78 +++++++ frontend/__tests__/env.test.ts | 36 ++++ frontend/__tests__/user-errors.test.ts | 38 ++++ frontend/app/(dashboard)/assets/page.tsx | 112 ++++++++-- frontend/app/(dashboard)/users/page.tsx | 21 +- frontend/app/scan/[assetId]/page.tsx | 3 +- .../departments/department-drawer.tsx | 201 ++++++++++++++---- frontend/hooks/useWebSocket.ts | 5 +- frontend/lib/api.ts | 4 +- frontend/lib/api/assets.ts | 2 + frontend/lib/api/users.ts | 36 ++++ frontend/lib/env.ts | 55 +++++ frontend/lib/query/hooks/useDepartments.ts | 46 +++- frontend/store/wallet.store.ts | 3 +- 20 files changed, 693 insertions(+), 78 deletions(-) create mode 100644 backend/src/users/dto/invite-user.dto.ts create mode 100644 frontend/__tests__/department-drawer.test.tsx create mode 100644 frontend/__tests__/env.test.ts create mode 100644 frontend/__tests__/user-errors.test.ts create mode 100644 frontend/lib/env.ts diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index b6aa7da3..ed5db7e9 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -340,7 +340,7 @@ jobs: - name: Build run: npm run build env: - NEXT_PUBLIC_API_URL: http://localhost:3001 + NEXT_PUBLIC_API_URL: http://localhost:6003/api - name: Unit tests run: npm run test -- --passWithNoTests diff --git a/README.md b/README.md index 79db28a5..05a289e3 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,23 @@ The backend will be available at `http://localhost:3001`. ```bash cd frontend +cp .env.example .env.local # optional: defaults work for local development npm install npm run dev ``` The frontend will be available at `http://localhost:3000`. +#### Frontend environment variables + +Validated at startup by `frontend/lib/env.ts`; a missing optional value falls back to the default, an invalid one fails fast with a readable message. + +| Variable | Default | Description | +| --- | --- | --- | +| `NEXT_PUBLIC_API_URL` | `http://localhost:6003/api` | Backend REST API base URL, including `/api` | +| `NEXT_PUBLIC_WS_URL` | derived from `NEXT_PUBLIC_API_URL` | Socket.IO endpoint for real-time notifications | +| `NEXT_PUBLIC_STELLAR_NETWORK` | `testnet` | Network the Freighter wallet must use (`testnet` or `mainnet`) | + --- ## Usage diff --git a/backend/src/assets/dto/list-assets-query.dto.ts b/backend/src/assets/dto/list-assets-query.dto.ts index 9385dd71..28ebc50f 100644 --- a/backend/src/assets/dto/list-assets-query.dto.ts +++ b/backend/src/assets/dto/list-assets-query.dto.ts @@ -9,6 +9,8 @@ export const ASSET_SORT_COLUMNS = [ 'assetTag', 'status', 'purchaseDate', + 'purchaseCost', + 'warrantyExpiry', ] as const; export class ListAssetsQueryDto extends PaginationQueryDto { diff --git a/backend/src/users/dto/invite-user.dto.ts b/backend/src/users/dto/invite-user.dto.ts new file mode 100644 index 00000000..5f0d01b6 --- /dev/null +++ b/backend/src/users/dto/invite-user.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsString, + MaxLength, +} from 'class-validator'; +import { UserRole } from '../entities/user.entity'; + +// The frontend sends lowercase role names ("staff"); accept those as well as +// the canonical enum values. +const ROLE_ALIASES: Record = { + admin: UserRole.ADMIN, + manager: UserRole.MANAGER, + staff: UserRole.EMPLOYEE, + employee: UserRole.EMPLOYEE, +}; + +export class InviteUserDto { + @ApiProperty({ example: 'new.hire@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'Jane' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + firstName: string; + + @ApiProperty({ example: 'Doe' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + lastName: string; + + @ApiProperty({ enum: UserRole }) + @Transform(({ value }) => + typeof value === 'string' ? (ROLE_ALIASES[value.toLowerCase()] ?? value) : value, + ) + @IsEnum(UserRole) + role: UserRole; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 3664c6f3..21e21817 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Patch, + Post, Param, Body, Query, @@ -24,6 +25,7 @@ import { GetUser } from '../auth/decorators/get-user.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { InviteUserDto } from './dto/invite-user.dto'; @ApiTags('users') @ApiBearerAuth('JWT-auth') @@ -76,4 +78,32 @@ export class UsersController { const requestingUserId = req.user?.id || req.user?.sub; return this.usersService.updateRole(id, role, requestingUserId); } + + @Post('invite') + @UseGuards(RolesGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Invite a new user (admin only)' }) + @ApiResponse({ status: 201, description: 'User invited' }) + @ApiResponse({ status: 403, description: 'Not an admin' }) + @ApiResponse({ status: 409, description: 'Email already registered' }) + invite(@Body() dto: InviteUserDto, @Req() req: any) { + const requestingUserId = req.user?.id || req.user?.sub; + return this.usersService.invite(dto, requestingUserId); + } + + @Patch(':id/active') + @UseGuards(RolesGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Activate or deactivate a user (admin only)' }) + @ApiResponse({ status: 200, description: 'User updated' }) + @ApiResponse({ status: 403, description: 'Not an admin, or self-deactivation' }) + @ApiResponse({ status: 404, description: 'User not found' }) + setActive( + @Param('id') id: string, + @Body('isActive') isActive: boolean, + @Req() req: any, + ) { + const requestingUserId = req.user?.id || req.user?.sub; + return this.usersService.setActive(id, isActive === true, requestingUserId); + } } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 2a966f5d..ebd00dbe 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -19,6 +19,7 @@ import { User, UserRole } from './entities/user.entity'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { AuditLogsService } from '../audit-logs/audit-logs.service'; import { normalizeEmail } from './utils/normalize-email'; +import * as crypto from 'crypto'; @Injectable() export class UsersService { @@ -140,7 +141,46 @@ export class UsersService { return this.sanitize(saved); } - async setActive(id: string, isActive: boolean) { + /** + * Creates an account for an invited person. They get an unusable random + * password and a 7-day password-set token. The raw token is only returned + * when EXPOSE_RESET_TOKEN=true (local development), exactly like the + * forgot-password flow, until email delivery is wired up. + */ + async invite( + dto: { email: string; firstName: string; lastName: string; role?: UserRole }, + invitedById?: string, + ) { + const created = await this.create({ + ...dto, + password: crypto.randomBytes(24).toString('base64url'), + }); + const inviteToken = crypto.randomBytes(32).toString('hex'); + const tokenHash = crypto + .createHash('sha256') + .update(inviteToken) + .digest('hex'); + await this.setPasswordResetToken( + created.id, + tokenHash, + new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + ); + await this.auditLogsService?.logAction({ + action: 'INVITED', + entityType: 'user', + entityId: created.id, + actorId: invitedById, + newValue: { email: created.email, role: created.role }, + }); + return process.env.EXPOSE_RESET_TOKEN === 'true' + ? { ...created, inviteToken } + : created; + } + + async setActive(id: string, isActive: boolean, requestingUserId?: string) { + if (requestingUserId && requestingUserId === id && !isActive) { + throw new ForbiddenException('Cannot deactivate your own account'); + } const user = await this.findById(id); user.isActive = isActive; const saved = await this.userRepository.save(user); diff --git a/frontend/__tests__/department-drawer.test.tsx b/frontend/__tests__/department-drawer.test.tsx new file mode 100644 index 00000000..dbd2faed --- /dev/null +++ b/frontend/__tests__/department-drawer.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { DepartmentDrawer } from "@/components/departments/department-drawer"; +import { + useDepartmentAssets, + useDepartmentUsers, +} from "@/lib/query/hooks/useDepartments"; + +jest.mock("@/lib/query/hooks/useDepartments", () => ({ + useDepartmentUsers: jest.fn(), + useDepartmentAssets: jest.fn(), +})); +jest.mock("@/components/ui/sheet", () => ({ + Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})); +jest.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children: React.ReactNode }) =>
{children}
, + TabsList: ({ children }: { children: React.ReactNode }) =>
{children}
, + TabsTrigger: ({ children }: { children: React.ReactNode }) => , + TabsContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const department = { + id: "dept-1", + name: "Engineering", + memberCount: 0, + assetCount: 0, + createdAt: "2024-01-01", +}; + +const ok = (items: unknown[], total = items.length) => ({ + data: { items, total, page: 1, limit: 10, totalPages: Math.max(1, Math.ceil(total / 10)) }, + isLoading: false, + isError: false, + refetch: jest.fn(), +}); + +describe("DepartmentDrawer", () => { + beforeEach(() => jest.clearAllMocks()); + + it("shows distinct empty states", () => { + (useDepartmentUsers as jest.Mock).mockReturnValue(ok([])); + (useDepartmentAssets as jest.Mock).mockReturnValue(ok([])); + render( {}} department={department} />); + expect(screen.getByText("No members in this department")).toBeInTheDocument(); + expect(screen.getByText("No assets in this department")).toBeInTheDocument(); + }); + + it("renders rows that link to the asset page", () => { + (useDepartmentUsers as jest.Mock).mockReturnValue( + ok([{ id: "u1", firstName: "Ada", lastName: "Lovelace", email: "ada@x.io", role: "ADMIN" }]), + ); + (useDepartmentAssets as jest.Mock).mockReturnValue( + ok([{ id: "a1", name: "Laptop", assetTag: "AST-1", status: "ACTIVE" }]), + ); + render( {}} department={department} />); + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.getByText("Laptop").closest("a")).toHaveAttribute("href", "/assets/a1"); + expect(screen.getByText("Members (1)")).toBeInTheDocument(); + }); + + it("shows an error state with a retry button", () => { + const refetch = jest.fn(); + (useDepartmentUsers as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch, + }); + (useDepartmentAssets as jest.Mock).mockReturnValue(ok([])); + render( {}} department={department} />); + expect(screen.getByText("Could not load members.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/env.test.ts b/frontend/__tests__/env.test.ts new file mode 100644 index 00000000..efe801ee --- /dev/null +++ b/frontend/__tests__/env.test.ts @@ -0,0 +1,36 @@ +import { parseEnv } from "@/lib/env"; + +describe("parseEnv", () => { + it("applies local defaults when nothing is set", () => { + const env = parseEnv({}); + expect(env.NEXT_PUBLIC_API_URL).toBe("http://localhost:6003/api"); + expect(env.NEXT_PUBLIC_STELLAR_NETWORK).toBe("testnet"); + expect(env.NEXT_PUBLIC_WS_URL).toBeUndefined(); + }); + + it("treats empty strings as unset", () => { + expect(parseEnv({ NEXT_PUBLIC_API_URL: "" }).NEXT_PUBLIC_API_URL).toBe( + "http://localhost:6003/api", + ); + }); + + it("accepts explicit values", () => { + const env = parseEnv({ + NEXT_PUBLIC_API_URL: "https://api.example.com/api", + NEXT_PUBLIC_WS_URL: "wss://api.example.com", + NEXT_PUBLIC_STELLAR_NETWORK: "mainnet", + }); + expect(env.NEXT_PUBLIC_API_URL).toBe("https://api.example.com/api"); + expect(env.NEXT_PUBLIC_WS_URL).toBe("wss://api.example.com"); + expect(env.NEXT_PUBLIC_STELLAR_NETWORK).toBe("mainnet"); + }); + + it("fails fast with a readable message on invalid values", () => { + expect(() => parseEnv({ NEXT_PUBLIC_API_URL: "not a url" })).toThrow( + /NEXT_PUBLIC_API_URL/, + ); + expect(() => parseEnv({ NEXT_PUBLIC_STELLAR_NETWORK: "devnet" })).toThrow( + /NEXT_PUBLIC_STELLAR_NETWORK/, + ); + }); +}); diff --git a/frontend/__tests__/user-errors.test.ts b/frontend/__tests__/user-errors.test.ts new file mode 100644 index 00000000..6c66ab14 --- /dev/null +++ b/frontend/__tests__/user-errors.test.ts @@ -0,0 +1,38 @@ +import { getUserActionErrorMessage } from "@/lib/api/users"; + +const withStatus = (status: number, message?: string) => ({ + response: { status, data: message ? { message } : {} }, +}); + +describe("getUserActionErrorMessage", () => { + it("explains a duplicate invite (409)", () => { + expect(getUserActionErrorMessage(withStatus(409), "invite")).toMatch( + /already been invited/i, + ); + }); + + it("explains a forbidden action (403)", () => { + expect(getUserActionErrorMessage(withStatus(403))).toMatch(/administrators/i); + }); + + it("prefers the server message for 403 and 400", () => { + expect( + getUserActionErrorMessage(withStatus(403, "Cannot change your own role")), + ).toBe("Cannot change your own role"); + expect(getUserActionErrorMessage(withStatus(400, "email must be an email"))).toBe( + "email must be an email", + ); + }); + + it("explains not found (404)", () => { + expect(getUserActionErrorMessage(withStatus(404))).toMatch(/not found/i); + }); + + it("explains server errors (5xx)", () => { + expect(getUserActionErrorMessage(withStatus(500))).toMatch(/server/i); + }); + + it("falls back to a generic message", () => { + expect(getUserActionErrorMessage(new Error("boom"))).toMatch(/something went wrong/i); + }); +}); diff --git a/frontend/app/(dashboard)/assets/page.tsx b/frontend/app/(dashboard)/assets/page.tsx index 33cf6298..6a9aef1c 100644 --- a/frontend/app/(dashboard)/assets/page.tsx +++ b/frontend/app/(dashboard)/assets/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Plus, Search, SlidersHorizontal, MapPin, X, Package, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -13,14 +13,86 @@ import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; const STATUS_OPTIONS = ["All", ...Object.values(AssetStatus)]; +const DEFAULT_SORT = "createdAt"; +const SORT_OPTIONS: { value: string; label: string }[] = [ + { value: "createdAt", label: "Recently added" }, + { value: "name", label: "Name" }, + { value: "assetTag", label: "Asset ID" }, + { value: "status", label: "Status" }, + { value: "purchaseDate", label: "Purchase date" }, + { value: "purchaseCost", label: "Value" }, + { value: "warrantyExpiry", label: "Warranty expiry" }, +]; + +function SortableHeader({ + label, + column, + sortBy, + sortOrder, + onSort, +}: { + label: string; + column: string; + sortBy: string; + sortOrder: "ASC" | "DESC"; + onSort: (column: string) => void; +}) { + const active = sortBy === column; + return ( + + + + ); +} + export default function AssetsPage() { const router = useRouter(); const searchParams = useSearchParams(); const [showModal, setShowModal] = useState(false); - const [search, setSearch] = useState(""); - const [status, setStatus] = useState(""); - const [page, setPage] = useState(1); + // Filters, sort and page live in the URL so a view survives a refresh and + // can be shared. + const [search, setSearch] = useState(searchParams.get("q") || ""); + const [status, setStatus] = useState(searchParams.get("status") || ""); + const [page, setPage] = useState(Number(searchParams.get("page")) || 1); const [locationId, setLocationId] = useState(searchParams.get("locationId") || ""); + const [sortBy, setSortBy] = useState(searchParams.get("sortBy") || DEFAULT_SORT); + const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">( + searchParams.get("order") === "ASC" ? "ASC" : "DESC", + ); + + useEffect(() => { + const params = new URLSearchParams(); + if (search) params.set("q", search); + if (status) params.set("status", status); + if (locationId) params.set("locationId", locationId); + if (page > 1) params.set("page", String(page)); + if (sortBy !== DEFAULT_SORT || sortOrder !== "DESC") { + params.set("sortBy", sortBy); + params.set("order", sortOrder); + } + const qs = params.toString(); + router.replace(qs ? `/assets?${qs}` : "/assets", { scroll: false }); + }, [search, status, locationId, page, sortBy, sortOrder, router]); + + const toggleSort = (column: string) => { + if (sortBy === column) { + setSortOrder((o) => (o === "ASC" ? "DESC" : "ASC")); + } else { + setSortBy(column); + setSortOrder("ASC"); + } + setPage(1); + }; const { data: locations = [] } = useLocations(); const activeLocation = locations.find((l) => l.id === locationId); @@ -31,6 +103,8 @@ export default function AssetsPage() { locationId: locationId || undefined, page, limit: 20, + sortBy, + sortOrder, }); const assets = data?.data ?? []; @@ -106,6 +180,24 @@ export default function AssetsPage() { ))} + + @@ -135,18 +227,12 @@ export default function AssetsPage() { - - + + - + diff --git a/frontend/app/(dashboard)/users/page.tsx b/frontend/app/(dashboard)/users/page.tsx index 6be4cc8d..51e64b89 100644 --- a/frontend/app/(dashboard)/users/page.tsx +++ b/frontend/app/(dashboard)/users/page.tsx @@ -6,6 +6,8 @@ import { format } from 'date-fns'; import { clsx } from 'clsx'; import { useUsersList, useUpdateUserRole, useToggleUserActive, useInviteUser } from '@/lib/query/hooks/useUsers'; import { useAuthStore } from '@/store/auth.store'; +import { getUserActionErrorMessage } from '@/lib/api/users'; +import { toast } from '@/components/ui/toast'; import { AppUser, UserRole } from '@/lib/api/users'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -47,7 +49,10 @@ export default function UsersPage() { const handleRoleChange = (user: AppUser, newRole: UserRole) => { if (newRole === user.role) return; - updateRole.mutate({ id: user.id, role: newRole }); + updateRole.mutate( + { id: user.id, role: newRole }, + { onError: (err) => toast.error(getUserActionErrorMessage(err, 'update')) }, + ); }; return ( @@ -267,10 +272,14 @@ export default function UsersPage() { open={!!deactivateTarget} onConfirm={async () => { if (deactivateTarget) { - await toggleActive.mutateAsync({ - id: deactivateTarget.id, - isActive: deactivateTarget.isActive === false, - }); + try { + await toggleActive.mutateAsync({ + id: deactivateTarget.id, + isActive: deactivateTarget.isActive === false, + }); + } catch (err) { + toast.error(getUserActionErrorMessage(err, 'update')); + } } setDeactivateTarget(null); }} @@ -335,7 +344,7 @@ function InviteUserModal({ onClose }: { onClose: () => void }) { await inviteUser.mutateAsync(form); onClose(); } catch (err: unknown) { - setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to invite user"); + setError(getUserActionErrorMessage(err, 'invite')); } }; diff --git a/frontend/app/scan/[assetId]/page.tsx b/frontend/app/scan/[assetId]/page.tsx index c840d0c5..260388b3 100644 --- a/frontend/app/scan/[assetId]/page.tsx +++ b/frontend/app/scan/[assetId]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { env } from "@/lib/env"; import { useParams, useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import { Package, AlertTriangle, LogIn, WifiOff } from "lucide-react"; @@ -18,7 +19,7 @@ interface PublicAsset { contactEmail?: string; } -const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:6003/api"; +const API_BASE = env.NEXT_PUBLIC_API_URL; export default function ScanPage() { const params = useParams(); diff --git a/frontend/components/departments/department-drawer.tsx b/frontend/components/departments/department-drawer.tsx index 601dd58d..6a8f9c92 100644 --- a/frontend/components/departments/department-drawer.tsx +++ b/frontend/components/departments/department-drawer.tsx @@ -1,9 +1,19 @@ 'use client'; -import { Department, useDepartmentAssets, useDepartmentUsers } from '@/lib/query/hooks/useDepartments'; +import { ReactNode, useState } from 'react'; +import Link from 'next/link'; +import { + Department, + DepartmentAsset, + DepartmentUser, + Paginated, + useDepartmentAssets, + useDepartmentUsers, +} from '@/lib/query/hooks/useDepartments'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; interface DepartmentDrawerProps { isOpen: boolean; @@ -11,10 +21,147 @@ interface DepartmentDrawerProps { department: Department | null; } -export function DepartmentDrawer({ isOpen, onClose, department }: DepartmentDrawerProps) { - const { data: users, isLoading: usersLoading } = useDepartmentUsers(department?.id); - const { data: assets, isLoading: assetsLoading } = useDepartmentAssets(department?.id); +interface PagedQuery { + data?: Paginated; + isLoading: boolean; + isError: boolean; + refetch: () => unknown; +} + +interface PagedListProps { + label: string; + query: PagedQuery; + page: number; + onPageChange: (page: number) => void; + emptyText: string; + renderRow: (item: T) => ReactNode; +} + +function PagedList({ + label, + query, + page, + onPageChange, + emptyText, + renderRow, +}: PagedListProps) { + if (query.isLoading) { + return

Loading {label}...

; + } + + if (query.isError) { + return ( +
+

Could not load {label}.

+ +
+ ); + } + + const items = query.data?.items ?? []; + if (items.length === 0) { + return

{emptyText}

; + } + + const totalPages = query.data?.totalPages ?? 1; + return ( + <> + {items.map(renderRow)} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + ); +} +function DepartmentDrawerBody({ department }: { department: Department }) { + const [userPage, setUserPage] = useState(1); + const [assetPage, setAssetPage] = useState(1); + const usersQuery = useDepartmentUsers(department.id, userPage); + const assetsQuery = useDepartmentAssets(department.id, assetPage); + + const memberCount = usersQuery.data?.total ?? department.memberCount; + const assetCount = assetsQuery.data?.total ?? department.assetCount; + + return ( + + + Members ({memberCount}) + Assets ({assetCount}) + + + + + label="members" + query={usersQuery} + page={userPage} + onPageChange={setUserPage} + emptyText="No members in this department" + renderRow={(user) => ( + +
+

+ {user.firstName} {user.lastName} +

+

{user.email}

+
+ {user.role} + + )} + /> +
+ + + + label="assets" + query={assetsQuery} + page={assetPage} + onPageChange={setAssetPage} + emptyText="No assets in this department" + renderRow={(asset) => ( + +
+

{asset.name}

+

Tag: {asset.assetTag}

+
+ {asset.status} + + )} + /> +
+
+ ); +} + +export function DepartmentDrawer({ isOpen, onClose, department }: DepartmentDrawerProps) { if (!department) return null; return ( @@ -22,48 +169,14 @@ export function DepartmentDrawer({ isOpen, onClose, department }: DepartmentDraw {department.name} -

{department.description || 'No description provided'}

+

+ {department.description || 'No description provided'} +

- - - Members ({department.memberCount}) - Assets ({department.assetCount}) - - - - {usersLoading ? ( -

Loading members...

- ) : ( - users?.map((user: any) => ( -
-
-

{user.firstName} {user.lastName}

-

{user.email}

-
- {user.role} -
- )) - )} -
- - - {assetsLoading ? ( -

Loading assets...

- ) : ( - assets?.map((asset: any) => ( -
-
-

{asset.name}

-

Tag: {asset.assetTag}

-
- {asset.status} -
- )) - )} -
-
+ {/* Keyed so paging resets when another department is opened. */} +
); -} \ No newline at end of file +} diff --git a/frontend/hooks/useWebSocket.ts b/frontend/hooks/useWebSocket.ts index 54948f72..7c006639 100644 --- a/frontend/hooks/useWebSocket.ts +++ b/frontend/hooks/useWebSocket.ts @@ -1,5 +1,6 @@ 'use client'; +import { env } from '@/lib/env'; import { useState, useEffect, useCallback } from 'react'; import { io, Socket } from 'socket.io-client'; @@ -26,7 +27,7 @@ interface UseWebSocketOptions { /** Derive the WebSocket URL from the API base URL, falling back to port 6003 */ function getWsUrl(): string { - const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const apiBase = env.NEXT_PUBLIC_API_URL; try { const url = new URL(apiBase); return `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`; @@ -44,7 +45,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { if (!options.userId) return; const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null; - const newSocket = io(process.env.NEXT_PUBLIC_WS_URL || getWsUrl(), { + const newSocket = io(env.NEXT_PUBLIC_WS_URL || getWsUrl(), { transports: ['websocket'], auth: { token }, query: { userId: options.userId }, diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index f95e6a6e..5d4f33a1 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,6 +1,8 @@ import axios from 'axios'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; +import { env } from './env'; + +const API_BASE = env.NEXT_PUBLIC_API_URL; export const api = axios.create({ baseURL: API_BASE, diff --git a/frontend/lib/api/assets.ts b/frontend/lib/api/assets.ts index b8c384d6..341b7b89 100644 --- a/frontend/lib/api/assets.ts +++ b/frontend/lib/api/assets.ts @@ -33,6 +33,8 @@ export interface AssetListFilters { locationId?: string; page?: number; limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; } export interface AssetListResponse { diff --git a/frontend/lib/api/users.ts b/frontend/lib/api/users.ts index 646fbbe1..6ae05362 100644 --- a/frontend/lib/api/users.ts +++ b/frontend/lib/api/users.ts @@ -26,6 +26,42 @@ export interface InviteUserInput { role: UserRole; } +type ApiErrorLike = { + response?: { status?: number; data?: { message?: string | string[] } }; +}; + +/** + * Turns a failed invite / role / active-state request into a specific, + * user-facing message instead of a generic error. + */ +export function getUserActionErrorMessage( + err: unknown, + action: 'invite' | 'update' = 'update', +): string { + const response = (err as ApiErrorLike)?.response; + const status = response?.status; + const raw = response?.data?.message; + const serverMessage = Array.isArray(raw) ? raw.join(', ') : raw; + + switch (status) { + case 409: + return action === 'invite' + ? 'A user with this email has already been invited.' + : 'This change conflicts with the current state of the user.'; + case 403: + return serverMessage || 'Only administrators can do this.'; + case 404: + return 'User not found. They may have been removed.'; + case 400: + return serverMessage || 'The request was invalid. Check the details and try again.'; + default: + if (status && status >= 500) { + return 'The server had a problem. Please try again in a moment.'; + } + return serverMessage || 'Something went wrong. Please try again.'; + } +} + export const usersApiClient = { getUsers: (search?: string): Promise => api.get('/users', { params: search ? { search } : undefined }).then((r) => r.data), diff --git a/frontend/lib/env.ts b/frontend/lib/env.ts new file mode 100644 index 00000000..5074c3cd --- /dev/null +++ b/frontend/lib/env.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +/** + * Validated frontend environment. Import `env` instead of reading + * `process.env.NEXT_PUBLIC_*` directly so every module shares one set of + * defaults and a bad value fails fast with a readable message. + */ +const emptyToUndefined = (value: unknown) => + typeof value === "string" && value.trim() === "" ? undefined : value; + +export const envSchema = z.object({ + NEXT_PUBLIC_API_URL: z.preprocess( + emptyToUndefined, + z + .string() + .url("NEXT_PUBLIC_API_URL must be a full URL, e.g. http://localhost:6003/api") + .default("http://localhost:6003/api"), + ), + NEXT_PUBLIC_WS_URL: z.preprocess( + emptyToUndefined, + z + .string() + .url("NEXT_PUBLIC_WS_URL must be a full URL, e.g. ws://localhost:6003") + .optional(), + ), + NEXT_PUBLIC_STELLAR_NETWORK: z.preprocess( + emptyToUndefined, + z + .enum(["testnet", "mainnet"], { + message: "NEXT_PUBLIC_STELLAR_NETWORK must be 'testnet' or 'mainnet'", + }) + .default("testnet"), + ), +}); + +export type Env = z.infer; + +export function parseEnv(raw: Record): Env { + const result = envSchema.safeParse(raw); + if (!result.success) { + const problems = result.error.issues + .map((issue) => ` - ${issue.path.join(".") || "env"}: ${issue.message}`) + .join("\n"); + throw new Error(`Invalid frontend environment configuration:\n${problems}`); + } + return result.data; +} + +// Next.js only inlines literal `process.env.NEXT_PUBLIC_X` reads, so each +// variable is referenced explicitly here. +export const env: Env = parseEnv({ + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL, + NEXT_PUBLIC_STELLAR_NETWORK: process.env.NEXT_PUBLIC_STELLAR_NETWORK, +}); diff --git a/frontend/lib/query/hooks/useDepartments.ts b/frontend/lib/query/hooks/useDepartments.ts index f293776c..d2b73fb9 100644 --- a/frontend/lib/query/hooks/useDepartments.ts +++ b/frontend/lib/query/hooks/useDepartments.ts @@ -35,22 +35,52 @@ export function useDepartments() { }); } -export function useDepartmentUsers(departmentId?: string) { - return useQuery({ - queryKey: ['departments', departmentId, 'users'], +export interface Paginated { + items: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface DepartmentUser { + id: string; + firstName: string; + lastName: string; + email: string; + role: string; + isActive?: boolean; +} + +export interface DepartmentAsset { + id: string; + name: string; + assetTag: string; + status: string; +} + +export const DEPARTMENT_PAGE_SIZE = 10; + +export function useDepartmentUsers(departmentId?: string, page = 1) { + return useQuery>({ + queryKey: ['departments', departmentId, 'users', page], queryFn: async () => { - const response = await api.get(`/departments/${departmentId}/users`); + const response = await api.get(`/departments/${departmentId}/users`, { + params: { page, limit: DEPARTMENT_PAGE_SIZE }, + }); return response.data; }, enabled: !!departmentId, }); } -export function useDepartmentAssets(departmentId?: string) { - return useQuery({ - queryKey: ['departments', departmentId, 'assets'], +export function useDepartmentAssets(departmentId?: string, page = 1) { + return useQuery>({ + queryKey: ['departments', departmentId, 'assets', page], queryFn: async () => { - const response = await api.get(`/departments/${departmentId}/assets`); + const response = await api.get(`/departments/${departmentId}/assets`, { + params: { page, limit: DEPARTMENT_PAGE_SIZE }, + }); return response.data; }, enabled: !!departmentId, diff --git a/frontend/store/wallet.store.ts b/frontend/store/wallet.store.ts index 3c60a0c1..65643b6e 100644 --- a/frontend/store/wallet.store.ts +++ b/frontend/store/wallet.store.ts @@ -1,3 +1,4 @@ +import { env } from '@/lib/env'; import { create } from 'zustand'; export interface WalletState { @@ -46,7 +47,7 @@ export const useWalletStore = create((set, get) => ({ const address = await freighter.getAddress(); const network = await freighter.getNetwork(); - const appNetwork = process.env.NEXT_PUBLIC_STELLAR_NETWORK || 'testnet'; + const appNetwork = env.NEXT_PUBLIC_STELLAR_NETWORK; if (network !== appNetwork) { set({ error: `Wrong network. Expected ${appNetwork}, got ${network}. Please switch your Freighter wallet to ${appNetwork}.`,
- Asset ID - - Name - Category - Status - Condition