diff --git a/pledge-now-pay-later/src/app/(auth)/login/page.tsx b/pledge-now-pay-later/src/app/(auth)/login/page.tsx index c93c674..a7a4877 100644 --- a/pledge-now-pay-later/src/app/(auth)/login/page.tsx +++ b/pledge-now-pay-later/src/app/(auth)/login/page.tsx @@ -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") + } } } diff --git a/pledge-now-pay-later/src/app/api/events/route.ts b/pledge-now-pay-later/src/app/api/events/route.ts index 31feaeb..68b876b 100644 --- a/pledge-now-pay-later/src/app/api/events/route.ts +++ b/pledge-now-pay-later/src/app/api/events/route.ts @@ -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 }) diff --git a/pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts b/pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts index 3c4b8c9..f1a66e3 100644 --- a/pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts +++ b/pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts @@ -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 }) diff --git a/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts index 0e5ded7..a9c307f 100644 --- a/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts +++ b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts @@ -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() diff --git a/pledge-now-pay-later/src/app/api/settings/route.ts b/pledge-now-pay-later/src/app/api/settings/route.ts index 73cbc59..4bda7d1 100644 --- a/pledge-now-pay-later/src/app/api/settings/route.ts +++ b/pledge-now-pay-later/src/app/api/settings/route.ts @@ -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 }) diff --git a/pledge-now-pay-later/src/app/dashboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/page.tsx index 4bc311d..2f5faa2 100644 --- a/pledge-now-pay-later/src/app/dashboard/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/page.tsx @@ -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(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) diff --git a/pledge-now-pay-later/src/lib/roles.ts b/pledge-now-pay-later/src/lib/roles.ts new file mode 100644 index 0000000..819ac0b --- /dev/null +++ b/pledge-now-pay-later/src/lib/roles.ts @@ -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 = { + // 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" + } +} diff --git a/pledge-now-pay-later/src/lib/session.ts b/pledge-now-pay-later/src/lib/session.ts index b1aa270..26bcc59 100644 --- a/pledge-now-pay-later/src/lib/session.ts +++ b/pledge-now-pay-later/src/lib/session.ts @@ -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 { 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 { + 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. diff --git a/temp_files/v3/CustomerResource.php b/temp_files/v3/CustomerResource.php index 12d950c..7ef659b 100644 --- a/temp_files/v3/CustomerResource.php +++ b/temp_files/v3/CustomerResource.php @@ -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')