Merge pull request #6 from bponders12-cell/fix/clubs-management-repair

fix(web): repair clubs management experience
This commit is contained in:
Billie Ponder 2026-09-20 02:53:53 -04:00 committed by GitHub
commit e784c4895d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 814 additions and 176 deletions

View File

@ -3013,6 +3013,120 @@ describe('Social Architecture Security Rules', () => {
}));
});
test('FP-14.3: Club logo/banner HTTPS URL invariant enforced at the storage layer', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await setDoc(doc(db, 'clubs/club_image_url_sec'), {
id: 'club_image_url_sec',
name: 'Image URL Security MC',
slug: 'image-url-security-mc',
visibility: 'public',
admissionPolicy: 'request',
ownerUid: 'alice',
memberCount: 3,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await setDoc(doc(db, 'clubs/club_image_url_sec/members/alice'), {
uid: 'alice',
clubId: 'club_image_url_sec',
role: 'owner',
status: 'active',
});
await setDoc(doc(db, 'clubs/club_image_url_sec/members/bob'), {
uid: 'bob',
clubId: 'club_image_url_sec',
role: 'admin',
status: 'active',
});
await setDoc(doc(db, 'clubs/club_image_url_sec/members/charlie'), {
uid: 'charlie',
clubId: 'club_image_url_sec',
role: 'member',
status: 'active',
displayRank: 'President',
});
});
const clubDocRef = (db) => doc(db, 'clubs/club_image_url_sec');
// 1. Owner sets valid https logo/banner -> ALLOW
await assertSucceeds(updateDoc(clubDocRef(alice), {
logoUrl: 'https://example.com/logo.png',
bannerUrl: 'https://cdn.example.com/banner.jpg',
updatedAt: new Date(),
}));
// 2. Owner clears logo with null and banner with empty string -> ALLOW
await assertSucceeds(updateDoc(clubDocRef(alice), {
logoUrl: null,
updatedAt: new Date(),
}));
await assertSucceeds(updateDoc(clubDocRef(alice), {
bannerUrl: '',
updatedAt: new Date(),
}));
// 3. Admin sets valid https URLs -> ALLOW
await assertSucceeds(updateDoc(clubDocRef(bob), {
logoUrl: 'https://example.com/admin-logo.png',
updatedAt: new Date(),
}));
// 4. Owner http:// URL -> DENY (insecure scheme)
await assertFails(updateDoc(clubDocRef(alice), {
logoUrl: 'http://evil.example.com/logo.png',
updatedAt: new Date(),
}));
// 5. Owner data: URL -> DENY
await assertFails(updateDoc(clubDocRef(alice), {
logoUrl: 'data:image/png;base64,iVBORw0KGgo=',
updatedAt: new Date(),
}));
// 6. Owner blob: URL -> DENY
await assertFails(updateDoc(clubDocRef(alice), {
bannerUrl: 'blob:https://example.com/fake-uuid',
updatedAt: new Date(),
}));
// 7. Owner javascript: URL -> DENY
await assertFails(updateDoc(clubDocRef(alice), {
bannerUrl: 'javascript:alert(1)',
updatedAt: new Date(),
}));
// 8. Owner malformed value -> DENY
await assertFails(updateDoc(clubDocRef(alice), {
logoUrl: 'not-a-url',
updatedAt: new Date(),
}));
// 9. Owner non-string URL value -> DENY
await assertFails(updateDoc(clubDocRef(alice), {
logoUrl: 42,
updatedAt: new Date(),
}));
// 10. Admin http:// URL -> DENY (same invariant for admins)
await assertFails(updateDoc(clubDocRef(bob), {
bannerUrl: 'http://evil.example.com/banner.jpg',
updatedAt: new Date(),
}));
// 11. Regular member writes valid https URL -> DENY (no management authority at all)
await assertFails(updateDoc(clubDocRef(charlie), {
logoUrl: 'https://example.com/member-logo.png',
updatedAt: new Date(),
}));
});
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();

View File

@ -701,6 +701,16 @@ service cloud.firestore {
return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid));
}
// Club logo/banner URLs: null or empty string clears; otherwise must be
// an https:// URL (case-insensitive scheme, matching URL() semantics in the app layer).
function isValidClubImageUrl(url) {
return url == null
|| url == ''
|| (url is string
&& url.size() <= 2048
&& url.matches('(?i)^https://\\S+$'));
}
function isValidMemberCountIncrement() {
let targetUid = ('lastMembershipChangeUid' in request.resource.data)
? request.resource.data.lastMembershipChangeUid
@ -779,6 +789,8 @@ service cloud.firestore {
&& (!('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'])
&& (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl))
&& (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl))
) ||
// Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount)
(
@ -790,6 +802,8 @@ service cloud.firestore {
&& (!('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'])
&& (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl))
&& (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl))
) ||
// Case 4: Member count increment/decrement by membership transitions ONLY
(

View File

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import {
fetchFounderClubContext,
FounderClubContext,
fetchClubApplications,
sanitizePortalError,
} from '../../../../../lib/club-data';
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
@ -23,6 +24,7 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
const searchParams = useSearchParams();
const [context, setContext] = useState<FounderClubContext | null>(null);
const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [unauthenticated, setUnauthenticated] = useState(false);
@ -58,6 +60,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
}
setContext(ctx);
try {
const apps = await fetchClubApplications(ctx.club.id, currentUser?.uid, {
allowDemoPreview: isDemoPreview,
});
setPendingApplicationsCount(apps.length);
} catch {
setPendingApplicationsCount(0);
}
} catch (err) {
setError(sanitizePortalError(err, "Club applications couldn't be loaded."));
} finally {
@ -71,8 +82,10 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
if (loading) {
return (
<div className="min-h-screen bg-surface p-6">
<OverviewSkeleton />
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton />
</div>
</div>
);
}
@ -125,14 +138,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
club={context.club}
permissions={context.permissions}
activeNav="applications"
pendingApplicationsCount={4}
upcomingRidesCount={2}
pendingApplicationsCount={pendingApplicationsCount}
upcomingRidesCount={0}
>
<FounderApplications
club={context.club}
permissions={context.permissions}
callerUid={context.currentUser.uid}
isDemoFixture={context.isDemoFixture}
onApplicationsCountChange={setPendingApplicationsCount}
/>
</FounderPortalShell>
);

View File

