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>