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 () => { test('FP-15: Club Applications membership request approval and decline security matrix', async () => {
const alice = testEnv.authenticatedContext('alice').firestore(); const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').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)); 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() { function isValidMemberCountIncrement() {
let targetUid = ('lastMembershipChangeUid' in request.resource.data) let targetUid = ('lastMembershipChangeUid' in request.resource.data)
? request.resource.data.lastMembershipChangeUid ? 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)) && (!('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']) && (!('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']) && (!('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) // 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)) && (!('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']) && (!('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']) && (!('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 // Case 4: Member count increment/decrement by membership transitions ONLY
( (

View File

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

View File

@ -71,9 +71,11 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-surface p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton /> <OverviewSkeleton />
</div> </div>
</div>
); );
} }
@ -125,8 +127,8 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
club={context.club} club={context.club}
permissions={context.permissions} permissions={context.permissions}
activeNav="profile" activeNav="profile"
pendingApplicationsCount={4} pendingApplicationsCount={0}
upcomingRidesCount={2} upcomingRidesCount={0}
> >
<FounderProfileEditor <FounderProfileEditor
club={context.club} 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,16 +71,19 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-surface p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton /> <OverviewSkeleton />
</div> </div>
</div>
); );
} }
// Unauthenticated safe error state // Unauthenticated safe error state
if (unauthenticated) { if (unauthenticated) {
return ( return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6"> <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 <SafeErrorState
title="Authentication Required" title="Authentication Required"
message="Sign in with your rider account to access club settings." message="Sign in with your rider account to access club settings."
@ -88,13 +91,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
onAction={() => router.push('/login')} onAction={() => router.push('/login')}
/> />
</div> </div>
</div>
); );
} }
// Unauthorized (Member or non-admin) safe error state // Unauthorized (Member or non-admin) safe error state
if (unauthorized) { if (unauthorized) {
return ( return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6"> <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 <SafeErrorState
title="Management Access Restricted" title="Management Access Restricted"
message="You must be an Owner or Admin of this Club to access Club Settings." message="You must be an Owner or Admin of this Club to access Club Settings."
@ -102,13 +107,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
onAction={() => router.push('/clubs')} onAction={() => router.push('/clubs')}
/> />
</div> </div>
</div>
); );
} }
// Error or missing context // Error or missing context
if (error || !context) { if (error || !context) {
return ( return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6"> <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 <SafeErrorState
title="Settings Unavailable" title="Settings Unavailable"
message={error ?? "Club settings couldn't be loaded."} message={error ?? "Club settings couldn't be loaded."}
@ -117,6 +124,7 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
onRetry={loadData} onRetry={loadData}
/> />
</div> </div>
</div>
); );
} }
@ -125,8 +133,8 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
club={context.club} club={context.club}
permissions={context.permissions} permissions={context.permissions}
activeNav="settings" activeNav="settings"
pendingApplicationsCount={4} pendingApplicationsCount={0}
upcomingRidesCount={2} upcomingRidesCount={0}
> >
<FounderSettingsEditor <FounderSettingsEditor
club={context.club} club={context.club}

View File

