From de5100058455a927b4f29f5ce65402c826debc73 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Thu, 14 May 2026 17:47:52 -0700 Subject: [PATCH] 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. --- .env.example | 29 +- CHANGELOG.md | 14 + README.md | 3 +- app/(app)/comms/comms-client.tsx | 88 +++- app/(app)/forum/[id]/page.tsx | 94 ++++ app/(app)/forum/[id]/thread-actions.tsx | 72 +++ app/(app)/forum/new/new-thread-form.tsx | 83 ++++ app/(app)/forum/new/page.tsx | 16 + app/(app)/forum/page.tsx | 93 ++++ app/(app)/incidents/[id]/page.tsx | 12 + app/(app)/ops/ops-client.tsx | 136 +++-- app/(app)/ops/page.tsx | 5 +- app/(app)/policies/[slug]/page.tsx | 23 + app/(app)/proposals/[id]/page.tsx | 12 + app/(app)/settings/page.tsx | 33 ++ app/(app)/settings/settings-client.tsx | 367 +++++++++++++- app/(landing)/page.tsx | 5 +- app/layout.tsx | 18 +- components/app-sidebar.tsx | 2 + components/comments/comment-thread.tsx | 127 +++++ components/comments/load-comments.ts | 37 ++ docs/HANDOFF.md | 77 +++ lib/actions/comms.ts | 89 ++++ lib/actions/forum.ts | 193 ++++++++ lib/actions/index.ts | 11 + lib/actions/invites.ts | 116 +++++ lib/actions/roles.ts | 170 +++++++ lib/actions/secrets.ts | 136 ++++- lib/actions/tiers.ts | 103 ++++ lib/auth-actions.ts | 38 +- lib/validations.ts | 99 ++++ next-env.d.ts | 2 +- public/logo.svg | 27 + .../021_forum_tiers_roles_invites_secrets.sql | 467 ++++++++++++++++++ scripts/schema.sql | 277 +++++++++++ 35 files changed, 2981 insertions(+), 93 deletions(-) create mode 100644 app/(app)/forum/[id]/page.tsx create mode 100644 app/(app)/forum/[id]/thread-actions.tsx create mode 100644 app/(app)/forum/new/new-thread-form.tsx create mode 100644 app/(app)/forum/new/page.tsx create mode 100644 app/(app)/forum/page.tsx create mode 100644 components/comments/comment-thread.tsx create mode 100644 components/comments/load-comments.ts create mode 100644 lib/actions/comms.ts create mode 100644 lib/actions/forum.ts create mode 100644 lib/actions/invites.ts create mode 100644 lib/actions/roles.ts create mode 100644 lib/actions/tiers.ts create mode 100644 public/logo.svg create mode 100644 scripts/021_forum_tiers_roles_invites_secrets.sql diff --git a/.env.example b/.env.example index fa9d078..c74f19c 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,11 @@ -# ───────────────────────────────────────────────────────────────────────────── -# hackerspace-management — environment variables -# ───────────────────────────────────────────────────────────────────────────── -# Copy this file to .env.local for development. For deployed environments, -# set the same keys in the host dashboard (Vercel, DigitalOcean, etc). +# ----------------------------------------------------------------------------- +# hackerspace-management environment variables +# ----------------------------------------------------------------------------- +# Copy to .env.local for development; set the same keys on your host for prod. # .env.local is git-ignored. Never commit real secrets. -# ───────────────────────────────────────────────────────────────────────────── +# ----------------------------------------------------------------------------- # Supabase (required) -# Project Settings → API in the Supabase dashboard. NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...replace-with-anon-key @@ -19,6 +17,13 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...replace-with-service-role-key # Public-facing base URL of this deployment. Used in OAuth redirects. NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Secrets vault encryption key (required for the /ops Secrets feature). +# 32 bytes of cryptographically random data, hex-encoded (64 hex chars). +# Generate with: openssl rand -hex 32 +# Without this set, the secrets feature falls back to legacy plain-text storage. +# Rotating this key invalidates all existing ciphertexts; re-enter each secret. +SECRETS_ENCRYPTION_KEY= + # Node env (optional, defaults to "development" locally) NODE_ENV=development @@ -26,8 +31,8 @@ NODE_ENV=development # Set to "1" in the Dockerfile so next.config.mjs emits standalone output. # DOCKER_BUILD=1 -# ───────────────────────────────────────────────────────────────────────────── -# Per-space integration credentials (PayPal, Zeffy, Venmo, Stripe) -# are stored in the database under the `integrations` table, not in env. -# The /api/paypal/sync endpoint reads them from there. -# ───────────────────────────────────────────────────────────────────────────── +# ----------------------------------------------------------------------------- +# Per-space integration credentials (PayPal, Zeffy, Venmo, Stripe) are stored +# in the database under the `integrations` table, not in env. The +# /api/paypal/sync endpoint reads them from there. +# ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6a153..c58203a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project are documented here. Format follows [Keep a ## [Unreleased] ### Added +- **Forum** at `/forum`: top-level discussion threads with categories, pinning, locking. Author or admin/board can edit; admin can delete. +- **Polymorphic comment threads** on proposals, incidents, and policies. New `comments` table keyed on `(entity_type, entity_id)`. Renders markdown. +- **User-creatable chat channels**: inline "+ New channel" form in `/comms` (name, description, type). RLS now allows any member to create; default channels stay protected. +- **Custom membership tiers with prices**: new `space_tiers` table per space. Settings -> Tiers tab to list, create, edit (including inline price edit), archive, and delete custom tiers. Built-in `plus`/`basic`/`associate` tiers seed on space creation and on existing spaces via backfill. `space_members.tier_id` FK backfilled from the legacy enum. +- **Customizable role labels and custom org roles**: `space_role_labels` (rename/recolor built-ins per space) and `space_custom_roles` (non-privileged extra labels) with member m2m. Server actions wired; UI surface pending in a follow-up. +- **Multi-code invites**: `space_invites` with per-code expiry, max-use cap, and enable/disable. Legacy `spaces.invite_code` backfilled as a permanent enabled invite. Settings -> Invites tab to manage. `joinSpace` honors any valid invite and increments `uses_count`. +- **Knowledge base markdown rendering**: clicking a KB entry opens a modal that renders `content` as markdown via react-markdown + remark-gfm. +- **AtlasLogo as the brand mark**: replaces the terminal icon on the landing nav and footer; `public/logo.svg` is the favicon. +- Live site link in `README.md`: . + +### Changed +- **Secrets vault is now encrypted at rest**: AES-256-GCM with a per-secret IV using a `SECRETS_ENCRYPTION_KEY` env var. The /ops list endpoint no longer sends plaintext or ciphertext to the client. A new `revealSecret(id)` server action is the only path to plaintext and writes a `secret.revealed` row to `activity_log`. Existing legacy plaintext rows continue to work. + +### Added (continued) - Production deployment to a self-hosted DigitalOcean Droplet with automatic HTTPS via Caddy and Let's Encrypt. - GitHub Actions workflow that pushes a deploy on every commit to `main`. Migrations are applied automatically via `_migrations_applied` tracking. - Daily encrypted `pg_dumpall` backup cron, retained 14 days, written to the persistent block volume. diff --git a/README.md b/README.md index be4b9c3..0a9dd63 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ A multi-tenant operating system for hackerspaces, makerspaces, and member-run shops. One installation can host many independent spaces. Each space owns its own members, tasks, projects, payments, knowledge base, proposals, incidents, policies, and chat channels. All data access is enforced at the database with row-level security. -Source: +- Live site: +- Source: ## Features diff --git a/app/(app)/comms/comms-client.tsx b/app/(app)/comms/comms-client.tsx index c1518aa..1c72a03 100644 --- a/app/(app)/comms/comms-client.tsx +++ b/app/(app)/comms/comms-client.tsx @@ -1,8 +1,11 @@ 'use client' import { useState, useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' import { createClient } from '@/lib/supabase/client' -import { Hash, Users2, Send, ChevronLeft } from 'lucide-react' +import { Hash, Users2, Send, ChevronLeft, Plus } from 'lucide-react' +import { createChannel } from '@/lib/actions' import type { Tables } from '@/types/database' type Channel = Tables<'comms_channels'> @@ -15,9 +18,33 @@ interface Props { } export default function CommsClient({ member, space, channels }: Props) { + const router = useRouter() const [selectedChannel, setSelectedChannel] = useState(channels[0] || null) const [messages, setMessages] = useState([]) const [newMessage, setNewMessage] = useState('') + const [showNewChannel, setShowNewChannel] = useState(false) + const [newChannelName, setNewChannelName] = useState('') + const [newChannelDescription, setNewChannelDescription] = useState('') + const [newChannelType, setNewChannelType] = useState<'general' | 'area' | 'project'>('general') + const [creatingChannel, setCreatingChannel] = useState(false) + + async function handleCreateChannel(e: React.FormEvent) { + e.preventDefault() + if (!newChannelName.trim()) return + setCreatingChannel(true) + const result = await createChannel({ + name: newChannelName.trim().toLowerCase(), + description: newChannelDescription.trim() || undefined, + channel_type: newChannelType, + }) + setCreatingChannel(false) + if ('error' in result && result.error) { toast.error(result.error); return } + toast.success('Channel created') + setNewChannelName('') + setNewChannelDescription('') + setShowNewChannel(false) + router.refresh() + } const [sending, setSending] = useState(false) const [showChannelList, setShowChannelList] = useState(true) const messagesEndRef = useRef(null) @@ -238,6 +265,65 @@ export default function CommsClient({ member, space, channels }: Props) { ))} )} + + {/* New channel inline form */} +
+ {!showNewChannel ? ( + + ) : ( +
+ setNewChannelName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))} + placeholder="channel-name" + autoFocus + maxLength={50} + required + className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" + /> + setNewChannelDescription(e.target.value)} + placeholder="Optional description" + maxLength={500} + className="w-full bg-background border border-border text-foreground font-sans text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" + /> + +
+ + +
+
+ )} +
diff --git a/app/(app)/forum/[id]/page.tsx b/app/(app)/forum/[id]/page.tsx new file mode 100644 index 0000000..f5cdf57 --- /dev/null +++ b/app/(app)/forum/[id]/page.tsx @@ -0,0 +1,94 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { Pin, Lock } from 'lucide-react' +import { createClient } from '@/lib/supabase/server' +import { CommentThread } from '@/components/comments/comment-thread' +import { loadComments } from '@/components/comments/load-comments' +import { ThreadActions } from './thread-actions' + +export default async function ForumThreadPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return null + + const { data: self } = await supabase + .from('space_members') + .select('id, space_id, role, display_name') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .single() + if (!self) return null + + const { data: thread } = await supabase + .from('forum_threads') + .select('id, title, body, category, pinned, locked, comment_count, created_at, author_id, space_id') + .eq('id', id) + .single() + if (!thread) notFound() + if (thread.space_id !== self.space_id) notFound() + + let authorName = 'Member' + if (thread.author_id) { + const { data: a } = await supabase + .from('space_members') + .select('display_name') + .eq('id', thread.author_id) + .single() + if (a?.display_name) authorName = a.display_name + } + + const comments = await loadComments(supabase, 'forum_thread', thread.id) + const isAuthor = self.id === thread.author_id + const canModerate = self.role === 'admin' || self.role === 'board' + const canEditThread = isAuthor || canModerate + + return ( +
+
+ + ← Forum + +
+ +
+
+
+ {thread.pinned && } + {thread.locked && } +

{thread.title}

+ {thread.category} +
+ {canEditThread && ( + + )} +
+

+ {authorName} · {new Date(thread.created_at).toLocaleString()} +

+ {thread.body && ( +
+ {thread.body} +
+ )} +
+ + +
+ ) +} diff --git a/app/(app)/forum/[id]/thread-actions.tsx b/app/(app)/forum/[id]/thread-actions.tsx new file mode 100644 index 0000000..2dc0f7b --- /dev/null +++ b/app/(app)/forum/[id]/thread-actions.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' +import { Pin, Lock, Trash2 } from 'lucide-react' +import { updateForumThread, deleteForumThread } from '@/lib/actions' + +interface Props { + threadId: string + pinned: boolean + locked: boolean + canModerate: boolean + isAuthor: boolean +} + +export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor }: Props) { + const router = useRouter() + + async function togglePinned() { + const result = await updateForumThread(threadId, { pinned: !pinned }) + if (result.error) { toast.error(result.error); return } + router.refresh() + } + + async function toggleLocked() { + const result = await updateForumThread(threadId, { locked: !locked }) + if (result.error) { toast.error(result.error); return } + router.refresh() + } + + async function handleDelete() { + if (!confirm('Delete this thread? All comments are deleted too.')) return + const result = await deleteForumThread(threadId) + if (result.error) { toast.error(result.error); return } + toast.success('Thread deleted') + router.push('/forum') + } + + return ( +
+ {canModerate && ( + <> + + + + )} + {(isAuthor || canModerate) && ( + + )} +
+ ) +} diff --git a/app/(app)/forum/new/new-thread-form.tsx b/app/(app)/forum/new/new-thread-form.tsx new file mode 100644 index 0000000..7b2f23f --- /dev/null +++ b/app/(app)/forum/new/new-thread-form.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' +import { createForumThread } from '@/lib/actions' + +const CATEGORIES = [ + { value: 'general', label: 'General' }, + { value: 'announcements', label: 'Announcements' }, + { value: 'projects', label: 'Projects' }, + { value: 'help', label: 'Help' }, + { value: 'feedback', label: 'Feedback' }, + { value: 'off-topic', label: 'Off topic' }, +] + +export function NewThreadForm() { + const router = useRouter() + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [category, setCategory] = useState('general') + const [saving, setSaving] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!title.trim()) return + setSaving(true) + const result = await createForumThread({ title: title.trim(), body: body.trim() || undefined, category }) + setSaving(false) + if ('error' in result && result.error) { toast.error(result.error); return } + if ('id' in result && result.id) { + toast.success('Thread created') + router.push(`/forum/${result.id}`) + } + } + + return ( +
+
+ + setTitle(e.target.value)} + maxLength={200} + required + autoFocus + className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary" + /> +
+
+ + +
+
+ +