Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 15 additions & 30 deletions app/dashboard/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin text-muted-foreground" />
<h2 className="mb-2 text-xl font-semibold">Loading...</h2>
<p className="text-muted-foreground">Please wait while we verify your access.</p>
</div>
</div>
)
}

if (authUser.role !== "admin") {
return null
}

return <AdminShell userName={authUser.name || "Admin"}>{children}</AdminShell>
function AdminLayoutContent({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
return <AdminShell userName={user?.name || "Admin"}>{children}</AdminShell>
}

export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<DashboardGuard
allow="admin"
loadingTitle="Loading admin dashboard"
loadingDescription="Please wait while we verify your access."
>
<AdminLayoutContent>{children}</AdminLayoutContent>
</DashboardGuard>
)
}
12 changes: 4 additions & 8 deletions app/dashboard/driver/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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")
}

Expand Down
56 changes: 21 additions & 35 deletions app/dashboard/investor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -283,24 +283,10 @@ export default function InvestorOverviewPage() {

if (authLoading) {
return <DashboardRouteLoading title="Loading investor overview" description="Preparing wallet and opportunity data." />
}

if (!authUser || authUser.role !== "investor") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Access denied</CardTitle>
<CardDescription>You need an investor account to access this dashboard.</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => router.push("/signin")} className="w-full">
Go to Sign in
</Button>
</CardContent>
</Card>
</div>
)
}

if (!authUser || authUser.role !== "investor") {
return <DashboardUnauthorized requiredRoles={["investor"]} currentRole={authUser?.role} />
}

return (
Expand Down
118 changes: 118 additions & 0 deletions components/dashboard/__tests__/dashboard-guard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DashboardGuard allow="admin" loadingTitle="Loading admin dashboard">
<p>secret</p>
</DashboardGuard>,
)

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(
<DashboardGuard allow="admin">
<p>secret</p>
</DashboardGuard>,
)

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(
<DashboardGuard allow="admin">
<p>secret</p>
</DashboardGuard>,
)

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(
<DashboardGuard allow={["admin", "investor"]}>
<p>secret</p>
</DashboardGuard>,
)

expect(screen.getByText("secret")).toBeInTheDocument()
})
})
57 changes: 57 additions & 0 deletions components/dashboard/dashboard-guard.tsx
Original file line number Diff line number Diff line change
@@ -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 <DashboardRouteLoading title={loadingTitle} description={loadingDescription} />
}

if (!isAuthorized) {
return <DashboardUnauthorized requiredRoles={allowedRoles} currentRole={user.role} />
}

return <>{children}</>
}
47 changes: 47 additions & 0 deletions components/dashboard/dashboard-quick-actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn("flex items-center gap-2", className)}>
{visibleActions.map((action) => (
<Button
key={action.href}
asChild
size="sm"
variant={action.primary ? "default" : "outline"}
className="h-9"
>
<Link href={action.href}>
<action.icon className="mr-2 h-4 w-4" />
<span className="truncate">{action.label}</span>
</Link>
</Button>
))}
</div>
)
}
Loading
Loading