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:
@@ -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>
|
||||
|
||||
@@ -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 }>}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Invite {
|
||||
max_uses: number | null
|
||||
uses_count: number
|
||||
is_enabled: boolean
|
||||
role: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user