feat: configurable member onboarding + UX/persona research

A new member who joins via invite code now walks through an
admin-defined onboarding sequence before reaching the app.

Schema (migration 022, folded into schema.sql Section 14):
- space_onboarding_steps: step_key, step_type
  (welcome|code_of_conduct|profile|payment|content), title, body,
  config jsonb, is_enabled, is_required, is_system, sort_order,
  unique per (space, step_key). Full RLS: members read, admin/board
  write, admin delete non-system.
- space_members.onboarding_completed_at + onboarding_progress jsonb.
- Trigger seeds four default steps per space (welcome, code of
  conduct [required], profile, dues). Backfill seeds existing spaces
  and marks all existing members complete so the new gate never
  traps them.

Flow:
- /onboarding (top-level route, outside the (app) group so no
  redirect loop). Progress bar, step counter, back/continue, per-type
  rendering. Profile step calls updateMyProfile; payment step is a
  non-blocking nudge with optional link and "Remind me later".
- (app)/layout.tsx redirects to /onboarding while
  onboarding_completed_at is null.
- createSpace marks the founder complete: founders configure the
  space, they do not run the member flow (matches admin-vs-end-user
  persona research).
- finishOnboarding re-checks required steps server-side so a tampered
  client cannot skip a required code-of-conduct acknowledgement.
  skipOnboarding only works when nothing is required.

Admin config:
- Settings -> Onboarding tab: reorder, edit title/body/payment link,
  toggle enabled/required, add custom content steps, delete
  non-system steps.

Safety:
- components/safe-markdown.tsx renders admin-authored markdown and a
  sanitized subset of HTML (rehype-raw + rehype-sanitize). Added
  those two deps.

Also:
- bio added to updateMyProfileSchema (members can self-set it; not
  blocked by the migration-015 trigger).
- docs/UX_AND_PERSONAS.md: 4 personas and prioritized UX audit
  findings. Onboarding was finding #1; 2-6 deferred and tracked.

