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

@@ -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 { 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.