diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index cae5efe..32ffd4a 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -3013,6 +3013,191 @@ describe('Social Architecture Security Rules', () => { })); }); + test('FP-15: Club Applications membership request approval and decline security matrix', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const unauth = testEnv.unauthenticatedContext().firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_apps_sec'), { + id: 'club_apps_sec', + name: 'Applications Security MC', + slug: 'applications-security-mc', + visibility: 'public', + admissionPolicy: 'request', + ownerUid: 'alice', + memberCount: 2, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + // Owner Alice + await setDoc(doc(db, 'clubs/club_apps_sec/members/alice'), { + uid: 'alice', + clubId: 'club_apps_sec', + role: 'owner', + status: 'active', + }); + // Active Admin Bob + await setDoc(doc(db, 'clubs/club_apps_sec/members/bob'), { + uid: 'bob', + clubId: 'club_apps_sec', + role: 'admin', + status: 'active', + }); + // Active Member Charlie with displayRank 'President' + await setDoc(doc(db, 'clubs/club_apps_sec/members/charlie'), { + uid: 'charlie', + clubId: 'club_apps_sec', + role: 'member', + status: 'active', + displayRank: 'President', + }); + // Applicant Dan (requested) + await setDoc(doc(db, 'clubs/club_apps_sec/members/dan'), { + uid: 'dan', + clubId: 'club_apps_sec', + role: 'member', + status: 'requested', + }); + // Applicant Frank (requested) + await setDoc(doc(db, 'clubs/club_apps_sec/members/frank'), { + uid: 'frank', + clubId: 'club_apps_sec', + role: 'member', + status: 'requested', + }); + // Applicant Gina (requested) + await setDoc(doc(db, 'clubs/club_apps_sec/members/gina'), { + uid: 'gina', + clubId: 'club_apps_sec', + role: 'member', + status: 'requested', + }); + // Applicant Harry (requested) + await setDoc(doc(db, 'clubs/club_apps_sec/members/harry'), { + uid: 'harry', + clubId: 'club_apps_sec', + role: 'member', + status: 'requested', + }); + // Member Jack (already active - stale request target) + await setDoc(doc(db, 'clubs/club_apps_sec/members/jack'), { + uid: 'jack', + clubId: 'club_apps_sec', + role: 'member', + status: 'active', + }); + }); + + // 1. Owner requested -> active with atomic memberCount increment -> ALLOW + const ownerApproveBatch = writeBatch(alice); + ownerApproveBatch.update(doc(alice, 'clubs/club_apps_sec/members/dan'), { + status: 'active', + role: 'member', + displayRank: 'Member', + updatedAt: new Date(), + }); + ownerApproveBatch.update(doc(alice, 'clubs/club_apps_sec'), { + memberCount: 3, + updatedAt: new Date(), + lastMembershipChangeUid: 'dan', + }); + await assertSucceeds(ownerApproveBatch.commit()); + + // 2. Admin requested -> active with atomic memberCount increment -> ALLOW + const adminApproveBatch = writeBatch(bob); + adminApproveBatch.update(doc(bob, 'clubs/club_apps_sec/members/frank'), { + status: 'active', + role: 'member', + displayRank: 'Member', + updatedAt: new Date(), + }); + adminApproveBatch.update(doc(bob, 'clubs/club_apps_sec'), { + memberCount: 4, + updatedAt: new Date(), + lastMembershipChangeUid: 'frank', + }); + await assertSucceeds(adminApproveBatch.commit()); + + // 3. Owner requested -> declined (memberCount unchanged) -> ALLOW + await assertSucceeds(updateDoc(doc(alice, 'clubs/club_apps_sec/members/gina'), { + status: 'declined', + updatedAt: new Date(), + })); + + // 4. Admin requested -> declined (memberCount unchanged) -> ALLOW + await assertSucceeds(updateDoc(doc(bob, 'clubs/club_apps_sec/members/harry'), { + status: 'declined', + updatedAt: new Date(), + })); + + // 5. Member Charlie (displayRank 'President') approve -> DENY + await testEnv.withSecurityRulesDisabled(async (context) => { + await setDoc(doc(context.firestore(), 'clubs/club_apps_sec/members/kevin'), { + uid: 'kevin', + clubId: 'club_apps_sec', + role: 'member', + status: 'requested', + }); + }); + const memberApproveBatch = writeBatch(charlie); + memberApproveBatch.update(doc(charlie, 'clubs/club_apps_sec/members/kevin'), { + status: 'active', + role: 'member', + updatedAt: new Date(), + }); + memberApproveBatch.update(doc(charlie, 'clubs/club_apps_sec'), { + memberCount: 5, + updatedAt: new Date(), + lastMembershipChangeUid: 'kevin', + }); + await assertFails(memberApproveBatch.commit()); + + // 6. Member Charlie decline -> DENY + await assertFails(updateDoc(doc(charlie, 'clubs/club_apps_sec/members/kevin'), { + status: 'declined', + updatedAt: new Date(), + })); + + // 7. Unauthenticated approve -> DENY + const unauthBatch = writeBatch(unauth); + unauthBatch.update(doc(unauth, 'clubs/club_apps_sec/members/kevin'), { + status: 'active', + role: 'member', + updatedAt: new Date(), + }); + unauthBatch.update(doc(unauth, 'clubs/club_apps_sec'), { + memberCount: 5, + updatedAt: new Date(), + lastMembershipChangeUid: 'kevin', + }); + await assertFails(unauthBatch.commit()); + + // 8. Admin approve without atomic memberCount increment -> DENY + await assertFails(updateDoc(doc(bob, 'clubs/club_apps_sec/members/kevin'), { + status: 'active', + role: 'member', + updatedAt: new Date(), + })); + + // 9. Admin approve stale (already active) target -> DENY + const staleBatch = writeBatch(bob); + staleBatch.update(doc(bob, 'clubs/club_apps_sec/members/jack'), { + status: 'active', + role: 'member', + updatedAt: new Date(), + }); + staleBatch.update(doc(bob, 'clubs/club_apps_sec'), { + memberCount: 5, + updatedAt: new Date(), + lastMembershipChangeUid: 'jack', + }); + await assertFails(staleBatch.commit()); + }); + describe('CLUB-2C.2: Membership Re-Entry Attack Matrix (Cases A - AF)', () => { async function setupClub(db, clubId, admissionPolicy, archived = false) { await setDoc(doc(db, `clubs/${clubId}`), { diff --git a/website/app/clubs/[clubId]/manage/applications/page.tsx b/website/app/clubs/[clubId]/manage/applications/page.tsx new file mode 100644 index 0000000..b8e67e7 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/applications/page.tsx @@ -0,0 +1,139 @@ +'use client'; + +import React, { useEffect, useState, use } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + fetchFounderClubContext, + FounderClubContext, + sanitizePortalError, +} from '../../../../../lib/club-data'; +import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell'; +import { FounderApplications } from '../../../../../components/founder-portal/founder-applications'; +import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state'; +import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state'; +import { firebaseAuth } from '../../../../../lib/firebase-client'; + +interface PageProps { + params: Promise<{ clubId: string }>; +} + +export default function ClubFounderApplicationsPage({ params }: PageProps) { + const { clubId } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [context, setContext] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [unauthenticated, setUnauthenticated] = useState(false); + const [unauthorized, setUnauthorized] = useState(false); + + async function loadData() { + setLoading(true); + setError(null); + setUnauthenticated(false); + setUnauthorized(false); + + try { + await firebaseAuth.authStateReady(); + const currentUser = firebaseAuth.currentUser; + const isDemoPreview = searchParams?.get('preview') === 'demo'; + + // Unauthenticated Guard: Direct access without demo preview requires authentication + if (!isDemoPreview && !currentUser) { + setUnauthenticated(true); + setLoading(false); + return; + } + + const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, { + allowDemoPreview: isDemoPreview, + }); + + // Authorization Guard: Owner and Admin can access Applications; Members cannot + if (!isDemoPreview && (!ctx.permissions.canApproveApplications && !ctx.permissions.canManageSettings)) { + setUnauthorized(true); + setLoading(false); + return; + } + + setContext(ctx); + } catch (err) { + setError(sanitizePortalError(err, "Club applications couldn't be loaded.")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, [clubId]); + + if (loading) { + return ( +
+ +
+ ); + } + + // Unauthenticated safe error state + if (unauthenticated) { + return ( +
+ router.push('/login')} + /> +
+ ); + } + + // Unauthorized (Member or non-admin) safe error state + if (unauthorized) { + return ( +
+ router.push('/clubs')} + /> +
+ ); + } + + // Error or missing context + if (error || !context) { + return ( +
+ router.push('/clubs')} + onRetry={loadData} + /> +
+ ); + } + + return ( + + + + ); +} diff --git a/website/components/founder-portal/founder-applications.tsx b/website/components/founder-portal/founder-applications.tsx new file mode 100644 index 0000000..8c2160b --- /dev/null +++ b/website/components/founder-portal/founder-applications.tsx @@ -0,0 +1,675 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { Club, UserPortalPermissions } from './types'; +import { + ClubApplication, + fetchClubApplications, + approveClubApplication, + declineClubApplication, + sanitizePortalError, +} from '../../lib/club-data'; + +interface FounderApplicationsProps { + club: Club; + permissions: UserPortalPermissions; + callerUid: string; + isDemoFixture?: boolean; +} + +export function FounderApplications({ + club, + permissions, + callerUid, + isDemoFixture = false, +}: FounderApplicationsProps) { + const [applications, setApplications] = useState([]); + const [selectedApplicantUid, setSelectedApplicantUid] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'name'>('newest'); + + // Mutation and modal states + const [mutatingUid, setMutatingUid] = useState(null); + const [actionError, setActionError] = useState(null); + const [approveModalApplicant, setApproveModalApplicant] = useState(null); + const [declineModalApplicant, setDeclineModalApplicant] = useState(null); + + // Local member count to reflect approval transitions immediately + const [localMemberCount, setLocalMemberCount] = useState(club.memberCount); + + async function loadApplications() { + setLoading(true); + setError(null); + try { + const data = await fetchClubApplications(club.id, callerUid, { + allowDemoPreview: isDemoFixture, + }); + setApplications(data); + if (data.length > 0) { + setSelectedApplicantUid((prev) => prev ?? data[0].uid); + } + } catch (err) { + setError(sanitizePortalError(err, "Applications couldn't be loaded.")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadApplications(); + }, [club.id, callerUid, isDemoFixture]); + + // Filtered and sorted applications + const filteredApplications = useMemo(() => { + let list = [...applications]; + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase().trim(); + list = list.filter((app) => { + const nameMatch = app.displayName.toLowerCase().includes(q); + const handleMatch = app.handle ? app.handle.toLowerCase().includes(q) : false; + return nameMatch || handleMatch; + }); + } + + list.sort((a, b) => { + if (sortBy === 'newest') { + return new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime(); + } + if (sortBy === 'oldest') { + return new Date(a.requestedAt).getTime() - new Date(b.requestedAt).getTime(); + } + return a.displayName.localeCompare(b.displayName); + }); + + return list; + }, [applications, searchQuery, sortBy]); + + const selectedApplicant = useMemo(() => { + if (!selectedApplicantUid) return filteredApplications[0] || null; + return applications.find((a) => a.uid === selectedApplicantUid) || filteredApplications[0] || null; + }, [applications, filteredApplications, selectedApplicantUid]); + + function getMonogram(name: string) { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return 'CA'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + + function formatDate(isoString: string) { + try { + const date = new Date(isoString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } catch { + return isoString; + } + } + + async function handleApprove(applicant: ClubApplication) { + if (mutatingUid) return; + setMutatingUid(applicant.uid); + setActionError(null); + + try { + if (!isDemoFixture) { + await approveClubApplication(club.id, callerUid, applicant.uid); + } + // Remove from list and increment member count + setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); + setLocalMemberCount((c) => c + 1); + setApproveModalApplicant(null); + + // Select next applicant if the approved one was selected + if (selectedApplicantUid === applicant.uid) { + const remaining = applications.filter((a) => a.uid !== applicant.uid); + setSelectedApplicantUid(remaining[0]?.uid || null); + } + } catch (err) { + setActionError(sanitizePortalError(err, 'Unable to approve application. Please try again.')); + } finally { + setMutatingUid(null); + } + } + + async function handleDecline(applicant: ClubApplication) { + if (mutatingUid) return; + setMutatingUid(applicant.uid); + setActionError(null); + + try { + if (!isDemoFixture) { + await declineClubApplication(club.id, callerUid, applicant.uid); + } + // Remove from list (non-destructive in Firestore, memberCount delta 0) + setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); + setDeclineModalApplicant(null); + + // Select next applicant if the declined one was selected + if (selectedApplicantUid === applicant.uid) { + const remaining = applications.filter((a) => a.uid !== applicant.uid); + setSelectedApplicantUid(remaining[0]?.uid || null); + } + } catch (err) { + setActionError(sanitizePortalError(err, 'Unable to decline application. Please try again.')); + } finally { + setMutatingUid(null); + } + } + + return ( +
+ {/* 1. PAGE HEADER */} +
+
+
+

APPLICATIONS

+ + {applications.length} PENDING + +
+

Review riders requesting to join this Club.

+
+
+ + {/* 2. SUMMARY METRIC CARDS ROW */} +
+ {/* Card 1: Pending */} +
+
+ Pending + +
+
+ {applications.length} +
+
Awaiting review
+
+ + {/* Card 2: Admission Policy */} +
+
+ Admission Policy + +
+
+ + {club.admissionPolicy} + +
+
Club join governance rule
+
+ + {/* Card 3: Active Members */} +
+
+ Active Members + +
+
+ {localMemberCount} +
+
Current club roster
+
+ + {/* Card 4: Reviewer Authority */} +
+
+ Review Authority + +
+
+ + {permissions.role === 'owner' ? 'OWNER ACCESS' : 'ADMIN ACCESS'} + +
+
Software administrative rights
+
+
+ + {/* Action Error Alert */} + {actionError && ( +
+ {actionError} + +
+ )} + + {/* 3. FILTER & SEARCH TOOLBAR */} +
+ {/* Filter Tab */} +
+ +
+ + {/* Controls Right */} +
+ {/* Text Search Bar */} +
+ setSearchQuery(e.target.value)} + className="w-full h-8 pl-8 pr-8 rounded-md bg-[#141923] border border-[#262D3D] text-xs text-white placeholder-slate-500 focus:outline-none focus:border-[#0088FF]" + aria-label="Search applicants" + /> + + + + {searchQuery && ( + + )} +
+ + {/* Sort By Dropdown */} +
+ SORT: + +
+
+
+ + {/* 4. MAIN CONTENT AREA */} + {loading ? ( +
+ + progress_activity + + Loading pending applications... +
+ ) : error ? ( +
+
+ error + ERROR +
+

{error}

+ +
+ ) : applications.length === 0 ? ( + /* Empty State 1: Default zero-pending queue */ +
+
+ person_add_disabled +
+
+

NO PENDING APPLICATIONS

+

+ There are no membership requests waiting for review. New requests will appear here when riders apply. +

+
+
+ ) : filteredApplications.length === 0 ? ( + /* Empty State 2: Filter zero-match */ +
+
+ search_off +
+
+

NO APPLICATIONS MATCH

+

+ No pending applications match "{searchQuery}". +

+ +
+
+ ) : ( + /* Two-Column Split View */ +
+ {/* Left Column: Dense Application Table */} +
+
+ + + + + + + + + + + {filteredApplications.map((app) => { + const isSelected = selectedApplicant?.uid === app.uid; + const isMutating = mutatingUid === app.uid; + + return ( + setSelectedApplicantUid(app.uid)} + className={`cursor-pointer transition-colors ${ + isSelected + ? 'bg-[#141923] border-l-2 border-[#0088FF]' + : 'hover:bg-[#141923]/50' + }`} + > + + + + + + ); + })} + +
RiderRequestedStatusActions
+
+
+ {getMonogram(app.displayName)} +
+
+
+ {app.displayName} + {isSelected && ( + + INSPECTING + + )} +
+ {app.handle && ( +
+ {app.handle} +
+ )} +
+
+
+ {formatDate(app.requestedAt)} + + + PENDING + + +
e.stopPropagation()} + > + + + +
+
+
+ + {/* Pagination / Count Footer */} +
+ + Showing {filteredApplications.length} of {applications.length} pending applications + +
+
+ + {/* Right Column: Selected Application Inspector Drawer */} + {selectedApplicant && ( +
+
+ {/* Top Cyan Accent Line */} +
+ +
+ {/* Header */} +
+
+
+ {getMonogram(selectedApplicant.displayName)} +
+
+

+ {selectedApplicant.displayName} +

+ {selectedApplicant.handle && ( +
+ {selectedApplicant.handle} +
+ )} +
+
+ +
+ + PENDING + + + JOIN REQUEST + +
+
+ +
+ + {/* Rider Spec Grid */} +
+
+ Requested + + {formatDate(selectedApplicant.requestedAt)} + +
+ +
+ Initial Club Rank + Member (Default) +
+ +
+ Administrative Role + Member (Non-admin) +
+
+ + {/* Mandatory Privacy Architecture Notice */} +
+
+ shield + MANDATORY PRIVACY ARCHITECTURE +
+

+ Club administrators do not receive rider telemetry, private routes, or live GPS upon application. Club membership alone never grants access to rider location. Location sharing is strictly scoped to mutually joined active Group Rides. +

+
+ + {/* Architecture Note */} +
+ ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant. +
+ + {/* Primary Actions */} +
+ + + +
+
+
+
+ )} +
+ )} + + {/* MODAL 1: APPROVE APPLICATION */} + {approveModalApplicant && ( +
+
+
+
+ + + APPROVAL CONFIRMATION + +
+
+ +

Approve Application?

+

+ Approve {approveModalApplicant.displayName}'s request to join {club.name}? On approval, this rider becomes an active Club member. +

+ +
+
+ Initial Club Rank: + Member (Default) +
+
+ Administrative Role: + Member (Default Member, non-admin) +
+
+ +
+ ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant. +
+ +
+ + +
+
+
+ )} + + {/* MODAL 2: DECLINE APPLICATION */} + {declineModalApplicant && ( +
+
+
+
+ + + DECLINE APPLICATION + +
+
+ +

Decline Application?

+

+ Decline {declineModalApplicant.displayName}'s request to join this Club? This removes the pending request. The rider's MotorSignal account remains unaffected. +

+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/website/components/founder-portal/founder-sidebar.tsx b/website/components/founder-portal/founder-sidebar.tsx index c96b983..94e4172 100644 --- a/website/components/founder-portal/founder-sidebar.tsx +++ b/website/components/founder-portal/founder-sidebar.tsx @@ -74,6 +74,7 @@ export function FounderSidebar({ function getRouteHref(id: string) { if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`; if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`; + if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`; if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`; return undefined; } @@ -88,7 +89,8 @@ export function FounderSidebar({ const href = getRouteHref(item.id); const isProfile = item.id === 'profile'; const isSettings = item.id === 'settings'; - const isSubRoute = isProfile || isSettings; + const isApplications = item.id === 'applications'; + const isSubRoute = isProfile || isSettings || isApplications; const content = ( <> diff --git a/website/components/founder-portal/founder-top-bar.tsx b/website/components/founder-portal/founder-top-bar.tsx index d2420cf..566fe17 100644 --- a/website/components/founder-portal/founder-top-bar.tsx +++ b/website/components/founder-portal/founder-top-bar.tsx @@ -61,6 +61,12 @@ export function FounderTopBar({ SETTINGS )} + {activeNav === 'applications' && ( + <> + / + APPLICATIONS + + )}
diff --git a/website/lib/club-data.ts b/website/lib/club-data.ts index a0e309d..d57e5e1 100644 --- a/website/lib/club-data.ts +++ b/website/lib/club-data.ts @@ -29,6 +29,19 @@ export interface ManageableClubSummary { logoUrl?: string | null; } +export interface ClubApplication { + uid: string; + clubId: string; + status: 'requested'; + role: ClubRole; + displayRank?: string | null; + requestedAt: string; + updatedAt: string; + displayName: string; + handle?: string | null; + avatarUrl?: string | null; +} + /** * Calculates software permissions derived solely from Administrative Role. * Club Display Rank does NOT grant software administrative access. @@ -902,4 +915,224 @@ export async function archiveClub( }); } +/** + * Fetches pending membership requests (status == 'requested') for a Club. + * Authoritative source: /clubs/{clubId}/members where status == 'requested'. + * Strictly bounded query (limit 25) with safe public profile resolution. + * Owner and Admin only. + */ +export async function fetchClubApplications( + clubId: string, + callerUid?: string, + options?: { allowDemoPreview?: boolean } +): Promise { + // Explicit opt-in development preview fixture ONLY + if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { + return FIXTURE_PENDING_APPLICATIONS.map((app) => ({ + uid: app.uid, + clubId, + status: 'requested' as const, + role: (app.role as ClubRole) || 'member', + displayRank: app.displayRank || null, + requestedAt: app.requestedAt || new Date().toISOString(), + updatedAt: app.updatedAt || new Date().toISOString(), + displayName: app.displayName || 'Club Applicant', + handle: app.handle || null, + avatarUrl: app.avatarUrl || null, + })); + } + + if (!callerUid) { + throw new Error('Authentication required.'); + } + + // Caller authorization check: must be active owner or admin + const callerMemberRef = doc(firestore, 'clubs', clubId, 'members', callerUid); + const callerSnap = await getDoc(callerMemberRef); + if (!callerSnap.exists()) { + throw new Error('Caller not a member of this club.'); + } + const callerData = callerSnap.data(); + if (callerData.status !== 'active') { + throw new Error('Caller is not an active member.'); + } + if (callerData.role !== 'owner' && callerData.role !== 'admin') { + throw new Error('Only club owners and administrators can review applications.'); + } + + const membersRef = collection(firestore, 'clubs', clubId, 'members'); + const q = query( + membersRef, + where('status', '==', 'requested'), + limit(25) + ); + + const snap = await getDocs(q); + const applications: ClubApplication[] = []; + + for (const d of snap.docs) { + const data = d.data(); + let displayName = 'Club Applicant'; + let handle: string | null = null; + let avatarUrl: string | null = null; + + // Safe point read to public_profiles/{uid} + try { + const profileSnap = await getDoc(doc(firestore, 'public_profiles', d.id)); + if (profileSnap.exists()) { + const pd = profileSnap.data(); + if (pd.displayName) displayName = pd.displayName; + if (pd.username) handle = `@${pd.username}`; + if (pd.profilePhotoUrl) avatarUrl = pd.profilePhotoUrl; + } + } catch { + // Conservative fallback - zero private leakage + } + + applications.push({ + uid: d.id, + clubId, + status: 'requested', + role: (data.role as ClubRole) || 'member', + displayRank: data.displayRank || null, + requestedAt: data.requestedAt?.toDate?.() ? data.requestedAt.toDate().toISOString() : (data.requestedAt || new Date().toISOString()), + updatedAt: data.updatedAt?.toDate?.() ? data.updatedAt.toDate().toISOString() : (data.updatedAt || new Date().toISOString()), + displayName, + handle, + avatarUrl, + }); + } + + // Sort newest first + applications.sort((a, b) => new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime()); + + return applications; +} + +/** + * Atomically approves a pending membership request. + * Transitions requested -> active, sets default role 'member' and displayRank 'Member', + * and increments club memberCount by 1 with lastMembershipChangeUid. + * Strictly verifies caller is active owner or admin. + */ +export async function approveClubApplication( + clubId: string, + callerUid: string, + targetUid: string +): Promise { + if (!callerUid) { + throw new Error('Authentication required.'); + } + if (!targetUid) { + throw new Error('Target applicant UID required.'); + } + + const clubRef = doc(firestore, 'clubs', clubId); + const callerRef = doc(firestore, 'clubs', clubId, 'members', callerUid); + const targetRef = doc(firestore, 'clubs', clubId, 'members', targetUid); + const banRef = doc(firestore, 'clubs', clubId, 'bans', targetUid); + + await runTransaction(firestore, async (tx) => { + const clubSnap = await tx.get(clubRef); + if (!clubSnap.exists()) { + throw new Error('Club not found.'); + } + const cd = clubSnap.data(); + if (cd.archivedAt) { + throw new Error('Archived clubs cannot admit new members.'); + } + + const callerSnap = await tx.get(callerRef); + if (!callerSnap.exists()) { + throw new Error('Caller not a member of this club.'); + } + const callerData = callerSnap.data(); + if (callerData.status !== 'active' || (callerData.role !== 'owner' && callerData.role !== 'admin')) { + throw new Error('Only club owners and administrators can approve applications.'); + } + + const banSnap = await tx.get(banRef); + if (banSnap.exists()) { + throw new Error('This application can no longer be approved.'); + } + + const targetSnap = await tx.get(targetRef); + if (!targetSnap.exists()) { + throw new Error('This application could not be found.'); + } + const targetData = targetSnap.data(); + if (targetData.status !== 'requested') { + throw new Error('This application is no longer pending.'); + } + + const now = serverTimestamp(); + + // 1. Transactionally update member to active with default role 'member' and default rank 'Member' + tx.update(targetRef, { + status: 'active', + role: 'member', + displayRank: 'Member', + joinedAt: now, + updatedAt: now, + }); + + // 2. Transactionally increment club memberCount and set lastMembershipChangeUid + tx.update(clubRef, { + memberCount: (cd.memberCount || 0) + 1, + updatedAt: now, + lastMembershipChangeUid: targetUid, + }); + }); +} + +/** + * Atomically declines a pending membership request. + * Transitions requested -> declined. Non-destructive: preserves history, does not delete doc. + * memberCount delta is 0. Rider account is unaffected. + * Strictly verifies caller is active owner or admin. + */ +export async function declineClubApplication( + clubId: string, + callerUid: string, + targetUid: string +): Promise { + if (!callerUid) { + throw new Error('Authentication required.'); + } + if (!targetUid) { + throw new Error('Target applicant UID required.'); + } + + const callerRef = doc(firestore, 'clubs', clubId, 'members', callerUid); + const targetRef = doc(firestore, 'clubs', clubId, 'members', targetUid); + + await runTransaction(firestore, async (tx) => { + const callerSnap = await tx.get(callerRef); + if (!callerSnap.exists()) { + throw new Error('Caller not a member of this club.'); + } + const callerData = callerSnap.data(); + if (callerData.status !== 'active' || (callerData.role !== 'owner' && callerData.role !== 'admin')) { + throw new Error('Only club owners and administrators can decline applications.'); + } + + const targetSnap = await tx.get(targetRef); + if (!targetSnap.exists()) { + throw new Error('This application could not be found.'); + } + const targetData = targetSnap.data(); + if (targetData.status !== 'requested') { + throw new Error('This application is no longer pending.'); + } + + const now = serverTimestamp(); + + // Non-destructive transition: requested -> declined. memberCount delta is 0. + tx.update(targetRef, { + status: 'declined', + updatedAt: now, + }); + }); +} + diff --git a/website/tests/founder-portal.test.cjs b/website/tests/founder-portal.test.cjs index 218d7dd..51b2c40 100644 --- a/website/tests/founder-portal.test.cjs +++ b/website/tests/founder-portal.test.cjs @@ -66,6 +66,9 @@ const { fetchEligibleTransferMembers, transferClubOwnership, archiveClub, + fetchClubApplications, + approveClubApplication, + declineClubApplication, } = clubData; const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures; @@ -716,7 +719,116 @@ async function runAllTests() { console.log(' PASS: FP-14 Tests A through X verified (Settings auth guard, Owner/Admin authorization, Member denial, visibility enum, admission enum, access mutation whitelist, immutable slug, rank limits, owner-only transfer, active target, atomic role transition, admin transfer restriction, owner-only archive, admin archive restriction, archive is not delete, no unarchive, no telemetry/billing, fixture safety, sanitized errors, sub-routing, profile separation, no hard delete, bounded query, zero N+1 reads).'); - console.log('--- ALL FOUNDER PORTAL TESTS PASSED (22/22) ---'); + // ========================================================================= + // TEST 23: FP-15 Applications Suite (Tests A - V) + // ========================================================================= + console.log('Testing: FP-15 Applications Suite (Tests A - V)...'); + + const applicationsPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/applications/page.tsx'), 'utf8'); + const applicationsCompSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-applications.tsx'), 'utf8'); + + // A. Route exists and reuses FounderPortalShell with activeNav="applications" + assert(fs.existsSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/applications/page.tsx')), 'Applications page route must exist'); + assert(applicationsPageSrc.includes(' active + const approveBody = clubDataSrc.substring(clubDataSrc.indexOf('function approveClubApplication'), clubDataSrc.indexOf('function declineClubApplication')); + assert(approveBody.includes("status: 'active'"), 'approveClubApplication must set status: active'); + + // I. Decline semantics: requested -> declined + const declineBody = clubDataSrc.substring(clubDataSrc.indexOf('function declineClubApplication')); + assert(declineBody.includes("status: 'declined'"), 'declineClubApplication must set status: declined'); + + // J. Default role: member + assert(approveBody.includes("role: 'member'"), 'approveClubApplication must set role: member'); + + // K. Default display rank: Member + assert(approveBody.includes("displayRank: 'Member'"), 'approveClubApplication must set displayRank: Member'); + + // L. memberCount +1 atomic approval behavior + assert(approveBody.includes("memberCount: (cd.memberCount || 0) + 1"), 'approveClubApplication must increment memberCount by 1'); + assert(approveBody.includes("lastMembershipChangeUid: targetUid"), 'approveClubApplication must set lastMembershipChangeUid to targetUid'); + + // M. Decline memberCount delta 0 (no memberCount mutation) + assert(!declineBody.includes('memberCount:'), 'declineClubApplication must not mutate memberCount'); + + // N. Banned application cannot approve + assert(approveBody.includes("bans', targetUid)"), 'approveClubApplication must check bans subcollection'); + assert(approveBody.includes('This application can no longer be approved.'), 'approveClubApplication must reject banned applicant'); + + // O. Stale request fails safely + assert(approveBody.includes("targetData.status !== 'requested'"), 'approveClubApplication must verify status is requested'); + assert(declineBody.includes("targetData.status !== 'requested'"), 'declineClubApplication must verify status is requested'); + + // P. Duplicate mutation protection + assert(applicationsCompSrc.includes('disabled={Boolean(mutatingUid)}'), 'Buttons must be disabled while mutating'); + assert(applicationsCompSrc.includes('if (mutatingUid) return;'), 'Mutation handlers must prevent concurrent double-clicks'); + + // Q. displayRank does not grant authority + assert.equal(getPortalPermissions('member').canApproveApplications, false, 'Member role cannot approve regardless of display rank'); + + // R. No private identity fields exposed + assert(!applicationsCompSrc.includes('app.telemetry'), 'Applications component must not expose telemetry data'); + assert(!applicationsCompSrc.includes('speedMph'), 'Applications component must not display speedMph'); + assert(!applicationsCompSrc.includes('leanAngle'), 'Applications component must not display leanAngle'); + assert(!applicationsCompSrc.includes('gForce'), 'Applications component must not display gForce'); + assert(!applicationsCompSrc.includes('stripe'), 'Applications component must not display billing info'); + assert(!applicationsCompSrc.includes('app.email'), 'Applications component must not display email'); + assert(!applicationsCompSrc.includes('app.phone'), 'Applications component must not display phone'); + + // S. Production failure does not use fixtures + let appProdThrew = false; + try { + process.env.NODE_ENV = 'production'; + await fetchClubApplications('test-club', ''); + } catch { + appProdThrew = true; + } finally { + process.env.NODE_ENV = 'test'; + } + assert.equal(appProdThrew, true, 'fetchClubApplications must reject unauthenticated call in production without fallback'); + + // T. Applications sidebar active state + assert(sidebarSrc.includes("if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;"), 'FounderSidebar must route to applications'); + assert(sidebarSrc.includes("isApplications = item.id === 'applications';"), 'FounderSidebar must treat applications as subroute'); + + // U. Top bar breadcrumb + assert(topBarSrc.includes("activeNav === 'applications'"), 'FounderTopBar must render applications breadcrumb'); + assert(topBarSrc.includes('APPLICATIONS'), 'FounderTopBar must include APPLICATIONS title'); + + // V. Non-destructive decline (no deletion, history preserved) + assert(!declineBody.includes('deleteDoc('), 'declineClubApplication must NOT delete document'); + assert(!applicationsCompSrc.includes('deleteDoc('), 'FounderApplications must NOT delete document'); + + console.log(' PASS: FP-15 Tests A through V verified (Route exists, Owner/Admin access, Member denial, Unauth denial, Bounded query limit 25, Status requested only, Approve requested->active, Decline requested->declined, Default role member, Default rank Member, memberCount +1 atomic, memberCount delta 0 decline, Ban rejection, Stale request protection, Duplicate click guard, Zero rank privilege, Zero private telemetry/contact leak, Production fixture isolation, Sidebar routing & active state, TopBar breadcrumb, Non-destructive history preservation).'); + + console.log('--- ALL FOUNDER PORTAL TESTS PASSED (23/23) ---'); } runAllTests().catch((err) => {