266/266 unit tests pass. Production build clean, /onboarding in the
route table. Migration 022 applies idempotently on the next deploy.
This commit is contained in:
Moheeb Zara
2026-05-15 15:38:40 -07:00
parent 96a1fb9ef6
commit 30bba045f8
17 changed files with 1243 additions and 5 deletions
+4
View File
@@ -5,6 +5,10 @@ All notable changes to this project are documented here. Format follows [Keep a
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Configurable member onboarding**: a new member who joins via invite code is walked through an admin-defined sequence of steps at `/onboarding` before reaching the app. Built-in step types: welcome, code of conduct (required acknowledgement), profile, dues/payment nudge. Admins can add custom content steps with sanitized markdown/HTML, reorder, enable/disable, and mark steps required from Settings -> Onboarding. Founders skip the member flow (their onboarding is space configuration). Existing members were backfilled as completed so nobody is trapped. Server-side enforcement of required steps. New tables: `space_onboarding_steps`; new columns `space_members.onboarding_completed_at` and `onboarding_progress`.
- `SafeMarkdown` component (react-markdown + remark-gfm + rehype-raw + rehype-sanitize) for rendering admin-authored markdown/HTML without XSS.
- `docs/UX_AND_PERSONAS.md`: persona model and the UX audit that drove onboarding.
- `bio` added to the self-service profile schema.
- **Forum** at `/forum`: top-level discussion threads with categories, pinning, locking. Author or admin/board can edit; admin can delete. - **Forum** at `/forum`: top-level discussion threads with categories, pinning, locking. Author or admin/board can edit; admin can delete.
- **Polymorphic comment threads** on proposals, incidents, and policies. New `comments` table keyed on `(entity_type, entity_id)`. Renders markdown. - **Polymorphic comment threads** on proposals, incidents, and policies. New `comments` table keyed on `(entity_type, entity_id)`. Renders markdown.
- **User-creatable chat channels**: inline "+ New channel" form in `/comms` (name, description, type). RLS now allows any member to create; default channels stay protected. - **User-creatable chat channels**: inline "+ New channel" form in `/comms` (name, description, type). RLS now allows any member to create; default channels stay protected.
+9
View File
@@ -25,6 +25,15 @@ export default async function AppLayout({ children }: { children: React.ReactNod
redirect('/login') redirect('/login')
} }
// New members walk through the space's configured onboarding before they
// reach the app. Founders (createSpace) and pre-existing members (migration
// 022 backfill) have onboarding_completed_at set, so this only gates
// genuinely-new joiners. /onboarding is outside this layout group, so there
// is no redirect loop.
if (!(member as { onboarding_completed_at?: string | null }).onboarding_completed_at) {
redirect('/onboarding')
}
// Count badges // Count badges
const { count: taskCount } = await supabase const { count: taskCount } = await supabase
.from('tasks') .from('tasks')
+18
View File
@@ -37,6 +37,12 @@ export default async function SettingsPage() {
.eq('space_id', member.space_id) .eq('space_id', member.space_id)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
const { data: onboardingSteps } = await 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', { ascending: true })
const isAdmin = member?.role === 'admin' const isAdmin = member?.role === 'admin'
return ( return (
@@ -74,6 +80,18 @@ export default async function SettingsPage() {
is_enabled: boolean is_enabled: boolean
created_at: string created_at: string
}>} }>}
onboardingSteps={(onboardingSteps ?? []) as Array<{
id: string
step_key: string
step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
title: string
body: string | null
config: Record<string, unknown>
is_enabled: boolean
is_required: boolean
is_system: boolean
sort_order: number
}>}
/> />
) )
} }
+177 -4
View File
@@ -2,7 +2,7 @@
import { useState } from 'react' import { useState } from 'react'
import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react' import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react'
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea, createTier, updateTier, deleteTier, createInvite, updateInvite, deleteInvite } from '@/lib/actions' import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea, createTier, updateTier, deleteTier, createInvite, updateInvite, deleteInvite, createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from '@/lib/actions'
import { toast } from 'sonner' import { toast } from 'sonner'
import { financialVisibilities, directoryVisibilities } from '@/lib/validations' import { financialVisibilities, directoryVisibilities } from '@/lib/validations'
@@ -27,6 +27,20 @@ interface Props {
areas: Area[] areas: Area[]
tiers: Tier[] tiers: Tier[]
invites: Invite[] invites: Invite[]
onboardingSteps: OnboardingStep[]
}
export interface OnboardingStep {
id: string
step_key: string
step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
title: string
body: string | null
config: Record<string, unknown>
is_enabled: boolean
is_required: boolean
is_system: boolean
sort_order: number
} }
export interface Tier { export interface Tier {
@@ -124,8 +138,9 @@ const INTEGRATIONS_CONFIG = [
}, },
] ]
export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas, tiers: initialTiers, invites: initialInvites }: Props) { export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas, tiers: initialTiers, invites: initialInvites, onboardingSteps: initialOnboarding }: Props) {
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'tiers' | 'invites' | 'integrations' | 'webhooks'>('space') const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'tiers' | 'invites' | 'onboarding' | 'integrations' | 'webhooks'>('space')
const [onboardingSteps, setOnboardingSteps] = useState<OnboardingStep[]>(initialOnboarding)
const [areas, setAreas] = useState<Area[]>(initialAreas) const [areas, setAreas] = useState<Area[]>(initialAreas)
const [tiers, setTiers] = useState<Tier[]>(initialTiers) const [tiers, setTiers] = useState<Tier[]>(initialTiers)
const [invites, setInvites] = useState<Invite[]>(initialInvites) const [invites, setInvites] = useState<Invite[]>(initialInvites)
@@ -268,7 +283,7 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
<div className="flex-1 p-4 md:p-6"> <div className="flex-1 p-4 md:p-6">
{/* Tabs */} {/* Tabs */}
<div className="bg-card border-b border-border flex gap-4 md:gap-6 px-2 mb-6 rounded-t overflow-x-auto"> <div className="bg-card border-b border-border flex gap-4 md:gap-6 px-2 mb-6 rounded-t overflow-x-auto">
{(['space', 'roles', 'tiers', 'invites', 'integrations', 'webhooks'] as const).map(tab => ( {(['space', 'roles', 'tiers', 'invites', 'onboarding', 'integrations', 'webhooks'] as const).map(tab => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
@@ -1038,6 +1053,164 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
</div> </div>
)} )}
{/* Onboarding Tab */}
{activeTab === 'onboarding' && (
<div className="bg-card rounded border border-border p-6">
<div className="flex items-center justify-between mb-4 gap-3">
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">New member onboarding</p>
<p className="font-sans text-sm text-muted-foreground mt-1">
These steps run, in order, the first time a new member opens the app. Reorder with the sort number. Disable a step to hide it. Built-in steps cannot be deleted, only disabled. Body text supports markdown and a safe subset of HTML.
</p>
</div>
{isAdmin && (
<button
onClick={async () => {
const result = await createOnboardingStep({
step_type: 'content',
title: 'New step',
body: 'Edit this content in Settings, Onboarding.',
sort_order: (onboardingSteps.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1),
})
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Step added. Edit it below.')
setOnboardingSteps(prev => [...prev, {
id: (result as { id: string }).id,
step_key: `custom-${(result as { id: string }).id.slice(0, 12)}`,
step_type: 'content',
title: 'New step',
body: 'Edit this content in Settings, Onboarding.',
config: {},
is_enabled: true,
is_required: false,
is_system: false,
sort_order: (onboardingSteps.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1),
}])
}}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
>
+ Custom step
</button>
)}
</div>
<ul className="space-y-3">
{[...onboardingSteps].sort((a, b) => a.sort_order - b.sort_order).map(s => (
<li key={s.id} className="border border-border rounded p-4">
<div className="flex items-center gap-3 mb-3 flex-wrap">
<input
type="number"
defaultValue={s.sort_order}
disabled={!isAdmin}
onBlur={async e => {
const next = parseInt(e.target.value, 10)
if (isNaN(next) || next === s.sort_order) return
const result = await updateOnboardingStep(s.id, { sort_order: next })
if (result.error) { toast.error(result.error); return }
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: next } : x))
}}
className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary"
/>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">{s.step_type.replace(/_/g, ' ')}</span>
<input
type="text"
defaultValue={s.title}
disabled={!isAdmin}
onBlur={async e => {
const next = e.target.value.trim()
if (!next || next === s.title) return
const result = await updateOnboardingStep(s.id, { title: next })
if (result.error) { toast.error(result.error); return }
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: next } : x))
}}
className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary"
/>
{s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
</div>
{(s.step_type === 'welcome' || s.step_type === 'code_of_conduct' || s.step_type === 'payment' || s.step_type === 'content') && (
<textarea
defaultValue={s.body ?? ''}
disabled={!isAdmin}
rows={4}
maxLength={50000}
onBlur={async e => {
const next = e.target.value
if (next === (s.body ?? '')) return
const result = await updateOnboardingStep(s.id, { body: next })
if (result.error) { toast.error(result.error); return }
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, body: next } : x))
}}
placeholder="Markdown / safe HTML. For payment steps, describe how to pay."
className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 focus:outline-none focus:border-primary mb-3"
/>
)}
{s.step_type === 'payment' && (
<input
type="url"
defaultValue={(s.config?.payment_url as string) ?? ''}
disabled={!isAdmin}
placeholder="Payment link (https://...)"
onBlur={async e => {
const next = e.target.value.trim()
if (next === ((s.config?.payment_url as string) ?? '')) return
const result = await updateOnboardingStep(s.id, { config: { ...s.config, payment_url: next } })
if (result.error) { toast.error(result.error); return }
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, config: { ...x.config, payment_url: next } } : x))
}}
className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 focus:outline-none focus:border-primary mb-3"
/>
)}
{isAdmin && (
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={s.is_enabled}
onChange={async e => {
const result = await updateOnboardingStep(s.id, { is_enabled: e.target.checked })
if (result.error) { toast.error(result.error); return }
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, is_enabled: e.target.checked } : x))
}}
/>
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">Enabled</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={s.is_required}
onChange={async e => {
const result = await updateOnboardingStep(s.id, { is_required: e.target.checked })
if (result.error) { toast.error(result.error); return }
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, is_required: e.target.checked } : x))
}}
/>
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">Required</span>
</label>
{!s.is_system && (
<button
onClick={async () => {
if (!confirm(`Delete step "${s.title}"?`)) return
const result = await deleteOnboardingStep(s.id)
if (result.error) { toast.error(result.error); return }
setOnboardingSteps(prev => prev.filter(x => x.id !== s.id))
toast.success('Step deleted')
}}
className="ml-auto font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
>
Delete
</button>
)}
</div>
)}
</li>
))}
</ul>
</div>
)}
{/* Webhooks Tab */} {/* Webhooks Tab */}
{activeTab === 'webhooks' && ( {activeTab === 'webhooks' && (
<div className="bg-card rounded border border-border p-6 space-y-6"> <div className="bg-card rounded border border-border p-6 space-y-6">
+239
View File
@@ -0,0 +1,239 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { AtlasLogo } from '@/components/resources/atlas-logo'
import { SafeMarkdown } from '@/components/safe-markdown'
import {
markOnboardingStepDone,
finishOnboarding,
skipOnboarding,
updateMyProfile,
} from '@/lib/actions'
type StepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
interface Step {
id: string
step_type: StepType
title: string
body: string | null
config: Record<string, unknown>
is_required: boolean
}
interface Props {
spaceName: string
steps: Step[]
canSkip: boolean
profile: {
display_name: string
handle: string
bio: string
skills: string[]
interests: string[]
}
}
export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProfile }: Props) {
const router = useRouter()
const [index, setIndex] = useState(0)
const [busy, setBusy] = useState(false)
const [acked, setAcked] = useState<Record<string, boolean>>({})
const [profile, setProfile] = useState(initialProfile)
const step = steps[index]
const isLast = index === steps.length - 1
const requireAck = step.config?.require_ack === true
const ackLabel = (step.config?.ack_label as string) || 'I have read and agree'
const ackOk = !requireAck || acked[step.id]
async function advance() {
setBusy(true)
await markOnboardingStepDone(step.id)
setBusy(false)
if (isLast) {
await finish()
} else {
setIndex(i => i + 1)
}
}
async function finish() {
setBusy(true)
const result = await finishOnboarding()
setBusy(false)
if (result.error) { toast.error(result.error); return }
router.push('/dashboard')
}
async function handleSkipAll() {
setBusy(true)
const result = await skipOnboarding()
setBusy(false)
if (result.error) { toast.error(result.error); return }
router.push('/dashboard')
}
async function saveProfileAndAdvance() {
setBusy(true)
const result = await updateMyProfile({
display_name: profile.display_name.trim() || undefined,
handle: profile.handle.trim() || null,
bio: profile.bio.trim() || null,
skills: profile.skills,
interests: profile.interests,
})
if ('error' in result && result.error) { setBusy(false); toast.error(result.error); return }
await markOnboardingStepDone(step.id)
setBusy(false)
if (isLast) await finish()
else setIndex(i => i + 1)
}
const progressPct = Math.round(((index) / steps.length) * 100)
return (
<div className="min-h-screen bg-sidebar flex flex-col">
<header className="px-4 md:px-6 py-4 flex items-center gap-2 border-b border-sidebar-border">
<AtlasLogo className="w-6 h-6 text-primary" />
<span className="font-mono text-sm font-bold text-white">{spaceName}</span>
{canSkip && (
<button
onClick={handleSkipAll}
disabled={busy}
className="ml-auto font-mono text-[11px] text-sidebar-foreground/60 hover:text-white transition"
>
Skip for now
</button>
)}
</header>
<div className="h-1 bg-sidebar-border">
<div className="h-1 bg-primary transition-all duration-300" style={{ width: `${progressPct}%` }} />
</div>
<main className="flex-1 flex items-start justify-center p-4 md:p-8">
<div className="w-full max-w-2xl bg-card border border-border rounded-lg mt-6 md:mt-12">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Step {index + 1} of {steps.length}
</p>
{step.is_required && (
<span className="font-mono text-[10px] text-amber-600">required</span>
)}
</div>
<div className="px-6 py-6">
<h1 className="font-sans text-xl font-semibold text-foreground mb-4">{step.title}</h1>
{step.step_type === 'profile' ? (
<div className="space-y-4">
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Display name</label>
<input
type="text"
value={profile.display_name}
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
maxLength={100}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
<input
type="text"
value={profile.handle}
onChange={e => setProfile({ ...profile, handle: e.target.value })}
maxLength={50}
placeholder="optional"
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Bio</label>
<textarea
value={profile.bio}
onChange={e => setProfile({ ...profile, bio: e.target.value })}
rows={3}
maxLength={2000}
placeholder="optional"
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Skills (comma separated)</label>
<input
type="text"
defaultValue={profile.skills.join(', ')}
onChange={e => setProfile({ ...profile, skills: e.target.value.split(',').map(s => s.trim()).filter(Boolean).slice(0, 40) })}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
</div>
) : step.step_type === 'payment' ? (
<div className="space-y-4">
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
{typeof step.config?.payment_url === 'string' && step.config.payment_url && (
<a
href={step.config.payment_url as string}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-primary text-white text-sm font-sans px-4 py-2 rounded hover:bg-primary/90 transition"
>
Set up payment
</a>
)}
</div>
) : (
<div className="space-y-4">
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
{requireAck && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!acked[step.id]}
onChange={e => setAcked({ ...acked, [step.id]: e.target.checked })}
className="mt-0.5"
/>
<span className="font-sans text-sm text-foreground">{ackLabel}</span>
</label>
)}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-border flex items-center justify-between gap-3">
<button
onClick={() => setIndex(i => Math.max(0, i - 1))}
disabled={index === 0 || busy}
className="font-sans text-sm text-muted-foreground hover:text-foreground transition disabled:opacity-40"
>
Back
</button>
<div className="flex items-center gap-2">
{step.step_type === 'payment' && !isLast && (
<button
onClick={advance}
disabled={busy}
className="font-sans text-sm text-muted-foreground hover:text-foreground transition px-3 py-2"
>
Remind me later
</button>
)}
<button
onClick={step.step_type === 'profile' ? saveProfileAndAdvance : advance}
disabled={busy || !ackOk}
className="bg-primary text-white text-sm font-sans px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{busy ? 'Saving...' : isLast ? 'Finish' : step.step_type === 'payment' ? 'I have set this up' : 'Continue'}
</button>
</div>
</div>
</div>
</main>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { OnboardingFlow } from './onboarding-flow'
export const dynamic = 'force-dynamic'
export default async function OnboardingPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('id, space_id, display_name, handle, bio, phone, skills, interests, onboarding_completed_at, spaces(name)')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.order('joined_at', { ascending: true })
.limit(1)
.maybeSingle()
if (!member) redirect(`/signup?email=${encodeURIComponent(user.email || '')}`)
if (member.onboarding_completed_at) redirect('/dashboard')
const { data: steps } = await supabase
.from('space_onboarding_steps')
.select('id, step_key, step_type, title, body, config, is_required')
.eq('space_id', member.space_id)
.eq('is_enabled', true)
.order('sort_order', { ascending: true })
// No steps configured: nothing to show. Mark complete and move on.
if (!steps || steps.length === 0) {
await supabase
.from('space_members')
.update({ onboarding_completed_at: new Date().toISOString() })
.eq('id', member.id)
redirect('/dashboard')
}
const canSkip = !steps.some(s => s.is_required)
const spaceName = (member.spaces as { name?: string } | null)?.name ?? 'your space'
return (
<OnboardingFlow
spaceName={spaceName}
steps={steps.map(s => ({
id: s.id,
step_type: s.step_type as 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content',
title: s.title,
body: s.body,
config: (s.config ?? {}) as Record<string, unknown>,
is_required: s.is_required,
}))}
canSkip={canSkip}
profile={{
display_name: member.display_name ?? '',
handle: member.handle ?? '',
bio: member.bio ?? '',
skills: (member.skills as string[] | null) ?? [],
interests: (member.interests as string[] | null) ?? [],
}}
/>
)
}
+43
View File
@@ -0,0 +1,43 @@
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
// Renders admin-authored markdown that MAY contain a constrained subset of raw
// HTML. rehype-raw parses the HTML; rehype-sanitize then strips anything
// dangerous (script, event handlers, javascript: URLs, etc). The author is a
// space admin/board member, but a stored-XSS payload would still run in other
// members' sessions within that tenant, so this is sanitized, not trusted.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
'*': [...(defaultSchema.attributes?.['*'] ?? []), 'className', 'style'],
a: [...(defaultSchema.attributes?.a ?? []), 'target', 'rel'],
},
tagNames: [
...(defaultSchema.tagNames ?? []),
'iframe',
],
// Allow only video embeds from well-known hosts; everything else dropped.
protocols: {
...defaultSchema.protocols,
src: ['https'],
href: ['https', 'http', 'mailto'],
},
}
export function SafeMarkdown({ children, className }: { children: string; className?: string }) {
return (
<div className={`prose prose-sm prose-neutral dark:prose-invert max-w-none break-words ${className ?? ''}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
>
{children}
</ReactMarkdown>
</div>
)
}
+34
View File
@@ -4,6 +4,40 @@ Append-only. Newest entries on top. Keep each entry to one screen.
--- ---
## 2026-05-15 (pass 12) — Configurable member onboarding + UX research
Branch: `main`.
### Context carried in
Pass 11's feature batch (forum, comments, tiers, invites, secrets encryption) deployed to production after one landmine: the `.gitignore` rule `secrets/` also matched `lib/secrets/`, so the first deploy failed on `Module not found: '@/lib/secrets/crypto'`. Fixed by anchoring the rule to `/secrets/` (commit `96a1fb9`); redeploy succeeded. Migration 021 is applied in prod (`_migrations_applied` has 8 rows). `SECRETS_ENCRYPTION_KEY` is set in `/opt/hackerspace-ops/.env.production` on the Droplet.
### What this pass does
UX research + persona work, a deep UI/UX audit, and the configurable member-onboarding feature.
### What shipped
- Migration `scripts/022_onboarding.sql` (folded into `schema.sql` Section 14): `space_onboarding_steps` (step_key, step_type in welcome|code_of_conduct|profile|payment|content, title, body, config jsonb, is_enabled, is_required, is_system, sort_order; unique per space+key). `space_members.onboarding_completed_at` and `onboarding_progress jsonb`. Trigger seeds 4 default steps per space; backfill seeds existing spaces and marks all existing members complete (so the new gate does not trap them). RLS: members read, admin/board write, admin delete non-system.
- `lib/actions/onboarding.ts`: admin CRUD (`createOnboardingStep`/`updateOnboardingStep`/`deleteOnboardingStep`), member progress (`markOnboardingStepDone`, `finishOnboarding` which server-side-enforces required steps, `skipOnboarding` which only works when nothing is required). Validations added; `bio` added to `updateMyProfileSchema`.
- `/onboarding` route (top-level, outside the `(app)` group so no redirect loop). `OnboardingFlow` client stepper: progress bar, step counter, back, per-type rendering (welcome/coc/content via `SafeMarkdown`, profile form -> `updateMyProfile`, payment nudge with optional link + "Remind me later"). Gate added in `app/(app)/layout.tsx`: redirect to `/onboarding` when `onboarding_completed_at` is null. `createSpace` now sets `onboarding_completed_at` for the founder.
- `components/safe-markdown.tsx`: rehype-raw + rehype-sanitize. Added `rehype-raw` and `rehype-sanitize` deps.
- Settings -> Onboarding admin tab: reorder (sort number), edit title/body/payment link, toggle enabled/required, add custom content step, delete non-system steps.
- `docs/UX_AND_PERSONAS.md`: 4 personas (founder, new member, operator, casual) and prioritized UX findings. Onboarding was finding #1; findings 2-6 (sidebar icon collision, inconsistent empty states, heading hierarchy, getting-started affordance, role-filtered nav) deferred.
### Open items / deferred
- UX findings 2-6 in `docs/UX_AND_PERSONAS.md` (icon collision, empty-state component, page-title primitives, admin checklist, role-filtered sidebar).
- Custom role labels / custom roles still have no Settings UI (data layer from pass 11 exists).
- `importMembers` / `importPaymentsCsv` still lack Zod schemas (pass-10 audit item).
### Verification
- `pnpm test` 266/266. `pnpm build` green, `/onboarding` in the route table.
- Migration 022 applies via `deploy.sh`'s idempotent runner on next deploy.
---
## 2026-05-14 (pass 11) — Forum, comments, secrets encryption, custom tiers/invites, logo ## 2026-05-14 (pass 11) — Forum, comments, secrets encryption, custom tiers/invites, logo
Branch: `main`. Pushed. Branch: `main`. Pushed.
+85
View File
@@ -0,0 +1,85 @@
# UX Notes and Personas
This document captures the persona model and the UX audit that drove the
configurable-onboarding feature. It is a working reference, not marketing copy.
## Personas
A single hackerspace install serves several distinct people. They share a
codebase but have different jobs-to-be-done. Research consistently shows the
first real onboarding in a multi-tenant tool belongs to an admin, not an end
user, and that role-based segmentation lifts activation 30 to 50 percent over a
one-size-fits-all flow. The product reflects that split.
### 1. The Founder / Admin ("Dana")
- Starts the space. Technical or technical-adjacent. Wants the space
configured: members imported, dues collected, areas defined, channels set up.
- Cares about configuration, permissions, and not losing data.
- Jobs: stand up the space, get money flowing, delegate.
- Onboarding need: an admin setup path, not the member welcome flow. The app
now marks the founder's `onboarding_completed_at` at space creation so they
land directly on the dashboard.
### 2. The New Member ("Sam")
- Joined via an invite code. May be brand new to makerspaces.
- Cares about: am I allowed in, what are the rules, how do I pay, who do I
talk to.
- Jobs: understand the code of conduct, set up dues, complete a profile, feel
oriented.
- Onboarding need: a short, friendly, mostly non-blocking flow. This is the
flow `/onboarding` implements. Real makerspace onboarding (Milwaukee, Dallas,
MakerFX, Asmbly) is consistently: orientation, sign a code-of-conduct/waiver,
start recurring dues, then tool training. The default steps mirror that.
### 3. The Operator / Board / Treasurer ("Riley")
- Recurring power user. Runs payments, proposals, incidents, area leads.
- Cares about visibility and throughput. Low onboarding need; high need for
scannable lists and good empty states.
### 4. The Casual / Associate Member ("Jess")
- Low-frequency. Claims a chore, reads the forum, checks a policy.
- Cares about a low-friction path to the one thing they came for. Sidebar
breadth is overwhelming for this persona; progressive disclosure matters.
## Onboarding principles applied
From current UX research (Appcues, Userpilot, NN/g progressive disclosure,
B2B activation studies) and makerspace practice:
- Short: 3 to 5 steps, not 10. Defaults ship 4 (welcome, code of conduct,
profile, payment).
- Personalized and configurable: each space edits, reorders, enables, requires,
or adds steps. Not one-size-fits-all.
- Visible progress: a progress bar and "Step X of N" reduce abandonment.
- Value before commitment: the welcome and rules come before the dues nudge;
payment is a non-blocking nudge by default ("Remind me later").
- Enforce only what matters: code of conduct is `required` by default; the
server double-checks required steps so a tampered client cannot skip them.
- Escape hatch: if a space marks nothing required, members can "Skip for now".
## UX audit findings (prioritized)
The audit reviewed every authenticated page, the auth flow, and the layout.
Onboarding was the single highest-leverage gap and is now addressed. The
remaining findings are tracked for follow-up passes:
1. (Done) No post-signup onboarding. Implemented as configurable steps.
2. Sidebar icon collision: Ops and Settings both use the `Settings2` icon.
Low effort, high clarity win.
3. Inconsistent empty states: roughly 17 variants. Forum, Policies, and
Recruitment are the good models; the rest are blank or minimal. Recommend a
single `Empty` component with icon, message, and a primary CTA.
4. Heading hierarchy differs between pages (dashboard large/white vs members
small/mono). Recommend `PageTitle` / `SectionTitle` primitives.
5. No in-app "getting started" affordance for admins. The onboarding admin tab
in Settings partially covers this for member-facing content; an admin
checklist is still worth adding.
6. Cognitive load for first-time members: 16+ sidebar items with no role
filtering. Consider hiding admin-only items for non-privileged roles.
Items 2 to 6 are intentionally deferred so the onboarding feature ships in a
reviewable change rather than a sprawling UI rewrite.
+3
View File
@@ -38,3 +38,6 @@ export * from './comms'
export * from './tiers' export * from './tiers'
export * from './roles' export * from './roles'
export * from './invites' export * from './invites'
// Configurable member onboarding
export * from './onboarding'
+212
View File
@@ -0,0 +1,212 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import {
createOnboardingStepSchema,
updateOnboardingStepSchema,
uuidSchema,
} from '@/lib/validations'
const ONBOARDING_ADMIN_ROLES = ['admin', 'board'] as const
// --- Admin: manage steps -----------------------------------------------------
export async function createOnboardingStep(input: {
step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
title: string
body?: string | null
config?: Record<string, unknown>
is_enabled?: boolean
is_required?: boolean
sort_order?: number
}) {
const v = parseInput(createOnboardingStepSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ONBOARDING_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
// Custom steps get a generated key; built-in keys are reserved for the
// seeded rows so they stay addressable.
const stepKey = `custom-${crypto.randomUUID().slice(0, 12)}`
const { data, error } = await supabase
.from('space_onboarding_steps')
.insert({
space_id: member.space_id,
step_key: stepKey,
step_type: v.data.step_type,
title: v.data.title,
body: v.data.body ?? null,
config: v.data.config ?? {},
is_enabled: v.data.is_enabled ?? true,
is_required: v.data.is_required ?? false,
is_system: false,
sort_order: v.data.sort_order ?? 100,
})
.select('id')
.single()
if (error) return { error: error.message }
revalidatePath('/settings')
return { id: data.id }
}
export async function updateOnboardingStep(stepId: string, updates: {
title?: string
body?: string | null
config?: Record<string, unknown>
is_enabled?: boolean
is_required?: boolean
sort_order?: number
}) {
const idCheck = parseInput(uuidSchema, stepId)
if (!idCheck.ok) return { error: 'Invalid step ID' }
const v = parseInput(updateOnboardingStepSchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ONBOARDING_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const patch: Record<string, unknown> = {}
if (v.data.title !== undefined) patch.title = v.data.title
if (v.data.body !== undefined) patch.body = v.data.body
if (v.data.config !== undefined) patch.config = v.data.config
if (v.data.is_enabled !== undefined) patch.is_enabled = v.data.is_enabled
if (v.data.is_required !== undefined) patch.is_required = v.data.is_required
if (v.data.sort_order !== undefined) patch.sort_order = v.data.sort_order
const { error } = await supabase
.from('space_onboarding_steps')
.update(patch)
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
export async function deleteOnboardingStep(stepId: string) {
const v = parseInput(uuidSchema, stepId)
if (!v.ok) return { error: 'Invalid step ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
// RLS also blocks deleting is_system rows; surface a clear message.
const { error, count } = await supabase
.from('space_onboarding_steps')
.delete({ count: 'exact' })
.eq('id', v.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
if (count === 0) return { error: 'Built-in steps cannot be deleted. Disable it instead.' }
revalidatePath('/settings')
return { success: true as const }
}
// --- Member: progress through the flow ---------------------------------------
export async function markOnboardingStepDone(stepId: string) {
const v = parseInput(uuidSchema, stepId)
if (!v.ok) return { error: 'Invalid step ID' }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: row } = await supabase
.from('space_members')
.select('onboarding_progress')
.eq('id', member.id)
.single()
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
const done = new Set(progress.completed_step_ids ?? [])
done.add(v.data)
const { error } = await supabase
.from('space_members')
.update({ onboarding_progress: { ...progress, completed_step_ids: Array.from(done) } })
.eq('id', member.id)
if (error) return { error: error.message }
return { success: true as const }
}
// Completes onboarding. Server-side double-check: every enabled+required step
// must be in completed_step_ids, so a tampered client cannot skip a required
// code-of-conduct acknowledgement.
export async function finishOnboarding() {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: steps } = await supabase
.from('space_onboarding_steps')
.select('id, is_required, is_enabled')
.eq('space_id', member.space_id)
.eq('is_enabled', true)
const { data: row } = await supabase
.from('space_members')
.select('onboarding_progress')
.eq('id', member.id)
.single()
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
const done = new Set(progress.completed_step_ids ?? [])
const requiredMissing = (steps ?? []).filter(s => s.is_required && !done.has(s.id))
if (requiredMissing.length > 0) {
return { error: 'Please complete the required steps before finishing.' }
}
const { error } = await supabase
.from('space_members')
.update({ onboarding_completed_at: new Date().toISOString() })
.eq('id', member.id)
if (error) return { error: error.message }
revalidatePath('/dashboard')
return { success: true as const }
}
// Skip is only honored when no enabled step is required.
export async function skipOnboarding() {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { count } = await supabase
.from('space_onboarding_steps')
.select('*', { count: 'exact', head: true })
.eq('space_id', member.space_id)
.eq('is_enabled', true)
.eq('is_required', true)
if ((count ?? 0) > 0) {
return { error: 'This space requires you to complete onboarding.' }
}
const { error } = await supabase
.from('space_members')
.update({ onboarding_completed_at: new Date().toISOString() })
.eq('id', member.id)
if (error) return { error: error.message }
revalidatePath('/dashboard')
return { success: true as const }
}
+3 -1
View File
@@ -74,7 +74,8 @@ export async function createSpace(formData: {
return { error: spaceErr?.message ?? 'Failed to create space' } return { error: spaceErr?.message ?? 'Failed to create space' }
} }
// Add user as admin // Add user as admin. The founder configures the space; they do not go
// through the member onboarding flow, so mark it complete up front.
const { error: memberErr } = await admin const { error: memberErr } = await admin
.from('space_members') .from('space_members')
.insert({ .insert({
@@ -86,6 +87,7 @@ export async function createSpace(formData: {
tier: 'plus', tier: 'plus',
status: 'current', status: 'current',
approved: true, approved: true,
onboarding_completed_at: new Date().toISOString(),
}) })
if (memberErr) { if (memberErr) {
+24
View File
@@ -318,11 +318,35 @@ export const updateMyProfileSchema = z.object({
display_name: z.string().min(1).max(100).optional(), display_name: z.string().min(1).max(100).optional(),
handle: z.string().max(50).optional().nullable(), handle: z.string().max(50).optional().nullable(),
phone: z.string().max(20).optional().nullable(), phone: z.string().max(20).optional().nullable(),
bio: z.string().max(2000).optional().nullable(),
skills: z.array(z.string().min(1).max(60)).max(40).optional(), skills: z.array(z.string().min(1).max(60)).max(40).optional(),
interests: z.array(z.string().min(1).max(60)).max(40).optional(), interests: z.array(z.string().min(1).max(60)).max(40).optional(),
willing_to: z.array(z.string().min(1).max(60)).max(20).optional(), willing_to: z.array(z.string().min(1).max(60)).max(20).optional(),
}) })
// ─── Onboarding ──────────────────────────────────────────────────────────────
export const onboardingStepTypeSchema = z.enum(['welcome', 'code_of_conduct', 'profile', 'payment', 'content'])
export const createOnboardingStepSchema = z.object({
step_type: onboardingStepTypeSchema,
title: z.string().min(1, 'Title is required').max(200),
body: z.string().max(50000).optional().nullable(),
config: z.record(z.unknown()).optional(),
is_enabled: z.boolean().optional(),
is_required: z.boolean().optional(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const updateOnboardingStepSchema = z.object({
title: z.string().min(1).max(200).optional(),
body: z.string().max(50000).optional().nullable(),
config: z.record(z.unknown()).optional(),
is_enabled: z.boolean().optional(),
is_required: z.boolean().optional(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const discloseAffiliationsSchema = z.object({ export const discloseAffiliationsSchema = z.object({
affiliations: z.array(z.string().min(1).max(200)).max(50).default([]), affiliations: z.array(z.string().min(1).max(200)).max(50).default([]),
}) })
+2
View File
@@ -60,6 +60,8 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "2.15.0", "recharts": "2.15.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^1.7.1", "sonner": "^1.7.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
+114
View File
@@ -149,6 +149,12 @@ importers:
recharts: recharts:
specifier: 2.15.0 specifier: 2.15.0
version: 2.15.0([email protected]([email protected]))([email protected]) version: 2.15.0([email protected]([email protected]))([email protected])
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
remark-gfm: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -2712,12 +2718,30 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
[email protected]:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
[email protected]:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
[email protected]:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
[email protected]:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
[email protected]:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
[email protected]: [email protected]:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
@@ -2732,6 +2756,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
[email protected]:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3742,6 +3769,12 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
[email protected]:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -4196,6 +4229,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
[email protected]:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -4270,6 +4306,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -6852,6 +6891,43 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.1.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
[email protected]:
dependencies:
'@types/hast': 3.0.4
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.1
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.1
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
parse5: 7.3.0
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.1
unist-util-position: 5.0.0
[email protected]: [email protected]:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -6872,10 +6948,28 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
[email protected]:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
[email protected]: [email protected]:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
[email protected]:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -6886,6 +6980,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@@ -8067,6 +8163,17 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
set-function-name: 2.0.2 set-function-name: 2.0.2
[email protected]:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
[email protected]:
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
[email protected]: [email protected]:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -8621,6 +8728,11 @@ snapshots:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
[email protected]:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
[email protected]: [email protected]:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -8716,6 +8828,8 @@ snapshots:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+137
View File
@@ -0,0 +1,137 @@
-- =============================================================================
-- 022: Configurable member onboarding
-- =============================================================================
-- A space defines an ordered list of onboarding steps. A member who joins
-- walks through the enabled steps before they land on the dashboard. Founders
-- (created via createSpace) skip member onboarding: their onboarding is the
-- admin configuration surface, not this flow.
--
-- Step types:
-- welcome info screen (markdown body), never blocking
-- code_of_conduct markdown body + required acknowledgement checkbox
-- profile prompts the member to fill display_name/handle/bio/skills
-- payment dues setup nudge: markdown body + optional link button
-- content fully custom sanitized markdown/HTML, optional ack
--
-- Built-in steps are is_system = true: they can be disabled and reordered but
-- not deleted. Custom content steps are is_system = false and deletable.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.space_onboarding_steps (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
step_key text NOT NULL CHECK (char_length(step_key) BETWEEN 1 AND 60),
step_type text NOT NULL CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content')),
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
body text,
config jsonb NOT NULL DEFAULT '{}',
is_enabled boolean NOT NULL DEFAULT true,
is_required boolean NOT NULL DEFAULT false,
is_system boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, step_key)
);
CREATE INDEX IF NOT EXISTS idx_onboarding_steps_space
ON public.space_onboarding_steps (space_id, sort_order);
ALTER TABLE public.space_members
ADD COLUMN IF NOT EXISTS onboarding_completed_at timestamptz;
ALTER TABLE public.space_members
ADD COLUMN IF NOT EXISTS onboarding_progress jsonb NOT NULL DEFAULT '{}';
-- Seed the four default steps on space creation.
CREATE OR REPLACE FUNCTION public.seed_default_onboarding_steps()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_onboarding_steps
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
VALUES
(NEW.id, 'welcome', 'welcome',
'Welcome to ' || NEW.name,
E'We are glad you are here.\n\nThis short setup gets you ready to use the space. It takes about a minute.',
'{}'::jsonb, true, false, true, 0),
(NEW.id, 'code_of_conduct', 'code_of_conduct',
'Code of Conduct',
E'Be excellent to each other.\n\n- Treat people and tools with respect.\n- Clean up after yourself.\n- Ask before using equipment you have not been trained on.\n\nAn admin can edit this text in Settings, Onboarding.',
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}'::jsonb,
true, true, true, 1),
(NEW.id, 'profile', 'profile',
'Complete your profile',
E'Tell the space who you are. You can change this anytime from your profile.',
'{}'::jsonb, true, false, true, 2),
(NEW.id, 'payment', 'payment',
'Set up your dues',
E'Membership dues keep the space running. Set up your recurring payment now so you do not lose access.\n\nAn admin can put the payment link and instructions here in Settings, Onboarding.',
'{}'::jsonb, true, false, true, 3)
ON CONFLICT (space_id, step_key) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_onboarding_steps ON public.spaces;
CREATE TRIGGER trg_seed_default_onboarding_steps
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_onboarding_steps();
-- Backfill default steps for existing spaces.
INSERT INTO public.space_onboarding_steps
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
SELECT
s.id,
d.step_key,
d.step_type,
CASE WHEN d.step_key = 'welcome' THEN 'Welcome to ' || s.name ELSE d.title END,
d.body,
d.config::jsonb,
true,
d.is_required,
true,
d.sort_order
FROM public.spaces s
CROSS JOIN LATERAL (
VALUES
('welcome', 'welcome', 'Welcome',
'We are glad you are here. This short setup gets you ready to use the space.',
'{}', false, 0),
('code_of_conduct', 'code_of_conduct', 'Code of Conduct',
'Be excellent to each other. An admin can edit this in Settings, Onboarding.',
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}', true, 1),
('profile', 'profile', 'Complete your profile',
'Tell the space who you are.',
'{}', false, 2),
('payment', 'payment', 'Set up your dues',
'Membership dues keep the space running. An admin can add the payment link in Settings, Onboarding.',
'{}', false, 3)
) AS d(step_key, step_type, title, body, config, is_required, sort_order)
ON CONFLICT (space_id, step_key) DO NOTHING;
-- Existing members have already been using the app: mark their onboarding done
-- so the new gate does not trap them.
UPDATE public.space_members
SET onboarding_completed_at = now()
WHERE onboarding_completed_at IS NULL;
ALTER TABLE public.space_onboarding_steps ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS onboarding_steps_select ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_insert ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_update ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_delete ON public.space_onboarding_steps;
-- Any member of the space can read steps (the joining member needs them).
CREATE POLICY onboarding_steps_select ON public.space_onboarding_steps FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY onboarding_steps_insert ON public.space_onboarding_steps FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY onboarding_steps_update ON public.space_onboarding_steps FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY onboarding_steps_delete ON public.space_onboarding_steps FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) AND NOT is_system);
DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_steps;
CREATE TRIGGER trg_onboarding_steps_touch
BEFORE UPDATE ON public.space_onboarding_steps
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+75
View File
@@ -1617,3 +1617,78 @@ CREATE TRIGGER trg_space_tiers_touch BEFORE UPDATE ON public.space_tier
CREATE TRIGGER trg_space_role_labels_touch BEFORE UPDATE ON public.space_role_labels FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); CREATE TRIGGER trg_space_role_labels_touch BEFORE UPDATE ON public.space_role_labels FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_custom_roles_touch BEFORE UPDATE ON public.space_custom_roles FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); CREATE TRIGGER trg_space_custom_roles_touch BEFORE UPDATE ON public.space_custom_roles FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_invites_touch BEFORE UPDATE ON public.space_invites FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); CREATE TRIGGER trg_space_invites_touch BEFORE UPDATE ON public.space_invites FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- =============================================================================
-- 14. Configurable member onboarding.
-- Equivalent to scripts/022_onboarding.sql.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.space_onboarding_steps (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
step_key text NOT NULL CHECK (char_length(step_key) BETWEEN 1 AND 60),
step_type text NOT NULL CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content')),
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
body text,
config jsonb NOT NULL DEFAULT '{}',
is_enabled boolean NOT NULL DEFAULT true,
is_required boolean NOT NULL DEFAULT false,
is_system boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, step_key)
);
CREATE INDEX IF NOT EXISTS idx_onboarding_steps_space ON public.space_onboarding_steps (space_id, sort_order);
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS onboarding_completed_at timestamptz;
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS onboarding_progress jsonb NOT NULL DEFAULT '{}';
CREATE OR REPLACE FUNCTION public.seed_default_onboarding_steps()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_onboarding_steps
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
VALUES
(NEW.id, 'welcome', 'welcome', 'Welcome to ' || NEW.name,
E'We are glad you are here.\n\nThis short setup gets you ready to use the space. It takes about a minute.',
'{}'::jsonb, true, false, true, 0),
(NEW.id, 'code_of_conduct', 'code_of_conduct', 'Code of Conduct',
E'Be excellent to each other.\n\n- Treat people and tools with respect.\n- Clean up after yourself.\n- Ask before using equipment you have not been trained on.',
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}'::jsonb, true, true, true, 1),
(NEW.id, 'profile', 'profile', 'Complete your profile',
E'Tell the space who you are. You can change this anytime from your profile.',
'{}'::jsonb, true, false, true, 2),
(NEW.id, 'payment', 'payment', 'Set up your dues',
E'Membership dues keep the space running. Set up your recurring payment now so you do not lose access.',
'{}'::jsonb, true, false, true, 3)
ON CONFLICT (space_id, step_key) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_onboarding_steps ON public.spaces;
CREATE TRIGGER trg_seed_default_onboarding_steps
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_onboarding_steps();
ALTER TABLE public.space_onboarding_steps ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS onboarding_steps_select ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_insert ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_update ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_delete ON public.space_onboarding_steps;
CREATE POLICY onboarding_steps_select ON public.space_onboarding_steps FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY onboarding_steps_insert ON public.space_onboarding_steps FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY onboarding_steps_update ON public.space_onboarding_steps FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY onboarding_steps_delete ON public.space_onboarding_steps FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) AND NOT is_system);
DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_steps;
CREATE TRIGGER trg_onboarding_steps_touch
BEFORE UPDATE ON public.space_onboarding_steps
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();