feat: open-source release prep, audit fixes, and deploy hardening

Production deploy completed (DigitalOcean Droplet, self-hosted Supabase,
Caddy + Let's Encrypt, GitHub Actions auto-deploy on push to main, daily
pg_dumpall backups). This commit lands the work that came out of the
deep audit pass and the open-source release prep.

Audit fixes:
- Webhook signing secret is now displayed in the settings UI with
  show/hide and copy-to-clipboard; the broken external docs.hackerspace.sh
  link is replaced with a link to the new local docs.
- Settings updateSpaceSettings parameter type now includes
  mission_statement so the field persists.
- members page enforces member_directory_visibility for non-admin viewers
  (admin_only, board_visible, members_visible).
- Mobile responsiveness pass: Projects Kanban grid, Members + Payments
  tables (column hiding on small screens), Proposal tally + voting
  buttons (2-col on mobile).

Open-source release scaffolding:
- LICENSE (MIT).
- CONTRIBUTING.md with dev setup, conventions, PR flow.
- SECURITY.md with disclosure policy.
- CHANGELOG.md.
- docs/WEBHOOKS.md with Node and Python signature verification examples.
- README.md rewritten for an open-source audience.

Removed v0/Vercel references:
- @vercel/analytics dependency removed.
- app/layout.tsx no longer imports Analytics, drops generator: 'v0.app'.
- Canonical deployment docs (docs/DEPLOYMENT.md, docs/README.md,
  docs/ARCHITECTURE.md, DATABASE.md, CLAUDE.md, README.md) now describe
  the self-hosted Droplet target only.
- e2e console-error check no longer filters [v0] noise.

