feat: add founder club applications workflow

This commit is contained in:
Developer 2026-09-09 08:37:34 -04:00
parent d544292a3c
commit 58193f6de4
7 changed files with 1354 additions and 2 deletions

View File

@ -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)', () => { describe('CLUB-2C.2: Membership Re-Entry Attack Matrix (Cases A - AF)', () => {
async function setupClub(db, clubId, admissionPolicy, archived = false) { async function setupClub(db, clubId, admissionPolicy, archived = false) {
await setDoc(doc(db, `clubs/${clubId}`), { await setDoc(doc(db, `clubs/${clubId}`), {

View File

@ -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<FounderClubContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-surface p-6">
<OverviewSkeleton />
</div>
);
}
// Unauthenticated safe error state
if (unauthenticated) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Authentication Required"
message="Sign in with your rider account to access club applications."
actionLabel="Sign In"
onAction={() => router.push('/login')}
/>
</div>
);
}
// Unauthorized (Member or non-admin) safe error state
if (unauthorized) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Management Access Restricted"
message="You must be an Owner or Admin of this Club to access Club Applications."
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
/>
</div>
);
}
// Error or missing context
if (error || !context) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Applications Unavailable"
message={error ?? "Club applications couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
);
}
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="applications"
pendingApplicationsCount={4}
upcomingRidesCount={2}
>
<FounderApplications
club={context.club}
permissions={context.permissions}
callerUid={context.currentUser.uid}
isDemoFixture={context.isDemoFixture}
/>
</FounderPortalShell>
);
}

View File

