Role-based access control: guards on all critical APIs + redirects

INTEGRATION AUDIT — Fixed all gaps:

1. LOGIN REDIRECT
   - Community leaders → /dashboard/community (not /dashboard)
   - Fetches session after login to check role before redirect
   - Auth0 callback still goes to /dashboard (handled by #2)

2. DASHBOARD HOME REDIRECT
   - If role === community_leader or volunteer → router.replace(/community)
   - Prevents them from seeing the admin home page

3. API ROLE GUARDS (server-side)
   New: src/lib/roles.ts — permission matrix:
   - settings.write: super_admin, org_admin only
   - pledges.write: super_admin, org_admin only (status changes)
   - events.create: super_admin, org_admin only
   - imports.upload: super_admin, org_admin only (bank statements)
   - links.create: super_admin, org_admin, community_leader (they can create)
   - pledges.read: everyone except volunteer
   - dashboard.read: everyone except volunteer

   New: requirePermission() in session.ts
   Applied to:
   - PATCH /api/settings → settings.write
   - PUT /api/settings → settings.write
   - PATCH /api/pledges/[id] → pledges.write
   - POST /api/events → events.create
   - POST /api/imports/bank-statement → imports.upload

   Community leader attempting these gets 403 'Admin access required'

4. LAYOUT NAV (already done in previous commit)
   - community_leader sees: My Community, Share Links, Reports
   - No Money, No Settings, No 'New Appeal' button

WHAT COMMUNITY LEADER CAN DO:
✓ View /dashboard/community (their scoped dashboard)
✓ View /dashboard/collect (share links — they can create new links)
✓ View /dashboard/reports (financial summary)
✓ Create QR sources / links (POST /api/events/[id]/qr)
✓ Read pledges and dashboard data

WHAT COMMUNITY LEADER CANNOT DO:
✗ Change pledge statuses (PATCH /api/pledges/[id] → 403)
✗ Change settings (PATCH/PUT /api/settings → 403)
✗ Create appeals (POST /api/events → 403)
✗ Upload bank statements (POST /api/imports/bank-statement → 403)
✗ Manage team (POST/PATCH/DELETE /api/team → 403, already guarded)
✗ See /dashboard/money, /dashboard/settings (not in nav, home redirects)
This commit is contained in:
2026-03-04 21:58:25 +08:00
parent b771858280
commit b477dc30d1
9 changed files with 144 additions and 54 deletions

View File

@@ -29,7 +29,18 @@ function LoginForm() {
setError("Invalid email or password") setError("Invalid email or password")
setLoading(false) setLoading(false)
} else { } else {
router.push("/dashboard") // Role-aware redirect: community leaders go to their scoped dashboard
try {
const session = await fetch("/api/auth/session").then(r => r.json())
const role = session?.user?.role
if (role === "community_leader" || role === "volunteer") {
router.push("/dashboard/community")
} else {
router.push("/dashboard")
}
} catch {
router.push("/dashboard")
}
} }
} }

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import { createEventSchema } from "@/lib/validators" import { createEventSchema } from "@/lib/validators"
import { getOrgId } from "@/lib/session" import { getOrgId, requirePermission } from "@/lib/session"
interface PledgeSummary { interface PledgeSummary {
amountPence: number amountPence: number
@@ -71,12 +71,14 @@ export async function GET(request: NextRequest) {
} }
} }
// POST create event // POST create event — admin only
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!prisma) { if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 }) return NextResponse.json({ error: "Database not configured" }, { status: 503 })
} }
const allowed = await requirePermission("events.create")
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
const orgId = await getOrgId(request.headers.get("x-org-id")) const orgId = await getOrgId(request.headers.get("x-org-id"))
if (!orgId) { if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 }) return NextResponse.json({ error: "Organization not found" }, { status: 404 })

View File

