MotorSignal-App/website/components/founder-portal/club-permission-matrix.tsx

239 lines
11 KiB
TypeScript

'use client';
import React, { useState } from 'react';
import { ClubCapability, ClubCapabilityDefinition, ClubRankDefinition } from './types';
interface ClubPermissionMatrixProps {
ranks: ClubRankDefinition[];
capabilities: ClubCapabilityDefinition[];
permissions: Record<string, Record<ClubCapability, boolean>>;
selectedRankId: string | null;
onTogglePermission: (rankId: string, capability: ClubCapability) => void;
onResetDefaults?: () => void;
onSavePermissions?: () => Promise<void>;
isSaving?: boolean;
canEdit: boolean;
/**
* Whether a server-side persistence contract for per-rank capability
* permissions actually exists. Defaults to false.
*
* The enforced club authorization model is role-based (Owner / Admin), not
* rank-based (see getPortalPermissions in lib/club-data.ts). No Firestore
* document or API persists this capability matrix today, so by default the
* matrix renders as a read-only reference of the standard governance model.
* The editable + save path is retained for when a backend contract ships
* (see website/docs/backend-contracts/club-rank-permissions.md).
*/
persistenceAvailable?: boolean;
}
export function ClubPermissionMatrix({
ranks,
capabilities,
permissions,
selectedRankId,
onTogglePermission,
onResetDefaults,
onSavePermissions,
isSaving = false,
canEdit,
persistenceAvailable = false,
}: ClubPermissionMatrixProps) {
const [saveSuccess, setSaveSuccess] = useState(false);
// Edit + save controls are only live when the caller both grants edit rights
// AND a real persistence backend is wired. Without persistence the matrix is
// a truthful read-only reference: it never mutates and never reports a save.
const editable = canEdit && persistenceAvailable && typeof onSavePermissions === 'function';
async function handleSave() {
if (!editable || !onSavePermissions) return;
await onSavePermissions();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2500);
}
return (
<section className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-6 shadow-lg">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-brand-border/60 pb-4">
<div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-[20px]">
tune
</span>
<h2 className="font-headline text-lg sm:text-xl font-bold text-white tracking-tight">
Operational Permission Matrix
</h2>
</div>
<p className="font-body text-xs sm:text-sm text-slate-400 mt-1">
Standard delegated operational privileges per squad rank. Administrative roles enforce backend limits.
</p>
</div>
{editable ? (
<div className="flex items-center gap-2.5 self-start sm:self-center">
{onResetDefaults && (
<button
type="button"
onClick={onResetDefaults}
className="px-3 py-1.5 rounded-xl text-xs font-mono text-slate-400 hover:text-white bg-surface-container hover:bg-surface-container-high border border-brand-border/60 transition-colors"
>
Reset Defaults
</button>
)}
<button
type="button"
disabled={isSaving}
onClick={handleSave}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-xl text-xs font-headline font-bold bg-brand-cyan text-black hover:bg-brand-cyan/90 disabled:opacity-50 transition-all shadow-[0_0_12px_rgba(0,242,254,0.3)]"
>
{isSaving ? (
<>
<span className="w-3.5 h-3.5 border-2 border-black border-t-transparent rounded-full animate-spin" />
Saving...
</>
) : saveSuccess ? (
<>
<span className="material-symbols-outlined text-[16px]">check_circle</span>
Saved
</>
) : (
<>
<span className="material-symbols-outlined text-[16px]">save</span>
Save Matrix
</>
)}
</button>
</div>
) : (
<span
className="inline-flex items-center gap-1.5 self-start sm:self-center px-3 py-1.5 rounded-xl text-[11px] font-mono text-slate-400 bg-surface-container border border-brand-border/60"
title="Per-rank permission customization is not yet available. Software access is enforced by Owner and Admin roles."
>
<span className="material-symbols-outlined text-[15px]">visibility</span>
View-only reference
</span>
)}
</div>
{/* Truthful availability notice: shown whenever persistence is not wired. */}
{!editable && (
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-xs font-body text-amber-200/90">
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0 mt-0.5">
info
</span>
<span>
This matrix shows MotorSignal&rsquo;s standard rank conventions for reference. Custom per-rank
permissions are not saved yet. Who can actually manage this club is enforced by the{' '}
<strong className="font-semibold text-amber-100">Owner</strong> and{' '}
<strong className="font-semibold text-amber-100">Admin</strong> roles in the member roster — see
Ranks &amp; Roles above. Per-rank customization is planned.
</span>
</div>
)}
{/* Desktop / Tablet Matrix Table */}
<div className="overflow-x-auto rounded-xl border border-brand-border/60 bg-surface-container/60">
<table className="w-full text-left border-collapse min-w-[720px]">
<thead>
<tr className="border-b border-brand-border/80 bg-surface-container-high/80 text-[11px] font-mono text-slate-400 uppercase tracking-wider">
<th className="py-3 px-4 font-semibold w-48">Squad Rank</th>
{capabilities.map((cap) => (
<th key={cap.id} className="py-3 px-3 font-semibold text-center">
<div className="flex flex-col items-center">
<span>{cap.label}</span>
<span className="text-[9px] text-slate-400 font-normal lowercase tracking-normal">
{cap.category}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-brand-border/40 font-body text-xs">
{ranks.map((rank) => {
const isSelected = selectedRankId === rank.id;
const rankPerms = permissions[rank.id] || {};
return (
<tr
key={rank.id}
className={`transition-colors ${
isSelected
? 'bg-brand-cyan/5 border-l-2 border-l-brand-cyan'
: 'hover:bg-surface-container-high/40'
}`}
>
<td className="py-3.5 px-4 font-medium">
<div className="flex items-center gap-2.5">
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0 border"
style={{
backgroundColor: rank.badgeBg,
borderColor: `${rank.badgeColor}40`,
color: rank.badgeColor,
}}
>
<span className="material-symbols-outlined text-[14px]">{rank.icon}</span>
</div>
<div>
<div className="font-headline font-bold text-white text-xs">{rank.title}</div>
<div className="font-mono text-[10px] text-slate-400">Level {rank.level}</div>
</div>
</div>
</td>
{capabilities.map((cap) => {
const isGranted = rankPerms[cap.id] ?? false;
const isImmutableOwner = rank.id === 'president';
const toggleDisabled = !editable || isImmutableOwner;
return (
<td key={cap.id} className="py-3.5 px-3 text-center">
<button
type="button"
disabled={toggleDisabled}
aria-readonly={!editable}
onClick={() => {
if (toggleDisabled) return;
onTogglePermission(rank.id, cap.id);
}}
aria-label={`${editable ? 'Toggle' : 'Standard setting:'} ${cap.label} for ${rank.title}`}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-75 disabled:cursor-not-allowed ${
editable ? 'cursor-pointer' : 'cursor-default'
} ${isGranted ? 'bg-brand-cyan' : 'bg-slate-700'}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-black shadow ring-0 transition duration-200 ease-in-out ${
isGranted ? 'translate-x-4' : 'translate-x-0 bg-slate-400'
}`}
/>
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Legend & Architecture Note */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs font-mono text-slate-400">
<div className="p-3 rounded-xl bg-surface-container/60 border border-brand-border/40 flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-brand-cyan shrink-0" />
<span>Active Toggle: Rank delegates authority to lead and coordinate squad members.</span>
</div>
<div className="p-3 rounded-xl bg-surface-container/60 border border-brand-border/40 flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">
shield
</span>
<span>Software RBAC: Owner and Admin roles enforce immutable security boundaries in Firestore.</span>
</div>
</div>
</section>
);
}