@ -71,8 +71,10 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
if (loading) {
return (
<div className="min-h-screen bg-surface p-6">
<OverviewSkeleton />
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton />
</div>
</div>
);
}
@ -125,8 +127,8 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
club={context.club}
permissions={context.permissions}
activeNav="profile"
pendingApplicationsCount={4}
upcomingRidesCount={2}
pendingApplicationsCount={0}
upcomingRidesCount={0}
>
<FounderProfileEditor
club={context.club}

View File

@ -0,0 +1,168 @@
'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 { RolesAndRanksSummary } from '../../../../../components/founder-portal/roles-and-ranks-summary';
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 ClubFounderRanksRolesPage({ 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 Ranks & Roles; Members cannot
if (!isDemoPreview && !ctx.permissions.canManageSettings) {
setUnauthorized(true);
setLoading(false);
return;
}
setContext(ctx);
} catch (err) {
setError(sanitizePortalError(err, "Ranks and roles couldn't be loaded."));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData();
}, [clubId]);
if (loading) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton />
</div>
</div>
);
}
// Unauthenticated safe error state
if (unauthenticated) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState
title="Authentication Required"
message="Sign in with your rider account to access club ranks and roles."
actionLabel="Sign In"
onAction={() => router.push('/login')}
/>
</div>
</div>
);
}
// Unauthorized (Member or non-admin) safe error state
if (unauthorized) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState
title="Management Access Restricted"
message="You must be an Owner or Admin of this Club to access Ranks & Roles."
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
/>
</div>
</div>
);
}
if (error || !context) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState
title="Ranks & Roles Unavailable"
message={error ?? "Ranks and roles couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
</div>
);
}
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="ranks_roles"
pendingApplicationsCount={0}
upcomingRidesCount={0}
>
<div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* Header */}
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
<div>
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-brand-cyan tracking-widest bg-brand-cyan/10 px-2.5 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
Governance &amp; Authority
</span>
<span className="font-mono text-xs text-slate-400 tracking-wider">
// {context.club.name}
</span>
</div>
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-white mt-2 tracking-tight">
Ranks &amp; Roles
</h1>
<p className="font-body text-sm text-slate-400 mt-1">
Administrative role authority vs honorary squad rank designations.
</p>
</div>
</section>
{/* Roles and Ranks Summary Component */}
<RolesAndRanksSummary
ownerName={context.ownerProfile?.displayName || context.club.founderName || 'Club Founder'}
memberCount={context.club.memberCount}
/>
</div>
</FounderPortalShell>
);
}

View File

@ -71,8 +71,10 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
if (loading) {
return (
<div className="min-h-screen bg-surface p-6">
<OverviewSkeleton />
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton />
</div>
</div>
);
}
@ -80,13 +82,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
// 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 className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState
title="Authentication Required"
message="Sign in with your rider account to access club settings."
actionLabel="Sign In"
onAction={() => router.push('/login')}
/>
</div>
</div>
);
}
@ -94,13 +98,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
// 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 className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<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>
</div>
);
}
@ -108,14 +114,16 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
// 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 className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState
title="Settings Unavailable"
message={error ?? "Club settings couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
</div>
);
}
@ -125,8 +133,8 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
club={context.club}
permissions={context.permissions}
activeNav="settings"
pendingApplicationsCount={4}
upcomingRidesCount={2}
pendingApplicationsCount={0}
upcomingRidesCount={0}
>
<FounderSettingsEditor
club={context.club}

View File

