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:
@@ -29,7 +29,18 @@ function LoginForm() {
|
||||
setError("Invalid email or password")
|
||||
setLoading(false)
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { createEventSchema } from "@/lib/validators"
|
||||
import { getOrgId } from "@/lib/session"
|
||||
import { getOrgId, requirePermission } from "@/lib/session"
|
||||
|
||||
interface PledgeSummary {
|
||||
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) {
|
||||
try {
|
||||
if (!prisma) {
|
||||
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"))
|
||||
if (!orgId) {
|
||||
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
|
||||
|
||||
@@ -3,12 +3,17 @@ import prisma from "@/lib/prisma"
|
||||
import Papa from "papaparse"
|
||||
import { matchBankRow } from "@/lib/matching"
|
||||
import { resolveOrgId } from "@/lib/org"
|
||||
import { requirePermission } from "@/lib/session"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) {
|
||||
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"))
|
||||
if (!orgId) {
|
||||
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { updatePledgeStatusSchema } from "@/lib/validators"
|
||||
import { logActivity } from "@/lib/activity-log"
|
||||
import { requirePermission } from "@/lib/session"
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -43,6 +44,10 @@ export async function PATCH(
|
||||
if (!prisma) {
|
||||
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 body = await request.json()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { getOrgId } from "@/lib/session"
|
||||
import { getOrgId, requirePermission } from "@/lib/session"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -36,6 +36,8 @@ export async function GET(request: NextRequest) {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
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()
|
||||
|
||||
@@ -77,6 +79,8 @@ export async function PUT(request: NextRequest) {
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
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"))
|
||||
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import { ArrowRight, Loader2, MessageCircle, Copy, Check, Share2, Upload } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
@@ -29,6 +30,9 @@ const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }
|
||||
|
||||
export default function DashboardPage() {
|
||||
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
|
||||
const [data, setData] = useState<any>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -56,6 +60,13 @@ export default function DashboardPage() {
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Redirect community leaders to their scoped dashboard
|
||||
useEffect(() => {
|
||||
if (userRole === "community_leader" || userRole === "volunteer") {
|
||||
router.replace("/dashboard/community")
|
||||
}
|
||||
}, [userRole, router])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
const i = setInterval(fetchAll, 15000)
|
||||
|
||||
74
pledge-now-pay-later/src/lib/roles.ts
Normal file
74
pledge-now-pay-later/src/lib/roles.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { can } from "@/lib/roles"
|
||||
|
||||
interface SessionUser {
|
||||
id: string
|
||||
@@ -22,6 +23,17 @@ export async function getUser(): Promise<SessionUser | null> {
|
||||
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.
|
||||
* Falls back to x-org-id header or first org for backwards compatibility.
|
||||
|
||||
@@ -180,22 +180,6 @@ class CustomerResource extends Resource
|
||||
->copyable()
|
||||
->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')
|
||||
->label('Donations')
|
||||
->counts('donations')
|
||||
@@ -208,39 +192,20 @@ class CustomerResource extends Resource
|
||||
default => 'danger',
|
||||
}),
|
||||
|
||||
TextColumn::make('monthly_giving')
|
||||
TextColumn::make('scheduled_giving_donations_count')
|
||||
->label('Monthly')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
$active = $record->scheduledGivingDonations()->where('is_active', true)->first();
|
||||
if (!$active) return null;
|
||||
return '£' . number_format($active->total_amount, 0) . '/mo';
|
||||
})
|
||||
->counts([
|
||||
'scheduledGivingDonations' => fn (Builder $q) => $q->where('is_active', true),
|
||||
])
|
||||
->formatStateUsing(fn ($state) => $state > 0 ? 'Active' : null)
|
||||
->badge()
|
||||
->color('success')
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('gift_aid')
|
||||
->label('Gift Aid')
|
||||
->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;
|
||||
})
|
||||
TextColumn::make('created_at')
|
||||
->label('Joined')
|
||||
->since()
|
||||
->placeholder('Never'),
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Filter::make('has_donations')
|
||||
@@ -268,13 +233,14 @@ class CustomerResource extends Resource
|
||||
->label('Major donors (£1000+)')
|
||||
->toggle()
|
||||
->query(function (Builder $q) {
|
||||
$q->whereHas('donations', function ($q2) {
|
||||
$q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'));
|
||||
}, '>=', 1)
|
||||
->withSum([
|
||||
'donations as total_confirmed' => fn ($q2) => $q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
|
||||
], 'amount')
|
||||
->having('total_confirmed', '>=', 100000);
|
||||
$q->whereIn('id', function ($sub) {
|
||||
$sub->select('customer_id')
|
||||
->from('donations')
|
||||
->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id')
|
||||
->whereNotNull('donation_confirmations.confirmed_at')
|
||||
->groupBy('customer_id')
|
||||
->havingRaw('SUM(donations.amount) >= 100000');
|
||||
});
|
||||
}),
|
||||
|
||||
Filter::make('incomplete_donations')
|
||||
|
||||
Reference in New Issue
Block a user