feat: forum, polymorphic comments, custom tiers, multi-invite, secrets encryption

Big feature batch driven by the super-audit findings and the user's
follow-up requests.

Schema migration 021 (folded into schema.sql Section 13):
- forum_threads + polymorphic comments table keyed on (entity_type,
  entity_id). Entity types: forum_thread, proposal, incident, policy.
  Trigger maintains forum_threads.comment_count and last_comment_at.
- space_tiers per-space with monthly_price_cents, billing_cadence,
  is_system, sort_order. Trigger seeds plus/basic/associate on every
  space INSERT and backfills existing spaces. space_members.tier_id FK
  added and backfilled from the legacy enum value.
- space_role_labels (rename/recolor built-in roles per space).
  space_custom_roles + space_member_custom_roles for non-privileged
  extra labels. RLS gates by admin/board.
- space_invites: multi-code invites with expires_at, max_uses,
  uses_count, is_enabled. Legacy spaces.invite_code backfilled as a
  permanent enabled invite.
- secrets.encrypted_value bytea + encryption_version. Secrets RLS
  tightened. New comms_channels INSERT policy: any member can create;
  default channels are protected from deletion.
- knowledge_base.render_markdown boolean (default true).

Secrets encryption-at-rest:
- AES-256-GCM in lib/secrets/crypto.ts. Master key from
  SECRETS_ENCRYPTION_KEY env (64 hex chars).
- New revealSecret(id) server action: only path to plaintext, writes
  secret.revealed to activity_log.
- /ops/page.tsx no longer selects value or encrypted_value on the list
  endpoint - the original screenshot bug where ciphertext was already
  in the page bundle before "Reveal" is fixed.

UI:
- /forum index, /forum/new, /forum/[id]. Pin/lock/delete actions for
  admin/board.
- CommentThread component embedded on /proposals/[id], /incidents/[id],
  /policies/[slug]. Markdown rendering via react-markdown + remark-gfm.
- /comms sidebar: inline "+ New channel" form.
- /ops KB: row click opens markdown-rendering viewer modal.
- /settings: new Tiers tab and Invites tab. Inline price edit and
  archive/delete on tiers; copy/enable/disable/delete on invites.
- AtlasLogo replaces the terminal icon on the landing nav and footer.
  public/logo.svg wired as the favicon in app/layout.tsx metadata.
- Sidebar has a Forum nav link.

Server actions added:
- lib/actions/forum.ts, comms.ts, tiers.ts, roles.ts, invites.ts.
- lib/actions/secrets.ts rewritten with updateSecret + revealSecret.

Auth flow:
- joinSpace honors codes from space_invites first (enabled, expiry,
  use cap), falls back to legacy spaces.invite_code, increments
  uses_count on success.

Env:
- SECRETS_ENCRYPTION_KEY documented in .env.example and set on the
  Droplet's /opt/hackerspace-ops/.env.production.

README: live site link added (https://hackerspace.sh).

266/266 unit tests pass. Production build clean. Migration 021 will be
applied on the next deploy via /opt/hackerspace-ops/deploy.sh's
idempotent migration runner.
This commit is contained in:
Moheeb Zara
2026-05-14 17:47:52 -07:00
parent 8ad1ca44b7
commit de51000584
35 changed files with 2981 additions and 93 deletions
+94 -42
View File
@@ -3,7 +3,10 @@
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, X, ChevronDown, Users2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
import { createClient } from '@/lib/supabase/client'
import { toast } from 'sonner'
import type { Tables, TablesInsert } from '@/types/database'
@@ -345,19 +348,19 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
const [loading, setLoading] = useState(false)
async function reveal() {
if (revealed) { setRevealed(false); return }
if (revealed) { setRevealed(false); setValue(null); return }
setLoading(true)
const supabase = createClient()
const { data } = await supabase.from('secrets').select('value').eq('id', secret.id).single()
const result = await revealSecret(secret.id)
setLoading(false)
if (data) { setValue(data.value); setRevealed(true) }
if (result.error) { toast.error(result.error); return }
setValue(result.value ?? '')
setRevealed(true)
}
async function handleDelete() {
if (!confirm(`Delete secret "${secret.title}"? This cannot be undone.`)) return
const supabase = createClient()
const { error } = await supabase.from('secrets').delete().eq('id', secret.id)
if (error) { toast.error(error.message); return }
const result = await deleteSecret(secret.id)
if (result.error) { toast.error(result.error); return }
toast.success('Secret deleted')
onDelete(secret.id)
}
@@ -402,7 +405,10 @@ function KbEntryRow({
onEdit: (e: KbEntry) => void
onDelete: (id: string) => void
}) {
async function handleDelete() {
const [viewing, setViewing] = useState(false)
async function handleDelete(e: React.MouseEvent) {
e.stopPropagation()
if (!confirm(`Delete "${entry.title}"?`)) return
const result = await deleteKbEntry(entry.id)
if ('error' in result && result.error) { toast.error(result.error); return }
@@ -410,44 +416,90 @@ function KbEntryRow({
onDelete(entry.id)
}
function handleEdit(e: React.MouseEvent) {
e.stopPropagation()
onEdit(entry)
}
return (
<div className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/20 transition group border-l-4 ${
entry.is_pinned
? entry.visibility === 'admin_only' ? 'border-l-red-400' : entry.visibility === 'board' ? 'border-l-amber-400' : 'border-l-primary'
: 'border-l-transparent'
}`}>
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0 text-base">
{entry.icon || (
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="font-sans text-sm font-medium text-foreground">{entry.title}</p>
{entry.is_pinned && <Pin className="w-3 h-3 text-primary flex-shrink-0" />}
<>
<div
onClick={() => setViewing(true)}
className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/20 transition group border-l-4 cursor-pointer ${
entry.is_pinned
? entry.visibility === 'admin_only' ? 'border-l-red-400' : entry.visibility === 'board' ? 'border-l-amber-400' : 'border-l-primary'
: 'border-l-transparent'
}`}
>
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0 text-base">
{entry.icon || (
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
</div>
<p className="font-mono text-[10px] text-muted-foreground line-clamp-2">{entry.content?.slice(0, 120)}</p>
<p className="font-mono text-[10px] text-muted-foreground/60 mt-1">
{entry.area && <span>{entry.area} · </span>}
updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name}
</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={`font-mono text-[10px] px-2 py-0.5 rounded border whitespace-nowrap ${VISIBILITY_COLORS[entry.visibility] ?? 'text-muted-foreground bg-muted border-border'}`}>
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button onClick={() => onEdit(entry)} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="font-sans text-sm font-medium text-foreground">{entry.title}</p>
{entry.is_pinned && <Pin className="w-3 h-3 text-primary flex-shrink-0" />}
</div>
<p className="font-mono text-[10px] text-muted-foreground line-clamp-2">{entry.content?.slice(0, 120)}</p>
<p className="font-mono text-[10px] text-muted-foreground/60 mt-1">
{entry.area && <span>{entry.area} · </span>}
updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name}
</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={`font-mono text-[10px] px-2 py-0.5 rounded border whitespace-nowrap ${VISIBILITY_COLORS[entry.visibility] ?? 'text-muted-foreground bg-muted border-border'}`}>
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button onClick={handleEdit} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
{viewing && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setViewing(false)}>
<div
onClick={e => e.stopPropagation()}
className="bg-card rounded border border-border w-full max-w-3xl max-h-[90vh] overflow-y-auto"
>
<div className="sticky top-0 bg-card border-b border-border px-5 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{entry.icon && <span className="text-lg">{entry.icon}</span>}
<h2 className="font-sans text-lg font-semibold text-foreground">{entry.title}</h2>
{entry.is_pinned && <Pin className="w-3.5 h-3.5 text-primary" />}
</div>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{entry.area && <span>{entry.area} · </span>}
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
{' · '}
updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name ?? 'unknown'}
</p>
</div>
<button onClick={() => onEdit(entry)} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => setViewing(false)} className="text-muted-foreground hover:text-foreground transition p-1" title="Close">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-5">
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.content ?? ''}</ReactMarkdown>
</div>
</div>
</div>
</div>
)}
</>
)
}
+4 -1
View File
@@ -17,7 +17,10 @@ export default async function OpsPage() {
supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }),
supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'),
canSeeSecrets
? supabase.from('secrets').select('id, title, area, created_at, label, value, description, icon, space_id, created_by, updated_at, category, notes').eq('space_id', member.space_id)
// CRITICAL: never select `value` or `encrypted_value` here. The list
// endpoint only returns metadata; the plaintext is fetched on demand
// through the revealSecret() server action.
? supabase.from('secrets').select('id, title, area, created_at, label, description, icon, space_id, created_by, updated_at, category, notes, encryption_version').eq('space_id', member.space_id)
: Promise.resolve({ data: [] as Tables<'secrets'>[] }),
])