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:
@@ -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
|
||||
}>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user