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

@@ -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')