feat: add founder club settings and governance lifecycle
This commit is contained in:
parent
5eb47e8c09
commit
d544292a3c
|
|
@ -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}`), {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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 = <String, dynamic>{
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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 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 (
|
||||
<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 settings."
|
||||
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 Settings."
|
||||
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="Settings Unavailable"
|
||||
message={error ?? "Club settings couldn't be loaded."}
|
||||
actionLabel="Return to Clubs"
|
||||
onAction={() => router.push('/clubs')}
|
||||
onRetry={loadData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FounderPortalShell
|
||||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
activeNav="settings"
|
||||
pendingApplicationsCount={4}
|
||||
upcomingRidesCount={2}
|
||||
>
|
||||
<FounderSettingsEditor
|
||||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
callerUid={context.currentUser.uid}
|
||||
isDemoFixture={context.isDemoFixture}
|
||||
onClubUpdated={(updated) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FounderPortalShell>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 = (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${
|
||||
isActive && isProfile ? 'text-secondary' : ''
|
||||
isActive && isSubRoute ? 'text-secondary' : ''
|
||||
}`}
|
||||
style={isActive && isProfile ? { fontVariationSettings: "'FILL' 1" } : undefined}
|
||||
style={isActive && isSubRoute ? { fontVariationSettings: "'FILL' 1" } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className={isActive && isProfile ? 'text-secondary font-semibold' : ''}>
|
||||
<span className={isActive && isSubRoute ? 'text-secondary font-semibold' : ''}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -117,7 +120,7 @@ export function FounderSidebar({
|
|||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
{isActive && isProfile && !item.badge && (
|
||||
{isActive && isSubRoute && !item.badge && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
||||
)}
|
||||
</>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@ export function FounderTopBar({
|
|||
<span className="text-secondary font-semibold uppercase">CLUB PROFILE</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'settings' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span className="text-secondary font-semibold uppercase">SETTINGS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center shrink-0">
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<ClubMember[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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('<FounderPortalShell'), 'Settings page must reuse FounderPortalShell');
|
||||
|
||||
// U. Profile fields are not editable from Settings
|
||||
assert(!settingsEditorSrc.includes('name="name"'), 'Settings editor must not have name field input');
|
||||
assert(!settingsEditorSrc.includes('name="description"'), 'Settings editor must not have description input');
|
||||
assert(!settingsEditorSrc.includes('name="homeRegion"'), 'Settings editor must not have homeRegion input');
|
||||
assert(!settingsEditorSrc.includes('name="motorcycleFocus"'), 'Settings editor must not have motorcycleFocus input');
|
||||
|
||||
// V. no hard delete
|
||||
assert(!settingsEditorSrc.includes('deleteDoc'), 'Settings editor must NOT call deleteDoc');
|
||||
assert(!clubDataSrc.includes('deleteDoc('), 'club-data.ts must NOT call deleteDoc');
|
||||
|
||||
// W. bounded ownership-target query
|
||||
const eligibleBody = clubDataSrc.substring(clubDataSrc.indexOf('function fetchEligibleTransferMembers'), clubDataSrc.indexOf('function transferClubOwnership'));
|
||||
assert(eligibleBody.includes('limit(25)'), 'fetchEligibleTransferMembers must bound query with limit(25)');
|
||||
assert(eligibleBody.includes("where('status', '==', 'active')"), 'fetchEligibleTransferMembers must query only active members');
|
||||
|
||||
// X. no N+1 profile query architecture
|
||||
assert(!eligibleBody.includes('public_profiles'), 'fetchEligibleTransferMembers must NOT query public_profiles');
|
||||
assert(!eligibleBody.includes('getDoc('), 'fetchEligibleTransferMembers must NOT call getDoc in loop');
|
||||
|
||||
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) ---');
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
|
|
@ -577,3 +725,4 @@ runAllTests().catch((err) => {
|
|||
});
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue