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.
58 lines
1.9 KiB
TypeScript
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>
|
|
)
|
|
}
|