diff --git a/app/dashboard/admin/layout.tsx b/app/dashboard/admin/layout.tsx index f0d17f5..a181e2e 100644 --- a/app/dashboard/admin/layout.tsx +++ b/app/dashboard/admin/layout.tsx @@ -1,39 +1,24 @@ "use client" import type React from "react" -import { useEffect } from "react" -import { Loader2 } from "lucide-react" -import { useRouter } from "next/navigation" import { AdminShell } from "@/components/dashboard/admin/admin-shell" +import { DashboardGuard } from "@/components/dashboard/dashboard-guard" import { useAuth } from "@/hooks/use-auth" -export default function AdminLayout({ children }: { children: React.ReactNode }) { - const { user: authUser, loading: authLoading } = useAuth() - const router = useRouter() - - useEffect(() => { - if (!authLoading && (!authUser || authUser.role !== "admin")) { - router.replace("/signin") - } - }, [authLoading, authUser, router]) - - if (authLoading || !authUser) { - return ( -
-
- -

Loading...

-

Please wait while we verify your access.

-
-
- ) - } - - if (authUser.role !== "admin") { - return null - } - - return {children} +function AdminLayoutContent({ children }: { children: React.ReactNode }) { + const { user } = useAuth() + return {children} } +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/app/dashboard/driver/page.tsx b/app/dashboard/driver/page.tsx index 6a9ef29..305d380 100644 --- a/app/dashboard/driver/page.tsx +++ b/app/dashboard/driver/page.tsx @@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { formatNaira } from "@/lib/currency" import dbConnect from "@/lib/dbConnect" -import { getSessionFromCookies } from "@/lib/auth/session" +import { requireDashboardSession } from "@/lib/auth/dashboard-guard" import { getDriverContract, getDriverPayments } from "@/lib/services/driver-contracts.service" import User from "@/models/User" @@ -36,16 +36,12 @@ function formatDateLabel(value: string | null) { } export default async function DriverDashboardPage() { - const session = await getSessionFromCookies() - if (!session?.userId) { - redirect("/signin") - } + const session = await requireDashboardSession("driver") await dbConnect() - const user = await User.findById(session.userId) - .select("name fullName email role") + const user = await User.findById(session.userId).select("name fullName email role") - if (!user || user.role !== "driver") { + if (!user) { redirect("/signin") } diff --git a/app/dashboard/investor/page.tsx b/app/dashboard/investor/page.tsx index ad6ed55..9d129f2 100644 --- a/app/dashboard/investor/page.tsx +++ b/app/dashboard/investor/page.tsx @@ -13,8 +13,8 @@ import { PortfolioActivityCard } from "@/components/dashboard/investor-overview/ import { InvestorStellarActivityPanel } from "@/components/dashboard/investor-overview/stellar-activity-panel" import { WalletsCard } from "@/components/dashboard/investor-overview/wallets-card" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" +import { DashboardUnauthorized } from "@/components/dashboard/dashboard-unauthorized" import { getUserDisplayName, useAuth } from "@/hooks/use-auth" import { useToast } from "@/hooks/use-toast" import { getPrivyFundingErrorMessage, startPrivyFunding } from "@/lib/auth/privy-funding" @@ -119,22 +119,22 @@ export default function InvestorOverviewPage() { const investorName = getUserDisplayName(authUser, "Investor") - const loadOpenPools = useCallback(async () => { - setIsPoolsLoading(true) - try { - const response = await fetch("/api/pools?status=OPEN") - const payload = await response.json() - if (!response.ok) { - throw new Error(payload.message || "Unable to load opportunities.") - } - setOpenPools((payload.pools || []).slice(0, 4)) - } catch (error) { - toast({ - title: "Unable to load opportunities", - description: error instanceof Error ? error.message : "Try again in a moment.", - variant: "destructive", - }) - } finally { + const loadOpenPools = useCallback(async () => { + setIsPoolsLoading(true) + try { + const response = await fetch("/api/pools?status=OPEN") + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.message || "Unable to load opportunities.") + } + setOpenPools((payload.pools || []).slice(0, 4)) + } catch (error) { + toast({ + title: "Unable to load opportunities", + description: error instanceof Error ? error.message : "Try again in a moment.", + variant: "destructive", + }) + } finally { setIsPoolsLoading(false) } }, [toast]) @@ -283,24 +283,10 @@ export default function InvestorOverviewPage() { if (authLoading) { return - } - - if (!authUser || authUser.role !== "investor") { - return ( -
- - - Access denied - You need an investor account to access this dashboard. - - - - - -
- ) + } + + if (!authUser || authUser.role !== "investor") { + return } return ( diff --git a/components/dashboard/__tests__/dashboard-guard.test.tsx b/components/dashboard/__tests__/dashboard-guard.test.tsx new file mode 100644 index 0000000..d3473d5 --- /dev/null +++ b/components/dashboard/__tests__/dashboard-guard.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const { mockUseAuth, mockReplace } = vi.hoisted(() => ({ + mockUseAuth: vi.fn(), + mockReplace: vi.fn(), +})) + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: mockUseAuth, +})) + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace, push: vi.fn(), back: vi.fn() }), +})) + +import { DashboardGuard } from "@/components/dashboard/dashboard-guard" +import { + getDashboardHomePath, + getDashboardRoleConfig, + isDashboardRole, + resolveAllowedRoles, +} from "@/lib/dashboard/roles" + +// --------------------------------------------------------------------------- +// Role config helpers +// --------------------------------------------------------------------------- + +describe("dashboard role config", () => { + it("recognizes known roles and rejects unknown ones", () => { + expect(isDashboardRole("admin")).toBe(true) + expect(isDashboardRole("driver")).toBe(true) + expect(isDashboardRole("superuser")).toBe(false) + expect(isDashboardRole(undefined)).toBe(false) + }) + + it("exposes a home path and quick actions per role", () => { + expect(getDashboardRoleConfig("investor")?.homePath).toBe("/dashboard/investor") + expect(getDashboardRoleConfig("driver")?.quickActions.length).toBeGreaterThan(0) + expect(getDashboardRoleConfig("nope")).toBeNull() + }) + + it("falls back to the dashboard root for unknown roles", () => { + expect(getDashboardHomePath("admin")).toBe("/dashboard/admin") + expect(getDashboardHomePath("ghost")).toBe("/dashboard") + }) + + it("normalizes a single role or a list into an array", () => { + expect(resolveAllowedRoles("admin")).toEqual(["admin"]) + expect(resolveAllowedRoles(["admin", "driver"])).toEqual(["admin", "driver"]) + }) +}) + +// --------------------------------------------------------------------------- +// DashboardGuard +// --------------------------------------------------------------------------- + +describe("DashboardGuard", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("shows the loading state while auth is resolving", () => { + mockUseAuth.mockReturnValue({ user: null, loading: true }) + + render( + +

