MotorSignal-App/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx

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 &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}
/>
{/* 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>
);
}