From d544292a3c9ccf6191e8f73efedb4a4708571da3 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 9 Sep 2026 07:51:48 -0400 Subject: [PATCH] feat: add founder club settings and governance lifecycle --- .../emulator-tests/firestore.rules.test.js | 163 +++ firestore.rules | 10 +- lib/services/clubs/club_service.dart | 8 - .../clubs/[clubId]/manage/settings/page.tsx | 158 +++ .../founder-settings-editor.tsx | 1023 +++++++++++++++++ .../founder-portal/founder-sidebar.tsx | 13 +- .../founder-portal/founder-top-bar.tsx | 6 + website/lib/club-data.ts | 240 +++- website/tests/founder-portal.test.cjs | 151 ++- 9 files changed, 1754 insertions(+), 18 deletions(-) create mode 100644 website/app/clubs/[clubId]/manage/settings/page.tsx create mode 100644 website/components/founder-portal/founder-settings-editor.tsx diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index c705dfd..cae5efe 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -2850,6 +2850,169 @@ describe('Social Architecture Security Rules', () => { })); }); + test('FP-14.2: Club Settings access authority and boundary isolation (Admin authority & attack matrix)', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const dave = testEnv.authenticatedContext('dave').firestore(); + const eve = testEnv.authenticatedContext('eve').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_settings_auth'), { + id: 'club_settings_auth', + name: 'Settings Authority MC', + slug: 'settings-authority-mc', + visibility: 'public', + admissionPolicy: 'request', + ownerUid: 'alice', + memberCount: 3, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + // Owner Alice + await setDoc(doc(db, 'clubs/club_settings_auth/members/alice'), { + uid: 'alice', + clubId: 'club_settings_auth', + role: 'owner', + status: 'active', + }); + // Active Admin Bob + await setDoc(doc(db, 'clubs/club_settings_auth/members/bob'), { + uid: 'bob', + clubId: 'club_settings_auth', + role: 'admin', + status: 'active', + }); + // Active Member Charlie with displayRank 'President' + await setDoc(doc(db, 'clubs/club_settings_auth/members/charlie'), { + uid: 'charlie', + clubId: 'club_settings_auth', + role: 'member', + status: 'active', + displayRank: 'President', + }); + // Removed former admin Dave + await setDoc(doc(db, 'clubs/club_settings_auth/members/dave'), { + uid: 'dave', + clubId: 'club_settings_auth', + role: 'admin', + status: 'removed', + }); + // Former admin Eve who left + await setDoc(doc(db, 'clubs/club_settings_auth/members/eve'), { + uid: 'eve', + clubId: 'club_settings_auth', + role: 'admin', + status: 'left', + }); + }); + + const clubDocRef = (db) => doc(db, 'clubs/club_settings_auth'); + + // 1. Active Admin valid visibility update -> ALLOW + await assertSucceeds(updateDoc(clubDocRef(bob), { + visibility: 'unlisted', + updatedAt: new Date(), + })); + await assertSucceeds(updateDoc(clubDocRef(bob), { + visibility: 'private', + updatedAt: new Date(), + })); + + // 2. Active Admin valid admissionPolicy update -> ALLOW + await assertSucceeds(updateDoc(clubDocRef(bob), { + admissionPolicy: 'open', + updatedAt: new Date(), + })); + await assertSucceeds(updateDoc(clubDocRef(bob), { + admissionPolicy: 'inviteOnly', + updatedAt: new Date(), + })); + + // 3. Admin invalid visibility enum -> DENY + await assertFails(updateDoc(clubDocRef(bob), { + visibility: 'hidden_leak', + updatedAt: new Date(), + })); + + // 4. Admin invalid admissionPolicy enum -> DENY + await assertFails(updateDoc(clubDocRef(bob), { + admissionPolicy: 'free_for_all', + updatedAt: new Date(), + })); + + // 5. Admin visibility + ownerUid escalation -> DENY + await assertFails(updateDoc(clubDocRef(bob), { + visibility: 'private', + ownerUid: 'bob', + updatedAt: new Date(), + })); + + // 6. Admin admission + archivedAt mutation -> DENY + await assertFails(updateDoc(clubDocRef(bob), { + admissionPolicy: 'inviteOnly', + archivedAt: new Date(), + updatedAt: new Date(), + })); + + // 7. Admin visibility + slug mutation -> DENY + await assertFails(updateDoc(clubDocRef(bob), { + visibility: 'unlisted', + slug: 'hacked-handle', + updatedAt: new Date(), + })); + + // 8. Admin visibility + memberCount mutation -> DENY + await assertFails(updateDoc(clubDocRef(bob), { + visibility: 'unlisted', + memberCount: 100, + updatedAt: new Date(), + })); + + // 9. Regular Member visibility/admission mutation -> DENY + await assertFails(updateDoc(clubDocRef(charlie), { + visibility: 'public', + updatedAt: new Date(), + })); + await assertFails(updateDoc(clubDocRef(charlie), { + admissionPolicy: 'open', + updatedAt: new Date(), + })); + + // 10. Member with displayRank President mutation -> DENY (displayRank grants 0 authority) + await assertFails(updateDoc(clubDocRef(charlie), { + visibility: 'unlisted', + updatedAt: new Date(), + })); + + // 11. Inactive/former Admin mutation -> DENY + await assertFails(updateDoc(clubDocRef(dave), { + visibility: 'unlisted', + updatedAt: new Date(), + })); + await assertFails(updateDoc(clubDocRef(eve), { + admissionPolicy: 'open', + updatedAt: new Date(), + })); + + // 12. Owner valid updates and enum validation + await assertSucceeds(updateDoc(clubDocRef(alice), { + visibility: 'public', + admissionPolicy: 'request', + updatedAt: new Date(), + })); + await assertFails(updateDoc(clubDocRef(alice), { + visibility: 'invalid_vis', + updatedAt: new Date(), + })); + await assertFails(updateDoc(clubDocRef(alice), { + admissionPolicy: 'invalid_policy', + updatedAt: new Date(), + })); + }); + 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/firestore.rules b/firestore.rules index fb2dc4b..673449f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -777,15 +777,19 @@ service cloud.firestore { 'archivedAt', 'updatedAt' ]) && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) + && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private']) + && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly']) ) || - // Case 3: Admin editing metadata (cannot change ownerUid, slug, createdAt, archivedAt, visibility, admissionPolicy, memberCount) + // Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount) ( isClubAdmin(request.auth.uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ - 'name', 'description', 'logoUrl', 'bannerUrl', - 'homeRegion', 'motorcycleFocus', 'updatedAt' + 'name', 'description', 'visibility', 'admissionPolicy', + 'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus', 'updatedAt' ]) && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) + && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private']) + && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly']) ) || // Case 4: Member count increment/decrement by membership transitions ONLY ( diff --git a/lib/services/clubs/club_service.dart b/lib/services/clubs/club_service.dart index fe1211f..af43c88 100644 --- a/lib/services/clubs/club_service.dart +++ b/lib/services/clubs/club_service.dart @@ -289,14 +289,6 @@ class ClubService { ); } - // Only owner can alter visibility or admission policy - if ((visibility != null || admissionPolicy != null) && !isOwner) { - throw const ClubOperationException( - 'Only the club owner can change visibility or admission policy.', - code: 'permission-denied', - ); - } - final updates = { 'updatedAt': FieldValue.serverTimestamp(), }; diff --git a/website/app/clubs/[clubId]/manage/settings/page.tsx b/website/app/clubs/[clubId]/manage/settings/page.tsx new file mode 100644 index 0000000..4ca7957 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/settings/page.tsx @@ -0,0 +1,158 @@ +'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 { FounderSettingsEditor } from '../../../../../components/founder-portal/founder-settings-editor'; +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 ClubFounderSettingsPage({ 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 Settings; Members cannot + if (!isDemoPreview && !ctx.permissions.canManageSettings) { + setUnauthorized(true); + setLoading(false); + return; + } + + setContext(ctx); + } catch (err) { + setError(sanitizePortalError(err, "Club settings 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 ( + + { + setContext((prev) => { + if (!prev) return null; + const isFormerOwner = updated.ownerUid && updated.ownerUid !== prev.club.ownerUid; + return { + ...prev, + club: { + ...prev.club, + ...updated, + }, + currentUser: isFormerOwner + ? { ...prev.currentUser, role: 'admin', displayRank: 'Admin' } + : prev.currentUser, + permissions: isFormerOwner + ? { ...prev.permissions, role: 'admin', canTransferOwnership: false } + : prev.permissions, + }; + }); + }} + /> + + ); +} diff --git a/website/components/founder-portal/founder-settings-editor.tsx b/website/components/founder-portal/founder-settings-editor.tsx new file mode 100644 index 0000000..08fd600 --- /dev/null +++ b/website/components/founder-portal/founder-settings-editor.tsx @@ -0,0 +1,1023 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + Club, + UserPortalPermissions, + ClubVisibility, + ClubAdmissionPolicy, + ClubMember, +} from './types'; +import { + updateClubAccessSettings, + fetchEligibleTransferMembers, + transferClubOwnership, + archiveClub, + sanitizePortalError, +} from '../../lib/club-data'; + +interface FounderSettingsEditorProps { + club: Club; + permissions: UserPortalPermissions; + callerUid?: string; + isDemoFixture?: boolean; + onClubUpdated?: (updated: Partial) => void; +} + +export function FounderSettingsEditor({ + club, + permissions, + callerUid, + isDemoFixture = false, + onClubUpdated, +}: FounderSettingsEditorProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const isDemo = searchParams?.get('preview') === 'demo' || isDemoFixture; + const queryParam = isDemo ? '?preview=demo' : ''; + const clubParam = club.slug || club.id; + + // Access Settings Form State + const [visibility, setVisibility] = useState(club.visibility || 'public'); + const [admissionPolicy, setAdmissionPolicy] = useState(club.admissionPolicy || 'request'); + + // Mutation / UI feedback states + const [isSavingAccess, setIsSavingAccess] = useState(false); + const [accessSaveStatus, setAccessSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [accessErrorMessage, setAccessErrorMessage] = useState(null); + + // Ownership Transfer Modal State + const [isTransferModalOpen, setIsTransferModalOpen] = useState(false); + const [eligibleMembers, setEligibleMembers] = useState([]); + const [loadingMembers, setLoadingMembers] = useState(false); + const [selectedTargetUid, setSelectedTargetUid] = useState(''); + const [isTransferring, setIsTransferring] = useState(false); + const [transferError, setTransferError] = useState(null); + + // Archive Modal State + const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false); + const [isArchiving, setIsArchiving] = useState(false); + const [archiveError, setArchiveError] = useState(null); + + const isOwner = permissions.role === 'owner' || permissions.canTransferOwnership; + const isAdmin = permissions.role === 'admin' || isOwner; + const canManageAccess = isAdmin && permissions.canManageSettings; + const isArchived = Boolean(club.archivedAt); + + const isAccessDirty = + visibility !== club.visibility || + admissionPolicy !== club.admissionPolicy; + + // Load eligible members for ownership transfer when modal opens + useEffect(() => { + if (isTransferModalOpen && callerUid) { + setLoadingMembers(true); + setTransferError(null); + fetchEligibleTransferMembers(club.id, callerUid, { allowDemoPreview: isDemo }) + .then((members) => { + setEligibleMembers(members); + if (members.length > 0) { + setSelectedTargetUid(members[0].uid); + } + }) + .catch((err) => { + setTransferError(sanitizePortalError(err, 'Failed to load eligible active members.')); + }) + .finally(() => { + setLoadingMembers(false); + }); + } + }, [isTransferModalOpen, club.id, callerUid, isDemo]); + + // Handle Save Access Settings + async function handleSaveAccess(e: React.FormEvent) { + e.preventDefault(); + if (!canManageAccess || isArchived || isSavingAccess || !isAccessDirty) return; + + setIsSavingAccess(true); + setAccessSaveStatus('idle'); + setAccessErrorMessage(null); + + try { + if (isDemo) { + await new Promise((resolve) => setTimeout(resolve, 600)); + setAccessSaveStatus('success'); + onClubUpdated?.({ visibility, admissionPolicy }); + } else { + if (!callerUid) { + throw new Error('Authentication required to update access settings.'); + } + await updateClubAccessSettings(club.id, callerUid, { + visibility, + admissionPolicy, + }); + setAccessSaveStatus('success'); + onClubUpdated?.({ visibility, admissionPolicy }); + } + } catch (err) { + setAccessSaveStatus('error'); + setAccessErrorMessage(sanitizePortalError(err, 'Failed to update access settings. Please try again.')); + } finally { + setIsSavingAccess(false); + } + } + + // Handle Ownership Transfer + async function handleConfirmTransfer() { + if (!isOwner || isArchived || isTransferring || !selectedTargetUid) return; + + setIsTransferring(true); + setTransferError(null); + + try { + if (isDemo) { + await new Promise((resolve) => setTimeout(resolve, 800)); + setIsTransferModalOpen(false); + onClubUpdated?.({ + ownerUid: selectedTargetUid, + founderName: 'Transferred Owner', + }); + } else { + if (!callerUid) { + throw new Error('Authentication required to transfer ownership.'); + } + await transferClubOwnership(club.id, callerUid, selectedTargetUid); + setIsTransferModalOpen(false); + onClubUpdated?.({ + ownerUid: selectedTargetUid, + }); + router.refresh(); + } + } catch (err) { + setTransferError(sanitizePortalError(err, 'Failed to transfer ownership. Please try again.')); + } finally { + setIsTransferring(false); + } + } + + // Handle Archive Club + async function handleConfirmArchive() { + if (!isOwner || isArchived || isArchiving) return; + + setIsArchiving(true); + setArchiveError(null); + + try { + if (isDemo) { + await new Promise((resolve) => setTimeout(resolve, 800)); + setIsArchiveModalOpen(false); + onClubUpdated?.({ archivedAt: new Date().toISOString() }); + } else { + if (!callerUid) { + throw new Error('Authentication required to archive club.'); + } + await archiveClub(club.id, callerUid); + setIsArchiveModalOpen(false); + onClubUpdated?.({ archivedAt: new Date().toISOString() }); + router.refresh(); + } + } catch (err) { + setArchiveError(sanitizePortalError(err, 'Failed to archive club. Please try again.')); + } finally { + setIsArchiving(false); + } + } + + return ( +
+ {/* 2. HEADER */} +
+
+
+ + PRECISION, NOT AGGRESSION + + + // {club.homeRegion ? `${club.homeRegion.toUpperCase()} ZONE` : 'REGIONAL SQUADRON'} + +
+

+ SETTINGS +

+

+ Configure Club access, governance, and administrative lifecycle. +

+
+
+ + {/* 3. TOP SUMMARY KPI CARDS (Row of 4) */} +
+ {/* Card 1: Club Visibility */} +
+
+ CLUB VISIBILITY + public +
+
+
+ {visibility} +
+
+ + + {visibility === 'public' + ? 'Listed in directory & search' + : visibility === 'unlisted' + ? 'Direct handle only' + : 'Restricted private club'} + +
+
+
+ + {/* Card 2: Admission Policy */} +
+
+ ADMISSION POLICY + assignment_ind +
+
+
+ {admissionPolicy === 'open' + ? 'OPEN' + : admissionPolicy === 'request' + ? 'REQUEST' + : 'INVITE ONLY'} +
+
+ + + {admissionPolicy === 'open' + ? 'Open join without review' + : admissionPolicy === 'request' + ? 'Leadership review required' + : 'Direct invitation only'} + +
+
+
+ + {/* Card 3: Club Handle */} +
+
+ CLUB HANDLE + lock +
+
+
+ @{club.slug} +
+
+ + Immutable reservation +
+
+
+ + {/* Card 4: Lifecycle State */} +
+
+ LIFECYCLE STATE + + {isArchived ? 'archive' : 'verified'} + +
+
+
+ {isArchived ? 'ARCHIVED' : 'ACTIVE'} +
+
+ + {isArchived ? 'Discovery Removed • Retained' : 'Operational • Nominal'} +
+
+
+
+ + {/* Archived Status Warning Banner */} + {isArchived && ( +
+ archive +
+
+ Club is Archived +
+
+ This Club has been archived. It is removed from public discovery queries and does not accept new join requests. Existing organizational records are retained. +
+
+
+ )} + + {/* 4. MAIN WORKSPACE (12-Column Grid) */} +
+ {/* Left Column (8 cols) */} +
+ {/* SECTION 1: CLUB ACCESS & DISCOVERY */} +
+
+
+
+ travel_explore +

+ SECTION 1: CLUB ACCESS & DISCOVERY +

+
+

+ Controls visibility across public index registries and member admission requirements. +

+
+ + {canManageAccess ? 'LEADERSHIP ACTION' : 'LEADERSHIP ONLY'} + +
+ + {/* Save Feedback Alerts */} + {accessSaveStatus === 'success' && ( +
+ check_circle + Club access settings saved successfully. +
+ )} + {accessSaveStatus === 'error' && accessErrorMessage && ( +
+ error + {accessErrorMessage} +
+ )} + + {/* Subsection A: Visibility */} +
+
+ + + CURRENT: {club.visibility.toUpperCase()} + +
+ +
+ {/* PUBLIC */} + + + {/* UNLISTED */} + + + {/* PRIVATE */} + +
+ +
+ info + Unlisted and private clubs are hidden from public directory and unauthenticated discovery. +
+
+ +
+ + {/* Subsection B: Admission Policy */} +
+
+ + + CURRENT: {club.admissionPolicy.toUpperCase()} + +
+ +
+ {/* OPEN */} + + + {/* REQUEST TO JOIN */} + + + {/* INVITE ONLY */} + +
+ +
+ lock + Admission policy and visibility are independent dimensions. +
+
+ + {/* Action Bar */} +
+
+ sync_saved_locally + Persists visibility and admission policy changes. +
+ +
+ + + {/* SECTION 2: CLUB IDENTIFIER & PUBLIC PROFILE */} +
+
+
+ fingerprint +

+ SECTION 2: CLUB IDENTIFIER & PUBLIC PROFILE +

+
+

+ Permanent club handle and navigation to public profile. +

+
+ +
+
+
+
+ lock + + Permanent Club Handle + + + IMMUTABLE RESERVATION + +
+
+ @{club.slug} +
+
+ +
+ + Edit Club Profile + arrow_forward + +
+
+ +
+ Club handles are permanently reserved and cannot be renamed or reassigned. +
+
+
+ + {/* SECTION 3: CLUB OWNERSHIP & GOVERNANCE */} +
+
+
+ admin_panel_settings +

+ SECTION 3: CLUB OWNERSHIP & GOVERNANCE +

+
+

+ Root administrative authority holder and ownership assignment ledger. +

+
+ +
+
+
+
+ {club.founderName + ? club.founderName + .split(/\s+/) + .map((w) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase() + : 'FO'} +
+
+
+ + {club.founderName || 'Current Owner'} + + {club.founderHandle && ( + {club.founderHandle} + )} + + OWNER + +
+

+ Primary administrative account holder since {club.foundedYear || '2024'} +

+
+
+ + +
+ + {/* Warning Notice */} +
+ warning + + Notice: The selected active member will become Club Owner. You will become a Club Admin. Ownership controls will transfer immediately after confirmation. + +
+
+
+ + {/* SECTION 4: CLUB LIFECYCLE */} +
+
+
+ archive +

+ SECTION 4: CLUB LIFECYCLE +

+
+

+ Administrative lifecycle state. +

+
+ +
+
+
+ ARCHIVE CLUB +
+

+ Archiving removes the Club from public discovery and prevents new open joins and join requests. Existing Club records are not deleted. +

+
+ + +
+ +
+ verified_user + + Invariant Guarantee: Club deletion is not supported. Archiving preserves existing Club records while removing the Club from public discovery queries. + +
+
+
+ + {/* Right Column (4 cols) - GOVERNANCE RAIL */} +
+
+ security + GOVERNANCE & BOUNDARIES +
+ + {/* Card 1: CLUB VS. PERSONAL SCOPE */} +
+
+ domain +

+ CLUB VS. PERSONAL SCOPE +

+
+

+ Club settings apply strictly to {club.name}. Club leadership cannot configure or override member telemetry preferences, GPS tracking, personal ride history, or individual account security. +

+
+ LEAST-PRIVILEGE RBAC + ENFORCED +
+
+ + {/* Card 2: LEADERSHIP GOVERNANCE */} +
+
+ vpn_key +

+ LEADERSHIP GOVERNANCE +

+
+

+ Club Owners and Admins may update visibility and admission policy. Only the Club Owner can transfer ownership or archive the Club. Admins cannot seize ownership or archive the Club. +

+
+ OWNER SEAT + 1 OWNER +
+
+ + {/* Card 3: CLUB HANDLE */} +
+
+ link_off +

+ CLUB HANDLE +

+
+

+ Club handles are permanently reserved and cannot be renamed or reassigned. +

+
+ CANONICAL SLUG + PERMANENT +
+
+ + {/* Card 4: PRIVACY CONTRACT */} +
+
+ shield + PRIVACY CONTRACT +
+
+ “Public Club Profile is organizational identity only. Member telemetry, GPS tracks, personal ride logs, and private account data are not included.” +
+

+ Contract enforced across all 12 Founder Portal administrative modules. +

+
+
+
+ + {/* OWNERSHIP TRANSFER MODAL */} + {isTransferModalOpen && ( +
+
+
+
+ swap_horiz +

+ Transfer Club Ownership +

+
+ +
+ +
+
+ Irreversible Authority Transfer: Transferring ownership gives complete administrative control of {club.name} to the selected member. You will immediately step down to Club Admin. +
+ + {transferError && ( +
+ {transferError} +
+ )} + +
+ + {loadingMembers ? ( +
+ progress_activity + Loading eligible active members... +
+ ) : eligibleMembers.length === 0 ? ( +
+ No eligible active members found in this Club to receive ownership. +
+ ) : ( + + )} +
+
+ +
+ + +
+
+
+ )} + + {/* ARCHIVE CONFIRMATION MODAL */} + {isArchiveModalOpen && ( +
+
+
+
+ archive +

+ Archive Club +

+
+ +
+ +
+
+ Archiving removes the Club from discovery and prevents new membership requests. Existing Club records are retained. +
+ +

+ This action cannot be undone through the portal. Archiving is not deletion: historical ride records, club achievements, and membership history remain safely preserved. +

+ + {archiveError && ( +
+ {archiveError} +
+ )} +
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/website/components/founder-portal/founder-sidebar.tsx b/website/components/founder-portal/founder-sidebar.tsx index 35c2808..c96b983 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 === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`; return undefined; } @@ -86,19 +87,21 @@ export function FounderSidebar({ const isActive = activeNav === item.id; const href = getRouteHref(item.id); const isProfile = item.id === 'profile'; + const isSettings = item.id === 'settings'; + const isSubRoute = isProfile || isSettings; const content = ( <>
{item.icon} - + {item.label}
@@ -117,7 +120,7 @@ export function FounderSidebar({ {item.badge} )} - {isActive && isProfile && !item.badge && ( + {isActive && isSubRoute && !item.badge && ( )} @@ -125,7 +128,7 @@ export function FounderSidebar({ const baseClasses = `w-full flex items-center justify-between px-3 py-2 rounded transition-colors text-xs ${ isActive - ? isProfile + ? isSubRoute ? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium' : 'bg-primary text-white font-medium' : 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface font-normal' diff --git a/website/components/founder-portal/founder-top-bar.tsx b/website/components/founder-portal/founder-top-bar.tsx index b7ca2b5..d2420cf 100644 --- a/website/components/founder-portal/founder-top-bar.tsx +++ b/website/components/founder-portal/founder-top-bar.tsx @@ -55,6 +55,12 @@ export function FounderTopBar({ CLUB PROFILE )} + {activeNav === 'settings' && ( + <> + / + SETTINGS + + )}
diff --git a/website/lib/club-data.ts b/website/lib/club-data.ts index bcbac42..a0e309d 100644 --- a/website/lib/club-data.ts +++ b/website/lib/club-data.ts @@ -1,4 +1,4 @@ -import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp } from 'firebase/firestore'; +import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction } from 'firebase/firestore'; import { firestore } from './firebase-client'; import { Club, @@ -8,6 +8,8 @@ import { UserPortalPermissions, ClubActivityItem, ClubRole, + ClubVisibility, + ClubAdmissionPolicy, } from '../components/founder-portal/types'; import { FIXTURE_FOUNDER_CLUB, @@ -665,3 +667,239 @@ export async function updateClubProfile( }); } +export interface UpdateClubAccessSettingsInput { + visibility: ClubVisibility; + admissionPolicy: ClubAdmissionPolicy; +} + +/** + * Whitelist update of club access controls (visibility and admissionPolicy). + * Authorizes active Owner and active Admin. + * Forbids mutating slug, ownerUid, memberCount, archivedAt, name, description, etc. + */ +export async function updateClubAccessSettings( + clubId: string, + callerUid: string, + input: UpdateClubAccessSettingsInput +): Promise { + if (!callerUid) { + throw new Error('Authentication required.'); + } + + const validVisibilities: ClubVisibility[] = ['public', 'unlisted', 'private']; + const validAdmissions: ClubAdmissionPolicy[] = ['open', 'request', 'inviteOnly']; + + if (!validVisibilities.includes(input.visibility)) { + throw new Error('Invalid club visibility setting.'); + } + if (!validAdmissions.includes(input.admissionPolicy)) { + throw new Error('Invalid club admission policy setting.'); + } + + const clubRef = doc(firestore, 'clubs', clubId); + const clubSnap = await getDoc(clubRef); + if (!clubSnap.exists()) { + throw new Error('Club not found.'); + } + + const cd = clubSnap.data(); + if (cd.archivedAt) { + throw new Error('Archived clubs cannot be modified.'); + } + + let isAuthorized = cd.ownerUid === callerUid; + if (!isAuthorized) { + try { + const memberSnap = await getDoc(doc(firestore, 'clubs', clubId, 'members', callerUid)); + if (memberSnap.exists()) { + const m = memberSnap.data(); + if (m.status === 'active' && (m.role === 'owner' || m.role === 'admin')) { + isAuthorized = true; + } + } + } catch { + // Unauthorized + } + } + + if (!isAuthorized) { + throw new Error('Permission denied. Only club owners and administrators can modify club access settings.'); + } + + await updateDoc(clubRef, { + visibility: input.visibility, + admissionPolicy: input.admissionPolicy, + updatedAt: serverTimestamp(), + }); +} + +/** + * Bounded query for active members eligible to receive club ownership. + * Excludes the current owner. + * Never performs N+1 profile queries; uses only legally readable member document fields. + */ +export async function fetchEligibleTransferMembers( + clubId: string, + callerUid: string, + options?: { allowDemoPreview?: boolean } +): Promise { + if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { + return [ + { + uid: 'demo-rider-02', + clubId, + status: 'active', + role: 'admin', + displayRank: 'Vice President', + updatedAt: new Date().toISOString(), + displayName: 'Elena Rostova', + handle: '@elena_rides', + }, + { + uid: 'demo-rider-03', + clubId, + status: 'active', + role: 'member', + displayRank: 'Road Captain', + updatedAt: new Date().toISOString(), + displayName: 'Dave Miller', + handle: '@dave_m', + }, + ]; + } + + if (!callerUid) { + throw new Error('Authentication required.'); + } + + const membersRef = collection(firestore, 'clubs', clubId, 'members'); + const q = query( + membersRef, + where('status', '==', 'active'), + limit(25) + ); + + const snap = await getDocs(q); + const eligible: ClubMember[] = []; + + for (const d of snap.docs) { + if (d.id === callerUid) continue; // Exclude current owner + const data = d.data(); + eligible.push({ + uid: d.id, + clubId, + status: 'active', + role: (data.role as ClubRole) || 'member', + displayRank: data.displayRank || null, + joinedAt: data.joinedAt?.toDate?.() ? data.joinedAt.toDate().toISOString() : (data.joinedAt || null), + updatedAt: data.updatedAt?.toDate?.() ? data.updatedAt.toDate().toISOString() : (data.updatedAt || new Date().toISOString()), + displayName: data.displayName || null, + handle: data.handle ? `@${data.handle}` : null, + }); + } + + return eligible; +} + +/** + * Atomically transfers club ownership from current owner to an active member. + * Strictly verifies caller is current owner and target is active member. + * Caller is demoted to admin; target is promoted to owner. + * Preserves atomic rules contract (Case 1). + */ +export async function transferClubOwnership( + clubId: string, + currentOwnerUid: string, + newOwnerUid: string +): Promise { + if (!currentOwnerUid) { + throw new Error('Authentication required.'); + } + if (!newOwnerUid || currentOwnerUid === newOwnerUid) { + throw new Error('Invalid ownership transfer target.'); + } + + const clubRef = doc(firestore, 'clubs', clubId); + const currentOwnerMemberRef = doc(firestore, 'clubs', clubId, 'members', currentOwnerUid); + const newOwnerMemberRef = doc(firestore, 'clubs', clubId, 'members', newOwnerUid); + + 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 transfer ownership.'); + } + if (cd.ownerUid !== currentOwnerUid) { + throw new Error('Only the club owner can transfer ownership.'); + } + + const newOwnerSnap = await tx.get(newOwnerMemberRef); + if (!newOwnerSnap.exists()) { + throw new Error('Target member does not exist in this club.'); + } + const md = newOwnerSnap.data(); + if (md.status !== 'active') { + throw new Error('Target rider must be an active club member to receive ownership.'); + } + + const now = serverTimestamp(); + + // 1. Update club root ownerUid + tx.update(clubRef, { + ownerUid: newOwnerUid, + updatedAt: now, + }); + + // 2. Demote current owner to admin + tx.update(currentOwnerMemberRef, { + role: 'admin', + updatedAt: now, + }); + + // 3. Promote new owner to owner role + tx.update(newOwnerMemberRef, { + role: 'owner', + updatedAt: now, + }); + }); +} + +/** + * Soft-deletes/archives a Club. Only permitted by the Owner. + * Removes club from discovery queries while preserving all historical records. + * Club deletion is NOT supported. + */ +export async function archiveClub( + clubId: string, + callerUid: string +): Promise { + if (!callerUid) { + throw new Error('Authentication required.'); + } + + const clubRef = doc(firestore, 'clubs', clubId); + const clubSnap = await getDoc(clubRef); + if (!clubSnap.exists()) { + throw new Error('Club not found.'); + } + + const cd = clubSnap.data(); + if (cd.archivedAt) { + throw new Error('Club is already archived.'); + } + + if (cd.ownerUid !== callerUid) { + throw new Error('Permission denied. Only the club owner can archive the club.'); + } + + const now = serverTimestamp(); + await updateDoc(clubRef, { + archivedAt: now, + updatedAt: now, + }); +} + + diff --git a/website/tests/founder-portal.test.cjs b/website/tests/founder-portal.test.cjs index b1f206d..218d7dd 100644 --- a/website/tests/founder-portal.test.cjs +++ b/website/tests/founder-portal.test.cjs @@ -33,6 +33,7 @@ function loadTsModule(relativePath) { collectionGroup: () => ({}), updateDoc: async () => {}, serverTimestamp: () => new Date(), + runTransaction: async (_db, cb) => cb({ get: async () => ({ exists: () => false }), update: () => {} }), }; } if (specifier.startsWith('.')) { @@ -61,6 +62,10 @@ const { fetchManageableClubs, fetchFounderClubContext, updateClubProfile, + updateClubAccessSettings, + fetchEligibleTransferMembers, + transferClubOwnership, + archiveClub, } = clubData; const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures; @@ -568,7 +573,150 @@ async function runAllTests() { console.log(' PASS: FP-13.1 Tests A through J verified (Footer removed, verified badge removed, stage terms removed, internal ID removed, media labels removed, preview identity-only, non-editable visibility/admission, O(1) reads, whitelist mutation, fixture safety).'); - console.log('--- ALL FOUNDER PORTAL TESTS PASSED (21/21) ---'); + // ========================================================================= + // TEST 22: FP-14 Club Settings Implementation Suite (Tests A through X) + // ========================================================================= + console.log('Testing: FP-14 Settings Suite (Tests A - X)...'); + + const settingsPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/settings/page.tsx'), 'utf8'); + const settingsEditorSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-settings-editor.tsx'), 'utf8'); + + // A. Settings auth guard + assert(settingsPageSrc.includes('firebaseAuth.currentUser'), 'Settings page must check firebaseAuth.currentUser'); + assert(settingsPageSrc.includes('setUnauthenticated(true)'), 'Settings page must set unauthenticated on missing user'); + assert(settingsPageSrc.includes('/login'), 'Settings page must direct unauthenticated users to /login'); + + // B. Owner/Admin page authorization + assert(settingsPageSrc.includes('ctx.permissions.canManageSettings'), 'Settings page must check canManageSettings'); + const ownerPerms = getPortalPermissions('owner'); + const adminPerms = getPortalPermissions('admin'); + assert.equal(ownerPerms.canManageSettings, true, 'Owner must be authorized to manage settings'); + assert.equal(adminPerms.canManageSettings, true, 'Admin must be authorized to access settings page'); + + // C. Member/non-member denial + const memberPerms = getPortalPermissions('member'); + const unauthPerms = getPortalPermissions('member', false); + assert.equal(memberPerms.canManageSettings, false, 'Member must be denied settings management access'); + assert.equal(unauthPerms.canManageSettings, false, 'Unauthenticated caller must be denied settings access'); + assert(settingsPageSrc.includes('Management Access Restricted'), 'Settings page must show restricted state for members'); + + // D. visibility enum exactly public/unlisted/private + const updateAccessBody = clubDataSrc.substring(clubDataSrc.indexOf('function updateClubAccessSettings'), clubDataSrc.indexOf('function fetchEligibleTransferMembers')); + assert(updateAccessBody.includes("'public', 'unlisted', 'private'"), 'updateClubAccessSettings must accept only public, unlisted, private'); + + // E. admission enum exactly open/request/inviteOnly + assert(updateAccessBody.includes("'open', 'request', 'inviteOnly'"), 'updateClubAccessSettings must accept only open, request, inviteOnly'); + + // F. access mutation whitelist + assert(updateAccessBody.includes('updateDoc(clubRef, {'), 'updateClubAccessSettings must call updateDoc'); + assert(updateAccessBody.includes('visibility: input.visibility,'), 'updateClubAccessSettings must write visibility'); + assert(updateAccessBody.includes('admissionPolicy: input.admissionPolicy,'), 'updateClubAccessSettings must write admissionPolicy'); + assert(updateAccessBody.includes('updatedAt: serverTimestamp(),'), 'updateClubAccessSettings must write updatedAt'); + assert(updateAccessBody.includes("m.role === 'owner' || m.role === 'admin'"), 'updateClubAccessSettings must authorize active owner and active admin'); + assert(updateAccessBody.includes("m.status === 'active'"), 'updateClubAccessSettings must verify active membership status'); + assert(!updateAccessBody.includes('slug:'), 'updateClubAccessSettings MUST NOT write slug'); + assert(!updateAccessBody.includes('ownerUid:'), 'updateClubAccessSettings MUST NOT write ownerUid'); + assert(!updateAccessBody.includes('memberCount:'), 'updateClubAccessSettings MUST NOT write memberCount'); + assert(!updateAccessBody.includes('archivedAt:'), 'updateClubAccessSettings MUST NOT write archivedAt'); + assert(!updateAccessBody.includes('name:'), 'updateClubAccessSettings MUST NOT write name'); + assert(!updateAccessBody.includes('description:'), 'updateClubAccessSettings MUST NOT write description'); + + // G. slug immutable + assert(settingsEditorSrc.includes('IMMUTABLE RESERVATION'), 'Settings editor must show immutable reservation badge for slug'); + assert(settingsEditorSrc.includes('Club handles are permanently reserved and cannot be renamed or reassigned.'), 'Settings editor must contain canonical immutable handle copy'); + assert(!settingsEditorSrc.includes('name="slug"'), 'Settings editor must not have slug input'); + + // H. rank does not escalate permissions + assert.equal(memberPerms.canTransferOwnership, false, 'Rank does not grant transfer ownership'); + assert.equal(memberPerms.canManageSettings, false, 'Rank does not grant manage settings'); + assert(settingsEditorSrc.includes('canManageAccess'), 'Settings editor must allow access settings management for owner and admin'); + assert(settingsEditorSrc.includes('!canManageAccess'), 'Settings editor must disable access settings for non-leadership'); + + // I. owner-only ownership transfer + assert.equal(ownerPerms.canTransferOwnership, true, 'Owner can transfer ownership'); + assert.equal(adminPerms.canTransferOwnership, false, 'Admin cannot transfer ownership'); + const transferBody = clubDataSrc.substring(clubDataSrc.indexOf('function transferClubOwnership'), clubDataSrc.indexOf('function archiveClub')); + assert(transferBody.includes('cd.ownerUid !== currentOwnerUid'), 'transferClubOwnership must check caller is owner'); + + // J. transfer target must be active member + assert(transferBody.includes("md.status !== 'active'"), 'transferClubOwnership must verify target is active member'); + + // K. ownership transfer role transition semantics + assert(transferBody.includes("ownerUid: newOwnerUid"), 'transferClubOwnership must update club root ownerUid to newOwnerUid'); + assert(transferBody.includes("role: 'admin'"), 'transferClubOwnership must demote current owner to admin'); + assert(transferBody.includes("role: 'owner'"), 'transferClubOwnership must promote new owner to owner'); + + // L. admin cannot transfer ownership + assert(settingsEditorSrc.includes('Transfer Ownership...'), 'Settings editor must include Transfer Ownership trigger'); + assert(settingsEditorSrc.includes('!isOwner'), 'Transfer trigger must be disabled for non-owners'); + + // M. owner-only archive + assert.equal(ownerPerms.role, 'owner', 'Owner role is owner'); + assert.equal(adminPerms.role, 'admin', 'Admin role is admin'); + assert.equal(ownerPerms.canTransferOwnership, true, 'Owner has exclusive owner transfer authority'); + assert.equal(adminPerms.canTransferOwnership, false, 'Admin lacks owner authority'); + const archiveBody = clubDataSrc.substring(clubDataSrc.indexOf('function archiveClub')); + assert(archiveBody.includes('cd.ownerUid !== callerUid'), 'archiveClub must verify caller is owner'); + + // N. admin cannot archive + assert(archiveBody.includes('Permission denied. Only the club owner can archive the club.'), 'archiveClub must throw on non-owner'); + + // O. archive is not delete + assert(archiveBody.includes('archivedAt: now,'), 'archiveClub must set archivedAt'); + assert(!archiveBody.includes('deleteDoc('), 'archiveClub MUST NOT delete document'); + + // P. no unarchive implementation + assert(!clubDataSrc.includes('unarchiveClub'), 'Must NOT implement unarchiveClub'); + assert(!settingsEditorSrc.includes('Unarchive'), 'Settings editor MUST NOT have Unarchive control'); + + // Q. no personal telemetry/account/billing data + assert(!settingsEditorSrc.includes('leanAngle'), 'Settings editor must not reference leanAngle'); + assert(!settingsEditorSrc.includes('speedMph'), 'Settings editor must not reference speedMph'); + assert(!settingsEditorSrc.includes('gForce'), 'Settings editor must not reference gForce'); + assert(!settingsEditorSrc.includes('stripe'), 'Settings editor must not reference billing'); + + // R. production fixture isolation + let transferProdThrew = false; + try { + process.env.NODE_ENV = 'production'; + await fetchEligibleTransferMembers('test-club', ''); + } catch { + transferProdThrew = true; + } finally { + process.env.NODE_ENV = 'test'; + } + assert.equal(transferProdThrew, true, 'fetchEligibleTransferMembers must not return mock in production without auth'); + + // S. sanitized errors + assert(settingsEditorSrc.includes('sanitizePortalError'), 'Settings editor must use sanitizePortalError'); + + // T. Settings route/sub-navigation state + assert(sidebarSrc.includes("if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;"), 'FounderSidebar must route to settings'); + assert(settingsPageSrc.includes('activeNav="settings"'), 'Settings page must pass activeNav="settings"'); + assert(settingsPageSrc.includes(' { @@ -577,3 +725,4 @@ runAllTests().catch((err) => { }); +