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" + /> +
+
+ + +
+
+ +