@ -261,7 +261,7 @@ export default function ClubManageDiscoveryPage() {
</div>
<div className="flex items-center gap-2 self-end sm:self-center font-mono text-xs text-brand-cyan group-hover:translate-x-1 transition-transform shrink-0 font-medium">
<span>Manage Cockpit</span>
<span>Manage Club</span>
<span className="material-symbols-outlined text-[20px]">
arrow_forward
</span>
@ -276,7 +276,7 @@ export default function ClubManageDiscoveryPage() {
<span className="material-symbols-outlined text-[18px] text-brand-cyan">shield</span>
<span>Only clubs where you are an active Owner or Admin appear here.</span>
</div>
<Link href="/clubs" className="font-mono text-xs text-brand-cyan hover:underline">
<Link href="/clubs#directory" className="font-mono text-xs text-brand-cyan hover:underline">
All Public Clubs →
</Link>
</div>

View File

@ -62,7 +62,7 @@ export function AttentionSection({
</div>
{/* 3 Triage Cards Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{/* Card 1: Membership Applications */}
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
<div>
@ -100,9 +100,9 @@ export function AttentionSection({
{pendingApplications.slice(0, 3).map((app) => (
<div
key={app.uid}
className="p-2.5 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-between gap-2"
className="p-2.5 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-between gap-2 flex-wrap"
>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="font-body text-xs font-semibold text-white truncate">
{app.displayName ?? 'New Rider'}
</div>
@ -111,7 +111,7 @@ export function AttentionSection({
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
{permissions.canApproveApplications && (
<button
onClick={() => onApproveApplication?.(app.uid)}

View File

@ -103,7 +103,7 @@ export function ClubMasthead({
<div className="flex items-center gap-1.5">
<span className="text-slate-500">Founder:</span>
<span className="text-slate-200 font-medium">
{club.founderName ?? 'Marcus Vance'}
{club.founderName ?? 'Club Founder'}
</span>
{club.founderHandle && (
<span className="font-mono text-[11px] text-brand-cyan">

View File

@ -1,12 +1,15 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ClubUpcomingRide, UserPortalPermissions } from './types';
import { EmptyState } from './empty-state';
interface UpcomingRidesSectionProps {
rides: ClubUpcomingRide[];
permissions: UserPortalPermissions;
clubId?: string;
onPlanRide?: () => void;
onManageRide?: (rideId: string) => void;
onViewRoster?: (rideId: string) => void;
@ -16,16 +19,20 @@ interface UpcomingRidesSectionProps {
export function ClubRideCard({
ride,
permissions,
clubId,
onManageRide,
onViewRoster,
onViewRoute,
}: {
ride: ClubUpcomingRide;
permissions: UserPortalPermissions;
clubId?: string;
onManageRide?: (rideId: string) => void;
onViewRoster?: (rideId: string) => void;
onViewRoute?: (routeId?: string) => void;
}) {
const params = useParams();
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75;
return (
@ -144,8 +151,10 @@ export function ClubRideCard({
)}
<button
onClick={() => onViewRoster?.(ride.id)}
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors cursor-pointer"
type="button"
disabled
title="Member roster view is planned for a future release"
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs font-medium cursor-not-allowed flex items-center justify-center"
>
View Roster ({ride.confirmedCount})
</button>
@ -166,11 +175,15 @@ export function ClubRideCard({
export function UpcomingRidesSection({
rides,
permissions,
clubId,
onPlanRide,
onManageRide,
onViewRoster,
onViewRoute,
}: UpcomingRidesSectionProps) {
const params = useParams();
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
return (
<section className="space-y-4" aria-label="Upcoming Club Rides">
<div className="flex items-center justify-between">
@ -184,13 +197,13 @@ export function UpcomingRidesSection({
</div>
{rides.length > 0 && (
<button
onClick={() => onViewRoster?.('all')}
className="font-mono text-xs text-brand-cyan hover:underline flex items-center gap-1 cursor-pointer"
<span
title="Full ride schedule is planned for a future release"
className="font-mono text-xs text-slate-500 flex items-center gap-1 cursor-not-allowed"
>
<span>Full Ride Schedule</span>
<span className="material-symbols-outlined text-[13px]">arrow_forward</span>
</button>
<span className="material-symbols-outlined text-[13px] text-slate-600">schedule</span>
</span>
)}
</div>
@ -210,6 +223,7 @@ export function UpcomingRidesSection({
key={ride.id}
ride={ride}
permissions={permissions}
clubId={targetClubId}
onManageRide={onManageRide}
onViewRoster={onViewRoster}
onViewRoute={onViewRoute}

View File

@ -15,6 +15,7 @@ interface FounderApplicationsProps {
permissions: UserPortalPermissions;
callerUid: string;
isDemoFixture?: boolean;
onApplicationsCountChange?: (count: number) => void;
}
export function FounderApplications({
@ -22,6 +23,7 @@ export function FounderApplications({
permissions,
callerUid,
isDemoFixture = false,
onApplicationsCountChange,
}: FounderApplicationsProps) {
const [applications, setApplications] = useState<ClubApplication[]>([]);
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
@ -47,6 +49,7 @@ export function FounderApplications({
allowDemoPreview: isDemoFixture,
});
setApplications(data);
onApplicationsCountChange?.(data.length);
if (data.length > 0) {
setSelectedApplicantUid((prev) => prev ?? data[0].uid);
}
@ -122,13 +125,14 @@ export function FounderApplications({
await approveClubApplication(club.id, callerUid, applicant.uid);
}
// Remove from list and increment member count
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid));
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setApplications(remaining);
onApplicationsCountChange?.(remaining.length);
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) {
@ -148,12 +152,13 @@ export function FounderApplications({
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));
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setApplications(remaining);
onApplicationsCountChange?.(remaining.length);
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) {
@ -164,7 +169,7 @@ export function FounderApplications({
}
return (
<div className="space-y-6">
<div className="max-w-6xl mx-auto space-y-6">
{/* 1. PAGE HEADER */}
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
@ -352,34 +357,35 @@ export function FounderApplications({
</div>
) : applications.length === 0 ? (
/* Empty State 1: Default zero-pending queue */
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 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 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 className="rounded-2xl glass-card border border-brand-border/60 p-8 sm:p-12 text-center shadow-xl relative overflow-hidden max-w-2xl mx-auto">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-brand-cyan/10 to-transparent" />
<div className="w-14 h-14 rounded-2xl bg-surface-container-high border border-brand-border/80 flex items-center justify-center mx-auto mb-4 text-slate-400 shadow-md">
<span className="material-symbols-outlined text-2xl text-slate-300">person_add_disabled</span>
</div>
<h3 className="text-base font-headline font-bold text-white tracking-tight">No Pending Applications</h3>
<p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
There are no membership requests waiting for review. New requests will appear here when riders apply to join this Club.
</p>
</div>
) : filteredApplications.length === 0 ? (
/* Empty State 2: Filter zero-match */
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 flex items-center justify-center mx-auto text-amber-400">
<div className="rounded-2xl glass-card border border-brand-border/60 p-8 sm:p-12 text-center shadow-xl relative overflow-hidden max-w-2xl mx-auto">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-amber-400/50 via-amber-400/10 to-transparent" />
<div className="w-14 h-14 rounded-2xl bg-surface-container-high border border-brand-border/80 flex items-center justify-center mx-auto mb-4 text-amber-400 shadow-md">
<span className="material-symbols-outlined text-2xl">search_off</span>
</div>
<div>
<h3 className="text-sm font-semibold text-white font-mono">No Applications Match</h3>
<p className="text-xs text-slate-400 mt-1">
No pending applications match &quot;{searchQuery}&quot;.
</p>
<h3 className="text-base font-headline font-bold text-white tracking-tight">No Applications Match</h3>
<p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
No pending applications match &quot;{searchQuery}&quot;.
</p>
<div className="mt-4">
<button
type="button"
onClick={() => setSearchQuery('')}
className="text-xs text-brand-cyan hover:underline font-medium inline-flex items-center space-x-1 mt-2"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-brand-border/80 text-xs font-mono text-brand-cyan transition-colors"
>
<span>Clear Search</span>
<span>→</span>
<span className="material-symbols-outlined text-sm">close</span>
</button>
</div>
</div>
@ -387,15 +393,15 @@ export function FounderApplications({
/* 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 rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg flex flex-col">
<div className="lg:col-span-7 xl:col-span-7 rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg flex flex-col">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<table className="w-full text-left border-collapse table-auto">
<thead>
<tr className="border-b border-brand-border/60 bg-surface-container-high/60 text-[11px] font-mono text-slate-400 tracking-wider">
<th className="py-3.5 px-4 font-medium">Rider</th>
<th className="py-3.5 px-4 font-medium">Requested</th>
<th className="py-3.5 px-4 font-medium">Status</th>
<th className="py-3.5 px-4 font-medium text-right">Actions</th>
<th className="py-3.5 px-4 font-medium w-[38%]">Rider</th>
<th className="py-3.5 px-4 font-medium w-[20%] whitespace-nowrap">Requested</th>
<th className="py-3.5 px-4 font-medium w-[16%] whitespace-nowrap">Status</th>
<th className="py-3.5 px-4 font-medium w-[26%] text-right whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-brand-border/40 text-xs">
@ -490,7 +496,7 @@ export function FounderApplications({
{/* Right Column: Selected Application Inspector Drawer */}
{selectedApplicant && (
<div className="lg:col-span-5 xl:col-span-4 space-y-4">
<div className="lg:col-span-5 xl:col-span-5 space-y-4">
<div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative">
{/* Top Cyan Accent Line */}
<div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" />

View File

@ -1,6 +1,7 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Club,
ClubMetrics,
@ -36,6 +37,7 @@ export function FounderOverview({
permissions,
onRefresh,
}: FounderOverviewProps) {
const router = useRouter();
const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps);
const [rides] = useState<ClubUpcomingRide[]>(initialRides);
const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics);
@ -142,9 +144,16 @@ export function FounderOverview({
<UpcomingRidesSection
rides={rides}
permissions={permissions}
clubId={club.slug || club.id}
onPlanRide={() => setActiveModal('plan_ride')}
onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)}
onViewRoster={(rideId) => showToast(`Displaying confirmed rider roster for Ride ID: ${rideId}`)}
onViewRoster={(rideId) => {
if (rideId === 'all') {
showToast('Full ride schedule is planned for a future release.');
} else {
showToast('Member roster details will be available in an upcoming release.');
}
}}
onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)}
/>

View File

@ -28,8 +28,8 @@ export function FounderPortalShell({
onPostAnnouncement,
onSwitchRole,
onSwitchClub,
pendingApplicationsCount = 4,
upcomingRidesCount = 2,
pendingApplicationsCount = 0,
upcomingRidesCount = 0,
children,
}: FounderPortalShellProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);

View File

@ -25,25 +25,51 @@ export function FounderProfileEditor({
const [description, setDescription] = useState(club.description || '');
const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
const [logoUrl, setLogoUrl] = useState(club.logoUrl || '');
const [bannerUrl, setBannerUrl] = useState(club.bannerUrl || '');
// Save states
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Image URL validation (HTTPS only or empty to clear)
function isValidHttpsImageUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return true;
try {
const parsed = new URL(trimmed);
return parsed.protocol === 'https:';
} catch {
return false;
}
}
// Validation
const trimmedName = name.trim();
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
const isDescValid = description.trim().length <= 500;
const isRegionValid = homeRegion.trim().length <= 100;
const isFocusValid = motorcycleFocus.trim().length <= 100;
const canSave = isNameValid && isDescValid && isRegionValid && isFocusValid && !isSaving && permissions.canManageSettings;
const isLogoUrlValid = isValidHttpsImageUrl(logoUrl);
const isBannerUrlValid = isValidHttpsImageUrl(bannerUrl);
const canSave =
isNameValid &&
isDescValid &&
isRegionValid &&
isFocusValid &&
isLogoUrlValid &&
isBannerUrlValid &&
!isSaving &&
permissions.canManageSettings;
const isDirty =
trimmedName !== (club.name || '').trim() ||
description.trim() !== (club.description || '').trim() ||
homeRegion.trim() !== (club.homeRegion || '').trim() ||
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim();
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim() ||
logoUrl.trim() !== (club.logoUrl || '').trim() ||
bannerUrl.trim() !== (club.bannerUrl || '').trim();
// Short initials for preview emblem
const initials = name
@ -61,6 +87,9 @@ export function FounderProfileEditor({
setSaveStatus('idle');
setErrorMessage(null);
const trimmedLogo = logoUrl.trim();
const trimmedBanner = bannerUrl.trim();
try {
if (isDemoFixture) {
// Simulated latency in development demo preview mode
@ -71,6 +100,8 @@ export function FounderProfileEditor({
description: description.trim(),
homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo || null,
bannerUrl: trimmedBanner || null,
});
} else {
if (!callerUid) {
@ -81,6 +112,8 @@ export function FounderProfileEditor({
description: description.trim(),
homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo,
bannerUrl: trimmedBanner,
});
setSaveStatus('success');
onProfileUpdated?.({
@ -88,6 +121,8 @@ export function FounderProfileEditor({
description: description.trim(),
homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo || null,
bannerUrl: trimmedBanner || null,
});
}
} catch (err) {
@ -99,7 +134,7 @@ export function FounderProfileEditor({
}
return (
<div className="space-y-6 pb-12">
<div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* 3. PAGE HEADER */}
<section className="border-b border-brand-border/60 pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
@ -123,7 +158,7 @@ export function FounderProfileEditor({
{/* 4. TOP 4 METRIC CARDS */}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Club Name */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Club Name</span>
@ -136,7 +171,7 @@ export function FounderProfileEditor({
</div>
{/* Card 2: Home Region */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Home Region</span>
@ -151,7 +186,7 @@ export function FounderProfileEditor({
</div>
{/* Card 3: Motorcycle Focus */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Motorcycle Focus</span>
@ -166,7 +201,7 @@ export function FounderProfileEditor({
</div>
{/* Card 4: Discovery Presence */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Discovery Presence</span>
@ -217,7 +252,7 @@ export function FounderProfileEditor({
)}
{/* SECTION 1: PUBLIC CLUB IDENTITY */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">badge</span>
@ -306,7 +341,7 @@ export function FounderProfileEditor({
</div>
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">explore</span>
@ -356,8 +391,8 @@ export function FounderProfileEditor({
</div>
</div>
{/* SECTION 3: LOGO & BANNER (Presentation Only) */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
{/* SECTION 3: LOGO & BANNER */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">palette</span>
@ -371,12 +406,23 @@ export function FounderProfileEditor({
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Logo Presentation Card */}
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-xs text-slate-400">
<div className="flex items-center justify-between font-mono text-xs text-slate-400">
<span>Club Emblem</span>
{logoUrl && (
<button
type="button"
onClick={() => setLogoUrl('')}
disabled={!permissions.canManageSettings || isSaving}
className="text-rose-400 hover:text-rose-300 transition-colors text-[11px] font-mono flex items-center gap-1 cursor-pointer"
>
<span className="material-symbols-outlined text-xs">close</span>
<span>Remove</span>
</button>
)}
</div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl flex flex-col items-center justify-center relative overflow-hidden">
{club.logoUrl ? (
<img src={club.logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain" />
{logoUrl ? (
<img src={logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain rounded-lg" />
) : (
<div className="w-20 h-20 rounded-xl border-2 border-brand-cyan bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative">
<span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span>
@ -386,21 +432,50 @@ export function FounderProfileEditor({
</div>
)}
</div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400">
<span className="material-symbols-outlined text-xs">info</span>
<span>Logo and banner editing is not available yet.</span>
<div className="space-y-2 pt-1">
<div className="space-y-1">
<input
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="https://example.com/logo.png"
className={`w-full bg-surface-container-low/90 border ${
!isLogoUrlValid ? 'border-error focus:border-error' : 'border-brand-border/80 focus:border-brand-cyan'
} rounded-xl px-3 py-2 text-xs font-mono text-white outline-none transition-colors`}
/>
{!isLogoUrlValid && (
<p className="font-mono text-[10px] text-error">
Must be a valid HTTPS URL (e.g. https://example.com/logo.png).
</p>
)}
</div>
<p className="font-mono text-[10px] text-slate-400">
Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank.
</p>
</div>
</div>
{/* Banner Presentation Card */}
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-xs text-slate-400">
<div className="flex items-center justify-between font-mono text-xs text-slate-400">
<span>Banner Presentation</span>
{bannerUrl && (
<button
type="button"
onClick={() => setBannerUrl('')}
disabled={!permissions.canManageSettings || isSaving}
className="text-rose-400 hover:text-rose-300 transition-colors text-[11px] font-mono flex items-center gap-1 cursor-pointer"
>
<span className="material-symbols-outlined text-xs">close</span>
<span>Remove</span>
</button>
)}
</div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl relative overflow-hidden flex items-center justify-center">
{club.bannerUrl ? (
{bannerUrl ? (
<img
src={club.bannerUrl}
src={bannerUrl}
alt="Club banner presentation"
className="w-full h-full object-cover"
/>
@ -409,21 +484,39 @@ export function FounderProfileEditor({
<div className="absolute inset-0 bg-[radial-gradient(#00f2fe_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
<div className="flex items-center gap-2 font-mono text-xs text-slate-400 z-10">
<span className="material-symbols-outlined text-base text-brand-cyan">image</span>
<span className="tracking-wider font-semibold text-white">{club.name}</span>
<span className="tracking-wider font-semibold text-white">{name || club.name}</span>
</div>
</div>
)}
</div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400">
<span className="material-symbols-outlined text-xs">info</span>
<span>Logo and banner editing is not available yet.</span>
<div className="space-y-2 pt-1">
<div className="space-y-1">
<input
type="url"
value={bannerUrl}
onChange={(e) => setBannerUrl(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="https://example.com/banner.jpg"
className={`w-full bg-surface-container-low/90 border ${
!isBannerUrlValid ? 'border-error focus:border-error' : 'border-brand-border/80 focus:border-brand-cyan'
} rounded-xl px-3 py-2 text-xs font-mono text-white outline-none transition-colors`}
/>
{!isBannerUrlValid && (
<p className="font-mono text-[10px] text-error">
Must be a valid HTTPS URL (e.g. https://example.com/banner.jpg).
</p>
)}
</div>
<p className="font-mono text-[10px] text-slate-400">
Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank.
</p>
</div>
</div>
</div>
</div>
{/* ACTION DOCKED FOOTER */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl">
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl">
<div className="flex items-center gap-2 font-body text-xs text-slate-300">
<span className="material-symbols-outlined text-brand-cyan text-base">check_circle</span>
<span>Saves public club profile information.</span>
@ -472,9 +565,9 @@ export function FounderProfileEditor({
<div className="rounded-xl bg-surface-container-low border border-brand-border/60 overflow-hidden shadow-lg">
{/* Banner Mockup */}
<div className="h-24 bg-surface-container-high relative overflow-hidden">
{club.bannerUrl ? (
{bannerUrl ? (
<img
src={club.bannerUrl}
src={bannerUrl}
alt="Directory banner preview"
className="w-full h-full object-cover"
/>
@ -488,9 +581,15 @@ export function FounderProfileEditor({
<div className="p-4 pt-0 relative">
{/* Emblem overlapping */}
<div className="-mt-8 w-14 h-14 rounded-xl bg-surface-container-high border-2 border-brand-cyan flex flex-col items-center justify-center text-brand-cyan shadow-md">
<span className="material-symbols-outlined text-xl">two_wheeler</span>
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
<div className="-mt-8 w-14 h-14 rounded-xl bg-surface-container-high border-2 border-brand-cyan flex flex-col items-center justify-center text-brand-cyan shadow-md overflow-hidden">
{logoUrl ? (
<img src={logoUrl} alt="Emblem preview" className="w-full h-full object-cover" />
) : (
<>
<span className="material-symbols-outlined text-xl">two_wheeler</span>
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
</>
)}
</div>
<div className="mt-2 flex items-center">

View File

@ -186,7 +186,7 @@ export function FounderSettingsEditor({
}
return (
<div className="space-y-6 pb-12">
<div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* 2. HEADER */}
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
@ -397,7 +397,7 @@ export function FounderSettingsEditor({
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
{/* PUBLIC */}
<button
type="button"
@ -505,7 +505,7 @@ export function FounderSettingsEditor({
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
{/* OPEN */}
<button
type="button"

View File

@ -21,8 +21,8 @@ export function FounderSidebar({
permissions,
activeNav = 'overview',
onNavSelect,
pendingApplicationsCount = 4,
upcomingRidesCount = 2,
pendingApplicationsCount = 0,
upcomingRidesCount = 0,
isOpenOnMobile = false,
onCloseMobile,
}: FounderSidebarProps) {
@ -39,17 +39,18 @@ export function FounderSidebar({
.join('') || 'ATL';
const navItemsTop = [
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary' },
{ id: 'profile', label: 'Club Profile', icon: 'shield' },
{ id: 'members', label: 'Members', icon: 'group', badge: club.memberCount.toString(), badgeType: 'neutral' },
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false },
{ id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false },
{ id: 'members', label: 'Members', icon: 'group', badge: 'SOON', badgeType: 'disabled', disabled: true },
{
id: 'applications',
label: 'Applications',
icon: 'person_add',
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} ALERT` : undefined,
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} PENDING` : undefined,
badgeType: 'error',
disabled: false,
},
{ id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech' },
{ id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech', disabled: false },
];
const navItemsMiddle = [
@ -57,24 +58,26 @@ export function FounderSidebar({
id: 'rides',
label: 'Rides',
icon: 'route',
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount} ACTIVE` : undefined,
badgeType: 'secondary',
badge: 'SOON',
badgeType: 'disabled',
disabled: true,
},
{ id: 'routes', label: 'Routes', icon: 'map' },
{ id: 'wall', label: 'Wall', icon: 'forum' },
{ id: 'chat', label: 'Chat', icon: 'chat' },
{ id: 'stats', label: 'Stats', icon: 'query_stats' },
{ id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true },
];
const navItemsBottom = [
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: '1', badgeType: 'neutral' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'settings', label: 'Settings', icon: 'settings', disabled: false },
];
function getRouteHref(id: string) {
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`;
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
return undefined;
}
@ -84,13 +87,43 @@ export function FounderSidebar({
onCloseMobile?.();
}
function renderNavItem(item: { id: string; label: string; icon: string; badge?: string; badgeType?: string }) {
function renderNavItem(item: {
id: string;
label: string;
icon: string;
badge?: string;
badgeType?: string;
disabled?: boolean;
}) {
const isActive = activeNav === item.id;
const href = getRouteHref(item.id);
const href = !item.disabled ? getRouteHref(item.id) : undefined;
const isProfile = item.id === 'profile';
const isSettings = item.id === 'settings';
const isApplications = item.id === 'applications';
const isSubRoute = isProfile || isSettings || isApplications;
const isSubRoute = isProfile || isSettings || isApplications || item.id === 'ranks_roles';
if (item.disabled) {
return (
<div
key={item.id}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs text-slate-500/60 cursor-not-allowed select-none"
title={`${item.label} is planned for a future release`}
aria-disabled="true"
>
<div className="flex items-center gap-2.5">
<span className="material-symbols-outlined text-[18px] text-slate-600">
{item.icon}
</span>
<span className="text-slate-500">{item.label}</span>
</div>
{item.badge && (
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-500 border border-brand-border/40">
{item.badge}
</span>
)}
</div>
);
}
const content = (
<>
@ -257,7 +290,7 @@ export function FounderSidebar({
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1">
<span className="font-body text-xs font-semibold text-on-surface truncate">
{club.founderName ?? 'Marcus Vance'}
{club.founderName ?? 'Club Founder'}
</span>
<span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0">
verified

View File

@ -50,46 +50,52 @@ export function FounderTopBar({
}, [userMenuOpen]);
return (
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-brand-border/60 z-40 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-[1440px] mx-auto h-full flex items-center justify-between gap-3 sm:gap-4">
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-brand-border/60 z-40">
<div className="w-full max-w-[1440px] mx-auto h-full px-4 sm:px-6 lg:px-8 flex items-center justify-between gap-3 sm:gap-4">
{/* Left: Mobile hamburger + Breadcrumbs + Switch Club */}
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
<div className="flex items-center gap-2.5 sm:gap-4 min-w-0">
{/* Mobile Hamburger Toggle */}
<button
onClick={onToggleMobileSidebar}
className="lg:hidden text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors"
className="lg:hidden text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors shrink-0"
aria-label="Open sidebar"
>
<span className="material-symbols-outlined text-[22px]">menu</span>
</button>
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0">
<Link href="/clubs" className="hover:text-on-surface transition-colors">
<div className="flex items-center gap-1.5 sm:gap-2 font-mono text-xs text-outline min-w-0">
<Link href="/clubs" className="hover:text-on-surface transition-colors shrink-0">
Clubs
</Link>
<span>/</span>
<span className="shrink-0">/</span>
<Link
href={`/clubs/${club.slug || club.id}/manage`}
className="text-on-surface font-semibold truncate max-w-[120px] sm:max-w-[180px] md:max-w-none hover:text-brand-cyan transition-colors"
className="text-on-surface font-semibold truncate max-w-[110px] sm:max-w-[160px] lg:max-w-[140px] xl:max-w-none hover:text-brand-cyan transition-colors"
>
{club.name}
</Link>
{activeNav === 'profile' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-brand-cyan font-semibold">Club Profile</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-brand-cyan font-semibold">Settings</span>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">Club Profile</span>
</>
)}
{activeNav === 'applications' && (
<>
<span className="text-outline-variant">/</span>
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold">Applications</span>
<span className="text-outline-variant shrink-0">/</span>
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold truncate">Applications</span>
</>
)}
{activeNav === 'ranks_roles' && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">Ranks &amp; Roles</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">Settings</span>
</>
)}
</div>
@ -100,14 +106,14 @@ export function FounderTopBar({
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs hover:bg-surface-container-high transition-colors"
>
<span className="material-symbols-outlined text-[15px] text-brand-cyan">swap_horiz</span>
<span className="hidden sm:inline">Switch Club</span>
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span>
<span className="hidden sm:inline lg:hidden xl:inline">Switch Club</span>
<span className="material-symbols-outlined text-[15px] text-outline hidden sm:inline lg:hidden xl:inline">expand_more</span>
</button>
</div>
</div>
{/* Middle: Search Input (desktop/tablet only) */}
<div className="flex-1 max-w-xs mx-2 lg:mx-4 min-w-0 hidden md:block">
{/* Middle: Search Input (desktop/tablet only, hidden on lg to prevent crush) */}
<div className="flex-1 max-w-xs mx-2 lg:mx-4 min-w-0 hidden md:block lg:hidden xl:block">
<div className="relative">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
search
@ -122,11 +128,11 @@ export function FounderTopBar({
</div>
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */}
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0">
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0 flex-wrap justify-end">
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
{process.env.NODE_ENV === 'development' ? (
<div
className="hidden xl:flex items-center gap-1 px-2 py-0.5 rounded-lg bg-surface-container-high border border-brand-border/60 text-[11px] font-mono text-outline"
className="hidden 2xl:flex items-center gap-1 px-2 py-0.5 rounded-lg bg-surface-container-high border border-brand-border/60 text-[11px] font-mono text-outline"
title="Interactive role preview for testing software permissions (Development Only)"
>
<span className="text-outline-variant">Dev Preview:</span>
@ -146,7 +152,7 @@ export function FounderTopBar({
</div>
) : (
<div
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-container border border-brand-border/60 text-[11px] font-mono text-outline capitalize font-medium"
className="hidden sm:flex lg:hidden xl:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-container border border-brand-border/60 text-[11px] font-mono text-outline capitalize font-medium"
title="Authoritative software role"
>
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
@ -169,15 +175,15 @@ export function FounderTopBar({
{permissions.canPlanRides && (
<button
onClick={onPlanRide}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all shrink-0"
className="flex items-center gap-1.5 px-3 sm:px-3.5 py-1.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all shrink-0"
>
<span className="material-symbols-outlined text-[15px]">add_road</span>
<span className="hidden sm:inline">Plan Club Ride</span>
<span className="hidden sm:inline lg:hidden xl:inline">Plan Club Ride</span>
</button>
)}
{/* Notifications */}
<div className="relative ml-1 shrink-0">
<div className="relative ml-0.5 sm:ml-1 shrink-0">
<button
className="w-8 h-8 rounded-xl bg-surface-container border border-brand-border/60 flex items-center justify-center text-outline hover:text-on-surface transition-colors"
title="Notifications"

View File

@ -37,10 +37,10 @@ export function MetricCard({
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
return (
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden">
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
{/* Top Header Row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-start justify-between gap-2 flex-wrap">
<span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span>
{badge}
</div>
@ -77,12 +77,12 @@ export function MetricCard({
{/* Footer Subtext */}
<div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 font-mono text-xs text-slate-400">
<div className="truncate">{subtextLeft}</div>
<div className="text-slate-400 truncate sm:text-right">{subtextRight}</div>
<div className="flex flex-col gap-1 font-mono text-xs text-slate-400">
{subtextLeft && <div className="leading-snug">{subtextLeft}</div>}
{subtextRight && <div className="text-slate-400 leading-snug">{subtextRight}</div>}
</div>
{disclaimer && (
<span className="text-[10px] font-mono text-slate-500 truncate" title={disclaimer}>
<span className="text-[10px] font-mono text-slate-500 line-clamp-2 leading-relaxed" title={disclaimer}>
{disclaimer}
</span>
)}
@ -93,7 +93,7 @@ export function MetricCard({
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
return (
<section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-5">
<section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 2xl:grid-cols-4 gap-4 lg:gap-5">
{/* 1. MEMBERS */}
<MetricCard
label="Members"
@ -101,12 +101,12 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
showSparkline={false}
badge={
metrics.membersActivePercent != null ? (
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30">
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
{metrics.membersActivePercent}% ACTIVE
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ROSTER</span>
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">ROSTER</span>
)
}
subtextLeft={
@ -134,11 +134,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
showSparkline={false}
badge={
metrics.pendingApplications > 0 ? (
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30">
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30 shrink-0">
REQUIRES REVIEW
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ALL CLEARED</span>
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">ALL CLEARED</span>
)
}
subtextLeft={
@ -157,7 +157,7 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
value={metrics.upcomingRidesCount}
showSparkline={false}
badge={
<span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
<span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium shrink-0">
{metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'}
</span>
}
@ -178,11 +178,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
isUnavailable={!metrics.clubMilesAvailable}
badge={
metrics.clubMilesAvailable ? (
<span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
<span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium max-w-full text-right leading-tight">
Completed Club Rides only
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">UNAVAILABLE</span>
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">UNAVAILABLE</span>
)
}
subtextLeft={

View File

@ -609,6 +609,32 @@ export interface UpdateClubProfileInput {
description: string;
homeRegion: string;
motorcycleFocus: string;
logoUrl?: string;
bannerUrl?: string;
}
/**
* Shared image URL validation for club logo/banner persistence.
* Accepts https:// URLs or blank values (clearing). Returns the trimmed
* URL, or null when the field should be cleared. Never normalizes an
* invalid URL into a valid one; throws on http://, data:, blob:,
* javascript:, or malformed values.
*/
export function validateClubImageUrl(value: string, fieldLabel: string): string | null {
const trimmed = (value ?? '').trim();
if (!trimmed) {
return null;
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error(`${fieldLabel} must be a valid HTTPS URL.`);
}
if (parsed.protocol !== 'https:') {
throw new Error(`${fieldLabel} must use a secure https:// URL.`);
}
return trimmed;
}
/**
@ -645,6 +671,10 @@ export async function updateClubProfile(
throw new Error('Motorcycle focus must not exceed 100 characters.');
}
const logoUrl = input.logoUrl !== undefined ? validateClubImageUrl(input.logoUrl, 'Club logo URL') : undefined;
const bannerUrl =
input.bannerUrl !== undefined ? validateClubImageUrl(input.bannerUrl, 'Club banner URL') : undefined;
const clubRef = doc(firestore, 'clubs', clubId);
const clubSnap = await getDoc(clubRef);
if (!clubSnap.exists()) {
@ -681,6 +711,8 @@ export async function updateClubProfile(
description,
homeRegion,
motorcycleFocus,
...(logoUrl !== undefined ? { logoUrl } : {}),
...(bannerUrl !== undefined ? { bannerUrl } : {}),
updatedAt: serverTimestamp(),
});
}

View File

@ -69,6 +69,7 @@ const {
fetchClubApplications,
approveClubApplication,
declineClubApplication,
validateClubImageUrl,
} = clubData;
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
@ -828,7 +829,125 @@ async function runAllTests() {
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) ---');
// =========================================================================
// TEST 24: Codex Review Findings — Ranks-Roles Access Guard + HTTPS Image URL Invariant
// =========================================================================
console.log('Testing: Codex findings — Ranks-Roles access guard & HTTPS image URL invariant...');
// --- 24A: Ranks & Roles route enforces canManageSettings guard ---
const ranksRolesPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/ranks-roles/page.tsx'), 'utf8');
// Owner/Admin allowed by the established permission model
assert.equal(getPortalPermissions('owner', true).canManageSettings, true, 'Owner must have canManageSettings');
assert.equal(getPortalPermissions('admin', true).canManageSettings, true, 'Admin must have canManageSettings');
// Ordinary member denied
assert.equal(getPortalPermissions('member', true).canManageSettings, false, 'Member must not have canManageSettings');
// Unauthenticated denied
assert.equal(getPortalPermissions('owner', false).isAuthenticated, false, 'Unauthenticated user must have isAuthenticated = false');
assert.equal(getPortalPermissions('owner', false).canManageSettings, false, 'Unauthenticated user must not have canManageSettings');
// Route source: unauthenticated guard, authorization guard, safe unauthorized state, demo-preview preserved
assert(ranksRolesPageSrc.includes("if (!isDemoPreview && !currentUser)"), 'Ranks-Roles page must guard unauthenticated access');
assert(ranksRolesPageSrc.includes('title="Authentication Required"'), 'Ranks-Roles page must render Authentication Required safe state');
assert(ranksRolesPageSrc.includes('!ctx.permissions.canManageSettings'), 'Ranks-Roles page must enforce canManageSettings gate');
assert(ranksRolesPageSrc.includes('setUnauthorized(true)'), 'Ranks-Roles page must set unauthorized state');
assert(ranksRolesPageSrc.includes('title="Management Access Restricted"'), 'Ranks-Roles page must render unauthorized safe state');
assert(ranksRolesPageSrc.includes('allowDemoPreview: isDemoPreview'), 'Ranks-Roles page must preserve demo-preview behavior');
// --- 24B: validateClubImageUrl shared data-layer validator ---
// Accepted: valid https URLs (returns trimmed value)
assert.equal(validateClubImageUrl('https://example.com/logo.png', 'Club logo URL'), 'https://example.com/logo.png');
assert.equal(validateClubImageUrl(' https://cdn.example.com/b banner.jpg ', 'Club banner URL'), 'https://cdn.example.com/b banner.jpg');
// Accepted: blank/empty values clear the field (returns null)
assert.equal(validateClubImageUrl('', 'Club logo URL'), null);
assert.equal(validateClubImageUrl(' ', 'Club banner URL'), null);
// Rejected: http://, data:, blob:, javascript:, ftp:, malformed
const rejectedUrls = [
'http://example.com/logo.png',
'data:image/png;base64,iVBORw0KGgo=',
'blob:https://example.com/uuid',
'javascript:alert(1)',
'ftp://example.com/logo.png',
'not-a-url',
'example.com/logo.png',
'https://',
];
for (const bad of rejectedUrls) {
let threw = false;
try {
validateClubImageUrl(bad, 'Club logo URL');
} catch (err) {
threw = true;
assert(
err.message === 'Club logo URL must be a valid HTTPS URL.' || err.message === 'Club logo URL must use a secure https:// URL.',
`Unexpected validation error message for ${bad}: ${err.message}`
);
}
assert.equal(threw, true, `validateClubImageUrl must reject: ${bad}`);
}
// Never silently normalizes an invalid URL into a valid one
assert.equal(validateClubImageUrl('https://example.com/x.png', 'Club logo URL'), 'https://example.com/x.png');
// --- 24C: updateClubProfile invokes validator before Firestore reads ---
const updateProfileBody = clubDataSrc.substring(clubDataSrc.indexOf('export async function updateClubProfile'), clubDataSrc.indexOf('export interface UpdateClubAccessSettingsInput'));
assert(updateProfileBody.includes('validateClubImageUrl(input.logoUrl'), 'updateClubProfile must validate logoUrl via validateClubImageUrl');
assert(updateProfileBody.includes('validateClubImageUrl(input.bannerUrl'), 'updateClubProfile must validate bannerUrl via validateClubImageUrl');
assert(updateProfileBody.indexOf('validateClubImageUrl(input.logoUrl') < updateProfileBody.indexOf('getDoc(clubRef)'), 'URL validation must run before Firestore reads');
assert(!updateProfileBody.includes('input.logoUrl.trim()'), 'updateClubProfile must not write unvalidated logoUrl');
assert(!updateProfileBody.includes('input.bannerUrl.trim()'), 'updateClubProfile must not write unvalidated bannerUrl');
// Behavioral: invalid URL rejected before any Firestore read (mocked getDoc never resolves a club)
let urlThrew = false;
try {
await updateClubProfile('any-club', 'caller-uid', {
name: 'Valid Club Name',
description: '',
homeRegion: '',
motorcycleFocus: '',
logoUrl: 'http://evil.example.com/x.png',
});
} catch (err) {
urlThrew = true;
assert.equal(err.message, 'Club logo URL must use a secure https:// URL.');
}
assert.equal(urlThrew, true, 'updateClubProfile must reject http:// logoUrl before touching Firestore');
// Behavioral: valid input with no URL fields reaches the not-found read (proves validation did not throw)
let notFoundThrew = false;
try {
await updateClubProfile('any-club', 'caller-uid', {
name: 'Valid Club Name',
description: '',
homeRegion: '',
motorcycleFocus: '',
logoUrl: 'https://example.com/ok.png',
bannerUrl: '',
});
} catch (err) {
notFoundThrew = true;
assert.equal(err.message, 'Club not found.', `Expected Firestore read after passing validation, got: ${err.message}`);
}
assert.equal(notFoundThrew, true, 'updateClubProfile with valid URLs must proceed to Firestore read');
// --- 24D: Firestore rules enforce the same invariant at the storage layer ---
const rulesSrc = fs.readFileSync(path.resolve(__dirname, '../../firestore.rules'), 'utf8');
assert(rulesSrc.includes('function isValidClubImageUrl(url)'), 'firestore.rules must define isValidClubImageUrl');
assert(rulesSrc.includes("url.matches('(?i)^https://\\\\S+$')"), 'firestore.rules must require https:// scheme');
const rulesCase2 = rulesSrc.substring(rulesSrc.indexOf('// Case 2: Owner general metadata update'), rulesSrc.indexOf('// Case 3: Admin editing metadata'));
const rulesCase3 = rulesSrc.substring(rulesSrc.indexOf('// Case 3: Admin editing metadata'), rulesSrc.indexOf('// Case 4: Member count'));
for (const [label, block] of [['Case 2 (owner)', rulesCase2], ['Case 3 (admin)', rulesCase3]]) {
assert(block.includes("isValidClubImageUrl(request.resource.data.logoUrl)"), `firestore.rules ${label} must validate logoUrl`);
assert(block.includes("isValidClubImageUrl(request.resource.data.bannerUrl)"), `firestore.rules ${label} must validate bannerUrl`);
}
console.log(' PASS: Ranks-Roles canManageSettings guard verified (owner/admin allowed, member/unauth denied, demo-preview preserved); HTTPS image URL invariant enforced in app layer and Firestore rules (https accepted, blank clears, http/data/blob/javascript/malformed rejected).');
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (24/24) ---');
}
runAllTests().catch((err) => {