diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 32ffd4a..2ba59c7 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -3013,6 +3013,120 @@ describe('Social Architecture Security Rules', () => { })); }); + test('FP-14.3: Club logo/banner HTTPS URL invariant enforced at the storage layer', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_image_url_sec'), { + id: 'club_image_url_sec', + name: 'Image URL Security MC', + slug: 'image-url-security-mc', + visibility: 'public', + admissionPolicy: 'request', + ownerUid: 'alice', + memberCount: 3, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + await setDoc(doc(db, 'clubs/club_image_url_sec/members/alice'), { + uid: 'alice', + clubId: 'club_image_url_sec', + role: 'owner', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_image_url_sec/members/bob'), { + uid: 'bob', + clubId: 'club_image_url_sec', + role: 'admin', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_image_url_sec/members/charlie'), { + uid: 'charlie', + clubId: 'club_image_url_sec', + role: 'member', + status: 'active', + displayRank: 'President', + }); + }); + + const clubDocRef = (db) => doc(db, 'clubs/club_image_url_sec'); + + // 1. Owner sets valid https logo/banner -> ALLOW + await assertSucceeds(updateDoc(clubDocRef(alice), { + logoUrl: 'https://example.com/logo.png', + bannerUrl: 'https://cdn.example.com/banner.jpg', + updatedAt: new Date(), + })); + + // 2. Owner clears logo with null and banner with empty string -> ALLOW + await assertSucceeds(updateDoc(clubDocRef(alice), { + logoUrl: null, + updatedAt: new Date(), + })); + await assertSucceeds(updateDoc(clubDocRef(alice), { + bannerUrl: '', + updatedAt: new Date(), + })); + + // 3. Admin sets valid https URLs -> ALLOW + await assertSucceeds(updateDoc(clubDocRef(bob), { + logoUrl: 'https://example.com/admin-logo.png', + updatedAt: new Date(), + })); + + // 4. Owner http:// URL -> DENY (insecure scheme) + await assertFails(updateDoc(clubDocRef(alice), { + logoUrl: 'http://evil.example.com/logo.png', + updatedAt: new Date(), + })); + + // 5. Owner data: URL -> DENY + await assertFails(updateDoc(clubDocRef(alice), { + logoUrl: 'data:image/png;base64,iVBORw0KGgo=', + updatedAt: new Date(), + })); + + // 6. Owner blob: URL -> DENY + await assertFails(updateDoc(clubDocRef(alice), { + bannerUrl: 'blob:https://example.com/fake-uuid', + updatedAt: new Date(), + })); + + // 7. Owner javascript: URL -> DENY + await assertFails(updateDoc(clubDocRef(alice), { + bannerUrl: 'javascript:alert(1)', + updatedAt: new Date(), + })); + + // 8. Owner malformed value -> DENY + await assertFails(updateDoc(clubDocRef(alice), { + logoUrl: 'not-a-url', + updatedAt: new Date(), + })); + + // 9. Owner non-string URL value -> DENY + await assertFails(updateDoc(clubDocRef(alice), { + logoUrl: 42, + updatedAt: new Date(), + })); + + // 10. Admin http:// URL -> DENY (same invariant for admins) + await assertFails(updateDoc(clubDocRef(bob), { + bannerUrl: 'http://evil.example.com/banner.jpg', + updatedAt: new Date(), + })); + + // 11. Regular member writes valid https URL -> DENY (no management authority at all) + await assertFails(updateDoc(clubDocRef(charlie), { + logoUrl: 'https://example.com/member-logo.png', + updatedAt: new Date(), + })); + }); + test('FP-15: Club Applications membership request approval and decline security matrix', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); diff --git a/firestore.rules b/firestore.rules index 673449f..17bd313 100644 --- a/firestore.rules +++ b/firestore.rules @@ -701,6 +701,16 @@ service cloud.firestore { return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid)); } + // Club logo/banner URLs: null or empty string clears; otherwise must be + // an https:// URL (case-insensitive scheme, matching URL() semantics in the app layer). + function isValidClubImageUrl(url) { + return url == null + || url == '' + || (url is string + && url.size() <= 2048 + && url.matches('(?i)^https://\\S+$')); + } + function isValidMemberCountIncrement() { let targetUid = ('lastMembershipChangeUid' in request.resource.data) ? request.resource.data.lastMembershipChangeUid @@ -779,6 +789,8 @@ service cloud.firestore { && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private']) && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly']) + && (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl)) + && (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl)) ) || // Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount) ( @@ -790,6 +802,8 @@ service cloud.firestore { && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private']) && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly']) + && (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl)) + && (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl)) ) || // Case 4: Member count increment/decrement by membership transitions ONLY ( diff --git a/website/app/clubs/[clubId]/manage/applications/page.tsx b/website/app/clubs/[clubId]/manage/applications/page.tsx index b8e67e7..8cda961 100644 --- a/website/app/clubs/[clubId]/manage/applications/page.tsx +++ b/website/app/clubs/[clubId]/manage/applications/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { fetchFounderClubContext, FounderClubContext, + fetchClubApplications, sanitizePortalError, } from '../../../../../lib/club-data'; import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell'; @@ -23,6 +24,7 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) { const searchParams = useSearchParams(); const [context, setContext] = useState(null); + const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [unauthenticated, setUnauthenticated] = useState(false); @@ -58,6 +60,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) { } setContext(ctx); + + try { + const apps = await fetchClubApplications(ctx.club.id, currentUser?.uid, { + allowDemoPreview: isDemoPreview, + }); + setPendingApplicationsCount(apps.length); + } catch { + setPendingApplicationsCount(0); + } } catch (err) { setError(sanitizePortalError(err, "Club applications couldn't be loaded.")); } finally { @@ -71,8 +82,10 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) { if (loading) { return ( -
- +
+
+ +
); } @@ -125,14 +138,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) { club={context.club} permissions={context.permissions} activeNav="applications" - pendingApplicationsCount={4} - upcomingRidesCount={2} + pendingApplicationsCount={pendingApplicationsCount} + upcomingRidesCount={0} > ); diff --git a/website/app/clubs/[clubId]/manage/profile/page.tsx b/website/app/clubs/[clubId]/manage/profile/page.tsx index 8a06364..b1a8ce7 100644 --- a/website/app/clubs/[clubId]/manage/profile/page.tsx +++ b/website/app/clubs/[clubId]/manage/profile/page.tsx @@ -71,8 +71,10 @@ export default function ClubFounderProfilePage({ params }: PageProps) { if (loading) { return ( -
- +
+
+ +
); } @@ -125,8 +127,8 @@ export default function ClubFounderProfilePage({ params }: PageProps) { club={context.club} permissions={context.permissions} activeNav="profile" - pendingApplicationsCount={4} - upcomingRidesCount={2} + pendingApplicationsCount={0} + upcomingRidesCount={0} > ; +} + +export default function ClubFounderRanksRolesPage({ params }: PageProps) { + const { clubId } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [context, setContext] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [unauthenticated, setUnauthenticated] = useState(false); + const [unauthorized, setUnauthorized] = useState(false); + + async function loadData() { + setLoading(true); + setError(null); + setUnauthenticated(false); + setUnauthorized(false); + + try { + await firebaseAuth.authStateReady(); + const currentUser = firebaseAuth.currentUser; + const isDemoPreview = searchParams?.get('preview') === 'demo'; + + // Unauthenticated Guard: Direct access without demo preview requires authentication + if (!isDemoPreview && !currentUser) { + setUnauthenticated(true); + setLoading(false); + return; + } + + const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, { + allowDemoPreview: isDemoPreview, + }); + + // Authorization Guard: Owner and Admin can access 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 ( +
+
+ +
+
+ ); + } + + // Unauthenticated safe error state + if (unauthenticated) { + return ( +
+
+ router.push('/login')} + /> +
+
+ ); + } + + // Unauthorized (Member or non-admin) safe error state + if (unauthorized) { + return ( +
+
+ router.push('/clubs')} + /> +
+
+ ); + } + + if (error || !context) { + return ( +
+
+ router.push('/clubs')} + onRetry={loadData} + /> +
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+
+
+ + Governance & Authority + + + // {context.club.name} + +
+

+ Ranks & Roles +

+

+ Administrative role authority vs honorary squad rank designations. +

+
+
+ + {/* Roles and Ranks Summary Component */} + +
+
+ ); +} diff --git a/website/app/clubs/[clubId]/manage/settings/page.tsx b/website/app/clubs/[clubId]/manage/settings/page.tsx index 4ca7957..1245907 100644 --- a/website/app/clubs/[clubId]/manage/settings/page.tsx +++ b/website/app/clubs/[clubId]/manage/settings/page.tsx @@ -71,8 +71,10 @@ export default function ClubFounderSettingsPage({ params }: PageProps) { if (loading) { return ( -
- +
+
+ +
); } @@ -80,13 +82,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) { // Unauthenticated safe error state if (unauthenticated) { return ( -
- router.push('/login')} - /> +
+
+ router.push('/login')} + /> +
); } @@ -94,13 +98,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) { // Unauthorized (Member or non-admin) safe error state if (unauthorized) { return ( -
- router.push('/clubs')} - /> +
+
+ router.push('/clubs')} + /> +
); } @@ -108,14 +114,16 @@ export default function ClubFounderSettingsPage({ params }: PageProps) { // Error or missing context if (error || !context) { return ( -
- router.push('/clubs')} - onRetry={loadData} - /> +
+
+ router.push('/clubs')} + onRetry={loadData} + /> +
); } @@ -125,8 +133,8 @@ export default function ClubFounderSettingsPage({ params }: PageProps) { club={context.club} permissions={context.permissions} activeNav="settings" - pendingApplicationsCount={4} - upcomingRidesCount={2} + pendingApplicationsCount={0} + upcomingRidesCount={0} >
- Manage Cockpit + Manage Club arrow_forward @@ -276,7 +276,7 @@ export default function ClubManageDiscoveryPage() { shield Only clubs where you are an active Owner or Admin appear here.
- + All Public Clubs →
diff --git a/website/components/founder-portal/attention-card.tsx b/website/components/founder-portal/attention-card.tsx index 449e994..8b1a0a0 100644 --- a/website/components/founder-portal/attention-card.tsx +++ b/website/components/founder-portal/attention-card.tsx @@ -62,7 +62,7 @@ export function AttentionSection({
{/* 3 Triage Cards Grid */} -
+
{/* Card 1: Membership Applications */}
@@ -100,9 +100,9 @@ export function AttentionSection({ {pendingApplications.slice(0, 3).map((app) => (
-
+
{app.displayName ?? 'New Rider'}
@@ -111,7 +111,7 @@ export function AttentionSection({
-
+
{permissions.canApproveApplications && ( @@ -166,11 +175,15 @@ export function ClubRideCard({ export function UpcomingRidesSection({ rides, permissions, + clubId, onPlanRide, onManageRide, onViewRoster, onViewRoute, }: UpcomingRidesSectionProps) { + const params = useParams(); + const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : ''); + return (
@@ -184,13 +197,13 @@ export function UpcomingRidesSection({
{rides.length > 0 && ( - + schedule + )}
@@ -210,6 +223,7 @@ export function UpcomingRidesSection({ key={ride.id} ride={ride} permissions={permissions} + clubId={targetClubId} onManageRide={onManageRide} onViewRoster={onViewRoster} onViewRoute={onViewRoute} diff --git a/website/components/founder-portal/founder-applications.tsx b/website/components/founder-portal/founder-applications.tsx index e784c8f..e0722d0 100644 --- a/website/components/founder-portal/founder-applications.tsx +++ b/website/components/founder-portal/founder-applications.tsx @@ -15,6 +15,7 @@ interface FounderApplicationsProps { permissions: UserPortalPermissions; callerUid: string; isDemoFixture?: boolean; + onApplicationsCountChange?: (count: number) => void; } export function FounderApplications({ @@ -22,6 +23,7 @@ export function FounderApplications({ permissions, callerUid, isDemoFixture = false, + onApplicationsCountChange, }: FounderApplicationsProps) { const [applications, setApplications] = useState([]); const [selectedApplicantUid, setSelectedApplicantUid] = useState(null); @@ -47,6 +49,7 @@ export function FounderApplications({ allowDemoPreview: isDemoFixture, }); setApplications(data); + onApplicationsCountChange?.(data.length); if (data.length > 0) { setSelectedApplicantUid((prev) => prev ?? data[0].uid); } @@ -122,13 +125,14 @@ export function FounderApplications({ await approveClubApplication(club.id, callerUid, applicant.uid); } // Remove from list and increment member count - setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); + const remaining = applications.filter((a) => a.uid !== applicant.uid); + setApplications(remaining); + onApplicationsCountChange?.(remaining.length); setLocalMemberCount((c) => c + 1); setApproveModalApplicant(null); // Select next applicant if the approved one was selected if (selectedApplicantUid === applicant.uid) { - const remaining = applications.filter((a) => a.uid !== applicant.uid); setSelectedApplicantUid(remaining[0]?.uid || null); } } catch (err) { @@ -148,12 +152,13 @@ export function FounderApplications({ await declineClubApplication(club.id, callerUid, applicant.uid); } // Remove from list (non-destructive in Firestore, memberCount delta 0) - setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); + const remaining = applications.filter((a) => a.uid !== applicant.uid); + setApplications(remaining); + onApplicationsCountChange?.(remaining.length); setDeclineModalApplicant(null); // Select next applicant if the declined one was selected if (selectedApplicantUid === applicant.uid) { - const remaining = applications.filter((a) => a.uid !== applicant.uid); setSelectedApplicantUid(remaining[0]?.uid || null); } } catch (err) { @@ -164,7 +169,7 @@ export function FounderApplications({ } return ( -
+
{/* 1. PAGE HEADER */}
@@ -352,34 +357,35 @@ export function FounderApplications({
) : applications.length === 0 ? ( /* Empty State 1: Default zero-pending queue */ -
-
- person_add_disabled -
-
-

No Pending Applications

-

- There are no membership requests waiting for review. New requests will appear here when riders apply. -

+
+
+
+ person_add_disabled
+

No Pending Applications

+

+ There are no membership requests waiting for review. New requests will appear here when riders apply to join this Club. +

) : filteredApplications.length === 0 ? ( /* Empty State 2: Filter zero-match */ -
-
+
+
+
search_off
-
-

No Applications Match

-

- No pending applications match "{searchQuery}". -

+

No Applications Match

+

+ No pending applications match "{searchQuery}". +

+
@@ -387,15 +393,15 @@ export function FounderApplications({ /* Two-Column Split View */
{/* Left Column: Dense Application Table */} -
+
- +
- - - - + + + + @@ -490,7 +496,7 @@ export function FounderApplications({ {/* Right Column: Selected Application Inspector Drawer */} {selectedApplicant && ( -
+
{/* Top Cyan Accent Line */}
diff --git a/website/components/founder-portal/founder-overview.tsx b/website/components/founder-portal/founder-overview.tsx index a555d26..5935d07 100644 --- a/website/components/founder-portal/founder-overview.tsx +++ b/website/components/founder-portal/founder-overview.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { Club, ClubMetrics, @@ -36,6 +37,7 @@ export function FounderOverview({ permissions, onRefresh, }: FounderOverviewProps) { + const router = useRouter(); const [pendingApps, setPendingApps] = useState(initialApps); const [rides] = useState(initialRides); const [metrics, setMetrics] = useState(initialMetrics); @@ -142,9 +144,16 @@ export function FounderOverview({ setActiveModal('plan_ride')} onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)} - onViewRoster={(rideId) => showToast(`Displaying confirmed rider roster for Ride ID: ${rideId}`)} + onViewRoster={(rideId) => { + if (rideId === 'all') { + showToast('Full ride schedule is planned for a future release.'); + } else { + showToast('Member roster details will be available in an upcoming release.'); + } + }} onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)} /> diff --git a/website/components/founder-portal/founder-portal-shell.tsx b/website/components/founder-portal/founder-portal-shell.tsx index 0ea4ba4..6877e43 100644 --- a/website/components/founder-portal/founder-portal-shell.tsx +++ b/website/components/founder-portal/founder-portal-shell.tsx @@ -28,8 +28,8 @@ export function FounderPortalShell({ onPostAnnouncement, onSwitchRole, onSwitchClub, - pendingApplicationsCount = 4, - upcomingRidesCount = 2, + pendingApplicationsCount = 0, + upcomingRidesCount = 0, children, }: FounderPortalShellProps) { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); diff --git a/website/components/founder-portal/founder-profile-editor.tsx b/website/components/founder-portal/founder-profile-editor.tsx index 41a782b..56b5461 100644 --- a/website/components/founder-portal/founder-profile-editor.tsx +++ b/website/components/founder-portal/founder-profile-editor.tsx @@ -25,25 +25,51 @@ export function FounderProfileEditor({ const [description, setDescription] = useState(club.description || ''); const [homeRegion, setHomeRegion] = useState(club.homeRegion || ''); const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || ''); + const [logoUrl, setLogoUrl] = useState(club.logoUrl || ''); + const [bannerUrl, setBannerUrl] = useState(club.bannerUrl || ''); // Save states const [isSaving, setIsSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [errorMessage, setErrorMessage] = useState(null); + // Image URL validation (HTTPS only or empty to clear) + function isValidHttpsImageUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return true; + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'https:'; + } catch { + return false; + } + } + // Validation const trimmedName = name.trim(); const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50; const isDescValid = description.trim().length <= 500; const isRegionValid = homeRegion.trim().length <= 100; const isFocusValid = motorcycleFocus.trim().length <= 100; - const canSave = isNameValid && isDescValid && isRegionValid && isFocusValid && !isSaving && permissions.canManageSettings; + const isLogoUrlValid = isValidHttpsImageUrl(logoUrl); + const isBannerUrlValid = isValidHttpsImageUrl(bannerUrl); + const canSave = + isNameValid && + isDescValid && + isRegionValid && + isFocusValid && + isLogoUrlValid && + isBannerUrlValid && + !isSaving && + permissions.canManageSettings; const isDirty = trimmedName !== (club.name || '').trim() || description.trim() !== (club.description || '').trim() || homeRegion.trim() !== (club.homeRegion || '').trim() || - motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim(); + motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim() || + logoUrl.trim() !== (club.logoUrl || '').trim() || + bannerUrl.trim() !== (club.bannerUrl || '').trim(); // Short initials for preview emblem const initials = name @@ -61,6 +87,9 @@ export function FounderProfileEditor({ setSaveStatus('idle'); setErrorMessage(null); + const trimmedLogo = logoUrl.trim(); + const trimmedBanner = bannerUrl.trim(); + try { if (isDemoFixture) { // Simulated latency in development demo preview mode @@ -71,6 +100,8 @@ export function FounderProfileEditor({ description: description.trim(), homeRegion: homeRegion.trim(), motorcycleFocus: motorcycleFocus.trim(), + logoUrl: trimmedLogo || null, + bannerUrl: trimmedBanner || null, }); } else { if (!callerUid) { @@ -81,6 +112,8 @@ export function FounderProfileEditor({ description: description.trim(), homeRegion: homeRegion.trim(), motorcycleFocus: motorcycleFocus.trim(), + logoUrl: trimmedLogo, + bannerUrl: trimmedBanner, }); setSaveStatus('success'); onProfileUpdated?.({ @@ -88,6 +121,8 @@ export function FounderProfileEditor({ description: description.trim(), homeRegion: homeRegion.trim(), motorcycleFocus: motorcycleFocus.trim(), + logoUrl: trimmedLogo || null, + bannerUrl: trimmedBanner || null, }); } } catch (err) { @@ -99,7 +134,7 @@ export function FounderProfileEditor({ } return ( -
+
{/* 3. PAGE HEADER */}
@@ -123,7 +158,7 @@ export function FounderProfileEditor({ {/* 4. TOP 4 METRIC CARDS */}
{/* Card 1: Club Name */} -
+
Club Name @@ -136,7 +171,7 @@ export function FounderProfileEditor({
{/* Card 2: Home Region */} -
+
Home Region @@ -151,7 +186,7 @@ export function FounderProfileEditor({
{/* Card 3: Motorcycle Focus */} -
+
Motorcycle Focus @@ -166,7 +201,7 @@ export function FounderProfileEditor({
{/* Card 4: Discovery Presence */} -
+
Discovery Presence @@ -217,7 +252,7 @@ export function FounderProfileEditor({ )} {/* SECTION 1: PUBLIC CLUB IDENTITY */} -
+
badge @@ -306,7 +341,7 @@ export function FounderProfileEditor({
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */} -
+
explore @@ -356,8 +391,8 @@ export function FounderProfileEditor({
- {/* SECTION 3: LOGO & BANNER (Presentation Only) */} -
+ {/* SECTION 3: LOGO & BANNER */} +
palette @@ -371,12 +406,23 @@ export function FounderProfileEditor({
{/* Logo Presentation Card */}
-
+
Club Emblem + {logoUrl && ( + + )}
- {club.logoUrl ? ( - Club logo + {logoUrl ? ( + Club logo ) : (
two_wheeler @@ -386,21 +432,50 @@ export function FounderProfileEditor({
)}
-
- info - Logo and banner editing is not available yet. +
+
+ 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 && ( +

+ Must be a valid HTTPS URL (e.g. https://example.com/logo.png). +

+ )} +
+

+ Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank. +

{/* Banner Presentation Card */}
-
+
Banner Presentation + {bannerUrl && ( + + )}
- {club.bannerUrl ? ( + {bannerUrl ? ( Club banner presentation @@ -409,21 +484,39 @@ export function FounderProfileEditor({
image - {club.name} + {name || club.name}
)}
-
- info - Logo and banner editing is not available yet. +
+
+ 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 && ( +

+ Must be a valid HTTPS URL (e.g. https://example.com/banner.jpg). +

+ )} +
+

+ Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank. +

{/* ACTION DOCKED FOOTER */} -
+
check_circle Saves public club profile information. @@ -472,9 +565,9 @@ export function FounderProfileEditor({
{/* Banner Mockup */}
- {club.bannerUrl ? ( + {bannerUrl ? ( Directory banner preview @@ -488,9 +581,15 @@ export function FounderProfileEditor({
{/* Emblem overlapping */} -
- two_wheeler - {initials} +
+ {logoUrl ? ( + Emblem preview + ) : ( + <> + two_wheeler + {initials} + + )}
diff --git a/website/components/founder-portal/founder-settings-editor.tsx b/website/components/founder-portal/founder-settings-editor.tsx index a2bc5f3..2c3f1bf 100644 --- a/website/components/founder-portal/founder-settings-editor.tsx +++ b/website/components/founder-portal/founder-settings-editor.tsx @@ -186,7 +186,7 @@ export function FounderSettingsEditor({ } return ( -
+
{/* 2. HEADER */}
@@ -397,7 +397,7 @@ export function FounderSettingsEditor({
-
+
{/* PUBLIC */}
-
+
{/* OPEN */} -
- +
+ Clubs - / + / {club.name} {activeNav === 'profile' && ( <> - / - Club Profile - - )} - {activeNav === 'settings' && ( - <> - / - Settings + / + Club Profile )} {activeNav === 'applications' && ( <> - / - Applications + / + Applications + + )} + {activeNav === 'ranks_roles' && ( + <> + / + Ranks & Roles + + )} + {activeNav === 'settings' && ( + <> + / + Settings )}
@@ -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" > swap_horiz - Switch Club - expand_more + Switch Club + expand_more
- {/* Middle: Search Input (desktop/tablet only) */} -
+ {/* Middle: Search Input (desktop/tablet only, hidden on lg to prevent crush) */} +
search @@ -122,11 +128,11 @@ export function FounderTopBar({
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */} -
+
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */} {process.env.NODE_ENV === 'development' ? (
Dev Preview: @@ -146,7 +152,7 @@ export function FounderTopBar({
) : (
@@ -169,15 +175,15 @@ export function FounderTopBar({ {permissions.canPlanRides && ( )} {/* Notifications */} -
+
RiderRequestedStatusActionsRiderRequestedStatusActions