diff --git a/__tests__/permissions.test.ts b/__tests__/permissions.test.ts index 8b8eb29..c3e8d5c 100644 --- a/__tests__/permissions.test.ts +++ b/__tests__/permissions.test.ts @@ -4,6 +4,8 @@ import { ADMIN_ROLES, TREASURER_ROLES, ACTIVE_STATUSES, + PRIVILEGE_STATUSES, + isPrivilegeEligible, hasRole, type Role, } from '@/lib/permissions' @@ -50,6 +52,22 @@ describe('ACTIVE_STATUSES', () => { }) }) +describe('isPrivilegeEligible / PRIVILEGE_STATUSES', () => { + it('only current + late may exercise a role/permission', () => { + expect([...PRIVILEGE_STATUSES].sort()).toEqual(['current', 'late'].sort()) + expect(isPrivilegeEligible('current')).toBe(true) + expect(isPrivilegeEligible('late')).toBe(true) + }) + it('unverified (pending approval) is NOT privilege-eligible', () => { + expect(isPrivilegeEligible('unverified')).toBe(false) + }) + it('inactive / null / undefined are NOT privilege-eligible', () => { + expect(isPrivilegeEligible('inactive')).toBe(false) + expect(isPrivilegeEligible(null)).toBe(false) + expect(isPrivilegeEligible(undefined)).toBe(false) + }) +}) + describe('hasRole', () => { it('returns true when role is in the allowed list', () => { expect(hasRole('admin', ADMIN_ROLES)).toBe(true) diff --git a/lib/auth-actions.ts b/lib/auth-actions.ts index 445d947..1d18cc9 100644 --- a/lib/auth-actions.ts +++ b/lib/auth-actions.ts @@ -4,6 +4,7 @@ import { createClient } from '@/lib/supabase/server' import { createAdminClient } from '@/lib/supabase/admin' import { redirect } from 'next/navigation' import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security' +import { ACTIVE_STATUSES } from '@/lib/permissions' function generateInviteCode() { // Cryptographically random, and wider (8 chars) so the code space is not @@ -157,6 +158,19 @@ export async function joinSpace(formData: { ? invite.role : 'member' + // Single active membership per user (the product's locked single-space + // model; getAuthMember's .single() fails closed on 2+). Reject a second + // join rather than letting it self-DoS the user out of every action. + const { data: existingMembership } = await admin + .from('space_members') + .select('id') + .eq('user_id', user.id) + .in('status', ACTIVE_STATUSES as unknown as string[]) + .maybeSingle() + if (existingMembership) { + return { error: 'You are already a member of a space.' } + } + const { data: newMember, error: memberErr } = await admin .from('space_members') .insert({ diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts index 1e3185f..4710218 100644 --- a/lib/auth-helpers.ts +++ b/lib/auth-helpers.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Tables } from '@/types/database' import type { ZodSchema } from 'zod' -import { ACTIVE_STATUSES, hasRole, type Role } from '@/lib/permissions' +import { ACTIVE_STATUSES, hasRole, isPrivilegeEligible, type Role } from '@/lib/permissions' /** * Loose Supabase client type for shared helpers. @@ -20,7 +20,7 @@ export type ServerSupabase = SupabaseClient /** Projection of `space_members` that server actions consume. */ export type Member = Pick< Tables<'space_members'>, - 'id' | 'space_id' | 'user_id' | 'role' | 'display_name' | 'handle' + 'id' | 'space_id' | 'user_id' | 'role' | 'status' | 'display_name' | 'handle' > /** @@ -50,7 +50,7 @@ export async function getAuthMember(supabase: ServerSupabase): Promise { const r = await requireMember(supabase) if (!r.ok) return r + // require_approval gate: an 'unverified' member holds NO privileged + // capability until an admin approves them, even if they redeemed a + // role-bearing invite. Without this, redeeming an admin invite into a + // require_approval space granted instant admin. + if (!isPrivilegeEligible(r.member.status)) { + return { ok: false, error: 'Your membership is pending approval.' } + } if (!hasRole(r.member.role, allowed)) { return { ok: false, error: errorLabel } } diff --git a/lib/permissions.ts b/lib/permissions.ts index 91f0e41..32858a1 100644 --- a/lib/permissions.ts +++ b/lib/permissions.ts @@ -24,6 +24,20 @@ export const ALL_ROLES = ROLES /** Statuses a member may have and still be allowed to act in the app. */ export const ACTIVE_STATUSES = ['current', 'unverified', 'late'] as const satisfies readonly MemberStatus[] +/** + * Statuses that may exercise a ROLE or PERMISSION (privileged actions). + * Deliberately excludes 'unverified': a member of a `require_approval` space + * is 'unverified' until an admin approves them, and must hold NO privileged + * capability in that window even if they redeemed a role-bearing invite + * (otherwise `require_approval` is silently void). 'late' keeps its role + * (dues lapse is grace -> late, never an authz downgrade). + */ +export const PRIVILEGE_STATUSES = ['current', 'late'] as const satisfies readonly MemberStatus[] + +export function isPrivilegeEligible(status: MemberStatus | null | undefined): boolean { + return status === 'current' || status === 'late' +} + /** * Returns true if `role` is one of the `allowed` roles. * Use this everywhere instead of `role === 'admin' || role === 'board'` chains.