@ -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<ClubApplication[]>([]);
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'name'>('newest');
// Mutation and modal states
const [mutatingUid, setMutatingUid] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [approveModalApplicant, setApproveModalApplicant] = useState<ClubApplication | null>(null);
const [declineModalApplicant, setDeclineModalApplicant] = useState<ClubApplication | null>(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 (
<div className="space-y-6">
{/* 1. PAGE HEADER */}
<div className="flex items-center justify-between pb-3 border-b border-[#262D3D]/60">
<div>
<div className="flex items-center space-x-3">
<h1 className="font-display font-bold text-2xl text-white tracking-wide">APPLICATIONS</h1>
<span
className="px-2 py-0.5 rounded text-xs font-mono font-medium bg-amber-500/10 border border-amber-500/40 text-amber-400"
aria-label={`${applications.length} pending applications`}
>
{applications.length} PENDING
</span>
</div>
<p className="text-xs text-slate-400 mt-1">Review riders requesting to join this Club.</p>
</div>
</div>
{/* 2. SUMMARY METRIC CARDS ROW */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Pending */}
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4 relative overflow-hidden">
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
<span>Pending</span>
<span className="w-2 h-2 rounded-full bg-amber-400 shadow-[0_0_6px_#f59e0b]" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-display font-bold text-2xl text-white">{applications.length}</span>
</div>
<div className="mt-1 text-[11px] text-slate-400">Awaiting review</div>
</div>
{/* Card 2: Admission Policy */}
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
<span>Admission Policy</span>
<span className="w-2 h-2 rounded-full bg-[#0088FF]" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-display font-bold text-xl text-white uppercase font-mono">
{club.admissionPolicy}
</span>
</div>
<div className="mt-1 text-[11px] text-slate-400">Club join governance rule</div>
</div>
{/* Card 3: Active Members */}
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
<span>Active Members</span>
<span className="w-2 h-2 rounded-full bg-[#10B981]" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-display font-bold text-2xl text-[#10B981]">{localMemberCount}</span>
</div>
<div className="mt-1 text-[11px] text-slate-400">Current club roster</div>
</div>
{/* Card 4: Reviewer Authority */}
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
<span>Review Authority</span>
<span className="w-2 h-2 rounded-full bg-slate-500" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-display font-bold text-xl text-slate-300 uppercase font-mono">
{permissions.role === 'owner' ? 'OWNER ACCESS' : 'ADMIN ACCESS'}
</span>
</div>
<div className="mt-1 text-[11px] text-slate-400">Software administrative rights</div>
</div>
</div>
{/* Action Error Alert */}
{actionError && (
<div className="p-3 rounded-md bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center justify-between">
<span>{actionError}</span>
<button
onClick={() => setActionError(null)}
className="text-slate-400 hover:text-white text-xs font-mono ml-3"
aria-label="Dismiss error"
>
✕
</button>
</div>
)}
{/* 3. FILTER & SEARCH TOOLBAR */}
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-3 flex flex-wrap items-center justify-between gap-3">
{/* Filter Tab */}
<div className="flex items-center space-x-1.5">
<button
type="button"
className="px-3 py-1.5 rounded-md bg-[#0088FF] text-white text-xs font-semibold shadow-sm transition-colors flex items-center space-x-1.5"
>
<span>All Pending</span>
<span className="px-1.5 py-0.2 rounded text-[10px] font-mono bg-black/30 text-white">
{applications.length}
</span>
</button>
</div>
{/* Controls Right */}
<div className="flex flex-1 sm:flex-initial items-center space-x-2.5">
{/* Text Search Bar */}
<div className="relative flex-1 sm:w-64">
<input
type="text"
placeholder="Search applicants by name or @handle..."
value={searchQuery}
onChange={(e) => 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"
/>
<svg
className="w-3.5 h-3.5 text-slate-500 absolute left-2.5 top-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-2 text-slate-500 hover:text-white text-xs"
aria-label="Clear search"
>
✕
</button>
)}
</div>
{/* Sort By Dropdown */}
<div className="flex items-center space-x-1 bg-[#141923] border border-[#262D3D] rounded-md px-2.5 h-8 text-xs text-slate-300">
<span className="text-[10px] font-mono text-slate-500 uppercase">SORT:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'newest' | 'oldest' | 'name')}
className="bg-transparent text-white focus:outline-none cursor-pointer pr-1 text-xs font-medium"
aria-label="Sort applicants"
>
<option value="newest" className="bg-[#141923] text-white">Newest First</option>
<option value="oldest" className="bg-[#141923] text-white">Oldest First</option>
<option value="name" className="bg-[#141923] text-white">Name</option>
</select>
</div>
</div>
</div>
{/* 4. MAIN CONTENT AREA */}
{loading ? (
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-12 text-center text-slate-400 text-xs">
<span className="material-symbols-outlined animate-spin text-2xl text-[#0088FF] mb-2 block">
progress_activity
</span>
Loading pending applications...
</div>
) : error ? (
<div className="p-6 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
<div className="flex items-center justify-center space-x-1.5 text-xs font-mono font-semibold text-rose-400">
<span className="material-symbols-outlined text-lg">error</span>
<span>ERROR</span>
</div>
<p className="text-xs text-slate-400">{error}</p>
<button
onClick={() => void loadApplications()}
className="px-3 py-1.5 rounded bg-[#141923] hover:bg-[#181C23] border border-[#262D3D] text-slate-200 text-xs font-medium transition-colors"
>
Try Again
</button>
</div>
) : applications.length === 0 ? (
/* Empty State 1: Default zero-pending queue */
<div className="p-8 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-[#141923] border border-[#262D3D] flex items-center justify-center mx-auto text-slate-400">
<span className="material-symbols-outlined text-2xl">person_add_disabled</span>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase font-mono">NO PENDING APPLICATIONS</h3>
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
There are no membership requests waiting for review. New requests will appear here when riders apply.
</p>
</div>
</div>
) : filteredApplications.length === 0 ? (
/* Empty State 2: Filter zero-match */
<div className="p-8 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-[#141923] border border-[#262D3D] flex items-center justify-center mx-auto text-amber-400">
<span className="material-symbols-outlined text-2xl">search_off</span>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase font-mono">NO APPLICATIONS MATCH</h3>
<p className="text-xs text-slate-400 mt-1">
No pending applications match &quot;{searchQuery}&quot;.
</p>
<button
onClick={() => setSearchQuery('')}
className="text-xs text-[#0088FF] hover:underline font-medium inline-flex items-center space-x-1 mt-2"
>
<span>Clear Search</span>
<span>→</span>
</button>
</div>
</div>
) : (
/* Two-Column Split View */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Dense Application Table */}
<div className="lg:col-span-7 xl:col-span-8 bg-[#0F131B] border border-[#262D3D] rounded-lg overflow-hidden flex flex-col">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-[#262D3D] bg-[#141923]/70 text-[11px] font-mono text-slate-400 uppercase tracking-wider">
<th className="py-3 px-4 font-medium">Rider</th>
<th className="py-3 px-4 font-medium">Requested</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#262D3D]/60 text-xs">
{filteredApplications.map((app) => {
const isSelected = selectedApplicant?.uid === app.uid;
const isMutating = mutatingUid === app.uid;
return (
<tr
key={app.uid}
onClick={() => setSelectedApplicantUid(app.uid)}
className={`cursor-pointer transition-colors ${
isSelected
? 'bg-[#141923] border-l-2 border-[#0088FF]'
: 'hover:bg-[#141923]/50'
}`}
>
<td className="py-3 px-4">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-md bg-[#0088FF]/20 border border-[#0088FF]/60 flex items-center justify-center font-display font-bold text-xs text-[#00F2FE] shrink-0">
{getMonogram(app.displayName)}
</div>
<div className="min-w-0">
<div className="font-medium text-white flex items-center gap-1.5 truncate">
{app.displayName}
{isSelected && (
<span className="px-1.5 py-0.2 rounded text-[9px] font-mono bg-[#0088FF]/20 text-[#00F2FE] border border-[#0088FF]/40 uppercase tracking-wider shrink-0">
INSPECTING
</span>
)}
</div>
{app.handle && (
<div className="text-[11px] font-mono text-slate-400 truncate">
{app.handle}
</div>
)}
</div>
</div>
</td>
<td className="py-3 px-4 font-mono text-[11px] text-slate-300 whitespace-nowrap">
{formatDate(app.requestedAt)}
</td>
<td className="py-3 px-4 whitespace-nowrap">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono font-medium bg-amber-500/10 border border-amber-500/40 text-amber-400">
PENDING
</span>
</td>
<td className="py-3 px-4 text-right whitespace-nowrap">
<div
className="flex items-center justify-end space-x-1.5"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setSelectedApplicantUid(app.uid)}
className="px-2.5 py-1 rounded bg-[#141923] hover:bg-[#181C23] border border-[#262D3D] text-slate-300 hover:text-white text-[11px] font-medium transition-colors"
>
Review
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setApproveModalApplicant(app)}
className="px-2.5 py-1 rounded bg-[#0088FF] text-white text-[11px] font-semibold hover:bg-[#0077e6] transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{isMutating ? '...' : 'Approve'}
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setDeclineModalApplicant(app)}
className="px-2.5 py-1 rounded bg-transparent hover:bg-rose-500/10 border border-[#262D3D] hover:border-rose-500/40 text-slate-400 hover:text-rose-400 text-[11px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Decline
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination / Count Footer */}
<div className="p-3.5 border-t border-[#262D3D] bg-[#0F131B] flex items-center justify-between text-xs text-slate-400">
<span className="font-mono text-[11px]">
Showing {filteredApplications.length} of {applications.length} pending applications
</span>
</div>
</div>
{/* Right Column: Selected Application Inspector Drawer */}
{selectedApplicant && (
<div className="lg:col-span-5 xl:col-span-4 space-y-4">
<div className="bg-[#141923] border border-[#262D3D] rounded-lg overflow-hidden shadow-lg relative">
{/* Top Cyan Accent Line */}
<div className="h-0.5 w-full bg-gradient-to-r from-[#0088FF] via-[#00F2FE] to-transparent" />
<div className="p-5 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="w-11 h-11 rounded-lg bg-[#0088FF]/20 border border-[#0088FF]/60 flex items-center justify-center font-display font-bold text-base text-[#00F2FE]">
{getMonogram(selectedApplicant.displayName)}
</div>
<div>
<h3 className="font-display font-bold text-base text-white">
{selectedApplicant.displayName}
</h3>
{selectedApplicant.handle && (
<div className="font-mono text-xs text-slate-400">
{selectedApplicant.handle}
</div>
)}
</div>
</div>
<div className="flex flex-col items-end space-y-1">
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-medium bg-amber-500/15 border border-amber-500/40 text-amber-400">
PENDING
</span>
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono text-slate-400 bg-[#0A0E15] border border-[#262D3D]">
JOIN REQUEST
</span>
</div>
</div>
<div className="border-t border-[#262D3D]" />
{/* Rider Spec Grid */}
<div className="space-y-2.5 text-xs">
<div className="flex justify-between items-center py-1 border-b border-[#262D3D]/50">
<span className="text-slate-400">Requested</span>
<span className="font-mono text-white text-[11px]">
{formatDate(selectedApplicant.requestedAt)}
</span>
</div>
<div className="flex justify-between items-center py-1 border-b border-[#262D3D]/50">
<span className="text-slate-400">Initial Club Rank</span>
<span className="font-mono text-white text-[11px]">Member (Default)</span>
</div>
<div className="flex justify-between items-center py-1 border-b border-[#262D3D]/50">
<span className="text-slate-400">Administrative Role</span>
<span className="font-mono text-white text-[11px]">Member (Non-admin)</span>
</div>
</div>
{/* Mandatory Privacy Architecture Notice */}
<div className="p-3 rounded-lg bg-[#0A0E15] border border-[#0088FF]/30 space-y-1.5">
<div className="flex items-center space-x-1.5 text-[11px] font-mono font-semibold text-[#00F2FE]">
<span className="material-symbols-outlined text-[16px] shrink-0">shield</span>
<span>MANDATORY PRIVACY ARCHITECTURE</span>
</div>
<p className="text-[11px] text-slate-400 leading-normal">
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.
</p>
</div>
{/* Architecture Note */}
<div className="p-2.5 rounded bg-[#0A0E15] border border-[#262D3D] text-[10px] text-slate-400 font-mono leading-tight">
ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant.
</div>
{/* Primary Actions */}
<div className="space-y-2 pt-1">
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setApproveModalApplicant(selectedApplicant)}
className="w-full h-9 rounded-md bg-[#0088FF] hover:bg-[#0077e6] text-white font-semibold text-xs shadow transition-colors flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">check</span>
<span>{mutatingUid === selectedApplicant.uid ? 'Processing...' : 'Approve Application'}</span>
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setDeclineModalApplicant(selectedApplicant)}
className="w-full h-9 rounded-md bg-transparent hover:bg-rose-500/10 border border-[#262D3D] hover:border-rose-500/40 text-slate-400 hover:text-rose-400 font-medium text-xs transition-colors flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">close</span>
<span>Decline Application</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* MODAL 1: APPROVE APPLICATION */}
{approveModalApplicant && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="bg-[#0F131B] border border-[#0088FF]/50 rounded-lg p-5 max-w-md w-full shadow-2xl relative">
<div className="flex items-center justify-between pb-2 mb-3 border-b border-[#262D3D]">
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-[#0088FF]" />
<span className="text-xs font-mono uppercase tracking-wider text-[#00F2FE]">
APPROVAL CONFIRMATION
</span>
</div>
</div>
<h4 className="font-display font-bold text-base text-white">Approve Application?</h4>
<p className="text-xs text-slate-300 mt-2 leading-relaxed">
Approve <span className="text-white font-semibold">{approveModalApplicant.displayName}</span>&apos;s request to join <span className="text-white font-semibold">{club.name}</span>? On approval, this rider becomes an active Club member.
</p>
<div className="mt-3 p-3 rounded bg-[#141923] border border-[#262D3D] space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-slate-400">Initial Club Rank:</span>
<span className="font-mono text-white">Member <span className="text-slate-500">(Default)</span></span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Administrative Role:</span>
<span className="font-mono text-white">Member <span className="text-slate-500">(Default Member, non-admin)</span></span>
</div>
</div>
<div className="mt-2.5 p-2 rounded bg-[#0A0E15] border border-[#262D3D] text-[10px] text-slate-400 font-mono leading-tight">
ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant.
</div>
<div className="mt-4 flex items-center justify-end space-x-2">
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setApproveModalApplicant(null)}
className="px-3 py-1.5 rounded text-xs text-slate-400 hover:text-white border border-[#262D3D] hover:bg-[#141923] transition-colors"
>
Cancel
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => void handleApprove(approveModalApplicant)}
className="px-3 py-1.5 rounded text-xs font-semibold text-white bg-[#0088FF] hover:bg-[#0077e6] transition-colors shadow-sm disabled:opacity-50"
>
{mutatingUid === approveModalApplicant.uid ? 'Approving...' : 'Approve Member'}
</button>
</div>
</div>
</div>
)}
{/* MODAL 2: DECLINE APPLICATION */}
{declineModalApplicant && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-5 max-w-md w-full shadow-2xl relative">
<div className="flex items-center justify-between pb-2 mb-3 border-b border-[#262D3D]">
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-slate-400" />
<span className="text-xs font-mono uppercase tracking-wider text-slate-300">
DECLINE APPLICATION
</span>
</div>
</div>
<h4 className="font-display font-bold text-base text-white">Decline Application?</h4>
<p className="text-xs text-slate-300 mt-2 leading-relaxed">
Decline <span className="text-white font-semibold">{declineModalApplicant.displayName}</span>&apos;s request to join this Club? This removes the pending request. The rider&apos;s MotorSignal account remains unaffected.
</p>
<div className="mt-4 flex items-center justify-end space-x-2">
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setDeclineModalApplicant(null)}
className="px-3 py-1.5 rounded text-xs text-slate-400 hover:text-white border border-[#262D3D] hover:bg-[#141923] transition-colors"
>
Cancel
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => void handleDecline(declineModalApplicant)}
className="px-3 py-1.5 rounded text-xs font-medium text-slate-300 hover:text-rose-400 border border-[#262D3D] hover:border-rose-500/40 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
>
{mutatingUid === declineModalApplicant.uid ? 'Declining...' : 'Decline Application'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -74,6 +74,7 @@ export function FounderSidebar({
function getRouteHref(id: string) { function getRouteHref(id: string) {
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`; if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${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}`; if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
return undefined; return undefined;
} }
@ -88,7 +89,8 @@ export function FounderSidebar({
const href = getRouteHref(item.id); const href = getRouteHref(item.id);
const isProfile = item.id === 'profile'; const isProfile = item.id === 'profile';
const isSettings = item.id === 'settings'; const isSettings = item.id === 'settings';
const isSubRoute = isProfile || isSettings; const isApplications = item.id === 'applications';
const isSubRoute = isProfile || isSettings || isApplications;
const content = ( const content = (
<> <>

View File

@ -61,6 +61,12 @@ export function FounderTopBar({
<span className="text-secondary font-semibold uppercase">SETTINGS</span> <span className="text-secondary font-semibold uppercase">SETTINGS</span>
</> </>
)} )}
{activeNav === 'applications' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-secondary font-semibold uppercase">APPLICATIONS</span>
</>
)}
</div> </div>
<div className="relative flex items-center shrink-0"> <div className="relative flex items-center shrink-0">

View File

@ -29,6 +29,19 @@ export interface ManageableClubSummary {
logoUrl?: string | null; 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. * Calculates software permissions derived solely from Administrative Role.
* Club Display Rank does NOT grant software administrative access. * 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<ClubApplication[]> {
// 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<void> {
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<void> {
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,
});
});
}

View File

@ -66,6 +66,9 @@ const {
fetchEligibleTransferMembers, fetchEligibleTransferMembers,
transferClubOwnership, transferClubOwnership,
archiveClub, archiveClub,
fetchClubApplications,
approveClubApplication,
declineClubApplication,
} = clubData; } = clubData;
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures; 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(' 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('<FounderPortalShell'), 'Applications page must reuse FounderPortalShell');
assert(applicationsPageSrc.includes('activeNav="applications"'), 'Applications page must pass activeNav="applications"');
// B. Owner can access
assert.equal(ownerPerms.canApproveApplications, true, 'Owner has canApproveApplications authority');
assert.equal(ownerPerms.canManageSettings, true, 'Owner has canManageSettings authority');
// C. Admin can access
assert.equal(adminPerms.canApproveApplications, true, 'Admin has canApproveApplications authority');
assert.equal(adminPerms.canManageSettings, true, 'Admin has canManageSettings authority');
// D. Member denied
assert.equal(memberPerms.canApproveApplications, false, 'Member must not have canApproveApplications authority');
assert.equal(memberPerms.canManageSettings, false, 'Member must not have canManageSettings authority');
assert(applicationsPageSrc.includes('Management Access Restricted'), 'Applications page must guard against unauthorized members');
// E. Unauthenticated denied
assert.equal(unauthPerms.isAuthenticated, false, 'Unauthenticated user has isAuthenticated = false');
assert.equal(unauthPerms.canApproveApplications, false, 'Unauthenticated user has canApproveApplications = false');
assert(applicationsPageSrc.includes('Authentication Required'), 'Applications page must guard against unauthenticated users');
// F. Pending query bounded to 25
const fetchAppsBody = clubDataSrc.substring(clubDataSrc.indexOf('function fetchClubApplications'), clubDataSrc.indexOf('function approveClubApplication'));
assert(fetchAppsBody.includes('limit(25)'), 'fetchClubApplications must bound query with limit(25)');
// G. Only requested statuses queried
assert(fetchAppsBody.includes("where('status', '==', 'requested')"), 'fetchClubApplications must query status == requested');
// H. Approve semantics: requested -> 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) => { runAllTests().catch((err) => {