feat: governance kernel, deployment hardening, local-dev stack
Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.
Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".
Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.
Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.
Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.
Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.
Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.
Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
This commit is contained in:
@@ -1,149 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import {
|
||||
LayoutDashboard, ListChecks, FolderKanban, Settings2, MessageSquare,
|
||||
Users, CreditCard, BookUser, Download, LogOut, ChevronDown
|
||||
} from 'lucide-react'
|
||||
import type { Tables } from '@/types/database'
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
section: 'WORKSPACE',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Tasks & Chores', href: '/tasks', icon: ListChecks, badge: 7 },
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Ops & Facilities', href: '/ops', icon: Settings2 },
|
||||
{ label: 'Comms', href: '/comms', icon: MessageSquare, badge: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'PEOPLE',
|
||||
items: [
|
||||
{ label: 'Members', href: '/members', icon: Users },
|
||||
{ label: 'Payments', href: '/payments', icon: CreditCard, badge: 12, badgeColor: 'text-orange-500 bg-orange-500/10' },
|
||||
{ label: 'Contacts', href: '/contacts', icon: BookUser },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'ADMIN',
|
||||
items: [
|
||||
{ label: 'Import / Sync', href: '/import', icon: Download },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings2 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface Props {
|
||||
member: Tables<'space_members'>
|
||||
space: Tables<'spaces'>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function AppShell({ member, space, children }: Props) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [signingOut, setSigningOut] = useState(false)
|
||||
|
||||
async function signOut() {
|
||||
setSigningOut(true)
|
||||
const supabase = createClient()
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const initials = (member.display_name || 'U')
|
||||
.split(' ')
|
||||
.map((n: string) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-[230px] flex-shrink-0 bg-sidebar flex flex-col h-full overflow-y-auto">
|
||||
{/* Logo */}
|
||||
<div className="px-4 pt-5 pb-4">
|
||||
<Link href="/dashboard" className="block">
|
||||
<span className="font-mono text-lg font-bold">
|
||||
<span className="text-primary">{'{'}</span>
|
||||
<span className="text-sidebar-foreground">hs</span>
|
||||
<span className="text-primary">{'}'}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="font-sans text-xs text-sidebar-foreground/50 mt-0.5 truncate">{space.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-2 space-y-4">
|
||||
{NAV.map(({ section, items }) => (
|
||||
<div key={section}>
|
||||
<p className="font-mono text-[10px] text-sidebar-foreground/30 tracking-widest px-2 mb-1">
|
||||
{section}
|
||||
</p>
|
||||
{items.map(({ label, href, icon: Icon, badge, badgeColor }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + '/')
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded text-sm font-sans transition group ${
|
||||
active
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{badge ? (
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
|
||||
badgeColor || 'text-sidebar-foreground/50 bg-sidebar-foreground/10'
|
||||
}`}>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="p-3 border-t border-sidebar-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-primary flex items-center justify-center text-xs font-mono font-bold text-white flex-shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-sans text-sidebar-foreground truncate">{member.display_name}</p>
|
||||
<p className="text-[10px] font-mono text-sidebar-foreground/40">
|
||||
{member.role} · {member.tier}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
disabled={signingOut}
|
||||
title="Sign out"
|
||||
className="text-sidebar-foreground/30 hover:text-sidebar-foreground/70 transition"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type Space = Tables<'spaces'>
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavLinkProps {
|
||||
@@ -93,12 +94,24 @@ export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge
|
||||
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} onClick={onNav} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} onClick={onNav} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Governance</p>
|
||||
</div>
|
||||
<NavLink href="/proposals" label="Proposals" icon={Vote} active={isActive('/proposals')} onClick={onNav} />
|
||||
<NavLink href="/incidents" label="Incidents" icon={ShieldAlert} active={isActive('/incidents')} onClick={onNav} />
|
||||
<NavLink href="/policies" label="Policies" icon={ScrollText} active={isActive('/policies')} onClick={onNav} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} onClick={onNav} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} onClick={onNav} />
|
||||
<NavLink href="/financials" label="Financials" icon={LineChart} active={isActive('/financials')} onClick={onNav} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
|
||||
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
|
||||
{isAdmin && (
|
||||
<NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} />
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
/**
|
||||
* Server-renderable markdown component for governance text (proposals,
|
||||
* incidents, policies). Renders bold, italic, lists, links, code, tables,
|
||||
* strikethrough, and autolinks via GFM. All HTML is escaped by react-markdown
|
||||
* by default; we never pass `rehypeRaw` so embedded `<script>` tags or
|
||||
* arbitrary HTML in user input are inert.
|
||||
*
|
||||
* `content` is treated as untrusted member input.
|
||||
*/
|
||||
export function MarkdownBody({ content, className = '' }: { content: string | null | undefined; className?: string }) {
|
||||
if (!content || content.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`markdown-body font-sans text-sm text-foreground/90 ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="text-xl font-semibold text-foreground mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-lg font-semibold text-foreground mt-4 mb-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-base font-semibold text-foreground mt-3 mb-1">{children}</h3>,
|
||||
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-outside pl-5 my-2 space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="bg-muted px-1 py-0.5 rounded font-mono text-xs">{children}</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted border border-border rounded p-3 my-2 overflow-x-auto font-mono text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 my-2 text-muted-foreground italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-3">
|
||||
<table className="border-collapse text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 bg-muted text-left font-semibold">{children}</th>
|
||||
),
|
||||
td: ({ children }) => <td className="border border-border px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="my-4 border-border" />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user