secret

+
, + ) + + expect(screen.getByText("Loading admin dashboard")).toBeInTheDocument() + expect(screen.queryByText("secret")).not.toBeInTheDocument() + }) + + it("redirects unauthenticated users to sign in", async () => { + mockUseAuth.mockReturnValue({ user: null, loading: false }) + + render( + +

secret

+
, + ) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/signin")) + expect(screen.queryByText("secret")).not.toBeInTheDocument() + }) + + it("renders an access-denied state for the wrong role", () => { + mockUseAuth.mockReturnValue({ user: { id: "1", role: "driver" }, loading: false }) + + render( + +

secret

+
, + ) + + expect(screen.getByText("Access denied")).toBeInTheDocument() + expect(screen.queryByText("secret")).not.toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + + it("renders children for an authorized role", () => { + mockUseAuth.mockReturnValue({ user: { id: "1", role: "admin" }, loading: false }) + + render( + +

secret

+
, + ) + + expect(screen.getByText("secret")).toBeInTheDocument() + }) +}) diff --git a/components/dashboard/dashboard-guard.tsx b/components/dashboard/dashboard-guard.tsx new file mode 100644 index 0000000..51a29b4 --- /dev/null +++ b/components/dashboard/dashboard-guard.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useEffect, type ReactNode } from "react" +import { useRouter } from "next/navigation" + +import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" +import { DashboardUnauthorized } from "@/components/dashboard/dashboard-unauthorized" +import { useAuth } from "@/hooks/use-auth" +import { resolveAllowedRoles, type DashboardRole } from "@/lib/dashboard/roles" + +interface DashboardGuardProps { + /** Role or roles permitted to view the guarded section. */ + allow: DashboardRole | DashboardRole[] + children: ReactNode + /** Where to send unauthenticated visitors. Defaults to the sign-in page. */ + redirectTo?: string + loadingTitle?: string + loadingDescription?: string +} + +/** + * Client-side route guard for role-protected dashboard sections. + * + * Edge middleware (`proxy.ts`) already enforces authentication for `/dashboard`, + * so this guard focuses on role authorization plus graceful loading and + * unauthorized states. Unauthenticated visitors are redirected to sign in; + * authenticated users with the wrong role get a recoverable access-denied view. + */ +export function DashboardGuard({ + allow, + children, + redirectTo = "/signin", + loadingTitle = "Loading dashboard", + loadingDescription = "Please wait while we verify your access.", +}: DashboardGuardProps) { + const { user, loading } = useAuth() + const router = useRouter() + + const allowedRoles = resolveAllowedRoles(allow) + const isAuthorized = Boolean(user?.role && allowedRoles.includes(user.role as DashboardRole)) + + useEffect(() => { + if (!loading && !user) { + router.replace(redirectTo) + } + }, [loading, user, router, redirectTo]) + + if (loading || !user) { + return + } + + if (!isAuthorized) { + return + } + + return <>{children} +} diff --git a/components/dashboard/dashboard-quick-actions.tsx b/components/dashboard/dashboard-quick-actions.tsx new file mode 100644 index 0000000..189f325 --- /dev/null +++ b/components/dashboard/dashboard-quick-actions.tsx @@ -0,0 +1,47 @@ +"use client" + +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { getDashboardRoleConfig, type DashboardQuickAction, type DashboardRole } from "@/lib/dashboard/roles" +import { cn } from "@/lib/utils" + +interface DashboardQuickActionsProps { + role: DashboardRole + /** Optional override; defaults to the role's configured quick actions. */ + actions?: DashboardQuickAction[] + /** Limit the number of actions rendered (useful in compact headers). */ + max?: number + className?: string +} + +/** + * Renders the per-role quick action slots configured in {@link getDashboardRoleConfig}. + * Used in dashboard headers so each role surfaces its most common task without + * the header needing to hard-code role-specific buttons. + */ +export function DashboardQuickActions({ role, actions, max, className }: DashboardQuickActionsProps) { + const resolvedActions = actions ?? getDashboardRoleConfig(role)?.quickActions ?? [] + const visibleActions = typeof max === "number" ? resolvedActions.slice(0, max) : resolvedActions + + if (visibleActions.length === 0) return null + + return ( +
+ {visibleActions.map((action) => ( + + ))} +
+ ) +} diff --git a/components/dashboard/dashboard-unauthorized.tsx b/components/dashboard/dashboard-unauthorized.tsx new file mode 100644 index 0000000..3a9be2b --- /dev/null +++ b/components/dashboard/dashboard-unauthorized.tsx @@ -0,0 +1,66 @@ +"use client" + +import Link from "next/link" +import { ShieldAlert } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { getDashboardRoleConfig, type DashboardRole } from "@/lib/dashboard/roles" +import { cn } from "@/lib/utils" + +interface DashboardUnauthorizedProps { + /** The roles that are allowed to view the section the user tried to reach. */ + requiredRoles?: DashboardRole[] + /** The role the current user actually has, used to offer a way back home. */ + currentRole?: string + title?: string + description?: string + className?: string +} + +function describeRequiredRoles(requiredRoles?: DashboardRole[]) { + if (!requiredRoles || requiredRoles.length === 0) return "the right" + const labels = requiredRoles.map((role) => getDashboardRoleConfig(role)?.label ?? role) + if (labels.length === 1) return `an ${labels[0]}` + return labels.join(" or ") +} + +/** + * Graceful "access denied" state shared across every protected dashboard + * section so unauthorized users get a consistent, recoverable experience. + */ +export function DashboardUnauthorized({ + requiredRoles, + currentRole, + title = "Access denied", + description, + className, +}: DashboardUnauthorizedProps) { + const homeConfig = getDashboardRoleConfig(currentRole) + const resolvedDescription = + description ?? `You need ${describeRequiredRoles(requiredRoles)} account to access this section.` + + return ( +
+ + + + + + {title} + {resolvedDescription} + + + {homeConfig ? ( + + ) : null} + + + +
+ ) +} diff --git a/components/dashboard/header.tsx b/components/dashboard/header.tsx index 1d16a53..eacb19f 100644 --- a/components/dashboard/header.tsx +++ b/components/dashboard/header.tsx @@ -4,6 +4,8 @@ import Link from "next/link" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { emitDashboardSidebarToggle } from "@/components/dashboard/sidebar-events" +import { DashboardQuickActions } from "@/components/dashboard/dashboard-quick-actions" +import { isDashboardRole, type DashboardRole } from "@/lib/dashboard/roles" import { ThemeToggle } from "@/components/theme-toggle" import { DropdownMenu, @@ -54,6 +56,14 @@ function inferRoleLabel(pathname: string, userStatus: string, role?: string) { return "User" } +function resolveDashboardRole(pathname: string, role?: string): DashboardRole | null { + if (isDashboardRole(role)) return role + if (pathname.includes("/dashboard/investor")) return "investor" + if (pathname.includes("/dashboard/driver")) return "driver" + if (pathname.includes("/dashboard/admin")) return "admin" + return null +} + export function Header({ userStatus = "Active", notificationCount = 0, @@ -75,6 +85,7 @@ export function Header({ const normalizedStatus = effectiveUserStatus.toLowerCase() const isVerified = STATUS_VARIANTS.has(normalizedStatus) + const dashboardRole = resolveDashboardRole(pathname, authUser?.role) const toggleTheme = () => setTheme(theme === "light" ? "dark" : "light") return ( @@ -116,6 +127,10 @@ export function Header({
+ {dashboardRole ? ( + + ) : null} +
{ + const session = await getSessionFromCookies() + if (!session?.userId) { + redirect("/signin") + } + + if (allow) { + const allowedRoles = Array.isArray(allow) ? allow : [allow] + if (!isDashboardRole(session.role) || !allowedRoles.includes(session.role)) { + redirect(getDashboardHomePath(session.role)) + } + } + + return session +} diff --git a/lib/dashboard/roles.ts b/lib/dashboard/roles.ts new file mode 100644 index 0000000..65432e8 --- /dev/null +++ b/lib/dashboard/roles.ts @@ -0,0 +1,83 @@ +import type { ComponentType } from "react" +import { Compass, FileText, PlusCircle, Receipt, ShieldCheck, Wallet } from "lucide-react" + +type DashboardIcon = ComponentType<{ className?: string }> + +/** + * Central source of truth for the role-aware dashboard shell. + * + * Sidebar navigation, header role badges, quick actions and route guards all + * derive their behaviour from this module so new dashboard modules can be added + * for a role without duplicating layout or authorization logic. + */ +export type DashboardRole = "driver" | "investor" | "admin" + +export const DASHBOARD_ROLES: DashboardRole[] = ["driver", "investor", "admin"] + +export interface DashboardQuickAction { + label: string + href: string + icon: DashboardIcon + /** Highlights the primary call to action for the role. */ + primary?: boolean +} + +export interface DashboardRoleConfig { + role: DashboardRole + /** Short label used in badges and headings. */ + label: string + /** Landing route a member of this role is sent to. */ + homePath: string + /** Quick action slots rendered in the dashboard header for the role. */ + quickActions: DashboardQuickAction[] +} + +export const DASHBOARD_ROLE_CONFIG: Record = { + investor: { + role: "investor", + label: "Investor", + homePath: "/dashboard/investor", + quickActions: [ + { label: "Fund Wallet", href: "/dashboard/investor/wallet", icon: PlusCircle, primary: true }, + { label: "Explore Opportunities", href: "/dashboard/investor/opportunities", icon: Compass }, + ], + }, + driver: { + role: "driver", + label: "Driver", + homePath: "/dashboard/driver", + quickActions: [ + { label: "Make Payment", href: "/dashboard/driver/repayment", icon: Wallet, primary: true }, + { label: "Payment History", href: "/dashboard/driver/payments", icon: Receipt }, + ], + }, + admin: { + role: "admin", + label: "Admin", + homePath: "/dashboard/admin", + quickActions: [ + { label: "Review KYC", href: "/dashboard/admin/kyc-management", icon: ShieldCheck, primary: true }, + { label: "Reports", href: "/dashboard/admin/reports", icon: FileText }, + ], + }, +} + +/** Type guard that narrows an arbitrary value to a known {@link DashboardRole}. */ +export function isDashboardRole(value: unknown): value is DashboardRole { + return typeof value === "string" && (DASHBOARD_ROLES as string[]).includes(value) +} + +/** Returns the config for a role, or `null` when the value is not a dashboard role. */ +export function getDashboardRoleConfig(value: unknown): DashboardRoleConfig | null { + return isDashboardRole(value) ? DASHBOARD_ROLE_CONFIG[value] : null +} + +/** Normalizes the list of roles allowed to view a section into an array. */ +export function resolveAllowedRoles(allow: DashboardRole | DashboardRole[]): DashboardRole[] { + return Array.isArray(allow) ? allow : [allow] +} + +/** Resolves the home path for a role, falling back to the generic dashboard root. */ +export function getDashboardHomePath(value: unknown): string { + return getDashboardRoleConfig(value)?.homePath ?? "/dashboard" +}