Landing page:
- GitHub icon link to the public repo in the nav and footer.
This commit is contained in:
Moheeb Zara
2026-05-14 16:28:16 -07:00
parent b8cd36abc3
commit 8ad1ca44b7
27 changed files with 662 additions and 720 deletions
+7 -7
View File
@@ -241,13 +241,13 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
<div className="bg-card rounded border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">MEMBER</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">TIER</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">JOINED</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">LAST PAYMENT</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden md:table-cell">JOINED</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden lg:table-cell">LAST PAYMENT</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">STATUS</th>
{isAdmin(currentRole) && (
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">ACTIONS</th>
@@ -280,11 +280,11 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
{m.tier?.toUpperCase()}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '—'}
<td className="px-4 py-3 font-mono text-xs text-muted-foreground hidden md:table-cell">
{m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '—'}
<td className="px-4 py-3 font-mono text-xs text-muted-foreground hidden lg:table-cell">
{(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'}
</td>
<td className="px-4 py-3">
{m.status === 'unverified' ? (
+36
View File
@@ -1,6 +1,12 @@
import { createClient } from '@/lib/supabase/server'
import { MembersClient } from './members-client'
// member_directory_visibility values come from space settings:
// admin_only — only admins see the directory
// board_visible — admins + board + treasurer see the directory
// members_visible — every member of the space sees the directory (default)
const ELEVATED_ROLES = new Set(['admin', 'board', 'treasurer'])
export default async function MembersPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
@@ -15,6 +21,36 @@ export default async function MembersPage() {
if (!self) return null
const { data: space } = await supabase
.from('spaces')
.select('member_directory_visibility')
.eq('id', self.space_id)
.single()
const visibility = space?.member_directory_visibility ?? 'members_visible'
const isAdmin = self.role === 'admin'
const isElevated = ELEVATED_ROLES.has(self.role)
const allowed =
visibility === 'members_visible' ||
(visibility === 'board_visible' && isElevated) ||
(visibility === 'admin_only' && isAdmin)
if (!allowed) {
return (
<div className="p-8 max-w-2xl">
<h1 className="font-mono text-sm tracking-widest uppercase text-muted-foreground mb-3">Members</h1>
<div className="bg-card border border-border rounded p-6">
<p className="font-sans text-sm text-foreground mb-2">The member directory is restricted in this space.</p>
<p className="font-sans text-sm text-muted-foreground">
A space admin has set member directory visibility to <code className="font-mono text-xs px-1 py-0.5 bg-muted rounded">{visibility}</code>.
Contact an admin if you need access.
</p>
</div>
</div>
)
}
const { data: members } = await supabase
.from('space_members')
.select('*')
+10 -8
View File
@@ -197,12 +197,14 @@ export function PaymentsClient({ payments: initialPayments, members, integration
<div className="bg-card rounded border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<table className="w-full">
<thead>
<tr className="border-b border-border">
{['PLATFORM', 'AMOUNT', 'FROM / NOTE', 'DATE', 'MEMBER'].map(h => (
<th key={h} className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">{h}</th>
))}
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">PLATFORM</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">AMOUNT</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden md:table-cell">FROM / NOTE</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden sm:table-cell">DATE</th>
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">MEMBER</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -212,11 +214,11 @@ export function PaymentsClient({ payments: initialPayments, members, integration
{p.platform?.toUpperCase()}
</td>
<td className="px-4 py-3 font-sans text-sm font-medium">{'$'}{Number(p.amount).toFixed(2)}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[200px] truncate">
{p.from_identifier}{p.from_note ? ` — "${p.from_note}"` : ''}
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[200px] truncate hidden md:table-cell">
{p.from_identifier}{p.from_note ? ` - "${p.from_note}"` : ''}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{p.transaction_date ? new Date(p.transaction_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}
<td className="px-4 py-3 font-mono text-xs text-muted-foreground hidden sm:table-cell">
{p.transaction_date ? new Date(p.transaction_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-'}
</td>
<td className="px-4 py-3">
{p.link_status === 'linked' && p.space_members ? (
+2 -2
View File
@@ -75,8 +75,8 @@ export function ProjectsClient({
</div>
<div className="p-4 md:p-6">
<div className="overflow-x-auto -mx-4 md:mx-0 px-4 md:px-0">
<div className="grid grid-cols-4 gap-4 min-w-[640px]">
<div className="md:overflow-x-auto md:-mx-0">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{STATUS_COLS.map(col => {
const items = columns[col.key] ?? []
return (
+1 -1
View File
@@ -92,7 +92,7 @@ export default async function ProposalDetailPage({ params }: { params: Promise<{
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
Tally
</p>
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<Tally label="Yes" value={proposal.outcome_yes} accent="text-primary" />
<Tally label="No" value={proposal.outcome_no} accent="text-orange-600" />
<Tally label="Abstain" value={proposal.outcome_abstain} accent="text-muted-foreground" />
@@ -53,13 +53,13 @@ export function ProposalActions({
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
{myVote ? 'Update your vote' : 'Cast your vote'}
</p>
<div className="grid grid-cols-4 gap-2 mb-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3">
{votePositions.map(p => (
<button
key={p}
type="button"
onClick={() => setPosition(p)}
className={`font-mono text-xs tracking-widest uppercase py-2 rounded border transition ${
className={`font-mono text-xs tracking-widest uppercase py-2.5 rounded border transition ${
position === p
? 'bg-primary text-white border-primary'
: 'border-border text-foreground hover:border-primary'
+88 -35
View File
@@ -134,6 +134,8 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
// Webhook
const [webhookSecret, setWebhookSecret] = useState(space.webhook_secret || '')
const [copiedWebhook, setCopiedWebhook] = useState(false)
const [copiedSecret, setCopiedSecret] = useState(false)
const [showWebhookSecret, setShowWebhookSecret] = useState(false)
async function handleSaveSpace(e: React.FormEvent) {
e.preventDefault()
@@ -191,7 +193,8 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
const result = await rotateWebhookSecret()
if (result.secret) {
setWebhookSecret(result.secret)
setMessage({ type: 'success', text: 'Webhook secret rotated!' })
setShowWebhookSecret(true)
setMessage({ type: 'success', text: 'New signing secret generated. Copy it now and store it securely.' })
}
setSaving(false)
}
@@ -202,6 +205,13 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
setTimeout(() => setCopiedWebhook(false), 2000)
}
function copySecretToClipboard(text: string) {
if (!text) return
navigator.clipboard.writeText(text)
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
}
function openIntegrationModal(config: typeof INTEGRATIONS_CONFIG[0]) {
const existing = integrations.find(i => i.platform === config.platform)
const initialForm: Record<string, string> = {}
@@ -673,44 +683,87 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
{/* Webhooks Tab */}
{activeTab === 'webhooks' && (
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-6">Webhook Endpoint</p>
<p className="font-sans text-sm text-muted-foreground mb-4">
POST members to this endpoint for external integration or automation.
</p>
<div className="bg-muted/50 border border-border rounded p-4 mb-4">
<code className="font-mono text-xs text-primary break-all">
https://api.hackerspace.sh/{space.slug}/members
</code>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => copyToClipboard(`https://api.hackerspace.sh/${space.slug}/members`)}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
>
<Copy className="w-3 h-3" />
{copiedWebhook ? 'Copied!' : 'Copy'}
</button>
<a
href="https://docs.hackerspace.sh/webhooks"
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition"
>
View Docs
</a>
{isAdmin && (
<div className="bg-card rounded border border-border p-6 space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Webhook Endpoint</p>
<p className="font-sans text-sm text-muted-foreground mb-4">
POST members to this endpoint for external integration or automation.
</p>
<div className="bg-muted/50 border border-border rounded p-4 mb-4">
<code className="font-mono text-xs text-primary break-all">
https://api.hackerspace.sh/{space.slug}/members
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleRotateWebhook}
disabled={saving}
onClick={() => copyToClipboard(`https://api.hackerspace.sh/${space.slug}/members`)}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
>
<RotateCcw className="w-3 h-3" />
Rotate Secret
<Copy className="w-3 h-3" />
{copiedWebhook ? 'Copied!' : 'Copy URL'}
</button>
<a
href="https://github.com/virgilvox/hackerspace-management/blob/main/docs/WEBHOOKS.md"
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition"
>
View Docs
</a>
</div>
</div>
<div className="border-t border-border pt-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Signing Secret</p>
<p className="font-sans text-sm text-muted-foreground mb-4">
Every delivery is signed with HMAC-SHA256 using this secret. See <a
href="https://github.com/virgilvox/hackerspace-management/blob/main/docs/WEBHOOKS.md#signing-and-verification"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>the docs</a> for verification examples in Node and Python.
</p>
<div className="bg-muted/50 border border-border rounded p-4 mb-4 flex items-center gap-3">
<code className="font-mono text-xs text-foreground break-all flex-1">
{webhookSecret
? (showWebhookSecret ? webhookSecret : '•'.repeat(Math.min(webhookSecret.length, 48)))
: <span className="text-muted-foreground italic">No secret set. Rotate to generate one.</span>}
</code>
{webhookSecret && (
<button
onClick={() => setShowWebhookSecret(v => !v)}
className="font-mono text-[10px] text-muted-foreground hover:text-foreground transition flex items-center gap-1 shrink-0"
title={showWebhookSecret ? 'Hide secret' : 'Show secret'}
>
{showWebhookSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{webhookSecret && (
<button
onClick={() => copySecretToClipboard(webhookSecret)}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
>
<Copy className="w-3 h-3" />
{copiedSecret ? 'Copied!' : 'Copy secret'}
</button>
)}
{isAdmin && (
<button
onClick={handleRotateWebhook}
disabled={saving}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1 disabled:opacity-50"
>
<RotateCcw className="w-3 h-3" />
{webhookSecret ? 'Rotate' : 'Generate'}
</button>
)}
</div>
{webhookSecret && (
<p className="font-sans text-[11px] text-muted-foreground mt-3">
Rotating immediately invalidates the previous secret. Update your receiver before rotating in production.
</p>
)}
</div>
</div>
+31 -3
View File
@@ -34,6 +34,13 @@ function IcoEye({ className }: { className?: string }) {
function IcoPin({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
}
function IcoGithub({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1.01-.02-1.98-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.09-.12-.29-.51-1.45.11-3.02 0 0 .97-.31 3.18 1.18.92-.26 1.91-.39 2.89-.39.98 0 1.97.13 2.89.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.57.23 2.73.11 3.02.74.8 1.18 1.83 1.18 3.09 0 4.42-2.69 5.4-5.25 5.68.41.35.78 1.04.78 2.11 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56 4.57-1.52 7.85-5.83 7.85-10.91C23.5 5.65 18.35.5 12 .5z" />
</svg>
)
}
// ─── Mini UI Previews ──────────────────────────────────────────────────────────
@@ -318,6 +325,15 @@ export default function LandingPage() {
<Link href="/resources" className="hidden sm:inline-flex font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
Resources
</Link>
<a
href="https://github.com/virgilvox/hackerspace-management"
target="_blank"
rel="noopener noreferrer"
aria-label="View source on GitHub"
className="inline-flex items-center justify-center w-9 h-9 rounded text-sidebar-foreground hover:text-white hover:bg-white/5 transition"
>
<IcoGithub className="w-5 h-5" />
</a>
<Link href="/login" className="font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
Log in
</Link>
@@ -502,9 +518,21 @@ export default function LandingPage() {
<IcoTerminal className="w-4 h-4 text-primary" />
<span className="font-mono text-xs text-sidebar-foreground">hackerspace.sh</span>
</div>
<p className="font-mono text-[10px] text-sidebar-foreground/50">
Management software for makerspaces and community workshops.
</p>
<div className="flex items-center gap-4">
<p className="font-mono text-[10px] text-sidebar-foreground/50">
Management software for makerspaces and community workshops.
</p>
<a
href="https://github.com/virgilvox/hackerspace-management"
target="_blank"
rel="noopener noreferrer"
aria-label="View source on GitHub"
className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 hover:text-white transition font-mono text-[10px]"
>
<IcoGithub className="w-3.5 h-3.5" />
source
</a>
</div>
</div>
</div>
</footer>
-3
View File
@@ -1,6 +1,5 @@
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import { Toaster } from 'sonner'
import './globals.css'
@@ -10,7 +9,6 @@ const _geistMono = Geist_Mono({ subsets: ["latin"] });
export const metadata: Metadata = {
title: 'hackerspace.sh — Manage Your Space',
description: 'The operating system for hackerspaces: members, tasks, projects, ops, payments, and comms.',
generator: 'v0.app',
icons: {
icon: [
{
@@ -40,7 +38,6 @@ export default function RootLayout({
<body className="font-sans antialiased">
{children}
<Toaster richColors position="bottom-right" />
<Analytics />
</body>
</html>
)