From df6951c3239bc5ba33d02b48aea700af90b97ddc Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 17:34:45 -0700 Subject: [PATCH] feat(invites): role-granting + usage-capped invites; audit hardening (P7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/?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. --- __tests__/forms.test.ts | 60 ++++++++++++++++---- app/(app)/customize/customize-client.tsx | 4 +- app/(app)/customize/page.tsx | 9 ++- app/(app)/customize/panels/invites-panel.tsx | 46 +++++++++++---- app/(app)/customize/panels/types.ts | 1 + app/join/[space]/page.tsx | 57 +++++++++++++++++++ app/login/page.tsx | 17 +++++- lib/actions/forms.ts | 6 +- lib/actions/invites.ts | 13 +++++ lib/auth-actions.ts | 14 ++++- lib/invite-logic.ts | 25 ++++++++ lib/validations.ts | 22 +++---- proxy.ts | 1 + scripts/029_invite_roles.sql | 19 +++++++ scripts/schema.sql | 1 + types/database.ts | 6 +- 16 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 app/join/[space]/page.tsx create mode 100644 lib/invite-logic.ts create mode 100644 scripts/029_invite_roles.sql diff --git a/__tests__/forms.test.ts b/__tests__/forms.test.ts index 0467c73..61bea32 100644 --- a/__tests__/forms.test.ts +++ b/__tests__/forms.test.ts @@ -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: 'Me@Example.COM', 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: 'Me@Example.COM', consent: true }) expect(r.success).toBe(true) if (r.success) expect(r.data.email).toBe('me@example.com') - 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') diff --git a/app/(app)/customize/customize-client.tsx b/app/(app)/customize/customize-client.tsx index 0bed4e5..dae602d 100644 --- a/app/(app)/customize/customize-client.tsx +++ b/app/(app)/customize/customize-client.tsx @@ -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' && } {section === 'areas' && } {section === 'area-leads' && } - {section === 'invites' && } + {section === 'invites' && } {section === 'onboarding' && } diff --git a/app/(app)/customize/page.tsx b/app/(app)/customize/page.tsx index d7fb234..f8cd4c4 100644 --- a/app/(app)/customize/page.tsx +++ b/app/(app)/customize/page.tsx @@ -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 ( } 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; 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 }>} diff --git a/app/(app)/customize/panels/invites-panel.tsx b/app/(app)/customize/panels/invites-panel.tsx index f46ee61..bc6e98d 100644 --- a/app/(app)/customize/panels/invites-panel.tsx +++ b/app/(app)/customize/panels/invites-panel.tsx @@ -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(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 ( { 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" > 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" /> 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" /> 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" /> - 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" /> + 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" /> + + )} @@ -64,6 +87,7 @@ export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean;
{inv.code} + grants {inv.role} {inv.label && {inv.label}} {!inv.is_enabled && disabled} {expired && expired} diff --git a/app/(app)/customize/panels/types.ts b/app/(app)/customize/panels/types.ts index 7d64e5a..ba0e98e 100644 --- a/app/(app)/customize/panels/types.ts +++ b/app/(app)/customize/panels/types.ts @@ -37,6 +37,7 @@ export interface Invite { max_uses: number | null uses_count: number is_enabled: boolean + role: string created_at: string } diff --git a/app/join/[space]/page.tsx b/app/join/[space]/page.tsx new file mode 100644 index 0000000..e2b1ceb --- /dev/null +++ b/app/join/[space]/page.tsx @@ -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/?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 ( +
+

Invite link not valid

+

+ This space could not be found. Check the link or ask whoever invited you for a new one. +

+
+ ) + } + + const signupHref = code + ? `/signup?invite=${encodeURIComponent(code)}` + : '/signup' + + return ( +
+

Join {spaceRow.name}

+

+ You have been invited to join {spaceRow.name} + {code ? ' with an invite code' : ''}. Create an account or sign in to continue. +

+ + Continue + +
+ ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 4762c04..59b4d43 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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) { diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts index 192eef4..95354a6 100644 --- a/lib/actions/forms.ts +++ b/lib/actions/forms.ts @@ -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' } diff --git a/lib/actions/invites.ts b/lib/actions/invites.ts index 187183e..587a899 100644 --- a/lib/actions/invites.ts +++ b/lib/actions/invites.ts @@ -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 = {} 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') diff --git a/lib/auth-actions.ts b/lib/auth-actions.ts index 77d928d..445d947 100644 --- a/lib/auth-actions.ts +++ b/lib/auth-actions.ts @@ -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, diff --git a/lib/invite-logic.ts b/lib/invite-logic.ts new file mode 100644 index 0000000..f6a1ec8 --- /dev/null +++ b/lib/invite-logic.ts @@ -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 +} diff --git a/lib/validations.ts b/lib/validations.ts index 480151c..7bf0083 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -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(), - 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', - }) +// 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(), +}) export const linkSubmissionsSchema = z.object({ memberId: z.string().uuid('Invalid member ID'), diff --git a/proxy.ts b/proxy.ts index 684dc86..041f675 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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 diff --git a/scripts/029_invite_roles.sql b/scripts/029_invite_roles.sql new file mode 100644 index 0000000..a77484e --- /dev/null +++ b/scripts/029_invite_roles.sql @@ -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'; diff --git a/scripts/schema.sql b/scripts/schema.sql index d0ba458..309c6cd 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -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() diff --git a/types/database.ts b/types/database.ts index a57bdcc..3372312 100644 --- a/types/database.ts +++ b/types/database.ts @@ -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: {