feat(tenant): single-tenant mode core (config SSOT + server/UI guards + branding)

Add lib/tenant.ts as the single source of truth for multi- vs single-tenant
deployment. Gate space creation (server + signup UI), add open-join to the one
space, hide the marketing shell, and route brand name + base URL through config.
Also commit the _migrations_applied table into the canonical schema so a fresh
instance is reproducible from schema.sql alone.

- lib/tenant.ts: resolveTenantConfig (pure) + tenantConfig() + appBaseUrl()
- lib/auth-actions.ts: createSpace refused when !allowSpaceCreation; joinSpace
  open-join resolves the configured/sole space with no invite code
- proxy.ts: marketing paths redirect to /login when showMarketing is off
- app/signup/page.tsx: single-tenant hides create/join chooser, brand=siteName
- app/layout.tsx + 4 link builders: brand/base-URL via config (no hackerspace.sh literal)
- scripts/schema.sql: public._migrations_applied tracking table
- .env.example: documented single-tenant + first-admin bootstrap vars
- package.json: pnpm setup
This commit is contained in:
Moheeb Zara
2026-08-05 12:43:11 -07:00
parent 85f3655e6f
commit 646d2e542c
12 changed files with 301 additions and 29 deletions
+4 -1
View File
@@ -1,13 +1,16 @@
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Toaster } from 'sonner'
import { tenantConfig } from '@/lib/tenant'
import './globals.css'
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
const _tenant = tenantConfig()
export const metadata: Metadata = {
title: 'hackerspace.sh — Manage Your Space',
title: `${_tenant.siteName} — Manage Your Space`,
description: 'The operating system for hackerspaces: members, tasks, projects, ops, payments, and comms.',
icons: {
icon: [{ url: '/logo.svg', type: 'image/svg+xml' }],
+20 -2
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { createSpace, joinSpace } from '@/lib/auth-actions'
import { tenantConfig } from '@/lib/tenant'
function generateInviteCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
@@ -19,9 +20,13 @@ function generateInviteCode() {
export default function SignupPage() {
const router = useRouter()
// Single-tenant instances host one space: there is no "create a space" path,
// so signup always means "join THE space". Preselect join and hide the
// chooser. createSpace is also refused server-side (defense in depth).
const tenant = tenantConfig()
const [step, setStep] = useState<'account' | 'space'>('account')
const [isReturningUser, setIsReturningUser] = useState(false)
const [mode, setMode] = useState<'create' | 'join' | null>(null)
const [mode, setMode] = useState<'create' | 'join' | null>(tenant.singleTenant ? 'join' : null)
const [fullName, setFullName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -158,7 +163,7 @@ export default function SignupPage() {
<div className="mb-8">
<span className="font-mono text-2xl font-bold">
<span className="text-[#d4f53c]">{'{'}</span>
<span className="text-white">hackerspace.sh</span>
<span className="text-white">{tenant.siteName}</span>
<span className="text-[#d4f53c]">{'}'}</span>
</span>
</div>
@@ -216,6 +221,8 @@ export default function SignupPage() {
/>
</div>
{!tenant.singleTenant && (
<>
<p className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase mb-3">
What are you doing?
</p>
@@ -257,6 +264,8 @@ export default function SignupPage() {
<p className="font-mono text-xs text-zinc-500">use an invite code</p>
</button>
</div>
</>
)}
{error && <p className="font-mono text-xs text-red-400 mb-4">{error}</p>}
@@ -356,6 +365,15 @@ export default function SignupPage() {
<p className="font-mono text-[10px] text-zinc-500">Default channels (general, announcements, ops) will be created automatically.</p>
</div>
</div>
) : tenant.singleTenant && tenant.openJoin ? (
<div className="mb-6 bg-[#232323] border border-[#333] rounded p-4">
<p className="font-mono text-[10px] text-zinc-500 mb-1">
You&apos;re joining <span className="text-white">{tenant.siteName}</span>.
</p>
<p className="font-mono text-[10px] text-zinc-500">
An administrator may need to approve your membership before you gain full access.
</p>
</div>
) : (
<div className="mb-6">
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">