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.
101 lines
2.9 KiB
TypeScript
101 lines
2.9 KiB
TypeScript
'use client'
|
||
|
||
import { useState, type KeyboardEvent } from 'react'
|
||
|
||
type Props = {
|
||
values: string[]
|
||
onChange: (next: string[]) => void
|
||
placeholder?: string
|
||
maxLength?: number
|
||
maxItems?: number
|
||
suggestions?: string[]
|
||
}
|
||
|
||
/**
|
||
* Minimal chip / token input. Press Enter or comma to commit the current
|
||
* value. Backspace on an empty input removes the last chip. Optional
|
||
* suggestion list shows un-used suggestions as click-to-add chips below.
|
||
*/
|
||
export function ChipInput({
|
||
values,
|
||
onChange,
|
||
placeholder,
|
||
maxLength = 80,
|
||
maxItems = 50,
|
||
suggestions,
|
||
}: Props) {
|
||
const [draft, setDraft] = useState('')
|
||
|
||
function commit(raw: string) {
|
||
const v = raw.trim()
|
||
if (!v) return
|
||
if (values.includes(v)) return
|
||
if (values.length >= maxItems) return
|
||
onChange([...values, v.slice(0, maxLength)])
|
||
setDraft('')
|
||
}
|
||
|
||
function remove(idx: number) {
|
||
onChange(values.filter((_, i) => i !== idx))
|
||
}
|
||
|
||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||
if (e.key === 'Enter' || e.key === ',') {
|
||
e.preventDefault()
|
||
commit(draft)
|
||
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
|
||
e.preventDefault()
|
||
onChange(values.slice(0, -1))
|
||
}
|
||
}
|
||
|
||
const remainingSuggestions = (suggestions ?? []).filter(s => !values.includes(s))
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-1.5 bg-background border border-border rounded px-2 py-1.5 focus-within:border-primary">
|
||
{values.map((v, i) => (
|
||
<span
|
||
key={`${v}-${i}`}
|
||
className="inline-flex items-center gap-1 bg-muted rounded px-2 py-0.5 font-mono text-xs"
|
||
>
|
||
{v}
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(i)}
|
||
className="text-muted-foreground hover:text-foreground"
|
||
aria-label={`Remove ${v}`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<input
|
||
type="text"
|
||
value={draft}
|
||
onChange={e => setDraft(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={() => commit(draft)}
|
||
placeholder={values.length === 0 ? placeholder : ''}
|
||
maxLength={maxLength}
|
||
className="flex-1 min-w-[120px] bg-transparent text-foreground font-sans text-sm py-1 px-1 focus:outline-none"
|
||
/>
|
||
</div>
|
||
{remainingSuggestions.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||
{remainingSuggestions.map(s => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
onClick={() => commit(s)}
|
||
className="font-mono text-[10px] tracking-widest border border-border text-muted-foreground hover:text-foreground hover:border-primary rounded px-2 py-0.5"
|
||
>
|
||
+ {s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|