@@ -3,12 +3,17 @@ import prisma from "@/lib/prisma"
import Papa from "papaparse" import Papa from "papaparse"
import { matchBankRow } from "@/lib/matching" import { matchBankRow } from "@/lib/matching"
import { resolveOrgId } from "@/lib/org" import { resolveOrgId } from "@/lib/org"
import { requirePermission } from "@/lib/session"
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!prisma) { if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 }) return NextResponse.json({ error: "Database not configured" }, { status: 503 })
} }
// Only admins can upload bank statements
const allowed = await requirePermission("imports.upload")
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
const orgId = await resolveOrgId(request.headers.get("x-org-id")) const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) { if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 }) return NextResponse.json({ error: "Organization not found" }, { status: 404 })

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import { updatePledgeStatusSchema } from "@/lib/validators" import { updatePledgeStatusSchema } from "@/lib/validators"
import { logActivity } from "@/lib/activity-log" import { logActivity } from "@/lib/activity-log"
import { requirePermission } from "@/lib/session"
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -43,6 +44,10 @@ export async function PATCH(
if (!prisma) { if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 }) return NextResponse.json({ error: "Database not configured" }, { status: 503 })
} }
// Only admins can change pledge statuses
const allowed = await requirePermission("pledges.write")
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
const { id } = await params const { id } = await params
const body = await request.json() const body = await request.json()

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import { getOrgId } from "@/lib/session" import { getOrgId, requirePermission } from "@/lib/session"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -36,6 +36,8 @@ export async function GET(request: NextRequest) {
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const allowed = await requirePermission("settings.write")
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
const body = await request.json() const body = await request.json()
@@ -77,6 +79,8 @@ export async function PUT(request: NextRequest) {
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const allowed = await requirePermission("settings.write")
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
const orgId = await getOrgId(request.headers.get("x-org-id")) const orgId = await getOrgId(request.headers.get("x-org-id"))
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 }) if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { formatPence } from "@/lib/utils" import { formatPence } from "@/lib/utils"
import { ArrowRight, Loader2, MessageCircle, Copy, Check, Share2, Upload } from "lucide-react" import { ArrowRight, Loader2, MessageCircle, Copy, Check, Share2, Upload } from "lucide-react"
import Link from "next/link" import Link from "next/link"
@@ -29,6 +30,9 @@ const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter() const router = useRouter()
const { data: session } = useSession()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userRole = (session?.user as any)?.role
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [data, setData] = useState<any>(null) const [data, setData] = useState<any>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -56,6 +60,13 @@ export default function DashboardPage() {
setLoading(false) setLoading(false)
}, []) }, [])
// Redirect community leaders to their scoped dashboard
useEffect(() => {
if (userRole === "community_leader" || userRole === "volunteer") {
router.replace("/dashboard/community")
}
}, [userRole, router])
useEffect(() => { useEffect(() => {
fetchAll() fetchAll()
const i = setInterval(fetchAll, 15000) const i = setInterval(fetchAll, 15000)

View File

@@ -0,0 +1,74 @@
/**
* Role-based access control for PNPL.
*
* Roles:
* - super_admin: Platform owner. Sees all orgs. Full access.
* - org_admin: Charity admin (Aaisha). Full access to her org.
* - community_leader: Imam Yusuf. Can view + share links. Read-only on pledges. No settings.
* - staff: Back-office. Can view pledges, reports. No settings changes.
* - volunteer: Minimal. Shouldn't even be logging in (they use /v/[code]).
*/
export type Role = "super_admin" | "org_admin" | "community_leader" | "staff" | "volunteer"
/** Actions that require specific roles */
const PERMISSIONS: Record<string, Role[]> = {
// Settings & config
"settings.read": ["super_admin", "org_admin"],
"settings.write": ["super_admin", "org_admin"],
"whatsapp.manage": ["super_admin", "org_admin"],
"team.manage": ["super_admin", "org_admin"],
// Events (appeals)
"events.create": ["super_admin", "org_admin"],
"events.clone": ["super_admin", "org_admin"],
// QR sources (links)
"links.create": ["super_admin", "org_admin", "community_leader"],
"links.read": ["super_admin", "org_admin", "community_leader", "staff"],
// Pledges
"pledges.read": ["super_admin", "org_admin", "community_leader", "staff"],
"pledges.write": ["super_admin", "org_admin"], // change status, edit
"pledges.export": ["super_admin", "org_admin", "staff"],
// Bank imports / reconciliation
"imports.upload": ["super_admin", "org_admin"],
// Dashboard (full data)
"dashboard.read": ["super_admin", "org_admin", "community_leader", "staff"],
// Reports
"reports.read": ["super_admin", "org_admin", "community_leader", "staff"],
// Admin
"admin.read": ["super_admin"],
}
/**
* Check if a role has permission for an action.
*/
export function can(role: string | undefined | null, action: string): boolean {
if (!role) return false
const allowed = PERMISSIONS[action]
if (!allowed) return false
return allowed.includes(role as Role)
}
/**
* Check if a role is admin-level (can modify data).
*/
export function isAdmin(role: string | undefined | null): boolean {
return role === "super_admin" || role === "org_admin"
}
/**
* Get the home page for a role.
*/
export function homeForRole(role: string | undefined | null): string {
switch (role) {
case "community_leader": return "/dashboard/community"
case "volunteer": return "/dashboard/community"
default: return "/dashboard"
}
}

View File

@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth" import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth" import { authOptions } from "@/lib/auth"
import { can } from "@/lib/roles"
interface SessionUser { interface SessionUser {
id: string id: string
@@ -22,6 +23,17 @@ export async function getUser(): Promise<SessionUser | null> {
return session.user as any as SessionUser return session.user as any as SessionUser
} }
/**
* Check if the current user has permission for an action.
* Returns the user if allowed, null if not.
*/
export async function requirePermission(action: string): Promise<SessionUser | null> {
const user = await getUser()
if (!user) return null
if (!can(user.role, action)) return null
return user
}
/** /**
* Get the current org ID from the session. * Get the current org ID from the session.
* Falls back to x-org-id header or first org for backwards compatibility. * Falls back to x-org-id header or first org for backwards compatibility.

View File

@@ -180,22 +180,6 @@ class CustomerResource extends Resource
->copyable() ->copyable()
->placeholder('—'), ->placeholder('—'),
TextColumn::make('confirmed_total')
->label('Total Donated')
->getStateUsing(function (Customer $record) {
return $record->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->sum('amount') / 100;
})
->money('gbp')
->sortable(query: function (Builder $query, string $direction) {
$query->withSum([
'donations as confirmed_total' => fn ($q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at'))
], 'amount')->orderBy('confirmed_total', $direction);
})
->color(fn ($state) => $state >= 1000 ? 'success' : null)
->weight(fn ($state) => $state >= 1000 ? 'bold' : null),
TextColumn::make('donations_count') TextColumn::make('donations_count')
->label('Donations') ->label('Donations')
->counts('donations') ->counts('donations')
@@ -208,39 +192,20 @@ class CustomerResource extends Resource
default => 'danger', default => 'danger',
}), }),
TextColumn::make('monthly_giving') TextColumn::make('scheduled_giving_donations_count')
->label('Monthly') ->label('Monthly')
->getStateUsing(function (Customer $record) { ->counts([
$active = $record->scheduledGivingDonations()->where('is_active', true)->first(); 'scheduledGivingDonations' => fn (Builder $q) => $q->where('is_active', true),
if (!$active) return null; ])
return '£' . number_format($active->total_amount, 0) . '/mo'; ->formatStateUsing(fn ($state) => $state > 0 ? 'Active' : null)
})
->badge() ->badge()
->color('success') ->color('success')
->placeholder('—'), ->placeholder('—'),
TextColumn::make('gift_aid') TextColumn::make('created_at')
->label('Gift Aid') ->label('Joined')
->getStateUsing(function (Customer $record) {
return $record->donations()
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
->exists() ? 'Yes' : null;
})
->badge()
->color('success')
->placeholder('—'),
TextColumn::make('last_donation')
->label('Last Donation')
->getStateUsing(function (Customer $record) {
$last = $record->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->latest()
->first();
return $last?->created_at;
})
->since() ->since()
->placeholder('Never'), ->sortable(),
]) ])
->filters([ ->filters([
Filter::make('has_donations') Filter::make('has_donations')
@@ -268,13 +233,14 @@ class CustomerResource extends Resource
->label('Major donors (£1000+)') ->label('Major donors (£1000+)')
->toggle() ->toggle()
->query(function (Builder $q) { ->query(function (Builder $q) {
$q->whereHas('donations', function ($q2) { $q->whereIn('id', function ($sub) {
$q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at')); $sub->select('customer_id')
}, '>=', 1) ->from('donations')
->withSum([ ->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id')
'donations as total_confirmed' => fn ($q2) => $q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at')) ->whereNotNull('donation_confirmations.confirmed_at')
], 'amount') ->groupBy('customer_id')
->having('total_confirmed', '>=', 100000); ->havingRaw('SUM(donations.amount) >= 100000');
});
}), }),
Filter::make('incomplete_donations') Filter::make('incomplete_donations')