234 lines
8.5 KiB
TypeScript
234 lines
8.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, use } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import {
|
|
fetchFounderClubContext,
|
|
FounderClubContext,
|
|
sanitizePortalError,
|
|
fetchClubMembers,
|
|
updateMemberRank,
|
|
DEFAULT_CLUB_RANKS,
|
|
DEFAULT_CLUB_CAPABILITIES,
|
|
DEFAULT_RANK_PERMISSIONS,
|
|
} from '../../../../../lib/club-data';
|
|
import { ClubMember, ClubCapability } from '../../../../../components/founder-portal/types';
|
|
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
|
|
import { RolesAndRanksSummary } from '../../../../../components/founder-portal/roles-and-ranks-summary';
|
|
import { ClubHierarchyBuilder } from '../../../../../components/founder-portal/club-hierarchy-builder';
|
|
import { ClubPermissionMatrix } from '../../../../../components/founder-portal/club-permission-matrix';
|
|
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 [members, setMembers] = useState<ClubMember[]>([]);
|
|
const [permissions, setPermissions] = useState(DEFAULT_RANK_PERMISSIONS);
|
|
const [selectedRankId, setSelectedRankId] = useState<string | null>('president');
|
|
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, clubMembers] = await Promise.all([
|
|
fetchFounderClubContext(clubId, currentUser?.uid, {
|
|
allowDemoPreview: isDemoPreview,
|
|
}),
|
|
fetchClubMembers(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);
|
|
setMembers(clubMembers);
|
|
} catch (err) {
|
|
setError(sanitizePortalError(err, "Ranks and roles couldn't be loaded."));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadData();
|
|
}, [clubId]);
|
|
|
|
async function handleUpdateMemberRank(memberUid: string, newRank: string) {
|
|
await updateMemberRank(clubId, memberUid, newRank);
|
|
setMembers((prev) =>
|
|
prev.map((m) => (m.uid === memberUid ? { ...m, displayRank: newRank } : m))
|
|
);
|
|
}
|
|
|
|
function handleTogglePermission(rankId: string, capability: ClubCapability) {
|
|
setPermissions((prev) => ({
|
|
...prev,
|
|
[rankId]: {
|
|
...prev[rankId],
|
|
[capability]: !prev[rankId]?.[capability],
|
|
},
|
|
}));
|
|
}
|
|
|
|
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="w-full 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 & 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 & 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}
|
|
/>
|
|
|
|
{/* Interactive Visual Hierarchy Builder Tree */}
|
|
<ClubHierarchyBuilder
|
|
clubId={clubId}
|
|
ranks={DEFAULT_CLUB_RANKS}
|
|
members={members}
|
|
selectedRankId={selectedRankId}
|
|
onSelectRank={setSelectedRankId}
|
|
onUpdateMemberRank={handleUpdateMemberRank}
|
|
canManageRanks={context.permissions.canManageSettings}
|
|
/>
|
|
|
|
{/* Interactive Permission Toggle Switches Matrix */}
|
|
{/*
|
|
Per-rank capability permissions have no server-side persistence contract yet.
|
|
The enforced authorization model is role-based (Owner / Admin) via
|
|
getPortalPermissions in lib/club-data.ts, not this rank matrix. Rendering the
|
|
matrix read-only (persistenceAvailable={false}) keeps it truthful: it shows the
|
|
standard governance conventions without claiming a save that does not occur.
|
|
See website/docs/backend-contracts/club-rank-permissions.md for the proposed
|
|
contract that would flip persistenceAvailable to true.
|
|
*/}
|
|
<ClubPermissionMatrix
|
|
ranks={DEFAULT_CLUB_RANKS}
|
|
capabilities={DEFAULT_CLUB_CAPABILITIES}
|
|
permissions={permissions}
|
|
selectedRankId={selectedRankId}
|
|
onTogglePermission={handleTogglePermission}
|
|
canEdit={context.permissions.canManageSettings}
|
|
persistenceAvailable={false}
|
|
/>
|
|
</div>
|
|
</FounderPortalShell>
|
|
);
|
|
}
|