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.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user