@ -261,7 +261,7 @@ export default function ClubManageDiscoveryPage() {
</div> </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"> <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]"> <span className="material-symbols-outlined text-[20px]">
arrow_forward arrow_forward
</span> </span>
@ -276,7 +276,7 @@ export default function ClubManageDiscoveryPage() {
<span className="material-symbols-outlined text-[18px] text-brand-cyan">shield</span> <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> <span>Only clubs where you are an active Owner or Admin appear here.</span>
</div> </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 → All Public Clubs →
</Link> </Link>
</div> </div>

View File

@ -62,7 +62,7 @@ export function AttentionSection({
</div> </div>
{/* 3 Triage Cards Grid */} {/* 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 */} {/* 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 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> <div>
@ -100,9 +100,9 @@ export function AttentionSection({
{pendingApplications.slice(0, 3).map((app) => ( {pendingApplications.slice(0, 3).map((app) => (
<div <div
key={app.uid} 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"> <div className="font-body text-xs font-semibold text-white truncate">
{app.displayName ?? 'New Rider'} {app.displayName ?? 'New Rider'}
</div> </div>
@ -111,7 +111,7 @@ export function AttentionSection({
</div> </div>
</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 && ( {permissions.canApproveApplications && (
<button <button
onClick={() => onApproveApplication?.(app.uid)} onClick={() => onApproveApplication?.(app.uid)}

View File

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

View File

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

View File

@ -15,6 +15,7 @@ interface FounderApplicationsProps {
permissions: UserPortalPermissions; permissions: UserPortalPermissions;
callerUid: string; callerUid: string;
isDemoFixture?: boolean; isDemoFixture?: boolean;
onApplicationsCountChange?: (count: number) => void;
} }
export function FounderApplications({ export function FounderApplications({
@ -22,6 +23,7 @@ export function FounderApplications({
permissions, permissions,
callerUid, callerUid,
isDemoFixture = false, isDemoFixture = false,
onApplicationsCountChange,
}: FounderApplicationsProps) { }: FounderApplicationsProps) {
const [applications, setApplications] = useState<ClubApplication[]>([]); const [applications, setApplications] = useState<ClubApplication[]>([]);
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null); const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
@ -47,6 +49,7 @@ export function FounderApplications({
allowDemoPreview: isDemoFixture, allowDemoPreview: isDemoFixture,
}); });
setApplications(data); setApplications(data);
onApplicationsCountChange?.(data.length);
if (data.length > 0) { if (data.length > 0) {
setSelectedApplicantUid((prev) => prev ?? data[0].uid); setSelectedApplicantUid((prev) => prev ?? data[0].uid);
} }
@ -122,13 +125,14 @@ export function FounderApplications({
await approveClubApplication(club.id, callerUid, applicant.uid); await approveClubApplication(club.id, callerUid, applicant.uid);
} }
// Remove from list and increment member count // 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); setLocalMemberCount((c) => c + 1);
setApproveModalApplicant(null); setApproveModalApplicant(null);
// Select next applicant if the approved one was selected // Select next applicant if the approved one was selected
if (selectedApplicantUid === applicant.uid) { if (selectedApplicantUid === applicant.uid) {
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setSelectedApplicantUid(remaining[0]?.uid || null); setSelectedApplicantUid(remaining[0]?.uid || null);
} }
} catch (err) { } catch (err) {
@ -148,12 +152,13 @@ export function FounderApplications({
await declineClubApplication(club.id, callerUid, applicant.uid); await declineClubApplication(club.id, callerUid, applicant.uid);
} }
// Remove from list (non-destructive in Firestore, memberCount delta 0) // 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); setDeclineModalApplicant(null);
// Select next applicant if the declined one was selected // Select next applicant if the declined one was selected
if (selectedApplicantUid === applicant.uid) { if (selectedApplicantUid === applicant.uid) {
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setSelectedApplicantUid(remaining[0]?.uid || null); setSelectedApplicantUid(remaining[0]?.uid || null);
} }
} catch (err) { } catch (err) {
@ -164,7 +169,7 @@ export function FounderApplications({
} }
return ( return (
<div className="space-y-6"> <div className="max-w-6xl mx-auto space-y-6">
{/* 1. PAGE HEADER */} {/* 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"> <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 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> </div>
) : applications.length === 0 ? ( ) : applications.length === 0 ? (
/* Empty State 1: Default zero-pending queue */ /* 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="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="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"> <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" />
<span className="material-symbols-outlined text-2xl">person_add_disabled</span> <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> </div>
<div> <h3 className="text-base font-headline font-bold text-white tracking-tight">No Pending Applications</h3>
<h3 className="text-sm font-semibold text-white font-mono">No Pending Applications</h3> <p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
<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 to join this Club.
There are no membership requests waiting for review. New requests will appear here when riders apply.
</p> </p>
</div> </div>
</div>
) : filteredApplications.length === 0 ? ( ) : filteredApplications.length === 0 ? (
/* Empty State 2: Filter zero-match */ /* 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="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="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="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> <span className="material-symbols-outlined text-2xl">search_off</span>
</div> </div>
<div> <h3 className="text-base font-headline font-bold text-white tracking-tight">No Applications Match</h3>
<h3 className="text-sm font-semibold text-white font-mono">No Applications Match</h3> <p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
<p className="text-xs text-slate-400 mt-1">
No pending applications match &quot;{searchQuery}&quot;. No pending applications match &quot;{searchQuery}&quot;.
</p> </p>
<div className="mt-4">
<button <button
type="button"
onClick={() => setSearchQuery('')} 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>Clear Search</span>
<span>→</span> <span className="material-symbols-outlined text-sm">close</span>
</button> </button>
</div> </div>
</div> </div>
@ -387,15 +393,15 @@ export function FounderApplications({
/* Two-Column Split View */ /* Two-Column Split View */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Dense Application Table */} {/* 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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse table-auto">
<thead> <thead>
<tr className="border-b border-brand-border/60 bg-surface-container-high/60 text-[11px] font-mono text-slate-400 tracking-wider"> <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 w-[38%]">Rider</th>
<th className="py-3.5 px-4 font-medium">Requested</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">Status</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 text-right">Actions</th> <th className="py-3.5 px-4 font-medium w-[26%] text-right whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-brand-border/40 text-xs"> <tbody className="divide-y divide-brand-border/40 text-xs">
@ -490,7 +496,7 @@ export function FounderApplications({
{/* Right Column: Selected Application Inspector Drawer */} {/* Right Column: Selected Application Inspector Drawer */}
{selectedApplicant && ( {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"> <div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative">
{/* Top Cyan Accent Line */} {/* Top Cyan Accent Line */}
<div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" /> <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'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { import {
Club, Club,
ClubMetrics, ClubMetrics,
@ -36,6 +37,7 @@ export function FounderOverview({
permissions, permissions,
onRefresh, onRefresh,
}: FounderOverviewProps) { }: FounderOverviewProps) {
const router = useRouter();
const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps); const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps);
const [rides] = useState<ClubUpcomingRide[]>(initialRides); const [rides] = useState<ClubUpcomingRide[]>(initialRides);
const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics); const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics);
@ -142,9 +144,16 @@ export function FounderOverview({
<UpcomingRidesSection <UpcomingRidesSection
rides={rides} rides={rides}
permissions={permissions} permissions={permissions}
clubId={club.slug || club.id}
onPlanRide={() => setActiveModal('plan_ride')} onPlanRide={() => setActiveModal('plan_ride')}
onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)} 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'}`)} onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)}
/> />

View File

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

View File

@ -25,25 +25,51 @@ export function FounderProfileEditor({
const [description, setDescription] = useState(club.description || ''); const [description, setDescription] = useState(club.description || '');
const [homeRegion, setHomeRegion] = useState(club.homeRegion || ''); const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || ''); const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
const [logoUrl, setLogoUrl] = useState(club.logoUrl || '');
const [bannerUrl, setBannerUrl] = useState(club.bannerUrl || '');
// Save states // Save states
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null); 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 // Validation
const trimmedName = name.trim(); const trimmedName = name.trim();
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50; const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
const isDescValid = description.trim().length <= 500; const isDescValid = description.trim().length <= 500;
const isRegionValid = homeRegion.trim().length <= 100; const isRegionValid = homeRegion.trim().length <= 100;
const isFocusValid = motorcycleFocus.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 = const isDirty =
trimmedName !== (club.name || '').trim() || trimmedName !== (club.name || '').trim() ||
description.trim() !== (club.description || '').trim() || description.trim() !== (club.description || '').trim() ||
homeRegion.trim() !== (club.homeRegion || '').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 // Short initials for preview emblem
const initials = name const initials = name
@ -61,6 +87,9 @@ export function FounderProfileEditor({
setSaveStatus('idle'); setSaveStatus('idle');
setErrorMessage(null); setErrorMessage(null);
const trimmedLogo = logoUrl.trim();
const trimmedBanner = bannerUrl.trim();
try { try {
if (isDemoFixture) { if (isDemoFixture) {
// Simulated latency in development demo preview mode // Simulated latency in development demo preview mode
@ -71,6 +100,8 @@ export function FounderProfileEditor({
description: description.trim(), description: description.trim(),
homeRegion: homeRegion.trim(), homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(), motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo || null,
bannerUrl: trimmedBanner || null,
}); });
} else { } else {
if (!callerUid) { if (!callerUid) {
@ -81,6 +112,8 @@ export function FounderProfileEditor({
description: description.trim(), description: description.trim(),
homeRegion: homeRegion.trim(), homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(), motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo,
bannerUrl: trimmedBanner,
}); });
setSaveStatus('success'); setSaveStatus('success');
onProfileUpdated?.({ onProfileUpdated?.({
@ -88,6 +121,8 @@ export function FounderProfileEditor({
description: description.trim(), description: description.trim(),
homeRegion: homeRegion.trim(), homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(), motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo || null,
bannerUrl: trimmedBanner || null,
}); });
} }
} catch (err) { } catch (err) {
@ -99,7 +134,7 @@ export function FounderProfileEditor({
} }
return ( return (
<div className="space-y-6 pb-12"> <div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* 3. PAGE HEADER */} {/* 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"> <section className="border-b border-brand-border/60 pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
@ -123,7 +158,7 @@ export function FounderProfileEditor({
{/* 4. TOP 4 METRIC CARDS */} {/* 4. TOP 4 METRIC CARDS */}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Club Name */} {/* 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="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"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Club Name</span> <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> </div>
{/* Card 2: Home Region */} {/* 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="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"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Home Region</span> <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> </div>
{/* Card 3: Motorcycle Focus */} {/* 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="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"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Motorcycle Focus</span> <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> </div>
{/* Card 4: Discovery Presence */} {/* 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="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"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Discovery Presence</span> <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 */} {/* 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 justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">badge</span> <span className="material-symbols-outlined text-brand-cyan text-xl">badge</span>
@ -306,7 +341,7 @@ export function FounderProfileEditor({
</div> </div>
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */} {/* 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 justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">explore</span> <span className="material-symbols-outlined text-brand-cyan text-xl">explore</span>
@ -356,8 +391,8 @@ export function FounderProfileEditor({
</div> </div>
</div> </div>
{/* SECTION 3: LOGO & BANNER (Presentation Only) */} {/* SECTION 3: LOGO & BANNER */}
<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 justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">palette</span> <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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Logo Presentation Card */} {/* 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="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> <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>
<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"> <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 ? ( {logoUrl ? (
<img src={club.logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain" /> <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"> <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> <span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span>
@ -386,21 +432,50 @@ export function FounderProfileEditor({
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400"> <div className="space-y-2 pt-1">
<span className="material-symbols-outlined text-xs">info</span> <div className="space-y-1">
<span>Logo and banner editing is not available yet.</span> <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>
</div> </div>
{/* Banner Presentation Card */} {/* 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="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> <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>
<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"> <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 <img
src={club.bannerUrl} src={bannerUrl}
alt="Club banner presentation" alt="Club banner presentation"
className="w-full h-full object-cover" 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="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"> <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="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>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400"> <div className="space-y-2 pt-1">
<span className="material-symbols-outlined text-xs">info</span> <div className="space-y-1">
<span>Logo and banner editing is not available yet.</span> <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>
</div> </div>
</div> </div>
{/* ACTION DOCKED FOOTER */} {/* 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"> <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 className="material-symbols-outlined text-brand-cyan text-base">check_circle</span>
<span>Saves public club profile information.</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"> <div className="rounded-xl bg-surface-container-low border border-brand-border/60 overflow-hidden shadow-lg">
{/* Banner Mockup */} {/* Banner Mockup */}
<div className="h-24 bg-surface-container-high relative overflow-hidden"> <div className="h-24 bg-surface-container-high relative overflow-hidden">
{club.bannerUrl ? ( {bannerUrl ? (
<img <img
src={club.bannerUrl} src={bannerUrl}
alt="Directory banner preview" alt="Directory banner preview"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -488,9 +581,15 @@ export function FounderProfileEditor({
<div className="p-4 pt-0 relative"> <div className="p-4 pt-0 relative">
{/* Emblem overlapping */} {/* 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"> <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="material-symbols-outlined text-xl">two_wheeler</span>
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span> <span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
</>
)}
</div> </div>
<div className="mt-2 flex items-center"> <div className="mt-2 flex items-center">

View File

@ -186,7 +186,7 @@ export function FounderSettingsEditor({
} }
return ( return (
<div className="space-y-6 pb-12"> <div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* 2. HEADER */} {/* 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"> <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 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> </span>
</div> </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 */} {/* PUBLIC */}
<button <button
type="button" type="button"
@ -505,7 +505,7 @@ export function FounderSettingsEditor({
</span> </span>
</div> </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 */} {/* OPEN */}
<button <button
type="button" type="button"

View File

@ -21,8 +21,8 @@ export function FounderSidebar({
permissions, permissions,
activeNav = 'overview', activeNav = 'overview',
onNavSelect, onNavSelect,
pendingApplicationsCount = 4, pendingApplicationsCount = 0,
upcomingRidesCount = 2, upcomingRidesCount = 0,
isOpenOnMobile = false, isOpenOnMobile = false,
onCloseMobile, onCloseMobile,
}: FounderSidebarProps) { }: FounderSidebarProps) {
@ -39,17 +39,18 @@ export function FounderSidebar({
.join('') || 'ATL'; .join('') || 'ATL';
const navItemsTop = [ const navItemsTop = [
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary' }, { id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false },
{ id: 'profile', label: 'Club Profile', icon: 'shield' }, { id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false },
{ id: 'members', label: 'Members', icon: 'group', badge: club.memberCount.toString(), badgeType: 'neutral' }, { id: 'members', label: 'Members', icon: 'group', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ {
id: 'applications', id: 'applications',
label: 'Applications', label: 'Applications',
icon: 'person_add', icon: 'person_add',
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} ALERT` : undefined, badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} PENDING` : undefined,
badgeType: 'error', 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 = [ const navItemsMiddle = [
@ -57,24 +58,26 @@ export function FounderSidebar({
id: 'rides', id: 'rides',
label: 'Rides', label: 'Rides',
icon: 'route', icon: 'route',
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount} ACTIVE` : undefined, badge: 'SOON',
badgeType: 'secondary', badgeType: 'disabled',
disabled: true,
}, },
{ id: 'routes', label: 'Routes', icon: 'map' }, { id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'wall', label: 'Wall', icon: 'forum' }, { id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'chat', label: 'Chat', icon: 'chat' }, { id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'stats', label: 'Stats', icon: 'query_stats' }, { id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true },
]; ];
const navItemsBottom = [ const navItemsBottom = [
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: '1', badgeType: 'neutral' }, { id: 'moderation', label: 'Moderation', icon: 'gavel', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'settings', label: 'Settings', icon: 'settings' }, { id: 'settings', label: 'Settings', icon: 'settings', disabled: false },
]; ];
function getRouteHref(id: string) { function getRouteHref(id: string) {
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`; if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`; if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`; if (id === '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}`; if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
return undefined; return undefined;
} }
@ -84,13 +87,43 @@ export function FounderSidebar({
onCloseMobile?.(); 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 isActive = activeNav === item.id;
const href = getRouteHref(item.id); const href = !item.disabled ? getRouteHref(item.id) : undefined;
const isProfile = item.id === 'profile'; const isProfile = item.id === 'profile';
const isSettings = item.id === 'settings'; const isSettings = item.id === 'settings';
const isApplications = item.id === 'applications'; 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 = ( const content = (
<> <>
@ -257,7 +290,7 @@ export function FounderSidebar({
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-body text-xs font-semibold text-on-surface truncate"> <span className="font-body text-xs font-semibold text-on-surface truncate">
{club.founderName ?? 'Marcus Vance'} {club.founderName ?? 'Club Founder'}
</span> </span>
<span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0"> <span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0">
verified verified

View File

@ -50,46 +50,52 @@ export function FounderTopBar({
}, [userMenuOpen]); }, [userMenuOpen]);
return ( 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"> <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 flex items-center justify-between gap-3 sm:gap-4"> <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 */} {/* 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 */} {/* Mobile Hamburger Toggle */}
<button <button
onClick={onToggleMobileSidebar} 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" aria-label="Open sidebar"
> >
<span className="material-symbols-outlined text-[22px]">menu</span> <span className="material-symbols-outlined text-[22px]">menu</span>
</button> </button>
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0"> <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"> <Link href="/clubs" className="hover:text-on-surface transition-colors shrink-0">
Clubs Clubs
</Link> </Link>
<span>/</span> <span className="shrink-0">/</span>
<Link <Link
href={`/clubs/${club.slug || club.id}/manage`} 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} {club.name}
</Link> </Link>
{activeNav === 'profile' && ( {activeNav === 'profile' && (
<> <>
<span className="text-outline-variant">/</span> <span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold">Club Profile</span> <span className="text-brand-cyan font-semibold truncate">Club Profile</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-brand-cyan font-semibold">Settings</span>
</> </>
)} )}
{activeNav === 'applications' && ( {activeNav === 'applications' && (
<> <>
<span className="text-outline-variant">/</span> <span className="text-outline-variant shrink-0">/</span>
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold">Applications</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> </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" 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="material-symbols-outlined text-[15px] text-brand-cyan">swap_horiz</span>
<span className="hidden sm:inline">Switch Club</span> <span className="hidden sm:inline lg:hidden xl:inline">Switch Club</span>
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span> <span className="material-symbols-outlined text-[15px] text-outline hidden sm:inline lg:hidden xl:inline">expand_more</span>
</button> </button>
</div> </div>
</div> </div>
{/* Middle: Search Input (desktop/tablet only) */} {/* 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"> <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"> <div className="relative">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]"> <span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
search search
@ -122,11 +128,11 @@ export function FounderTopBar({
</div> </div>
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */} {/* 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) */} {/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
{process.env.NODE_ENV === 'development' ? ( {process.env.NODE_ENV === 'development' ? (
<div <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)" title="Interactive role preview for testing software permissions (Development Only)"
> >
<span className="text-outline-variant">Dev Preview:</span> <span className="text-outline-variant">Dev Preview:</span>
@ -146,7 +152,7 @@ export function FounderTopBar({
</div> </div>
) : ( ) : (
<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" title="Authoritative software role"
> >
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
@ -169,15 +175,15 @@ export function FounderTopBar({
{permissions.canPlanRides && ( {permissions.canPlanRides && (
<button <button
onClick={onPlanRide} 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="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> </button>
)} )}
{/* Notifications */} {/* Notifications */}
<div className="relative ml-1 shrink-0"> <div className="relative ml-0.5 sm:ml-1 shrink-0">
<button <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" 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" title="Notifications"

View File

@ -37,10 +37,10 @@ export function MetricCard({
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary; const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
return ( 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" /> <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 */} {/* 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> <span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span>
{badge} {badge}
</div> </div>
@ -77,12 +77,12 @@ export function MetricCard({
{/* Footer Subtext */} {/* Footer Subtext */}
<div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40"> <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="flex flex-col gap-1 font-mono text-xs text-slate-400">
<div className="truncate">{subtextLeft}</div> {subtextLeft && <div className="leading-snug">{subtextLeft}</div>}
<div className="text-slate-400 truncate sm:text-right">{subtextRight}</div> {subtextRight && <div className="text-slate-400 leading-snug">{subtextRight}</div>}
</div> </div>
{disclaimer && ( {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} {disclaimer}
</span> </span>
)} )}
@ -93,7 +93,7 @@ export function MetricCard({
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) { export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
return ( 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 */} {/* 1. MEMBERS */}
<MetricCard <MetricCard
label="Members" label="Members"
@ -101,12 +101,12 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
showSparkline={false} showSparkline={false}
badge={ badge={
metrics.membersActivePercent != null ? ( 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" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
{metrics.membersActivePercent}% ACTIVE {metrics.membersActivePercent}% ACTIVE
</span> </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={ subtextLeft={
@ -134,11 +134,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
showSparkline={false} showSparkline={false}
badge={ badge={
metrics.pendingApplications > 0 ? ( 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 REQUIRES REVIEW
</span> </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={ subtextLeft={
@ -157,7 +157,7 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
value={metrics.upcomingRidesCount} value={metrics.upcomingRidesCount}
showSparkline={false} showSparkline={false}
badge={ 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'} {metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'}
</span> </span>
} }
@ -178,11 +178,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
isUnavailable={!metrics.clubMilesAvailable} isUnavailable={!metrics.clubMilesAvailable}
badge={ badge={
metrics.clubMilesAvailable ? ( 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 Completed Club Rides only
</span> </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={ subtextLeft={

View File

@ -609,6 +609,32 @@ export interface UpdateClubProfileInput {
description: string; description: string;
homeRegion: string; homeRegion: string;
motorcycleFocus: 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.'); 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 clubRef = doc(firestore, 'clubs', clubId);
const clubSnap = await getDoc(clubRef); const clubSnap = await getDoc(clubRef);
if (!clubSnap.exists()) { if (!clubSnap.exists()) {
@ -681,6 +711,8 @@ export async function updateClubProfile(
description, description,
homeRegion, homeRegion,
motorcycleFocus, motorcycleFocus,
...(logoUrl !== undefined ? { logoUrl } : {}),
...(bannerUrl !== undefined ? { bannerUrl } : {}),
updatedAt: serverTimestamp(), updatedAt: serverTimestamp(),
}); });
} }

View File

@ -69,6 +69,7 @@ const {
fetchClubApplications, fetchClubApplications,
approveClubApplication, approveClubApplication,
declineClubApplication, declineClubApplication,
validateClubImageUrl,
} = clubData; } = clubData;
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures; 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(' 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) => { runAllTests().catch((err) => {