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:
Moheeb Zara
2026-05-16 17:34:45 -07:00
parent 8bbe99d34f
commit df6951c323
16 changed files with 253 additions and 48 deletions
+49 -11
View File
@@ -23,6 +23,8 @@ import {
slugify,
deriveFieldKeys,
} from '@/lib/forms-logic'
import { INVITE_ROLES, isInviteRole, canAssignInviteRole } from '@/lib/invite-logic'
import { createInviteSchema, updateInviteSchema } from '@/lib/validations'
// ─── formSlug ────────────────────────────────────────────────────────────────
@@ -197,27 +199,24 @@ describe('getPublicFormSchema', () => {
describe('submitFormSchema', () => {
const ID = '11111111-1111-1111-1111-111111111111'
it('requires formId or slug', () => {
it('requires a valid formId (slug is no longer accepted post-028)', () => {
expect(submitFormSchema.safeParse({ answers: {} }).success).toBe(false)
expect(submitFormSchema.safeParse({ slug: 'a', answers: {} }).success).toBe(true)
expect(submitFormSchema.safeParse({ slug: 'a', answers: {} }).success).toBe(false)
expect(submitFormSchema.safeParse({ formId: 'nope' }).success).toBe(false)
expect(submitFormSchema.safeParse({ formId: ID }).success).toBe(true)
})
it('defaults answers to an empty object', () => {
const r = submitFormSchema.safeParse({ slug: 'a' })
const r = submitFormSchema.safeParse({ formId: ID })
expect(r.success).toBe(true)
if (r.success) expect(r.data.answers).toEqual({})
})
it('rejects a bad formId or slug', () => {
expect(submitFormSchema.safeParse({ formId: 'nope' }).success).toBe(false)
expect(submitFormSchema.safeParse({ slug: 'Bad Slug' }).success).toBe(false)
})
it('email is optional/nullable and normalized; consent is boolean', () => {
expect(submitFormSchema.safeParse({ slug: 'a', email: null }).success).toBe(true)
expect(submitFormSchema.safeParse({ slug: 'a', email: 'not-an-email' }).success).toBe(false)
const r = submitFormSchema.safeParse({ slug: 'a', email: '[email protected]', consent: true })
expect(submitFormSchema.safeParse({ formId: ID, email: null }).success).toBe(true)
expect(submitFormSchema.safeParse({ formId: ID, email: 'not-an-email' }).success).toBe(false)
const r = submitFormSchema.safeParse({ formId: ID, email: '[email protected]', consent: true })
expect(r.success).toBe(true)
if (r.success) expect(r.data.email).toBe('[email protected]')
expect(submitFormSchema.safeParse({ slug: 'a', consent: 'yes' }).success).toBe(false)
expect(submitFormSchema.safeParse({ formId: ID, consent: 'yes' }).success).toBe(false)
})
})
@@ -462,6 +461,45 @@ describe('evaluateRequiredFormStep', () => {
// ─── slugify / deriveFieldKeys ───────────────────────────────────────────────
describe('invite role policy', () => {
it('INVITE_ROLES is the member_role set; isInviteRole guards it', () => {
expect([...INVITE_ROLES].sort()).toEqual(
['admin', 'associate', 'board', 'member', 'treasurer'].sort(),
)
expect(isInviteRole('admin')).toBe(true)
expect(isInviteRole('superuser')).toBe(false)
expect(isInviteRole('')).toBe(false)
})
it('admin may grant any role', () => {
for (const r of INVITE_ROLES) expect(canAssignInviteRole('admin', r), r).toBe(true)
})
it('board may grant anything except admin', () => {
expect(canAssignInviteRole('board', 'admin')).toBe(false)
for (const r of ['board', 'treasurer', 'member', 'associate']) {
expect(canAssignInviteRole('board', r), r).toBe(true)
}
})
it('non-admin/board (or unknown) cannot grant anything, and unknown targets are rejected', () => {
expect(canAssignInviteRole('member', 'member')).toBe(false)
expect(canAssignInviteRole('treasurer', 'associate')).toBe(false)
expect(canAssignInviteRole('admin', 'superuser')).toBe(false)
})
it('createInviteSchema defaults role to member and validates the enum', () => {
const r = createInviteSchema.safeParse({})
expect(r.success).toBe(true)
if (r.success) expect(r.data.role).toBe('member')
expect(createInviteSchema.safeParse({ role: 'admin' }).success).toBe(true)
expect(createInviteSchema.safeParse({ role: 'superuser' }).success).toBe(false)
expect(updateInviteSchema.safeParse({ role: 'board' }).success).toBe(true)
expect(updateInviteSchema.safeParse({}).success).toBe(true)
expect(updateInviteSchema.safeParse({ role: 'nope' }).success).toBe(false)
})
})
describe('slugify / deriveFieldKeys', () => {
it('slugifies with the requested separator and trims runs', () => {
expect(slugify('Hello World', '-')).toBe('hello-world')
+3 -1
View File
@@ -33,6 +33,8 @@ interface AreaLead {
interface Props {
isAdmin: boolean
creatorRole: string
spaceSlug: string
roleLabels: RoleLabelRow[]
customRoles: CustomRole[]
tiers: Tier[]
@@ -78,7 +80,7 @@ export function CustomizeClient(props: Props) {
{section === 'tiers' && <TiersPanel isAdmin={props.isAdmin} tiers={props.tiers} />}
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} creatorRole={props.creatorRole} spaceSlug={props.spaceSlug} />}
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} forms={props.forms} />}
</div>
</div>
+6 -3
View File
@@ -11,12 +11,13 @@ export default async function CustomizePage() {
const { data: member } = await supabase
.from('space_members')
.select('space_id, role')
.select('space_id, role, spaces(slug)')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
if (!member) redirect('/login')
const spaceSlug = (member.spaces as { slug?: string } | null)?.slug ?? ''
const isAdmin = member.role === 'admin'
const canCustomize = member.role === 'admin' || member.role === 'board'
if (!canCustomize) redirect('/dashboard')
@@ -36,7 +37,7 @@ export default async function CustomizePage() {
supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id),
supabase.from('space_custom_roles').select('id, slug, name, description, color, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('space_tiers').select('id, slug, name, description, monthly_price_cents, billing_cadence, is_system, is_archived, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, role, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('forms').select('id, title, kind, status').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'),
@@ -48,10 +49,12 @@ export default async function CustomizePage() {
return (
<CustomizeClient
isAdmin={isAdmin}
creatorRole={member.role}
spaceSlug={spaceSlug}
roleLabels={(roleLabels ?? []) as Array<{ role: string; display_name: string | null; description: string | null; color: string | null }>}
customRoles={(customRoles ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; color: string | null; sort_order: number }>}
tiers={(tiers ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; monthly_price_cents: number; billing_cadence: string; is_system: boolean; is_archived: boolean; sort_order: number }>}
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>}
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; role: string; created_at: string }>}
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'; title: string; body: string | null; config: Record<string, unknown>; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
forms={(forms ?? []) as Array<{ id: string; title: string; kind: string; status: string }>}
areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>}
+35 -11
View File
@@ -8,17 +8,30 @@ import { Card } from './card'
import { useConfirm } from '@/components/ui/confirm'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import type { Invite } from './types'
import { INVITE_ROLES, canAssignInviteRole } from '@/lib/invite-logic'
function inviteLink(code: string) {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/signup?invite=${encodeURIComponent(code)}`
}
export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean; invites: Invite[] }) {
export function InvitesPanel({
isAdmin,
invites: initial,
creatorRole,
spaceSlug,
}: {
isAdmin: boolean
invites: Invite[]
creatorRole: string
spaceSlug: string
}) {
const confirm = useConfirm()
const [invites, setInvites] = useState<Invite[]>(initial)
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState({ code: '', label: '', expires_at: '', max_uses: '' })
const [d, setD] = useState({ code: '', label: '', expires_at: '', max_uses: '', role: 'member', singleUse: false })
const assignableRoles = INVITE_ROLES.filter(r => canAssignInviteRole(creatorRole, r))
function inviteLink(code: string) {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/join/${spaceSlug}?code=${encodeURIComponent(code)}`
}
return (
<Card
@@ -30,19 +43,29 @@ export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean;
<form
onSubmit={async e => {
e.preventDefault()
const result = await createInvite({ code: d.code.trim().toUpperCase() || undefined, label: d.label.trim() || undefined, expires_at: d.expires_at || undefined, max_uses: d.max_uses ? Number(d.max_uses) : undefined })
const maxUses = d.singleUse ? 1 : (d.max_uses ? Number(d.max_uses) : undefined)
const result = await createInvite({ code: d.code.trim().toUpperCase() || undefined, label: d.label.trim() || undefined, expires_at: d.expires_at || undefined, max_uses: maxUses, role: d.role as 'admin' | 'board' | 'treasurer' | 'member' | 'associate' })
if ('error' in result && result.error) { toast.error(result.error); return }
const c = result as { id: string; code: string }
toast.success(`Invite created: ${c.code}`)
setInvites(prev => [{ id: c.id, code: c.code, label: d.label.trim() || null, expires_at: d.expires_at || null, max_uses: d.max_uses ? Number(d.max_uses) : null, uses_count: 0, is_enabled: true, created_at: new Date().toISOString() }, ...prev])
setShowNew(false); setD({ code: '', label: '', expires_at: '', max_uses: '' })
setInvites(prev => [{ id: c.id, code: c.code, label: d.label.trim() || null, expires_at: d.expires_at || null, max_uses: maxUses ?? null, uses_count: 0, is_enabled: true, role: d.role, created_at: new Date().toISOString() }, ...prev])
setShowNew(false); setD({ code: '', label: '', expires_at: '', max_uses: '', role: 'member', singleUse: false })
}}
className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-2 gap-3"
>
<input type="text" maxLength={32} value={d.code} onChange={e => setD({ ...d, code: e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '') })} placeholder="CODE (blank = auto)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<input type="text" maxLength={100} value={d.label} onChange={e => setD({ ...d, label: e.target.value })} placeholder="Label (optional)" className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<input type="datetime-local" value={d.expires_at} onChange={e => setD({ ...d, expires_at: e.target.value })} className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<input type="number" min="1" value={d.max_uses} onChange={e => setD({ ...d, max_uses: e.target.value })} placeholder="Max uses (blank = unlimited)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<input type="number" min="1" value={d.max_uses} disabled={d.singleUse} onChange={e => setD({ ...d, max_uses: e.target.value })} placeholder="Max uses (blank = unlimited)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary disabled:opacity-50" />
<select value={d.role} onChange={e => setD({ ...d, role: e.target.value })} className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary">
{assignableRoles.map(r => (
<option key={r} value={r}>Grants: {r}</option>
))}
</select>
<label className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
<input type="checkbox" checked={d.singleUse} onChange={e => setD({ ...d, singleUse: e.target.checked, max_uses: e.target.checked ? '' : d.max_uses })} />
Single use (one join, then auto-disables)
</label>
<button type="submit" className="md:col-span-2 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">Create invite</button>
</form>
)}
@@ -64,6 +87,7 @@ export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean;
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-sm font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{inv.code}</code>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">grants {inv.role}</span>
{inv.label && <span className="font-sans text-sm text-foreground">{inv.label}</span>}
{!inv.is_enabled && <span className="font-mono text-[10px] text-red-500">disabled</span>}
{expired && <span className="font-mono text-[10px] text-red-500">expired</span>}
+1
View File
@@ -37,6 +37,7 @@ export interface Invite {
max_uses: number | null
uses_count: number
is_enabled: boolean
role: string
created_at: string
}
+57
View File
@@ -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>
)
}
+15 -2
View File
@@ -13,6 +13,19 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false)
const [oauthLoading, setOauthLoading] = useState<'github' | 'google' | null>(null)
// Honor ?next= so a signed-out visitor to a public_auth form (or any gated
// link) returns where they were headed. Only same-origin absolute paths are
// allowed — never an external URL or protocol-relative // — to avoid an
// open-redirect.
function safeNext(): string {
if (typeof window === 'undefined') return '/dashboard'
const raw = new URLSearchParams(window.location.search).get('next')
if (raw && raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/\\')) {
return raw
}
return '/dashboard'
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
@@ -23,7 +36,7 @@ export default function LoginPage() {
setError(error.message)
setLoading(false)
} else {
router.push('/dashboard')
router.push(safeNext())
router.refresh()
}
}
@@ -35,7 +48,7 @@ export default function LoginPage() {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(safeNext())}`,
},
})
if (error) {
+3 -3
View File
@@ -316,11 +316,11 @@ export async function submitForm(input: unknown) {
const body = v.data
const admin = createAdminClient()
let q = admin
const { data: form } = await admin
.from('forms')
.select('id, space_id, slug, kind, visibility, status, schema, legal_text, version')
q = body.formId ? q.eq('id', body.formId) : q.eq('slug', body.slug as string)
const { data: form } = await q.single()
.eq('id', body.formId)
.maybeSingle()
if (!form) return { error: 'Form not found' }
if (form.status !== 'published') {
return { error: 'This form is not accepting responses' }
+13
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { createInviteSchema, updateInviteSchema, uuidSchema } from '@/lib/validations'
import { canAssignInviteRole } from '@/lib/invite-logic'
const INVITE_ADMIN_ROLES = ['admin', 'board'] as const
@@ -22,6 +23,7 @@ export async function createInvite(input: {
expires_at?: string | null
max_uses?: number | null
is_enabled?: boolean
role?: 'admin' | 'board' | 'treasurer' | 'member' | 'associate'
}) {
const v = parseInput(createInviteSchema, input)
if (!v.ok) return { error: v.error }
@@ -31,6 +33,10 @@ export async function createInvite(input: {
if (!auth.ok) return { error: auth.error }
const { member } = auth
if (!canAssignInviteRole(member.role, v.data.role)) {
return { error: `You cannot create an invite that grants the "${v.data.role}" role.` }
}
// Generate a unique code if not provided. Try up to 5 times in case of collision.
let code = v.data.code
if (!code) {
@@ -52,6 +58,7 @@ export async function createInvite(input: {
expires_at: v.data.expires_at ?? null,
max_uses: v.data.max_uses ?? null,
is_enabled: v.data.is_enabled ?? true,
role: v.data.role,
created_by: member.id,
})
.select('id, code')
@@ -67,6 +74,7 @@ export async function updateInvite(inviteId: string, updates: {
expires_at?: string | null
max_uses?: number | null
is_enabled?: boolean
role?: 'admin' | 'board' | 'treasurer' | 'member' | 'associate'
}) {
const idCheck = parseInput(uuidSchema, inviteId)
if (!idCheck.ok) return { error: 'Invalid invite ID' }
@@ -78,11 +86,16 @@ export async function updateInvite(inviteId: string, updates: {
if (!auth.ok) return { error: auth.error }
const { member } = auth
if (v.data.role !== undefined && !canAssignInviteRole(member.role, v.data.role)) {
return { error: `You cannot set an invite to grant the "${v.data.role}" role.` }
}
const patch: Record<string, unknown> = {}
if (v.data.label !== undefined) patch.label = v.data.label
if (v.data.expires_at !== undefined) patch.expires_at = v.data.expires_at
if (v.data.max_uses !== undefined) patch.max_uses = v.data.max_uses
if (v.data.is_enabled !== undefined) patch.is_enabled = v.data.is_enabled
if (v.data.role !== undefined) patch.role = v.data.role
const { error } = await supabase
.from('space_invites')
+11 -3
View File
@@ -117,10 +117,10 @@ export async function joinSpace(formData: {
// Try the new multi-code invites table first. Falls back to the legacy
// spaces.invite_code (a permanent default) if the code isn't found there.
let invite: { id: string; space_id: string; max_uses: number | null; uses_count: number; expires_at: string | null; is_enabled: boolean } | null = null
let invite: { id: string; space_id: string; max_uses: number | null; uses_count: number; expires_at: string | null; is_enabled: boolean; role: string } | null = null
const { data: inviteRow } = await admin
.from('space_invites')
.select('id, space_id, max_uses, uses_count, expires_at, is_enabled')
.select('id, space_id, max_uses, uses_count, expires_at, is_enabled, role')
.eq('code', code)
.maybeSingle()
if (inviteRow) invite = inviteRow as typeof invite
@@ -149,6 +149,14 @@ export async function joinSpace(formData: {
if (lookupErr || !space) return { error: 'Invalid invite code' }
// Honor the invite's granted role (default 'member' for the legacy
// spaces.invite_code path or any invite created before migration 029).
// Validated against the enum defensively even though the DB constrains it.
const grantedRole =
invite && ['admin', 'board', 'treasurer', 'member', 'associate'].includes(invite.role)
? invite.role
: 'member'
const { data: newMember, error: memberErr } = await admin
.from('space_members')
.insert({
@@ -156,7 +164,7 @@ export async function joinSpace(formData: {
user_id: user.id,
display_name: formData.displayName || user.email,
email: user.email,
role: 'member',
role: grantedRole,
tier: 'basic',
status: space.require_approval ? 'unverified' : 'current',
approved: true,
+25
View File
@@ -0,0 +1,25 @@
// Pure invite-role policy. No deps so it is unit-testable and shared by the
// server action, the validations, and the panel UI.
export const INVITE_ROLES = ['admin', 'board', 'treasurer', 'member', 'associate'] as const
export type InviteRole = (typeof INVITE_ROLES)[number]
export function isInviteRole(v: string): v is InviteRole {
return (INVITE_ROLES as readonly string[]).includes(v)
}
/**
* Whether a member with `creatorRole` may create an invite that grants
* `target`. Invite creation is already admin/board-gated upstream; this is
* the privilege-escalation guard on top of that:
* - admin may grant any role
* - board may grant anything EXCEPT admin (a board member cannot mint
* admin access)
* - anyone else: no (they never reach invite creation anyway)
*/
export function canAssignInviteRole(creatorRole: string, target: string): boolean {
if (!isInviteRole(target)) return false
if (creatorRole === 'admin') return true
if (creatorRole === 'board') return target !== 'admin'
return false
}
+7 -7
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { INVITE_ROLES } from './invite-logic'
/**
* Accepts every date-ish string the app produces and normalizes to a
@@ -476,6 +477,7 @@ export const createInviteSchema = z.object({
expires_at: flexibleDateTime(),
max_uses: z.number().int().min(1).max(100000).optional().nullable(),
is_enabled: z.boolean().optional(),
role: z.enum(INVITE_ROLES).optional().default('member'),
})
export const updateInviteSchema = z.object({
@@ -483,6 +485,7 @@ export const updateInviteSchema = z.object({
expires_at: flexibleDateTime(),
max_uses: z.number().int().min(1).max(100000).optional().nullable(),
is_enabled: z.boolean().optional(),
role: z.enum(INVITE_ROLES).optional(),
})
export const createChannelSchema = z.object({
@@ -649,17 +652,14 @@ export const formIdSchema = z.object({ formId: z.string().uuid('Invalid form ID'
// Envelope only. The answers object is validated dynamically against the
// form's stored field schema inside submitForm (see lib/forms-schema.ts).
export const submitFormSchema = z
.object({
formId: z.string().uuid('Invalid form ID').optional(),
slug: formSlug.optional(),
// formId is required: form slugs are only unique per space now, so a bare
// slug is ambiguous. Both callers (public page + onboarding) submit by id.
export const submitFormSchema = z.object({
formId: z.string().uuid('Invalid form ID'),
answers: z.record(z.unknown()).default({}),
email: emailField().optional().nullable(),
consent: z.boolean().optional(),
})
.refine(d => Boolean(d.formId) || Boolean(d.slug), {
message: 'formId or slug is required',
})
export const linkSubmissionsSchema = z.object({
memberId: z.string().uuid('Invalid member ID'),
+1
View File
@@ -11,6 +11,7 @@ const PUBLIC_ROUTES = [
'/',
'/login',
'/signup',
'/join',
'/auth',
'/api/health',
// Public form / waiver fill page. Only /f/[slug] lives here and must be
+19
View File
@@ -0,0 +1,19 @@
-- =============================================================================
-- 029: Invite codes can grant a role
-- =============================================================================
-- An invite can now mint a member at a chosen role (e.g. a single-use link
-- that makes the joiner an admin). The role reuses the existing member_role
-- enum, so the value set is constrained by the type. Default 'member'
-- preserves the behaviour of every existing invite and the legacy
-- spaces.invite_code path.
--
-- Security note: who may CREATE a role-granting invite is enforced in the
-- application (lib/actions/invites.ts + lib/invite-logic.ts): invite creation
-- is admin/board-gated, and only an admin may create an invite that grants
-- 'admin'. This column just records the granted role; joinSpace applies it.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS.
-- =============================================================================
ALTER TABLE public.space_invites
ADD COLUMN IF NOT EXISTS role public.member_role NOT NULL DEFAULT 'member';
+1
View File
@@ -1574,6 +1574,7 @@ CREATE TABLE IF NOT EXISTS public.space_invites (
max_uses integer CHECK (max_uses IS NULL OR max_uses > 0),
uses_count integer NOT NULL DEFAULT 0 CHECK (uses_count >= 0),
is_enabled boolean NOT NULL DEFAULT true,
role member_role NOT NULL DEFAULT 'member',
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
+3 -3
View File
@@ -65,9 +65,9 @@ export type Database = {
Relationships: []
}
space_invites: {
Row: { id: string; space_id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_by: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; code: string; label?: string | null; expires_at?: string | null; max_uses?: number | null; uses_count?: number; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; code?: string; label?: string | null; expires_at?: string | null; max_uses?: number | null; uses_count?: number; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
Row: { id: string; space_id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; role: Database["public"]["Enums"]["member_role"]; created_by: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; code: string; label?: string | null; expires_at?: string | null; max_uses?: number | null; uses_count?: number; is_enabled?: boolean; role?: Database["public"]["Enums"]["member_role"]; created_by?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; code?: string; label?: string | null; expires_at?: string | null; max_uses?: number | null; uses_count?: number; is_enabled?: boolean; role?: Database["public"]["Enums"]["member_role"]; created_by?: string | null; created_at?: string; updated_at?: string }
Relationships: []
}
space_onboarding_steps: {