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:
Moheeb Zara
2026-05-14 04:52:11 -07:00
parent 8b9128704b
commit ea8d7a5f91
99 changed files with 13823 additions and 1940 deletions
+15
View File
@@ -18,6 +18,13 @@ export default async function SettingsPage() {
const { data: integrations } = await supabase
.from('integrations').select('*').eq('space_id', member.space_id)
const { data: areas } = await supabase
.from('space_areas')
.select('id, code, name, icon, sort_order, is_archived')
.eq('space_id', member.space_id)
.order('sort_order', { ascending: true })
.order('name', { ascending: true })
const isAdmin = member?.role === 'admin'
return (
@@ -26,6 +33,14 @@ export default async function SettingsPage() {
isAdmin={isAdmin}
integrations={integrations ?? []}
currentRole={member.role}
areas={(areas ?? []) as Array<{
id: string
code: string
name: string
icon: string | null
sort_order: number
is_archived: boolean
}>}
/>
)
}
+282 -3
View File
@@ -2,7 +2,17 @@
import { useState } from 'react'
import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react'
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret } from '@/lib/actions'
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea } from '@/lib/actions'
import { financialVisibilities, directoryVisibilities } from '@/lib/validations'
type Area = {
id: string
code: string
name: string
icon: string | null
sort_order: number
is_archived: boolean
}
import type { Tables } from '@/types/database'
type Space = Tables<'spaces'>
@@ -13,6 +23,7 @@ interface Props {
isAdmin: boolean
integrations: Integration[]
currentRole: string
areas: Area[]
}
const ROLES_INFO = [
@@ -87,18 +98,32 @@ const INTEGRATIONS_CONFIG = [
},
]
export default function SettingsClient({ space, isAdmin, integrations, currentRole }: Props) {
export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas }: Props) {
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'integrations' | 'webhooks'>('space')
const [areas, setAreas] = useState<Area[]>(initialAreas)
const [newArea, setNewArea] = useState({ code: '', name: '' })
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Space form
// Space form (basic + mission statement)
const spaceExt = space as Space & {
mission_statement?: string | null
financial_visibility?: string
member_directory_visibility?: string
}
const [spaceForm, setSpaceForm] = useState({
name: space.name || '',
slug: space.slug || '',
city: space.city || '',
require_approval: space.require_approval ?? true,
public_member_directory: space.public_member_directory ?? false,
mission_statement: spaceExt.mission_statement || '',
})
// Visibility form (governance) — separate server action, separate save.
const [visibilityForm, setVisibilityForm] = useState({
financial_visibility: (spaceExt.financial_visibility as string | undefined) || 'board_visible',
member_directory_visibility: (spaceExt.member_directory_visibility as string | undefined) || 'members_visible',
})
// Integration config modal
@@ -124,6 +149,20 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
setSaving(false)
}
async function handleSaveVisibility(e: React.FormEvent) {
e.preventDefault()
if (!isAdmin) return
setSaving(true)
setMessage(null)
const result = await updateSpaceVisibility(visibilityForm)
if ('error' in result && result.error) {
setMessage({ type: 'error', text: result.error })
} else {
setMessage({ type: 'success', text: 'Visibility updated.' })
}
setSaving(false)
}
async function handleSaveIntegration(e: React.FormEvent) {
e.preventDefault()
if (!editingIntegration || !isAdmin) return
@@ -284,6 +323,19 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
</div>
</div>
<div className="pt-4 border-t border-border">
<label className="font-sans text-sm text-foreground block mb-1.5">Mission statement</label>
<textarea
value={spaceForm.mission_statement}
onChange={e => setSpaceForm(f => ({ ...f, mission_statement: e.target.value }))}
disabled={!isAdmin}
maxLength={5000}
rows={4}
placeholder="One or two sentences. Rendered at the foot of every page as a reminder to members and the platform itself."
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
/>
</div>
<div className="pt-4 border-t border-border">
<p className="font-sans text-sm text-foreground mb-1.5">Invite code</p>
<div className="flex items-center gap-2">
@@ -317,6 +369,233 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
</div>
)}
{/* Governance Visibility (still under Space tab) */}
{activeTab === 'space' && (
<div className="bg-card rounded border border-border p-6 mt-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">Visibility</p>
<p className="font-mono text-[10px] text-muted-foreground mb-6">
Who sees financial data and the member directory. These flags propagate to the
/financials page and (in a future pass) to the members directory.
</p>
<form onSubmit={handleSaveVisibility} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="font-sans text-sm text-foreground block mb-1.5">Financial data visibility</label>
<select
value={visibilityForm.financial_visibility}
onChange={e => setVisibilityForm(f => ({ ...f, financial_visibility: e.target.value }))}
disabled={!isAdmin}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
>
{financialVisibilities.map(v => (
<option key={v} value={v}>{v.replace(/_/g, ' ')}</option>
))}
</select>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Treasurer-only is the default in cooperatives historically. Board-visible is
the recommended default. All-members-visible is full transparency.
</p>
</div>
<div>
<label className="font-sans text-sm text-foreground block mb-1.5">Member directory visibility</label>
<select
value={visibilityForm.member_directory_visibility}
onChange={e => setVisibilityForm(f => ({ ...f, member_directory_visibility: e.target.value }))}
disabled={!isAdmin}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
>
{directoryVisibilities.map(v => (
<option key={v} value={v}>{v.replace(/_/g, ' ')}</option>
))}
</select>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Public-members-visible exposes the directory at a public URL. Count-only
surfaces the headcount but no individual names.
</p>
</div>
</div>
{isAdmin && (
<div>
<button
type="submit"
disabled={saving}
className="bg-primary text-white font-sans text-sm px-6 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save visibility'}
</button>
</div>
)}
</form>
</div>
)}
{/* Areas (still under Space tab) */}
{activeTab === 'space' && (
<div className="bg-card rounded border border-border p-6 mt-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">Areas</p>
<p className="font-mono text-[10px] text-muted-foreground mb-6">
Areas appear in the Tasks, Projects, and KB dropdowns. Defaults are seeded when a space
is created. Rename, reorder, or archive freely; existing tasks tagged with the old
name will not auto-rename.
</p>
<ul className="divide-y divide-border mb-6">
{areas.length === 0 ? (
<li className="py-3 font-sans text-sm text-muted-foreground">No areas yet.</li>
) : (
areas.map(area => (
<li key={area.id} className="py-2 flex items-center gap-3">
<input
type="number"
value={area.sort_order}
onChange={async e => {
const next = parseInt(e.target.value || '0', 10)
setAreas(prev => prev.map(a => (a.id === area.id ? { ...a, sort_order: next } : a)))
}}
onBlur={async e => {
if (!isAdmin) return
const next = parseInt(e.target.value || '0', 10)
await updateArea({ areaId: area.id, sort_order: next })
}}
disabled={!isAdmin}
className="w-14 bg-background border border-border rounded px-2 py-1 font-mono text-xs text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
aria-label="Sort order"
/>
<input
type="text"
value={area.name}
onChange={e => {
const next = e.target.value
setAreas(prev => prev.map(a => (a.id === area.id ? { ...a, name: next } : a)))
}}
onBlur={async e => {
if (!isAdmin) return
const next = e.target.value.trim()
if (!next || next === area.name) return
const result = await updateArea({ areaId: area.id, name: next })
if ('error' in result && result.error) {
setMessage({ type: 'error', text: result.error })
}
}}
disabled={!isAdmin}
maxLength={80}
className={`flex-1 bg-background border border-border rounded px-3 py-1.5 font-sans text-sm rounded ${
area.is_archived ? 'text-muted-foreground line-through' : 'text-foreground'
} disabled:opacity-50 focus:outline-none focus:border-primary`}
/>
<span className="font-mono text-[10px] text-muted-foreground/60 w-24 truncate" title={area.code}>
{area.code}
</span>
{isAdmin && (
<>
<button
type="button"
onClick={async () => {
const result = await updateArea({ areaId: area.id, is_archived: !area.is_archived })
if ('error' in result && result.error) {
setMessage({ type: 'error', text: result.error })
} else {
setAreas(prev =>
prev.map(a =>
a.id === area.id ? { ...a, is_archived: !a.is_archived } : a,
),
)
}
}}
className="font-mono text-[10px] tracking-widest uppercase border border-border text-muted-foreground hover:text-foreground rounded px-2 py-1"
>
{area.is_archived ? 'restore' : 'archive'}
</button>
<button
type="button"
onClick={async () => {
if (!confirm(`Delete area "${area.name}" entirely? Existing tagged items keep the name as plain text. Use Archive instead if it has been used.`)) return
const result = await deleteArea(area.id)
if ('error' in result && result.error) {
setMessage({ type: 'error', text: result.error })
} else {
setAreas(prev => prev.filter(a => a.id !== area.id))
}
}}
className="font-mono text-[10px] tracking-widest uppercase border border-border text-muted-foreground hover:text-red-500 hover:border-red-500 rounded px-2 py-1"
>
delete
</button>
</>
)}
</li>
))
)}
</ul>
{isAdmin && (
<form
onSubmit={async e => {
e.preventDefault()
const code = newArea.code.trim().toLowerCase()
const name = newArea.name.trim()
if (!code || !name) return
setSaving(true)
const result = await createArea({ code, name })
if ('error' in result && result.error) {
setMessage({ type: 'error', text: result.error })
} else if ('data' in result && result.data) {
setAreas(prev =>
[...prev, result.data as Area].sort(
(a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name),
),
)
setNewArea({ code: '', name: '' })
}
setSaving(false)
}}
className="border-t border-border pt-4 grid grid-cols-1 sm:grid-cols-[1fr_2fr_auto] gap-3 items-end"
>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Code
</label>
<input
type="text"
value={newArea.code}
onChange={e =>
setNewArea(f => ({
...f,
code: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''),
}))
}
maxLength={40}
placeholder="leather"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Display name
</label>
<input
type="text"
value={newArea.name}
onChange={e => setNewArea(f => ({ ...f, name: e.target.value }))}
maxLength={80}
placeholder="Leatherwork"
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
/>
</div>
<button
type="submit"
disabled={saving || !newArea.code.trim() || !newArea.name.trim()}
className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
Add area
</button>
</form>
)}
</div>
)}
{/* Users & Roles Tab */}
{activeTab === 'roles' && (
<div className="bg-card rounded border border-border p-6">