Files
hackerspace.sh/app/join/[space]/page.tsx
T
Moheeb Zara df6951c323 feat(invites): role-granting + usage-capped invites; audit hardening (P7)
Invites
- migration 029: space_invites.role (member_role enum, default
  'member' — legacy invites and the spaces.invite_code path keep
  granting member). schema.sql + types/database.ts mirrored
- lib/invite-logic.ts (pure, tested): canAssignInviteRole — admin may
  grant any role; board may grant anything except admin; nobody else.
  Privilege-escalation guard on top of the existing admin/board gate
- createInvite/updateInvite take + enforce role via that guard;
  createInviteSchema/updateInviteSchema validate the enum
- joinSpace applies the invite's granted role (validated), default
  member; uses_count/max_uses/expiry already enforced
- invites panel: role picker (filtered to what the creator may grant),
  one-click "single use", per-invite role badge
- space-scoped invite link /join/<space>?code=… via a presentational
  app/join/[space] landing that hands off to the proven
  /signup?invite= flow; /join added to middleware PUBLIC_ROUTES

Audit hardening (from this pass's review)
- /login now honors a validated same-origin ?next= (was hardcoded to
  /dashboard) so the public_auth form sign-in round-trips; OAuth
  threads next through the callback (which already honored it)
- submitForm requires formId; submitFormSchema drops the now-ambiguous
  slug path (form slugs are per-space after 028). Both callers already
  pass formId. Tests updated

337 tests; build verified (Compiled successfully, exit 0). No deploy.
2026-05-16 17:34:45 -07:00

58 lines
1.9 KiB
TypeScript

import Link from 'next/link'
import { createAdminClient } from '@/lib/supabase/admin'
export const dynamic = 'force-dynamic'
// Friendly, space-scoped landing for an invite link
// (/join/<spaceSlug>?code=CODE). Purely presentational: it resolves the
// space name for context and hands off to the existing, battle-tested
// /signup?invite=CODE join flow rather than reimplementing auth.
export default async function JoinPage({
params,
searchParams,
}: {
params: Promise<{ space: string }>
searchParams: Promise<{ code?: string }>
}) {
const { space } = await params
const { code } = await searchParams
const admin = createAdminClient()
const { data: spaceRow } = await admin
.from('spaces')
.select('name')
.eq('slug', space)
.maybeSingle()
if (!spaceRow) {
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center p-6 text-center">
<h1 className="text-xl font-semibold">Invite link not valid</h1>
<p className="mt-2 text-sm text-muted-foreground">
This space could not be found. Check the link or ask whoever invited you for a new one.
</p>
</main>
)
}
const signupHref = code
? `/signup?invite=${encodeURIComponent(code)}`
: '/signup'
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center p-6 text-center">
<h1 className="text-2xl font-semibold">Join {spaceRow.name}</h1>
<p className="mt-2 text-sm text-muted-foreground">
You have been invited to join {spaceRow.name}
{code ? ' with an invite code' : ''}. Create an account or sign in to continue.
</p>
<Link
href={signupHref}
className="mt-6 inline-flex items-center rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-white hover:bg-primary/90"
>
Continue
</Link>
</main>
)
}