this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add organization workspaces

+2067 -111
+83 -2
app/(creator)/actions.ts
··· 4 4 5 5 import { getServerAuthSession } from "@/lib/auth"; 6 6 import { createDraftForm } from "@/lib/forms"; 7 + import { 8 + createOrganization, 9 + createOrganizationInviteLink, 10 + deleteOrganization, 11 + removeOrganizationMember, 12 + renameOrganization, 13 + revokeOrganizationInviteLink, 14 + } from "@/lib/organizations"; 15 + import { parseWorkspaceValue, setActiveWorkspaceCookie, getActiveWorkspaceForUser } from "@/lib/workspaces"; 7 16 8 - export async function createFormAction() { 17 + async function requireSessionUser() { 9 18 const session = await getServerAuthSession(); 10 19 11 20 if (!session?.user?.id) { 12 21 redirect("/"); 13 22 } 14 23 15 - const form = await createDraftForm(session.user.id); 24 + return session.user.id; 25 + } 26 + 27 + function settingsRedirectTarget(formData: FormData, fallbackOrganizationId?: string | null) { 28 + const rawValue = String(formData.get("returnOrganizationId") ?? fallbackOrganizationId ?? "").trim(); 29 + return rawValue ? `/settings?organization=${encodeURIComponent(rawValue)}` : "/settings"; 30 + } 31 + 32 + export async function createFormAction() { 33 + const userId = await requireSessionUser(); 34 + const { activeWorkspace } = await getActiveWorkspaceForUser(userId); 35 + const form = await createDraftForm(userId, activeWorkspace); 36 + 16 37 redirect(`/forms/${form.id}/edit`); 17 38 } 39 + 40 + export async function setActiveWorkspaceAction(formData: FormData) { 41 + const userId = await requireSessionUser(); 42 + const { options } = await getActiveWorkspaceForUser(userId); 43 + const nextValue = String(formData.get("workspace") ?? "personal"); 44 + const isValidOption = options.some((option) => option.value === nextValue); 45 + 46 + await setActiveWorkspaceCookie(isValidOption ? parseWorkspaceValue(nextValue) : { kind: "personal" }); 47 + } 48 + 49 + export async function createOrganizationAction(formData: FormData) { 50 + const userId = await requireSessionUser(); 51 + const organization = await createOrganization(userId, { name: formData.get("name") }); 52 + 53 + await setActiveWorkspaceCookie({ kind: "organization", organizationId: organization.id }); 54 + redirect(settingsRedirectTarget(formData, organization.id)); 55 + } 56 + 57 + export async function renameOrganizationAction(formData: FormData) { 58 + const userId = await requireSessionUser(); 59 + const organizationId = String(formData.get("organizationId") ?? ""); 60 + await renameOrganization(userId, organizationId, { name: formData.get("name") }); 61 + redirect(settingsRedirectTarget(formData, organizationId)); 62 + } 63 + 64 + export async function deleteOrganizationAction(formData: FormData) { 65 + const userId = await requireSessionUser(); 66 + await deleteOrganization(userId, String(formData.get("organizationId") ?? "")); 67 + await setActiveWorkspaceCookie({ kind: "personal" }); 68 + redirect("/settings"); 69 + } 70 + 71 + export async function createOrganizationInviteLinkAction(formData: FormData) { 72 + const userId = await requireSessionUser(); 73 + const organizationId = String(formData.get("organizationId") ?? ""); 74 + await createOrganizationInviteLink(userId, organizationId); 75 + redirect(settingsRedirectTarget(formData, organizationId)); 76 + } 77 + 78 + export async function revokeOrganizationInviteLinkAction(formData: FormData) { 79 + const userId = await requireSessionUser(); 80 + const organizationId = String(formData.get("organizationId") ?? ""); 81 + await revokeOrganizationInviteLink( 82 + userId, 83 + organizationId, 84 + String(formData.get("inviteLinkId") ?? ""), 85 + ); 86 + redirect(settingsRedirectTarget(formData, organizationId)); 87 + } 88 + 89 + export async function removeOrganizationMemberAction(formData: FormData) { 90 + const userId = await requireSessionUser(); 91 + const organizationId = String(formData.get("organizationId") ?? ""); 92 + await removeOrganizationMember( 93 + userId, 94 + organizationId, 95 + String(formData.get("memberUserId") ?? ""), 96 + ); 97 + redirect(settingsRedirectTarget(formData, organizationId)); 98 + }
+9 -6
app/(creator)/dashboard/page.tsx
··· 5 5 import { EmptyState } from "@/components/empty-state"; 6 6 import { Button } from "@/components/ui/button"; 7 7 import { getServerAuthSession } from "@/lib/auth"; 8 - import { listFormsForOwner } from "@/lib/forms"; 8 + import { listFormsForWorkspace } from "@/lib/forms"; 9 + import { getActiveWorkspaceForUser } from "@/lib/workspaces"; 9 10 10 11 export default async function DashboardPage() { 11 12 const session = await getServerAuthSession(); 12 - const forms = await listFormsForOwner(session!.user.id); 13 + const workspaceState = await getActiveWorkspaceForUser(session!.user.id); 14 + const forms = await listFormsForWorkspace(session!.user.id, workspaceState.activeWorkspace); 15 + const workspaceLabel = workspaceState.activeOption.label; 13 16 14 17 return forms.length === 0 ? ( 15 18 <div className="space-y-8"> 16 19 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-6 lg:flex-row lg:items-end lg:justify-between"> 17 20 <div className="space-y-2"> 18 21 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Dashboard</p> 19 - <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">Your forms</h1> 22 + <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">{workspaceLabel}</h1> 20 23 <p className="max-w-2xl text-sm leading-6 text-[var(--muted)]"> 21 - Create a form, open a draft, or review responses. 24 + Create a form, open a draft, or review responses in the active workspace. 22 25 </p> 23 26 </div> 24 27 <form action={createFormAction}> ··· 31 34 32 35 <EmptyState 33 36 eyebrow="No forms yet" 34 - title="Create your first form" 37 + title={`Create the first form in ${workspaceLabel}`} 35 38 description="New forms start as drafts so you can edit before publishing." 36 39 action={ 37 40 <form action={createFormAction}> ··· 44 47 /> 45 48 </div> 46 49 ) : ( 47 - <DashboardFormBrowser forms={forms} /> 50 + <DashboardFormBrowser forms={forms} workspaceLabel={workspaceLabel} /> 48 51 ); 49 52 }
+1
app/(creator)/forms/[id]/responses/[responseId]/page.tsx
··· 35 35 <div> 36 36 <p className="text-xs font-semibold uppercase tracking-[0.28em] text-[var(--accent)]">Submission replay</p> 37 37 <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Submission #{response.submissionNumber} · {formatDate(response.submittedAt)}</h1> 38 + <p className="mt-2 text-xs font-medium uppercase tracking-[0.18em] text-[var(--muted)]">{response.workspace.label}</p> 38 39 <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Review submitted answers in form order.</p> 39 40 </div> 40 41 <Link href={`/forms/${id}/responses`}>
+1
app/(creator)/forms/[id]/responses/page.tsx
··· 37 37 <div> 38 38 <p className="text-xs font-semibold uppercase tracking-[0.28em] text-[var(--accent)]">Responses</p> 39 39 <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">{form.title}</h1> 40 + <p className="mt-2 text-xs font-medium uppercase tracking-[0.18em] text-[var(--muted)]">{form.workspace.label}</p> 40 41 <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Review anonymous submissions in form order.</p> 41 42 </div> 42 43 <div className="flex flex-wrap gap-3 lg:justify-end">
+3 -1
app/(creator)/forms/new/route.ts
··· 2 2 3 3 import { getServerAuthSession } from "@/lib/auth"; 4 4 import { createDraftForm } from "@/lib/forms"; 5 + import { getActiveWorkspaceForUser } from "@/lib/workspaces"; 5 6 6 7 export async function GET() { 7 8 const session = await getServerAuthSession(); ··· 10 11 redirect("/"); 11 12 } 12 13 13 - const form = await createDraftForm(session.user.id); 14 + const { activeWorkspace } = await getActiveWorkspaceForUser(session.user.id); 15 + const form = await createDraftForm(session.user.id, activeWorkspace); 14 16 redirect(`/forms/${form.id}/edit`); 15 17 }
+9 -1
app/(creator)/layout.tsx
··· 3 3 import { redirect } from "next/navigation"; 4 4 5 5 import { SignOutButton } from "@/components/auth/sign-out-button"; 6 + import { WorkspaceSwitcher } from "@/components/workspace-switcher"; 6 7 import { getServerAuthSession } from "@/lib/auth"; 8 + import { getActiveWorkspaceForUser, serializeWorkspaceValue } from "@/lib/workspaces"; 7 9 8 10 export default async function CreatorLayout({ children }: { children: React.ReactNode }) { 9 11 const session = await getServerAuthSession(); ··· 11 13 if (!session?.user?.id) { 12 14 redirect("/"); 13 15 } 16 + 17 + const workspaceState = await getActiveWorkspaceForUser(session.user.id); 14 18 15 19 return ( 16 20 <main className="mx-auto min-h-screen w-full max-w-7xl px-6 py-8 lg:px-10"> ··· 27 31 Lively Forms 28 32 </Link> 29 33 </div> 30 - <div className="flex items-center gap-5"> 34 + <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:gap-5"> 31 35 <Link href="/dashboard" className="text-sm font-medium text-[var(--muted)] transition hover:text-[var(--ink)]"> 32 36 Dashboard 33 37 </Link> 34 38 <Link href="/settings" className="text-sm font-medium text-[var(--muted)] transition hover:text-[var(--ink)]"> 35 39 Settings 36 40 </Link> 41 + <WorkspaceSwitcher 42 + options={workspaceState.options} 43 + activeValue={serializeWorkspaceValue(workspaceState.activeWorkspace)} 44 + /> 37 45 <SignOutButton /> 38 46 </div> 39 47 </header>
+16 -3
app/(creator)/settings/page.tsx
··· 1 - import { ThemeSettingsPanel } from "@/components/theme-settings-panel"; 1 + import { SettingsShell } from "@/components/settings-shell"; 2 + import { getServerAuthSession } from "@/lib/auth"; 3 + import { listOrganizationsForUser } from "@/lib/organizations"; 4 + 5 + export default async function SettingsPage({ 6 + searchParams, 7 + }: { 8 + searchParams: Promise<{ organization?: string | string[] }>; 9 + }) { 10 + const session = await getServerAuthSession(); 11 + const organizations = await listOrganizationsForUser(session!.user.id); 12 + const resolvedSearchParams = await searchParams; 13 + const initialOrganizationId = Array.isArray(resolvedSearchParams.organization) 14 + ? resolvedSearchParams.organization[0] ?? null 15 + : resolvedSearchParams.organization ?? null; 2 16 3 - export default function SettingsPage() { 4 - return <ThemeSettingsPanel />; 17 + return <SettingsShell organizations={organizations} initialOrganizationId={initialOrganizationId} />; 5 18 }
+66
app/join/[token]/page.tsx
··· 1 + import Link from "next/link"; 2 + import { redirect } from "next/navigation"; 3 + 4 + import { acceptOrganizationInviteAction } from "@/app/join/actions"; 5 + import { Button } from "@/components/ui/button"; 6 + import { Card } from "@/components/ui/card"; 7 + import { getServerAuthSession } from "@/lib/auth"; 8 + import { getOrganizationInviteState } from "@/lib/organizations"; 9 + 10 + export default async function JoinOrganizationPage({ params }: { params: Promise<{ token: string }> }) { 11 + const session = await getServerAuthSession(); 12 + 13 + if (!session?.user?.id) { 14 + redirect("/"); 15 + } 16 + 17 + const { token } = await params; 18 + const invite = await getOrganizationInviteState(session.user.id, token); 19 + 20 + return ( 21 + <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-6 py-10 lg:px-10"> 22 + <Card className="w-full p-8 lg:p-10"> 23 + {invite ? ( 24 + <> 25 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Organization invite</p> 26 + <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Join {invite.organizationName}</h1> 27 + <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 28 + {invite.alreadyMember 29 + ? "You already belong to this organization. You can switch to it from the workspace selector." 30 + : "Accept this invite to collaborate in the organization workspace."} 31 + </p> 32 + 33 + <div className="mt-8 flex flex-wrap gap-3"> 34 + {invite.alreadyMember ? ( 35 + <Link href="/dashboard"> 36 + <Button>Open dashboard</Button> 37 + </Link> 38 + ) : ( 39 + <form action={acceptOrganizationInviteAction}> 40 + <input type="hidden" name="token" value={token} /> 41 + <Button type="submit">Join organization</Button> 42 + </form> 43 + )} 44 + <Link href="/dashboard"> 45 + <Button variant="secondary">Cancel</Button> 46 + </Link> 47 + </div> 48 + </> 49 + ) : ( 50 + <> 51 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Organization invite</p> 52 + <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Invite not available</h1> 53 + <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 54 + This invite link is invalid or has been revoked. 55 + </p> 56 + <div className="mt-8"> 57 + <Link href="/dashboard"> 58 + <Button>Back to dashboard</Button> 59 + </Link> 60 + </div> 61 + </> 62 + )} 63 + </Card> 64 + </main> 65 + ); 66 + }
+21
app/join/actions.ts
··· 1 + "use server"; 2 + 3 + import { redirect } from "next/navigation"; 4 + 5 + import { getServerAuthSession } from "@/lib/auth"; 6 + import { acceptOrganizationInvite } from "@/lib/organizations"; 7 + import { setActiveWorkspaceCookie } from "@/lib/workspaces"; 8 + 9 + export async function acceptOrganizationInviteAction(formData: FormData) { 10 + const session = await getServerAuthSession(); 11 + 12 + if (!session?.user?.id) { 13 + redirect("/"); 14 + } 15 + 16 + const token = String(formData.get("token") ?? ""); 17 + const { organization } = await acceptOrganizationInvite(session.user.id, token); 18 + 19 + await setActiveWorkspaceCookie({ kind: "organization", organizationId: organization.id }); 20 + redirect("/dashboard"); 21 + }
+77
bun.lock
··· 10 10 "@dnd-kit/sortable": "^10.0.0", 11 11 "@dnd-kit/utilities": "^3.2.2", 12 12 "@prisma/client": "6", 13 + "@radix-ui/react-select": "^2.2.6", 13 14 "class-variance-authority": "^0.7.1", 14 15 "clsx": "^2.1.1", 15 16 "framer-motion": "^12.38.0", ··· 107 108 "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], 108 109 109 110 "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], 111 + 112 + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], 113 + 114 + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], 115 + 116 + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], 117 + 118 + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], 110 119 111 120 "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], 112 121 ··· 222 231 223 232 "@prisma/get-platform": ["@prisma/get-platform@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3" } }, "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA=="], 224 233 234 + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], 235 + 236 + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], 237 + 238 + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], 239 + 240 + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], 241 + 242 + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], 243 + 244 + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], 245 + 246 + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], 247 + 248 + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], 249 + 250 + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], 251 + 252 + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], 253 + 254 + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], 255 + 256 + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], 257 + 258 + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], 259 + 260 + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], 261 + 262 + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], 263 + 264 + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 265 + 266 + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], 267 + 268 + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], 269 + 270 + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], 271 + 272 + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], 273 + 274 + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], 275 + 276 + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], 277 + 278 + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], 279 + 280 + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], 281 + 282 + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], 283 + 284 + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], 285 + 225 286 "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], 226 287 227 288 "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], ··· 341 402 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 342 403 343 404 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 405 + 406 + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], 344 407 345 408 "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], 346 409 ··· 452 515 453 516 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 454 517 518 + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 519 + 455 520 "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], 456 521 457 522 "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], ··· 563 628 "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], 564 629 565 630 "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], 631 + 632 + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], 566 633 567 634 "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 568 635 ··· 848 915 849 916 "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], 850 917 918 + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], 919 + 920 + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], 921 + 922 + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], 923 + 851 924 "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], 852 925 853 926 "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], ··· 965 1038 "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], 966 1039 967 1040 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 1041 + 1042 + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], 1043 + 1044 + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], 968 1045 969 1046 "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], 970 1047
+1
components/auth/sign-out-button.tsx
··· 13 13 <Button 14 14 size="sm" 15 15 variant="secondary" 16 + className="shrink-0 whitespace-nowrap" 16 17 onClick={() => 17 18 startTransition(() => { 18 19 void signOut({ callbackUrl: "/" });
+3 -3
components/dashboard-form-browser.tsx
··· 25 25 return left.localeCompare(right, undefined, { sensitivity: "base" }); 26 26 } 27 27 28 - export function DashboardFormBrowser({ forms }: { forms: FormListItem[] }) { 28 + export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormListItem[]; workspaceLabel: string }) { 29 29 const router = useRouter(); 30 30 const pathname = usePathname(); 31 31 const searchParams = useSearchParams(); ··· 108 108 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-6 lg:flex-row lg:items-end lg:justify-between"> 109 109 <div className="space-y-2"> 110 110 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Dashboard</p> 111 - <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">Your forms</h1> 112 - <p className="max-w-2xl text-sm leading-6 text-[var(--muted)]">Create a form, open a draft, or review responses.</p> 111 + <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">{workspaceLabel}</h1> 112 + <p className="max-w-2xl text-sm leading-6 text-[var(--muted)]">Create a form, open a draft, or review responses in the active workspace.</p> 113 113 </div> 114 114 <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> 115 115 <div className="inline-flex h-12 items-center gap-1 rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] p-1">
+1
components/form-builder-panels.tsx
··· 48 48 <h1 className="font-display text-4xl text-[var(--ink)]">{form.title}</h1> 49 49 <FormStatusBadge status={form.status} /> 50 50 </div> 51 + <p className="mt-2 text-xs font-medium uppercase tracking-[0.18em] text-[var(--muted)]">{form.workspace.label}</p> 51 52 <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Edit blocks, update settings, and review responses.</p> 52 53 </div> 53 54 <div className="flex flex-wrap items-center gap-3 lg:justify-end">
+238
components/organization-settings-panel.tsx
··· 1 + "use client"; 2 + 3 + import { Link2, Trash2 } from "lucide-react"; 4 + import { usePathname, useRouter, useSearchParams } from "next/navigation"; 5 + import { useMemo, useState } from "react"; 6 + 7 + import { 8 + createOrganizationAction, 9 + createOrganizationInviteLinkAction, 10 + deleteOrganizationAction, 11 + removeOrganizationMemberAction, 12 + renameOrganizationAction, 13 + revokeOrganizationInviteLinkAction, 14 + } from "@/app/(creator)/actions"; 15 + import { Badge } from "@/components/ui/badge"; 16 + import { Button } from "@/components/ui/button"; 17 + import { Input } from "@/components/ui/input"; 18 + import { ToastViewport, type ToastData } from "@/components/ui/toast"; 19 + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 20 + import type { OrganizationSettingsSummary } from "@/lib/form-types"; 21 + import { formatDate } from "@/lib/utils"; 22 + 23 + export function OrganizationSettingsPanel({ 24 + organizations, 25 + initialOrganizationId, 26 + }: { 27 + organizations: OrganizationSettingsSummary[]; 28 + initialOrganizationId: string | null; 29 + }) { 30 + const router = useRouter(); 31 + const pathname = usePathname(); 32 + const searchParams = useSearchParams(); 33 + const [selectedOrganizationId, setSelectedOrganizationId] = useState<string | null>(initialOrganizationId ?? organizations[0]?.id ?? null); 34 + const [toasts, setToasts] = useState<ToastData[]>([]); 35 + const selectedOrganization = useMemo( 36 + () => organizations.find((organization) => organization.id === selectedOrganizationId) ?? organizations[0] ?? null, 37 + [organizations, selectedOrganizationId], 38 + ); 39 + 40 + function showToast(message: string, variant: ToastData["variant"] = "success") { 41 + const id = crypto.randomUUID(); 42 + setToasts((current) => [...current, { id, message, variant }]); 43 + } 44 + 45 + function dismissToast(id: string) { 46 + setToasts((current) => current.filter((toast) => toast.id !== id)); 47 + } 48 + 49 + async function copyInviteLink(token: string) { 50 + try { 51 + const url = `${window.location.origin}/join/${token}`; 52 + await navigator.clipboard.writeText(url); 53 + showToast("Copied invite link."); 54 + } catch { 55 + showToast("Could not copy invite link.", "error"); 56 + } 57 + } 58 + 59 + return ( 60 + <> 61 + <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 62 + <div> 63 + <div> 64 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Organizations</p> 65 + <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Workspace settings</h1> 66 + <p className="mt-3 max-w-3xl text-sm leading-6 text-[var(--muted)]"> 67 + Create shared workspaces, manage members, and issue invite links. Inside an organization, every member can work with forms. Only the owner can manage the organization itself. 68 + </p> 69 + </div> 70 + 71 + <div className="mt-8 border-t border-[color:var(--line)] pt-8"> 72 + <form action={createOrganizationAction} className="flex flex-col gap-3 lg:flex-row lg:items-end"> 73 + <input type="hidden" name="returnOrganizationId" value={selectedOrganizationId ?? ""} /> 74 + <label className="flex-1 space-y-2 text-sm text-[var(--muted)]"> 75 + <span className="font-medium text-[var(--ink)]">New organization name</span> 76 + <Input name="name" placeholder="Acme Studio" /> 77 + </label> 78 + <Button type="submit">Create organization</Button> 79 + </form> 80 + </div> 81 + 82 + <div className="mt-8 space-y-5"> 83 + {organizations.length === 0 ? ( 84 + <div className="border-t border-[color:var(--line)] py-6 text-sm text-[var(--muted)]"> 85 + You are not part of any organizations yet. 86 + </div> 87 + ) : null} 88 + 89 + {selectedOrganization ? ( 90 + <> 91 + <div className="max-w-md border-t border-[color:var(--line)] pt-8"> 92 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Organization</p> 93 + <p className="mt-2 text-sm leading-6 text-[var(--muted)]">The settings below apply to the selected organization.</p> 94 + <div className="mt-3"> 95 + <Select 96 + value={selectedOrganization.id} 97 + onValueChange={(value) => { 98 + setSelectedOrganizationId(value); 99 + 100 + const nextParams = new URLSearchParams(searchParams.toString()); 101 + nextParams.set("organization", value); 102 + router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }); 103 + }} 104 + > 105 + <SelectTrigger className="min-w-[240px]"> 106 + <SelectValue /> 107 + </SelectTrigger> 108 + <SelectContent> 109 + {organizations.map((organization) => ( 110 + <SelectItem key={organization.id} value={organization.id}> 111 + {organization.name} 112 + {organization.isOwner ? " · Owner" : ""} 113 + </SelectItem> 114 + ))} 115 + </SelectContent> 116 + </Select> 117 + </div> 118 + </div> 119 + 120 + <div className="border-t border-[color:var(--line)] pt-8"> 121 + <div className="border-b border-[color:var(--line)] pb-5"> 122 + <div className="flex flex-wrap items-center gap-3"> 123 + <h2 className="font-display text-3xl text-[var(--ink)]">{selectedOrganization.name}</h2> 124 + <Badge className="rounded-full">{selectedOrganization.isOwner ? "Owner" : "Member"}</Badge> 125 + </div> 126 + 127 + {selectedOrganization.isOwner ? ( 128 + <form key={selectedOrganization.id} action={renameOrganizationAction} className="mt-5 flex max-w-xl flex-col gap-3 sm:flex-row sm:items-center"> 129 + <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 130 + <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 131 + <div className="flex-1"> 132 + <Input name="name" defaultValue={selectedOrganization.name} /> 133 + </div> 134 + <Button type="submit" variant="secondary"> 135 + Rename organization 136 + </Button> 137 + </form> 138 + ) : null} 139 + </div> 140 + 141 + <div className="mt-6 space-y-8"> 142 + <div> 143 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Members</p> 144 + <div className="mt-4 space-y-4"> 145 + {selectedOrganization.members.map((member) => ( 146 + <div key={member.userId} className="flex flex-col gap-3 py-1 sm:flex-row sm:items-center sm:justify-between"> 147 + <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> 148 + <p className="font-medium text-[var(--ink)]">{member.name || member.email || "Unnamed member"}</p> 149 + {member.email ? <p className="text-sm text-[var(--muted)]">{member.email}</p> : null} 150 + <Badge className="rounded-full">{member.role === "OWNER" ? "Owner" : "Member"}</Badge> 151 + </div> 152 + {selectedOrganization.isOwner && member.role !== "OWNER" ? ( 153 + <form action={removeOrganizationMemberAction}> 154 + <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 155 + <input type="hidden" name="memberUserId" value={member.userId} /> 156 + <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 157 + <Button variant="secondary" size="sm" type="submit"> 158 + Remove 159 + </Button> 160 + </form> 161 + ) : null} 162 + </div> 163 + ))} 164 + </div> 165 + </div> 166 + 167 + <div className="border-t border-[color:var(--line)] pt-8"> 168 + <div className="flex items-center justify-between gap-3"> 169 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Invite links</p> 170 + {selectedOrganization.isOwner ? ( 171 + <form action={createOrganizationInviteLinkAction}> 172 + <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 173 + <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 174 + <Button variant="secondary" size="sm" type="submit"> 175 + <Link2 className="size-4" /> 176 + New invite link 177 + </Button> 178 + </form> 179 + ) : null} 180 + </div> 181 + <div className="mt-4 space-y-4"> 182 + {selectedOrganization.inviteLinks.length === 0 ? ( 183 + <div className="py-4 text-sm text-[var(--muted)]"> 184 + No invite links yet. 185 + </div> 186 + ) : null} 187 + {selectedOrganization.inviteLinks.map((inviteLink) => { 188 + return ( 189 + <div key={inviteLink.id} className="flex flex-col gap-3 py-1 sm:flex-row sm:items-center sm:justify-between"> 190 + <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> 191 + <button 192 + type="button" 193 + onClick={() => copyInviteLink(inviteLink.token)} 194 + className="truncate font-mono text-[13px] text-[var(--ink)] transition hover:text-[var(--accent)]" 195 + > 196 + {`/join/${inviteLink.token}`} 197 + </button> 198 + <p className="text-xs text-[var(--muted)]">Created {formatDate(inviteLink.createdAt)}</p> 199 + </div> 200 + {selectedOrganization.isOwner ? ( 201 + <form action={revokeOrganizationInviteLinkAction}> 202 + <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 203 + <input type="hidden" name="inviteLinkId" value={inviteLink.id} /> 204 + <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 205 + <Button variant="secondary" size="sm" type="submit"> 206 + Revoke 207 + </Button> 208 + </form> 209 + ) : null} 210 + </div> 211 + ); 212 + })} 213 + </div> 214 + </div> 215 + 216 + {selectedOrganization.isOwner ? ( 217 + <div className="border-t border-[color:var(--line)] pt-8"> 218 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Danger zone</p> 219 + <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Deleting an organization also deletes its forms, responses, memberships, and invite links.</p> 220 + <form action={deleteOrganizationAction} className="mt-4"> 221 + <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 222 + <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 223 + <Button variant="danger" type="submit"> 224 + <Trash2 className="size-4" /> 225 + Delete organization 226 + </Button> 227 + </form> 228 + </div> 229 + ) : null} 230 + </div> 231 + </div> 232 + </> 233 + ) : null} 234 + </div> 235 + </div> 236 + </> 237 + ); 238 + }
+91
components/settings-shell.tsx
··· 1 + "use client"; 2 + 3 + import { Building2, Palette } from "lucide-react"; 4 + import { useState } from "react"; 5 + 6 + import { OrganizationSettingsPanel } from "@/components/organization-settings-panel"; 7 + import { ThemeSettingsPanel } from "@/components/theme-settings-panel"; 8 + import { cn } from "@/lib/utils"; 9 + import type { OrganizationSettingsSummary } from "@/lib/form-types"; 10 + 11 + type SettingsSection = "organizations" | "appearance"; 12 + 13 + const sectionMeta: Record<SettingsSection, { title: string; icon: typeof Building2; description: string }> = { 14 + organizations: { 15 + title: "Organizations", 16 + icon: Building2, 17 + description: "Members, invites, and owner controls.", 18 + }, 19 + appearance: { 20 + title: "Appearance", 21 + icon: Palette, 22 + description: "Theme and visual preferences.", 23 + }, 24 + }; 25 + 26 + export function SettingsShell({ 27 + organizations, 28 + initialOrganizationId, 29 + }: { 30 + organizations: OrganizationSettingsSummary[]; 31 + initialOrganizationId: string | null; 32 + }) { 33 + const [selection, setSelection] = useState<SettingsSection>("organizations"); 34 + 35 + return ( 36 + <div className="space-y-6"> 37 + <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 38 + <div> 39 + <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Settings</p> 40 + <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Workspace and site preferences</h1> 41 + <p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 42 + Manage shared workspaces and choose how Lively Forms looks across creator pages and public forms. 43 + </p> 44 + </div> 45 + </section> 46 + 47 + <div className="grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)] lg:items-start"> 48 + <div className="space-y-2 border-b border-[color:var(--line)] pb-6 lg:border-b-0 lg:border-r lg:pb-0 lg:pr-8"> 49 + {(Object.keys(sectionMeta) as SettingsSection[]).map((section) => { 50 + const meta = sectionMeta[section]; 51 + const Icon = meta.icon; 52 + const selected = selection === section; 53 + 54 + return ( 55 + <button 56 + key={section} 57 + type="button" 58 + onClick={() => setSelection(section)} 59 + className={cn( 60 + "group flex w-full items-center gap-3 rounded-[16px] px-3 py-3 text-left transition", 61 + selected 62 + ? "bg-[var(--surface-strong)] text-[var(--ink)]" 63 + : "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--ink)]", 64 + )} 65 + > 66 + <div className={cn( 67 + "rounded-full p-1.5", 68 + selected ? "bg-[var(--accent-soft)] text-[var(--accent-ink)]" : "bg-[var(--bg-strong)] text-[var(--muted)]", 69 + )}> 70 + <Icon className="size-3.5" /> 71 + </div> 72 + <div className="min-w-0 flex-1"> 73 + <p className="text-sm font-medium">{meta.title}</p> 74 + <p className="mt-1 truncate text-xs opacity-80">{meta.description}</p> 75 + </div> 76 + </button> 77 + ); 78 + })} 79 + </div> 80 + 81 + <div className="min-w-0"> 82 + {selection === "organizations" ? ( 83 + <OrganizationSettingsPanel organizations={organizations} initialOrganizationId={initialOrganizationId} /> 84 + ) : ( 85 + <ThemeSettingsPanel /> 86 + )} 87 + </div> 88 + </div> 89 + </div> 90 + ); 91 + }
+4 -13
components/theme-settings-panel.tsx
··· 2 2 3 3 import { LaptopMinimal, MoonStar, SunMedium } from "lucide-react"; 4 4 5 - import { Card } from "@/components/ui/card"; 6 5 import { useThemePreference } from "@/components/theme-provider"; 7 - import { cn, sentenceCase } from "@/lib/utils"; 6 + import { cn } from "@/lib/utils"; 8 7 import type { ThemePreference } from "@/lib/theme"; 9 8 10 9 const options: Array<{ ··· 34 33 ]; 35 34 36 35 export function ThemeSettingsPanel() { 37 - const { preference, resolvedTheme, setPreference } = useThemePreference(); 36 + const { preference, setPreference } = useThemePreference(); 38 37 39 38 return ( 40 - <Card className="p-6 lg:p-8"> 39 + <div> 41 40 <div> 42 41 <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Appearance</p> 43 42 <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Theme settings</h1> ··· 78 77 })} 79 78 </div> 80 79 81 - <div className="mt-8 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-5 py-4"> 82 - <p className="text-sm text-[var(--muted)]"> 83 - Selected mode: <span className="font-medium text-[var(--ink)]">{sentenceCase(preference)}</span> 84 - </p> 85 - <p className="mt-2 text-sm text-[var(--muted)]"> 86 - Active theme right now: <span className="font-medium text-[var(--ink)]">{sentenceCase(resolvedTheme)}</span> 87 - </p> 88 - </div> 89 - </Card> 80 + </div> 90 81 ); 91 82 }
+136
components/ui/select.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import * as SelectPrimitive from "@radix-ui/react-select"; 5 + import { Check, ChevronDown, ChevronUp } from "lucide-react"; 6 + 7 + import { cn } from "@/lib/utils"; 8 + 9 + function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { 10 + return <SelectPrimitive.Root data-slot="select" {...props} />; 11 + } 12 + 13 + function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { 14 + return <SelectPrimitive.Group data-slot="select-group" {...props} />; 15 + } 16 + 17 + function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { 18 + return <SelectPrimitive.Value data-slot="select-value" {...props} />; 19 + } 20 + 21 + function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) { 22 + return ( 23 + <SelectPrimitive.Trigger 24 + data-slot="select-trigger" 25 + className={cn( 26 + "flex h-9 w-full items-center justify-between gap-2 rounded-xl bg-[var(--surface-strong)] pl-3 pr-2 text-xs font-semibold text-[var(--ink)] ring-1 ring-[color:var(--line)] outline-none transition focus:ring-2 focus:ring-[var(--ink)] focus:ring-offset-2 focus:ring-offset-[var(--bg)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", 27 + className, 28 + )} 29 + {...props} 30 + > 31 + {children} 32 + <SelectPrimitive.Icon asChild> 33 + <ChevronDown className="size-4 shrink-0 text-[var(--muted)]" /> 34 + </SelectPrimitive.Icon> 35 + </SelectPrimitive.Trigger> 36 + ); 37 + } 38 + 39 + function SelectContent({ className, children, position = "popper", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) { 40 + return ( 41 + <SelectPrimitive.Portal> 42 + <SelectPrimitive.Content 43 + data-slot="select-content" 44 + className={cn( 45 + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-[color:var(--line)] bg-[var(--bg-strong)] text-[var(--ink)] shadow-[var(--shadow-card)]", 46 + position === "popper" && 47 + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", 48 + className, 49 + )} 50 + position={position} 51 + {...props} 52 + > 53 + <SelectScrollUpButton /> 54 + <SelectPrimitive.Viewport 55 + className={cn( 56 + "p-1", 57 + position === "popper" && 58 + "h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]", 59 + )} 60 + > 61 + {children} 62 + </SelectPrimitive.Viewport> 63 + <SelectScrollDownButton /> 64 + </SelectPrimitive.Content> 65 + </SelectPrimitive.Portal> 66 + ); 67 + } 68 + 69 + function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { 70 + return ( 71 + <SelectPrimitive.Label 72 + data-slot="select-label" 73 + className={cn("px-2 py-1.5 text-xs font-semibold text-[var(--muted)]", className)} 74 + {...props} 75 + /> 76 + ); 77 + } 78 + 79 + function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { 80 + return ( 81 + <SelectPrimitive.Item 82 + data-slot="select-item" 83 + className={cn( 84 + "relative flex w-full cursor-default items-center gap-2 rounded-lg py-2 pl-8 pr-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-[var(--accent-soft)] data-[highlighted]:text-[var(--ink)]", 85 + className, 86 + )} 87 + {...props} 88 + > 89 + <span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center"> 90 + <SelectPrimitive.ItemIndicator> 91 + <Check className="size-4" /> 92 + </SelectPrimitive.ItemIndicator> 93 + </span> 94 + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> 95 + </SelectPrimitive.Item> 96 + ); 97 + } 98 + 99 + function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { 100 + return <SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-[var(--line)]", className)} {...props} />; 101 + } 102 + 103 + function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { 104 + return ( 105 + <SelectPrimitive.ScrollUpButton 106 + className={cn("flex cursor-default items-center justify-center py-1 text-[var(--muted)]", className)} 107 + {...props} 108 + > 109 + <ChevronUp className="size-4" /> 110 + </SelectPrimitive.ScrollUpButton> 111 + ); 112 + } 113 + 114 + function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { 115 + return ( 116 + <SelectPrimitive.ScrollDownButton 117 + className={cn("flex cursor-default items-center justify-center py-1 text-[var(--muted)]", className)} 118 + {...props} 119 + > 120 + <ChevronDown className="size-4" /> 121 + </SelectPrimitive.ScrollDownButton> 122 + ); 123 + } 124 + 125 + export { 126 + Select, 127 + SelectContent, 128 + SelectGroup, 129 + SelectItem, 130 + SelectLabel, 131 + SelectScrollDownButton, 132 + SelectScrollUpButton, 133 + SelectSeparator, 134 + SelectTrigger, 135 + SelectValue, 136 + };
+61
components/workspace-switcher.tsx
··· 1 + "use client"; 2 + 3 + import { useRouter } from "next/navigation"; 4 + import { useTransition } from "react"; 5 + 6 + import { setActiveWorkspaceAction } from "@/app/(creator)/actions"; 7 + import { 8 + Select, 9 + SelectContent, 10 + SelectItem, 11 + SelectTrigger, 12 + SelectValue, 13 + } from "@/components/ui/select"; 14 + import { cn } from "@/lib/utils"; 15 + import type { CreatorWorkspaceOption } from "@/lib/form-types"; 16 + 17 + export function WorkspaceSwitcher({ 18 + options, 19 + activeValue, 20 + }: { 21 + options: CreatorWorkspaceOption[]; 22 + activeValue: string; 23 + }) { 24 + const router = useRouter(); 25 + const [isPending, startTransition] = useTransition(); 26 + const selectKey = options 27 + .map((option) => `${option.value}:${option.label}:${option.kind === "organization" && option.isOwner ? "owner" : "member"}`) 28 + .join("|"); 29 + 30 + return ( 31 + <Select 32 + key={selectKey} 33 + value={activeValue} 34 + disabled={isPending} 35 + onValueChange={(value) => { 36 + const formData = new FormData(); 37 + formData.set("workspace", value); 38 + 39 + startTransition(async () => { 40 + await setActiveWorkspaceAction(formData); 41 + router.refresh(); 42 + }); 43 + }} 44 + > 45 + <SelectTrigger 46 + aria-label="Select workspace" 47 + className={cn("min-w-[168px]", isPending && "opacity-70")} 48 + > 49 + <SelectValue /> 50 + </SelectTrigger> 51 + <SelectContent align="end"> 52 + {options.map((option) => ( 53 + <SelectItem key={option.value} value={option.value}> 54 + {option.label} 55 + {option.kind === "organization" && option.isOwner ? " · Owner" : ""} 56 + </SelectItem> 57 + ))} 58 + </SelectContent> 59 + </Select> 60 + ); 61 + }
+63 -1
lib/form-types.ts
··· 1 - import type { FormStatus } from "@prisma/client"; 1 + import type { FormStatus, OrganizationMemberRole } from "@prisma/client"; 2 2 3 3 import type { SerializedBlock } from "@/lib/blocks"; 4 4 5 5 export type BuilderBlock = SerializedBlock; 6 6 export type PublicBlock = SerializedBlock; 7 7 export type AnswerValue = string | string[]; 8 + 9 + export type WorkspaceReference = 10 + | { 11 + kind: "personal"; 12 + label: string; 13 + } 14 + | { 15 + kind: "organization"; 16 + organizationId: string; 17 + label: string; 18 + }; 19 + 20 + export type ActiveWorkspace = 21 + | { kind: "personal" } 22 + | { kind: "organization"; organizationId: string }; 23 + 24 + export type CreatorWorkspaceOption = 25 + | { 26 + kind: "personal"; 27 + value: string; 28 + label: string; 29 + isOwner: true; 30 + } 31 + | { 32 + kind: "organization"; 33 + organizationId: string; 34 + value: string; 35 + label: string; 36 + isOwner: boolean; 37 + }; 8 38 9 39 export type BuilderForm = { 10 40 id: string; ··· 18 48 status: FormStatus; 19 49 updatedAt: string; 20 50 responseCount: number; 51 + workspace: WorkspaceReference; 21 52 blocks: BuilderBlock[]; 22 53 }; 23 54 ··· 41 72 status: FormStatus; 42 73 updatedAt: string; 43 74 responseCount: number; 75 + workspace: WorkspaceReference; 44 76 }; 45 77 46 78 export type ResponseListItem = { ··· 54 86 id: string; 55 87 submittedAt: string; 56 88 submissionNumber: number; 89 + formTitle: string; 90 + workspace: WorkspaceReference; 57 91 answers: Record<string, AnswerValue>; 58 92 blocks: SerializedBlock[]; 59 93 }; 94 + 95 + export type ResponseReviewFormSummary = { 96 + id: string; 97 + title: string; 98 + workspace: WorkspaceReference; 99 + }; 100 + 101 + export type OrganizationMemberSummary = { 102 + userId: string; 103 + name: string | null; 104 + email: string | null; 105 + role: OrganizationMemberRole; 106 + }; 107 + 108 + export type OrganizationInviteLinkSummary = { 109 + id: string; 110 + token: string; 111 + createdAt: string; 112 + revokedAt: string | null; 113 + }; 114 + 115 + export type OrganizationSettingsSummary = { 116 + id: string; 117 + name: string; 118 + isOwner: boolean; 119 + members: OrganizationMemberSummary[]; 120 + inviteLinks: OrganizationInviteLinkSummary[]; 121 + };
+77 -25
lib/forms.ts
··· 18 18 import { db } from "@/lib/db"; 19 19 import { AppError } from "@/lib/errors"; 20 20 import type { 21 + ActiveWorkspace, 21 22 BuilderForm, 22 23 FormListItem, 23 24 PublicForm, 24 25 ResponseDetail, 25 26 ResponseListItem, 27 + ResponseReviewFormSummary, 28 + WorkspaceReference, 26 29 } from "@/lib/form-types"; 27 30 import { 28 31 blockUpdateSchema, ··· 32 35 reorderBlocksSchema, 33 36 } from "@/lib/validators"; 34 37 import { slugify } from "@/lib/utils"; 38 + import { assertOrganizationMember, workspaceAccessWhere, workspaceFilterWhere } from "@/lib/workspaces"; 35 39 36 40 const builderFormInclude = { 41 + organization: { 42 + select: { 43 + id: true, 44 + name: true, 45 + }, 46 + }, 37 47 blocks: { 38 48 orderBy: { 39 49 position: "asc", ··· 54 64 include: { 55 65 form: { 56 66 include: { 67 + organization: { 68 + select: { 69 + id: true; 70 + name: true; 71 + }; 72 + }; 57 73 blocks: { 58 74 orderBy: { 59 75 position: "asc"; ··· 82 98 ResponseListItem, 83 99 } from "@/lib/form-types"; 84 100 101 + function toWorkspaceReference(form: { organizationId: string | null; organization: { id: string; name: string } | null }): WorkspaceReference { 102 + if (form.organizationId && form.organization) { 103 + return { 104 + kind: "organization", 105 + organizationId: form.organizationId, 106 + label: form.organization.name, 107 + }; 108 + } 109 + 110 + return { 111 + kind: "personal", 112 + label: "Personal workspace", 113 + }; 114 + } 115 + 85 116 function toBuilderForm(form: BuilderFormRecord): BuilderForm { 86 117 return { 87 118 id: form.id, ··· 95 126 status: form.status, 96 127 updatedAt: form.updatedAt.toISOString(), 97 128 responseCount: form._count.responses, 129 + workspace: toWorkspaceReference(form), 98 130 blocks: form.blocks.map(serializeBlock), 99 131 }; 100 132 } ··· 167 199 return `${base}-${crypto.randomUUID().slice(0, 8)}`; 168 200 } 169 201 170 - async function assertOwner(userId: string, formId: string) { 202 + async function assertAccessibleForm(userId: string, formId: string) { 171 203 const form = await db.form.findFirst({ 172 204 where: { 173 205 id: formId, 174 - userId, 206 + ...workspaceAccessWhere(userId), 175 207 }, 176 208 include: builderFormInclude, 177 209 }); ··· 294 326 })); 295 327 } 296 328 297 - export async function listFormsForOwner(userId: string): Promise<FormListItem[]> { 329 + export async function listFormsForWorkspace(userId: string, workspace: ActiveWorkspace): Promise<FormListItem[]> { 330 + if (workspace.kind === "organization") { 331 + await assertOrganizationMember(userId, workspace.organizationId); 332 + } 333 + 298 334 const forms = await db.form.findMany({ 299 - where: { userId }, 335 + where: workspaceFilterWhere(userId, workspace), 300 336 include: { 337 + organization: { 338 + select: { 339 + id: true, 340 + name: true, 341 + }, 342 + }, 301 343 _count: { 302 344 select: { 303 345 responses: true, ··· 317 359 status: form.status, 318 360 updatedAt: form.updatedAt.toISOString(), 319 361 responseCount: form._count.responses, 362 + workspace: toWorkspaceReference(form), 320 363 })); 321 364 } 322 365 323 - export async function createDraftForm(userId: string) { 366 + export async function createDraftForm(userId: string, workspace: ActiveWorkspace) { 367 + if (workspace.kind === "organization") { 368 + await assertOrganizationMember(userId, workspace.organizationId); 369 + } 370 + 324 371 const slug = await createUniqueSlug(`untitled-form-${Math.random().toString(36).slice(2, 6)}`); 325 372 326 373 const form = await db.form.create({ 327 374 data: { 328 375 userId, 376 + organizationId: workspace.kind === "organization" ? workspace.organizationId : null, 329 377 title: "Untitled form", 330 378 description: "", 331 379 slug, ··· 340 388 } 341 389 342 390 export async function getOwnedFormForBuilder(userId: string, formId: string) { 343 - const form = await assertOwner(userId, formId); 391 + const form = await assertAccessibleForm(userId, formId); 344 392 return toBuilderForm(form); 345 393 } 346 394 347 395 export async function updateOwnedFormMetadata(userId: string, formId: string, payload: unknown) { 348 396 const parsed = formMetadataSchema.parse(payload); 349 - await assertOwner(userId, formId); 397 + await assertAccessibleForm(userId, formId); 350 398 await ensureUniqueSlug(parsed.slug, formId); 351 399 352 400 const form = await db.form.update({ ··· 359 407 } 360 408 361 409 export async function deleteOwnedForm(userId: string, formId: string) { 362 - await assertOwner(userId, formId); 410 + await assertAccessibleForm(userId, formId); 363 411 364 412 await db.form.delete({ 365 413 where: { id: formId }, ··· 368 416 369 417 export async function setOwnedFormPublished(userId: string, formId: string, payload: unknown) { 370 418 const parsed = publishFormSchema.parse(payload); 371 - const form = await assertOwner(userId, formId); 419 + const form = await assertAccessibleForm(userId, formId); 372 420 373 421 if (parsed.published && form.blocks.length === 0) { 374 422 throw new AppError("Add at least one block before publishing.", 422); ··· 387 435 388 436 export async function addOwnedBlock(userId: string, formId: string, payload: unknown) { 389 437 const parsed = createBlockSchema.parse(payload); 390 - const form = await assertOwner(userId, formId); 438 + const form = await assertAccessibleForm(userId, formId); 391 439 const position = form.blocks.length; 392 440 393 441 const titleByType: Record<FormBlockType, string> = { ··· 415 463 416 464 export async function updateOwnedBlock(userId: string, formId: string, blockId: string, payload: unknown) { 417 465 const parsed = blockUpdateSchema.parse(payload); 418 - await assertOwner(userId, formId); 466 + await assertAccessibleForm(userId, formId); 419 467 420 468 const block = await db.formBlock.findFirst({ 421 469 where: { 422 470 id: blockId, 423 471 formId, 424 - form: { 425 - userId, 426 - }, 427 472 }, 428 473 }); 429 474 ··· 446 491 } 447 492 448 493 export async function deleteOwnedBlock(userId: string, formId: string, blockId: string) { 449 - await assertOwner(userId, formId); 494 + await assertAccessibleForm(userId, formId); 450 495 451 496 const block = await db.formBlock.findFirst({ 452 497 where: { 453 498 id: blockId, 454 499 formId, 455 - form: { 456 - userId, 457 - }, 458 500 }, 459 501 }); 460 502 ··· 485 527 486 528 export async function reorderOwnedBlocks(userId: string, formId: string, payload: unknown) { 487 529 const parsed = reorderBlocksSchema.parse(payload); 488 - const form = await assertOwner(userId, formId); 530 + const form = await assertAccessibleForm(userId, formId); 489 531 490 532 const currentIds = form.blocks.map((block) => block.id).sort(); 491 533 const nextIds = [...parsed.blockIds].sort(); ··· 579 621 } 580 622 581 623 export async function listResponsesForOwnedForm(userId: string, formId: string) { 582 - const form = await assertOwner(userId, formId); 624 + const form = await assertAccessibleForm(userId, formId); 583 625 const responses = await db.response.findMany({ 584 626 where: { 585 627 formId, ··· 588 630 }); 589 631 590 632 return { 591 - form, 633 + form: { 634 + id: form.id, 635 + title: form.title, 636 + workspace: toWorkspaceReference(form), 637 + } satisfies ResponseReviewFormSummary, 592 638 responses: responses 593 639 .map((response, index) => ({ 594 640 id: response.id, ··· 604 650 } 605 651 606 652 export async function getOwnedResponseDetail(userId: string, formId: string, responseId: string): Promise<ResponseDetail> { 607 - await assertOwner(userId, formId); 653 + await assertAccessibleForm(userId, formId); 608 654 609 655 const response = await db.response.findFirst({ 610 656 where: { 611 657 id: responseId, 612 658 formId, 613 - form: { 614 - userId, 615 - }, 659 + form: workspaceAccessWhere(userId), 616 660 }, 617 661 include: { 618 662 form: { 619 663 include: { 664 + organization: { 665 + select: { 666 + id: true, 667 + name: true, 668 + }, 669 + }, 620 670 blocks: { 621 671 orderBy: { 622 672 position: "asc", ··· 654 704 id: response.id, 655 705 submittedAt: response.submittedAt.toISOString(), 656 706 submissionNumber, 707 + formTitle: response.form.title, 708 + workspace: toWorkspaceReference(response.form), 657 709 answers: 658 710 typeof response.answersJson === "object" && response.answersJson && !Array.isArray(response.answersJson) 659 711 ? (response.answersJson as Record<string, string | string[]>)
+276
lib/organizations.ts
··· 1 + import { OrganizationMemberRole } from "@prisma/client"; 2 + 3 + import { db } from "@/lib/db"; 4 + import { AppError } from "@/lib/errors"; 5 + import type { 6 + OrganizationInviteLinkSummary, 7 + OrganizationMemberSummary, 8 + OrganizationSettingsSummary, 9 + } from "@/lib/form-types"; 10 + import { assertOrganizationMember, assertOrganizationOwner } from "@/lib/workspaces"; 11 + 12 + function normalizeOrganizationName(value: unknown) { 13 + const name = typeof value === "string" ? value.trim() : ""; 14 + 15 + if (name.length < 2) { 16 + throw new AppError("Organization name must be at least 2 characters.", 422); 17 + } 18 + 19 + if (name.length > 80) { 20 + throw new AppError("Organization name must be 80 characters or fewer.", 422); 21 + } 22 + 23 + return name; 24 + } 25 + 26 + function toMemberSummary(member: { 27 + userId: string; 28 + role: OrganizationMemberRole; 29 + user: { name: string | null; email: string | null }; 30 + }): OrganizationMemberSummary { 31 + return { 32 + userId: member.userId, 33 + name: member.user.name, 34 + email: member.user.email, 35 + role: member.role, 36 + }; 37 + } 38 + 39 + function toInviteSummary(invite: { 40 + id: string; 41 + token: string; 42 + createdAt: Date; 43 + revokedAt: Date | null; 44 + }): OrganizationInviteLinkSummary { 45 + return { 46 + id: invite.id, 47 + token: invite.token, 48 + createdAt: invite.createdAt.toISOString(), 49 + revokedAt: invite.revokedAt ? invite.revokedAt.toISOString() : null, 50 + }; 51 + } 52 + 53 + export async function listOrganizationsForUser(userId: string): Promise<OrganizationSettingsSummary[]> { 54 + const memberships = await db.organizationMembership.findMany({ 55 + where: { userId }, 56 + include: { 57 + organization: { 58 + include: { 59 + memberships: { 60 + include: { 61 + user: { 62 + select: { 63 + name: true, 64 + email: true, 65 + }, 66 + }, 67 + }, 68 + orderBy: [{ role: "asc" }, { createdAt: "asc" }], 69 + }, 70 + inviteLinks: { 71 + where: { 72 + revokedAt: null, 73 + }, 74 + orderBy: { 75 + createdAt: "desc", 76 + }, 77 + }, 78 + }, 79 + }, 80 + }, 81 + orderBy: { 82 + organization: { 83 + name: "asc", 84 + }, 85 + }, 86 + }); 87 + 88 + return memberships.map((membership) => ({ 89 + id: membership.organizationId, 90 + name: membership.organization.name, 91 + isOwner: membership.role === OrganizationMemberRole.OWNER, 92 + members: membership.organization.memberships.map(toMemberSummary), 93 + inviteLinks: membership.organization.inviteLinks.map(toInviteSummary), 94 + })); 95 + } 96 + 97 + export async function createOrganization(userId: string, payload: { name: unknown }) { 98 + const name = normalizeOrganizationName(payload.name); 99 + 100 + return db.organization.create({ 101 + data: { 102 + name, 103 + ownerId: userId, 104 + memberships: { 105 + create: { 106 + userId, 107 + role: OrganizationMemberRole.OWNER, 108 + }, 109 + }, 110 + }, 111 + }); 112 + } 113 + 114 + export async function renameOrganization(userId: string, organizationId: string, payload: { name: unknown }) { 115 + const name = normalizeOrganizationName(payload.name); 116 + await assertOrganizationOwner(userId, organizationId); 117 + 118 + return db.organization.update({ 119 + where: { id: organizationId }, 120 + data: { name }, 121 + }); 122 + } 123 + 124 + export async function deleteOrganization(userId: string, organizationId: string) { 125 + await assertOrganizationOwner(userId, organizationId); 126 + 127 + await db.organization.delete({ 128 + where: { id: organizationId }, 129 + }); 130 + } 131 + 132 + export async function createOrganizationInviteLink(userId: string, organizationId: string) { 133 + await assertOrganizationOwner(userId, organizationId); 134 + 135 + return db.organizationInviteLink.create({ 136 + data: { 137 + organizationId, 138 + createdById: userId, 139 + token: crypto.randomUUID(), 140 + }, 141 + }); 142 + } 143 + 144 + export async function revokeOrganizationInviteLink(userId: string, organizationId: string, inviteLinkId: string) { 145 + await assertOrganizationOwner(userId, organizationId); 146 + 147 + const inviteLink = await db.organizationInviteLink.findFirst({ 148 + where: { 149 + id: inviteLinkId, 150 + organizationId, 151 + }, 152 + }); 153 + 154 + if (!inviteLink) { 155 + throw new AppError("Invite link not found.", 404); 156 + } 157 + 158 + return db.organizationInviteLink.update({ 159 + where: { id: inviteLinkId }, 160 + data: { 161 + revokedAt: inviteLink.revokedAt ?? new Date(), 162 + }, 163 + }); 164 + } 165 + 166 + export async function removeOrganizationMember(userId: string, organizationId: string, memberUserId: string) { 167 + const ownerMembership = await assertOrganizationOwner(userId, organizationId); 168 + 169 + if (ownerMembership.userId === memberUserId) { 170 + throw new AppError("The organization owner cannot be removed.", 422); 171 + } 172 + 173 + const membership = await db.organizationMembership.findUnique({ 174 + where: { 175 + organizationId_userId: { 176 + organizationId, 177 + userId: memberUserId, 178 + }, 179 + }, 180 + }); 181 + 182 + if (!membership) { 183 + throw new AppError("Member not found.", 404); 184 + } 185 + 186 + await db.organizationMembership.delete({ 187 + where: { 188 + organizationId_userId: { 189 + organizationId, 190 + userId: memberUserId, 191 + }, 192 + }, 193 + }); 194 + } 195 + 196 + export async function getOrganizationInviteState(userId: string, token: string) { 197 + const inviteLink = await db.organizationInviteLink.findUnique({ 198 + where: { token }, 199 + include: { 200 + organization: true, 201 + }, 202 + }); 203 + 204 + if (!inviteLink || inviteLink.revokedAt) { 205 + return null; 206 + } 207 + 208 + const existingMembership = await db.organizationMembership.findUnique({ 209 + where: { 210 + organizationId_userId: { 211 + organizationId: inviteLink.organizationId, 212 + userId, 213 + }, 214 + }, 215 + }); 216 + 217 + return { 218 + organizationId: inviteLink.organizationId, 219 + organizationName: inviteLink.organization.name, 220 + alreadyMember: Boolean(existingMembership), 221 + }; 222 + } 223 + 224 + export async function acceptOrganizationInvite(userId: string, token: string) { 225 + const inviteLink = await db.organizationInviteLink.findUnique({ 226 + where: { token }, 227 + include: { 228 + organization: true, 229 + }, 230 + }); 231 + 232 + if (!inviteLink || inviteLink.revokedAt) { 233 + throw new AppError("This invite link is no longer valid.", 404); 234 + } 235 + 236 + const membership = await db.organizationMembership.upsert({ 237 + where: { 238 + organizationId_userId: { 239 + organizationId: inviteLink.organizationId, 240 + userId, 241 + }, 242 + }, 243 + update: {}, 244 + create: { 245 + organizationId: inviteLink.organizationId, 246 + userId, 247 + role: OrganizationMemberRole.MEMBER, 248 + }, 249 + }); 250 + 251 + return { 252 + membership, 253 + organization: inviteLink.organization, 254 + }; 255 + } 256 + 257 + export async function getOrganizationDetailsForMember(userId: string, organizationId: string) { 258 + await assertOrganizationMember(userId, organizationId); 259 + 260 + return db.organization.findUnique({ 261 + where: { id: organizationId }, 262 + include: { 263 + memberships: { 264 + include: { 265 + user: { 266 + select: { 267 + name: true, 268 + email: true, 269 + }, 270 + }, 271 + }, 272 + }, 273 + inviteLinks: true, 274 + }, 275 + }); 276 + }
+8 -1
lib/response-exports.ts
··· 4 4 import { db } from "@/lib/db"; 5 5 import { AppError } from "@/lib/errors"; 6 6 import { slugify } from "@/lib/utils"; 7 + import { workspaceAccessWhere } from "@/lib/workspaces"; 7 8 8 9 export type ResponseExportFormat = "csv" | "xlsx"; 9 10 ··· 36 37 const form = await db.form.findFirst({ 37 38 where: { 38 39 id: formId, 39 - userId, 40 + ...workspaceAccessWhere(userId), 40 41 }, 41 42 include: { 43 + organization: { 44 + select: { 45 + id: true, 46 + name: true, 47 + }, 48 + }, 42 49 blocks: { 43 50 orderBy: { 44 51 position: "asc",
+167
lib/workspaces.ts
··· 1 + import { cookies } from "next/headers"; 2 + import { OrganizationMemberRole, type Prisma } from "@prisma/client"; 3 + 4 + import { db } from "@/lib/db"; 5 + import { AppError } from "@/lib/errors"; 6 + import type { ActiveWorkspace, CreatorWorkspaceOption } from "@/lib/form-types"; 7 + 8 + export const ACTIVE_WORKSPACE_COOKIE = "lively-active-workspace"; 9 + 10 + export function serializeWorkspaceValue(workspace: ActiveWorkspace) { 11 + return workspace.kind === "personal" ? "personal" : `organization:${workspace.organizationId}`; 12 + } 13 + 14 + export function parseWorkspaceValue(value: string | null | undefined): ActiveWorkspace { 15 + if (!value || value === "personal") { 16 + return { kind: "personal" }; 17 + } 18 + 19 + if (value.startsWith("organization:")) { 20 + const organizationId = value.slice("organization:".length).trim(); 21 + 22 + if (organizationId) { 23 + return { kind: "organization", organizationId }; 24 + } 25 + } 26 + 27 + return { kind: "personal" }; 28 + } 29 + 30 + export async function setActiveWorkspaceCookie(workspace: ActiveWorkspace) { 31 + const cookieStore = await cookies(); 32 + cookieStore.set(ACTIVE_WORKSPACE_COOKIE, serializeWorkspaceValue(workspace), { 33 + path: "/", 34 + sameSite: "lax", 35 + httpOnly: false, 36 + maxAge: 60 * 60 * 24 * 365, 37 + }); 38 + } 39 + 40 + export async function clearActiveWorkspaceCookie() { 41 + const cookieStore = await cookies(); 42 + cookieStore.delete(ACTIVE_WORKSPACE_COOKIE); 43 + } 44 + 45 + export async function listCreatorWorkspaceOptions(userId: string): Promise<CreatorWorkspaceOption[]> { 46 + const memberships = await db.organizationMembership.findMany({ 47 + where: { userId }, 48 + include: { 49 + organization: true, 50 + }, 51 + orderBy: { 52 + organization: { 53 + name: "asc", 54 + }, 55 + }, 56 + }); 57 + 58 + return [ 59 + { 60 + kind: "personal", 61 + label: "Personal workspace", 62 + value: "personal", 63 + isOwner: true, 64 + }, 65 + ...memberships.map((membership) => ({ 66 + kind: "organization" as const, 67 + organizationId: membership.organizationId, 68 + label: membership.organization.name, 69 + value: serializeWorkspaceValue({ kind: "organization", organizationId: membership.organizationId }), 70 + isOwner: membership.role === OrganizationMemberRole.OWNER, 71 + })), 72 + ]; 73 + } 74 + 75 + export async function getActiveWorkspaceForUser(userId: string) { 76 + const options = await listCreatorWorkspaceOptions(userId); 77 + const cookieStore = await cookies(); 78 + const parsed = parseWorkspaceValue(cookieStore.get(ACTIVE_WORKSPACE_COOKIE)?.value); 79 + 80 + if (parsed.kind === "organization") { 81 + const match = options.find((option) => option.kind === "organization" && option.organizationId === parsed.organizationId); 82 + 83 + if (match) { 84 + return { 85 + activeWorkspace: parsed, 86 + activeOption: match, 87 + options, 88 + }; 89 + } 90 + } 91 + 92 + return { 93 + activeWorkspace: { kind: "personal" } satisfies ActiveWorkspace, 94 + activeOption: options[0], 95 + options, 96 + }; 97 + } 98 + 99 + export function workspaceAccessWhere(userId: string): Prisma.FormWhereInput { 100 + return { 101 + OR: [ 102 + { 103 + userId, 104 + organizationId: null, 105 + }, 106 + { 107 + organization: { 108 + memberships: { 109 + some: { 110 + userId, 111 + }, 112 + }, 113 + }, 114 + }, 115 + ], 116 + }; 117 + } 118 + 119 + export function workspaceFilterWhere(userId: string, workspace: ActiveWorkspace): Prisma.FormWhereInput { 120 + if (workspace.kind === "personal") { 121 + return { 122 + userId, 123 + organizationId: null, 124 + }; 125 + } 126 + 127 + return { 128 + organizationId: workspace.organizationId, 129 + organization: { 130 + memberships: { 131 + some: { 132 + userId, 133 + }, 134 + }, 135 + }, 136 + }; 137 + } 138 + 139 + export async function assertOrganizationMember(userId: string, organizationId: string) { 140 + const membership = await db.organizationMembership.findUnique({ 141 + where: { 142 + organizationId_userId: { 143 + organizationId, 144 + userId, 145 + }, 146 + }, 147 + include: { 148 + organization: true, 149 + }, 150 + }); 151 + 152 + if (!membership) { 153 + throw new AppError("Organization not found.", 404); 154 + } 155 + 156 + return membership; 157 + } 158 + 159 + export async function assertOrganizationOwner(userId: string, organizationId: string) { 160 + const membership = await assertOrganizationMember(userId, organizationId); 161 + 162 + if (membership.role !== OrganizationMemberRole.OWNER) { 163 + throw new AppError("Only the organization owner can do that.", 403); 164 + } 165 + 166 + return membership; 167 + }
+2
openspec/changes/archive/2026-04-09-introduce-organizations/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-09
+115
openspec/changes/archive/2026-04-09-introduce-organizations/design.md
··· 1 + ## Context 2 + 3 + Lively Forms currently has a simple creator model: a signed-in user owns forms personally and all creator authorization checks are based on `form.userId`. That keeps the product simple for solo usage, but it prevents teams from sharing forms, reviewing responses together, or managing a common workspace. 4 + 5 + This change introduces organizations as collaborative workspaces while preserving personal forms. It is cross-cutting because it affects data modeling, access control, dashboard scoping, form creation/editing, response review/export, and new organization management screens. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Add organization workspaces without removing personal workspaces. 11 + - Let any signed-in user create an organization and become its owner. 12 + - Let organization owners rename/delete the organization, manage members, and manage invite links. 13 + - Let all organization members use the same form-related permissions within that organization. 14 + - Keep existing personal forms working with minimal migration risk. 15 + - Make workspace context visible in the creator experience so users know whether they are working personally or inside an organization. 16 + 17 + **Non-Goals:** 18 + - Granular roles beyond owner vs member in v1. 19 + - Per-form permissions inside an organization. 20 + - Billing, audit logs, or enterprise admin controls. 21 + - Public organization pages or directories. 22 + - Converting organizations into a fully generic workspace abstraction used everywhere in the product. 23 + 24 + ## Decisions 25 + 26 + ### 1. Keep personal ownership as-is and add optional organization ownership to forms 27 + Rather than replacing the current model with a generic `Workspace` table, forms should remain personally owned by default and optionally belong to an organization via an `organizationId`. This preserves the current personal-form behavior and reduces migration complexity. 28 + 29 + **Why:** 30 + - Existing data and queries already assume personal ownership. 31 + - Personal forms remain first-class without needing a synthetic personal workspace record. 32 + - The migration path is simpler because existing forms can stay attached to their current user. 33 + 34 + **Alternatives considered:** 35 + - Full generic workspace abstraction for users and organizations: more flexible long-term, but substantially more invasive for a first collaboration release. 36 + - Organization-only ownership with mandatory migration of all forms: rejected because it weakens the current solo flow. 37 + 38 + ### 2. Model organization membership with two roles: OWNER and MEMBER 39 + Organizations should use a membership table with exactly two roles in v1. The owner has organization-admin privileges. Members share all form-related permissions equally inside that organization. 40 + 41 + **Why:** 42 + - Matches the requested product behavior. 43 + - Keeps authorization rules predictable and easy to explain. 44 + - Avoids premature complexity from editors/admins/viewers. 45 + 46 + **Alternatives considered:** 47 + - Multiple granular roles: rejected as out of scope for the first release. 48 + - Owner-only form management with limited members: rejected because the desired collaboration model is equal member access to forms. 49 + 50 + ### 3. Use invite-link records managed by the organization owner 51 + Invite links should be first-class records tied to an organization and created/revoked by the owner. Joining through a valid invite adds the signed-in user as a member if they are not already in the organization. 52 + 53 + **Why:** 54 + - Simple onboarding flow for small teams. 55 + - Supports multiple links over time if needed. 56 + - Gives the owner explicit control over active invite entry points. 57 + 58 + **Alternatives considered:** 59 + - Email-only invitations: rejected because the user explicitly requested invite links. 60 + - One permanent invite token per organization: simpler, but weaker lifecycle control than managed invite records. 61 + 62 + ### 4. Centralize workspace-aware authorization in shared server helpers 63 + The codebase already uses shared helpers in `lib/forms.ts` for ownership checks. This change should extend that pattern with shared access helpers that answer questions like: personal owner? organization member? organization owner? 64 + 65 + **Why:** 66 + - Prevents access logic from drifting across routes and pages. 67 + - Makes the transition from personal-only to workspace-aware authorization safer. 68 + - Supports builder, response review, and export consistently. 69 + 70 + **Alternatives considered:** 71 + - Inline access checks per route/page: rejected because this change touches too many surfaces. 72 + 73 + ### 5. Add explicit creator workspace selection in the UI 74 + The creator experience should expose an active workspace context, starting with personal plus any organizations the user belongs to. Dashboard and form creation should use that active context. 75 + 76 + **Why:** 77 + - Users need to know where new forms will be created. 78 + - Dashboard lists must be clearly scoped. 79 + - It reduces confusion when the same user belongs to multiple organizations. 80 + 81 + **Alternatives considered:** 82 + - Global mixed list of all forms across personal and org contexts: rejected because it makes ownership and creation context ambiguous. 83 + - Separate dedicated organization app section only: rejected because it fragments the creator workflow. 84 + 85 + ### 6. Treat organization admin as owner-only, but form operations as member-wide 86 + Rename/delete organization, member management, and invite-link management should require owner role. Form creation, editing, publishing, response review, and response export should require membership only. 87 + 88 + **Why:** 89 + - Directly matches the requested permission model. 90 + - Cleanly separates organization administration from everyday collaboration. 91 + 92 + **Alternatives considered:** 93 + - Owner-only publishing or response access: rejected because members are meant to have the same permissions for workspace work. 94 + 95 + ## Risks / Trade-offs 96 + 97 + - **Workspace-aware access expands many queries** → Centralize helper functions and update creator surfaces incrementally around those helpers. 98 + - **Mixed personal/org ownership can complicate UI wording** → Use explicit workspace labels and creation context in dashboard/builder surfaces. 99 + - **Invite-link misuse or stale links** → Support explicit owner-managed revocation and keep links tied to one organization. 100 + - **Membership edge cases (already a member, owner leaving, delete with active forms)** → Define simple v1 rules and enforce them consistently in server actions. 101 + - **This model may constrain future granular roles** → Keep membership and invite models extensible even if v1 only uses OWNER/MEMBER. 102 + 103 + ## Migration Plan 104 + 105 + - Add organization, membership, and invite-link tables plus optional `organizationId` on forms. 106 + - Keep existing forms as personal forms owned by their current user. 107 + - Update server-side access helpers before exposing organization UI. 108 + - Roll out workspace selection and organization management screens after the data model and authorization layer are in place. 109 + - If rollback is needed, disable organization UI and routes while preserving personal-form behavior. 110 + 111 + ## Open Questions 112 + 113 + - Whether invite links should be single-use or reusable in v1. Initial recommendation: reusable until revoked. 114 + - Whether organization owners can leave the organization without transferring ownership. Initial recommendation: no, ownership must be transferred or the org deleted. 115 + - Whether personal and organization forms should be movable between workspaces in v1. Initial recommendation: not in scope for the initial release.
+32
openspec/changes/archive/2026-04-09-introduce-organizations/proposal.md
··· 1 + ## Why 2 + 3 + Lively Forms currently treats every form as a single user’s personal resource, which blocks teams from collaborating in a shared workspace. Adding organizations now unlocks multi-user use cases while keeping permissions simple: all members can work with organization forms, and only the organization owner handles organization administration. 4 + 5 + ## What Changes 6 + 7 + - Add organizations as a new shared workspace type alongside a user’s personal workspace. 8 + - Allow a signed-in user to create an organization and become its owner. 9 + - Allow organization owners to generate and manage invite links for joining the organization. 10 + - Allow invited users to join an organization through an invite link. 11 + - Grant all organization members the same permissions for organization forms, responses, and exports. 12 + - Restrict organization rename, delete, membership management, and invite-link management to the organization owner. 13 + - **BREAKING** Change creator access semantics from strictly user-owned forms to workspace-based access covering personal and organization-owned forms. 14 + 15 + ## Capabilities 16 + 17 + ### New Capabilities 18 + - `organizations`: Create and manage organizations, members, and owner-only organization administration. 19 + - `organization-invites`: Join organizations through invite links managed by the organization owner. 20 + 21 + ### Modified Capabilities 22 + - `creator-auth`: Expand creator authorization from personal ownership only to workspace-aware access for personal and organization resources. 23 + - `conversational-form-builder`: Allow creators to create and manage forms within a selected personal or organization workspace. 24 + - `dashboard-form-views`: Update dashboard browsing so creators can view forms within the active workspace context. 25 + - `response-review`: Allow organization members to review responses for organization forms they can access. 26 + - `response-export`: Allow organization members to export responses for organization forms they can access. 27 + 28 + ## Impact 29 + 30 + - Affected areas: authentication/authorization, form ownership model, dashboard context, builder flows, response review/export, and new organization management surfaces. 31 + - Likely code: Prisma schema, auth/session helpers, form query helpers in `lib/forms.ts`, dashboard pages, builder pages, response pages, and new organization routes/components. 32 + - Likely systems: workspace selection in the creator UI, membership checks, invite-link lifecycle handling, and migration of existing forms into a personal-workspace model.
+51
openspec/changes/archive/2026-04-09-introduce-organizations/specs/conversational-form-builder/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Creator can create and manage form metadata 4 + The system SHALL allow an authenticated creator to create a form draft within their personal workspace or an organization workspace they belong to and edit the title and shareable identity for any form in an accessible workspace. 5 + 6 + #### Scenario: Creator creates a new personal form 7 + - **WHEN** an authenticated creator creates a form in their personal workspace 8 + - **THEN** the system creates a new draft form in that creator's personal workspace 9 + 10 + #### Scenario: Creator creates a new organization form 11 + - **WHEN** an authenticated creator creates a form in an organization workspace they belong to 12 + - **THEN** the system creates a new draft form in that organization workspace 13 + 14 + #### Scenario: Creator updates an accessible form title 15 + - **WHEN** an authenticated creator edits the title of a form in a workspace they can access 16 + - **THEN** the system saves the updated title for that form 17 + 18 + ### Requirement: Creator can manage an ordered list of supported blocks 19 + The system SHALL allow an authenticated creator to add, select, edit, reorder, and remove blocks in a form located in an accessible workspace using only the supported v0 block types: text, short text, long text, single choice, and multiple choice. 20 + 21 + #### Scenario: Creator adds a block 22 + - **WHEN** an authenticated creator adds a supported block type to a form in an accessible workspace 23 + - **THEN** the system appends the new block to the form with a stable block identifier 24 + 25 + #### Scenario: Creator reorders blocks 26 + - **WHEN** an authenticated creator changes the order of blocks in a form in an accessible workspace 27 + - **THEN** the system saves the new block order for the form 28 + 29 + #### Scenario: Creator removes a block 30 + - **WHEN** an authenticated creator removes a block from a form in an accessible workspace 31 + - **THEN** the system removes that block from the current form structure 32 + 33 + ### Requirement: Creator can publish and unpublish forms 34 + The system SHALL allow an authenticated creator to publish or unpublish a form in an accessible workspace and SHALL expose a public shareable route only for published forms. 35 + 36 + #### Scenario: Creator publishes a form 37 + - **WHEN** an authenticated creator publishes a form in an accessible workspace 38 + - **THEN** the system marks the form as published and makes its public route available for respondents 39 + 40 + #### Scenario: Creator unpublishes a form 41 + - **WHEN** an authenticated creator unpublishes a form in an accessible workspace 42 + - **THEN** the system marks the form as unavailable for new public submissions 43 + 44 + ## ADDED Requirements 45 + 46 + ### Requirement: Builder reflects the active workspace context 47 + The system SHALL show the active workspace context when a creator creates or edits a form so they can tell whether the form belongs to their personal workspace or an organization. 48 + 49 + #### Scenario: Creator opens a form builder in an organization workspace 50 + - **WHEN** an authenticated creator opens a form that belongs to an organization workspace 51 + - **THEN** the system shows that organization context in the builder chrome
+16
openspec/changes/archive/2026-04-09-introduce-organizations/specs/creator-auth/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Creator ownership is enforced for managed forms 4 + The system SHALL allow creators to manage personal forms they own and organization forms belonging to organizations where they are members, and SHALL prevent access to editing, publishing, response review, or response export for forms outside those accessible workspaces. 5 + 6 + #### Scenario: Creator opens a personal form they own 7 + - **WHEN** an authenticated creator requests a personal form they own 8 + - **THEN** the system allows them to edit and manage that form 9 + 10 + #### Scenario: Creator opens an organization form in a joined organization 11 + - **WHEN** an authenticated creator requests a form belonging to an organization where they are a member 12 + - **THEN** the system allows them to manage that form according to organization membership access 13 + 14 + #### Scenario: Creator opens another creator's inaccessible form 15 + - **WHEN** an authenticated creator requests a form they do not own personally and that does not belong to an organization they are part of 16 + - **THEN** the system denies access to form management, response review, and response export for that form
+12
openspec/changes/archive/2026-04-09-introduce-organizations/specs/dashboard-form-views/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Dashboard views are scoped to the active workspace 4 + The system SHALL scope dashboard form listings to the creator's selected personal or organization workspace. 5 + 6 + #### Scenario: Creator views personal workspace dashboard 7 + - **WHEN** an authenticated creator selects their personal workspace in the dashboard 8 + - **THEN** the system shows only forms from that personal workspace 9 + 10 + #### Scenario: Creator views organization workspace dashboard 11 + - **WHEN** an authenticated creator selects an organization workspace they belong to in the dashboard 12 + - **THEN** the system shows only forms from that organization workspace
+30
openspec/changes/archive/2026-04-09-introduce-organizations/specs/organization-invites/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Organization owner can create and revoke invite links 4 + The system SHALL allow the organization owner to create invite links for that organization and SHALL allow the owner to revoke active invite links. 5 + 6 + #### Scenario: Owner creates an invite link 7 + - **WHEN** the organization owner creates an invite link 8 + - **THEN** the system issues a joinable invite link for that organization 9 + 10 + #### Scenario: Owner revokes an invite link 11 + - **WHEN** the organization owner revokes an active invite link 12 + - **THEN** the system prevents that link from being used for new joins 13 + 14 + ### Requirement: Authenticated user can join organization through a valid invite link 15 + The system SHALL allow an authenticated user to join an organization through a valid invite link managed by that organization. 16 + 17 + #### Scenario: User joins via valid invite 18 + - **WHEN** an authenticated user opens a valid organization invite link and accepts the invitation 19 + - **THEN** the system adds that user as a member of the organization 20 + 21 + #### Scenario: User opens revoked or invalid invite link 22 + - **WHEN** an authenticated user opens an invalid or revoked organization invite link 23 + - **THEN** the system does not add them to the organization 24 + 25 + ### Requirement: Existing members are not duplicated through invite acceptance 26 + The system SHALL prevent duplicate membership when a user who is already a member opens a valid invite link for the same organization. 27 + 28 + #### Scenario: Existing member opens valid invite 29 + - **WHEN** an authenticated user who already belongs to the organization accepts a valid invite link 30 + - **THEN** the system keeps a single membership record and does not create a duplicate
+37
openspec/changes/archive/2026-04-09-introduce-organizations/specs/organizations/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Authenticated user can create an organization 4 + The system SHALL allow an authenticated user to create an organization and SHALL make that user the owner of the new organization. 5 + 6 + #### Scenario: User creates an organization 7 + - **WHEN** an authenticated user submits a valid organization name 8 + - **THEN** the system creates the organization and records that user as its owner 9 + 10 + ### Requirement: Organization members share form-workspace permissions 11 + The system SHALL allow every member of an organization to create and manage forms, review responses, and export responses within that organization. 12 + 13 + #### Scenario: Member works with organization forms 14 + - **WHEN** an authenticated member opens an organization workspace 15 + - **THEN** the system allows that member to use the same form-related features as any other non-owner member in that organization 16 + 17 + ### Requirement: Organization owner manages organization administration 18 + The system SHALL restrict organization rename, delete, membership management, and invite-link management to the organization owner. 19 + 20 + #### Scenario: Owner renames organization 21 + - **WHEN** the organization owner updates the organization name 22 + - **THEN** the system saves the new organization name 23 + 24 + #### Scenario: Owner removes a member 25 + - **WHEN** the organization owner removes a member from the organization 26 + - **THEN** the system revokes that member's access to the organization workspace 27 + 28 + #### Scenario: Non-owner attempts organization administration 29 + - **WHEN** a non-owner member attempts to rename, delete, manage members, or manage invite links for the organization 30 + - **THEN** the system denies the request 31 + 32 + ### Requirement: Creator UI exposes personal and organization workspaces 33 + The system SHALL present a creator with their personal workspace and any organizations they belong to as selectable workspace contexts. 34 + 35 + #### Scenario: User belongs to one or more organizations 36 + - **WHEN** an authenticated creator opens the creator experience 37 + - **THEN** the system shows the personal workspace plus each accessible organization as available workspace contexts
+19
openspec/changes/archive/2026-04-09-introduce-organizations/specs/response-export/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Creator can export owned form responses in CSV and XLSX formats 4 + The system SHALL allow an authenticated creator to export responses in CSV and XLSX formats for personal forms they own and organization forms belonging to organizations where they are members. 5 + 6 + #### Scenario: Creator exports personal form responses as CSV 7 + - **WHEN** an authenticated creator requests a CSV export for a personal form they own 8 + - **THEN** the system downloads a CSV file containing that form's responses 9 + 10 + #### Scenario: Creator exports organization form responses as XLSX 11 + - **WHEN** an authenticated creator requests an XLSX export for a form belonging to an organization where they are a member 12 + - **THEN** the system downloads an XLSX file containing that form's responses 13 + 14 + ### Requirement: Export requests are restricted by form ownership 15 + The system SHALL allow response exports only for forms in workspaces the authenticated creator can access and SHALL deny export access for forms outside those workspaces. 16 + 17 + #### Scenario: Creator exports inaccessible form 18 + - **WHEN** an authenticated creator requests an export for a form they do not own personally and that does not belong to an organization they are part of 19 + - **THEN** the system denies access and does not return response data
+23
openspec/changes/archive/2026-04-09-introduce-organizations/specs/response-review/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Creator can list responses for owned forms 4 + The system SHALL allow an authenticated creator to view submitted responses for personal forms they own and organization forms belonging to organizations where they are members, and SHALL prevent them from listing responses for forms outside those accessible workspaces. 5 + 6 + #### Scenario: Creator opens responses for a personal form they own 7 + - **WHEN** an authenticated creator opens the responses view for a personal form they own 8 + - **THEN** the system displays the submitted responses for that form 9 + 10 + #### Scenario: Creator opens responses for an organization form in a joined organization 11 + - **WHEN** an authenticated creator opens the responses view for a form belonging to an organization where they are a member 12 + - **THEN** the system displays the submitted responses for that form 13 + 14 + #### Scenario: Creator opens responses for an inaccessible form 15 + - **WHEN** an authenticated creator opens the responses view for a form they do not own personally and that does not belong to an organization they are part of 16 + - **THEN** the system denies access to that form's submitted responses 17 + 18 + ### Requirement: Creator can start response exports from the responses view 19 + The system SHALL present response export actions in the responses view for a form in a workspace the authenticated creator can access. 20 + 21 + #### Scenario: Creator opens responses for an accessible form 22 + - **WHEN** an authenticated creator opens the responses view for a form in a workspace they can access 23 + - **THEN** the system displays available export actions for the supported response export formats
+34
openspec/changes/archive/2026-04-09-introduce-organizations/tasks.md
··· 1 + ## 1. Data model and migration 2 + 3 + - [x] 1.1 Add Prisma models for organizations, memberships, and invite links. 4 + - [x] 1.2 Add optional organization ownership fields to forms while preserving personal ownership. 5 + - [x] 1.3 Generate and apply the migration for the new organization data model. 6 + - [x] 1.4 Update shared TypeScript/domain types for workspace-aware forms and organization entities. 7 + 8 + ## 2. Workspace-aware authorization 9 + 10 + - [x] 2.1 Add shared server helpers for personal ownership, organization membership, and organization-owner checks. 11 + - [x] 2.2 Refactor form, response review, and response export access checks to use workspace-aware authorization. 12 + - [x] 2.3 Prevent non-owner members from performing organization admin actions. 13 + 14 + ## 3. Organization and invite flows 15 + 16 + - [x] 3.1 Add server endpoints or actions to create, rename, and delete organizations. 17 + - [x] 3.2 Add server endpoints or actions to list members, remove members, and manage invite links. 18 + - [x] 3.3 Add the authenticated join flow for valid organization invite links. 19 + - [x] 3.4 Handle duplicate-member and revoked/invalid-invite cases cleanly. 20 + 21 + ## 4. Creator workspace UI 22 + 23 + - [x] 4.1 Add creator UI for selecting the active personal or organization workspace. 24 + - [x] 4.2 Scope dashboard form listings to the active workspace. 25 + - [x] 4.3 Update form creation so a new form is created in the selected workspace. 26 + - [x] 4.4 Show workspace context in the builder and responses surfaces. 27 + - [x] 4.5 Add organization settings UI for owner-only admin actions and invite-link management. 28 + 29 + ## 5. Verification 30 + 31 + - [x] 5.1 Verify personal-form flows still work after introducing organizations. 32 + - [x] 5.2 Verify organization members can create/manage forms and review/export responses in the organization workspace. 33 + - [x] 5.3 Verify non-member access is denied and non-owner org admin actions are denied. 34 + - [x] 5.4 Run the production build and fix any type, migration, or route issues introduced by the change.
+24 -13
openspec/specs/conversational-form-builder/spec.md
··· 1 1 ## ADDED Requirements 2 2 3 3 ### Requirement: Creator can create and manage form metadata 4 - The system SHALL allow an authenticated creator to create a form draft and edit its title and shareable identity needed for public publishing. 4 + The system SHALL allow an authenticated creator to create a form draft within their personal workspace or an organization workspace they belong to and edit the title and shareable identity for any form in an accessible workspace. 5 5 6 - #### Scenario: Creator creates a new form 7 - - **WHEN** an authenticated creator creates a form 8 - - **THEN** the system creates a new draft form owned by that creator 6 + #### Scenario: Creator creates a new personal form 7 + - **WHEN** an authenticated creator creates a form in their personal workspace 8 + - **THEN** the system creates a new draft form in that creator's personal workspace 9 + 10 + #### Scenario: Creator creates a new organization form 11 + - **WHEN** an authenticated creator creates a form in an organization workspace they belong to 12 + - **THEN** the system creates a new draft form in that organization workspace 9 13 10 - #### Scenario: Creator updates form title 11 - - **WHEN** an authenticated creator edits the title of an owned form 14 + #### Scenario: Creator updates an accessible form title 15 + - **WHEN** an authenticated creator edits the title of a form in a workspace they can access 12 16 - **THEN** the system saves the updated title for that form 13 17 14 18 ### Requirement: Creator can manage an ordered list of supported blocks 15 - The system SHALL allow an authenticated creator to add, select, edit, reorder, and remove blocks in a form using only the supported v0 block types: text, short text, long text, single choice, and multiple choice. 19 + The system SHALL allow an authenticated creator to add, select, edit, reorder, and remove blocks in a form located in an accessible workspace using only the supported v0 block types: text, short text, long text, single choice, and multiple choice. 16 20 17 21 #### Scenario: Creator adds a block 18 - - **WHEN** an authenticated creator adds a supported block type to an owned form 22 + - **WHEN** an authenticated creator adds a supported block type to a form in an accessible workspace 19 23 - **THEN** the system appends the new block to the form with a stable block identifier 20 24 21 25 #### Scenario: Creator reorders blocks 22 - - **WHEN** an authenticated creator changes the order of blocks in an owned form 26 + - **WHEN** an authenticated creator changes the order of blocks in a form in an accessible workspace 23 27 - **THEN** the system saves the new block order for the form 24 28 25 29 #### Scenario: Creator removes a block 26 - - **WHEN** an authenticated creator removes a block from an owned form 30 + - **WHEN** an authenticated creator removes a block from a form in an accessible workspace 27 31 - **THEN** the system removes that block from the current form structure 28 32 29 33 ### Requirement: Builder exposes block editing through a master-detail layout ··· 49 53 - **THEN** the system allows content updates without exposing answer validation settings 50 54 51 55 ### Requirement: Creator can publish and unpublish forms 52 - The system SHALL allow an authenticated creator to publish or unpublish an owned form and SHALL expose a public shareable route only for published forms. 56 + The system SHALL allow an authenticated creator to publish or unpublish a form in an accessible workspace and SHALL expose a public shareable route only for published forms. 53 57 54 58 #### Scenario: Creator publishes a form 55 - - **WHEN** an authenticated creator publishes an owned form 59 + - **WHEN** an authenticated creator publishes a form in an accessible workspace 56 60 - **THEN** the system marks the form as published and makes its public route available for respondents 57 61 58 62 #### Scenario: Creator unpublishes a form 59 - - **WHEN** an authenticated creator unpublishes an owned form 63 + - **WHEN** an authenticated creator unpublishes a form in an accessible workspace 60 64 - **THEN** the system marks the form as unavailable for new public submissions 61 65 62 66 ### Requirement: Builder chrome stays focused on editing ··· 84 88 #### Scenario: Creator leaves follow-up link incomplete 85 89 - **WHEN** an authenticated creator leaves the follow-up link label or URL empty 86 90 - **THEN** the system does not save or expose a partial follow-up link for the form 91 + 92 + ### Requirement: Builder reflects the active workspace context 93 + The system SHALL show the active workspace context when a creator creates or edits a form so they can tell whether the form belongs to their personal workspace or an organization. 94 + 95 + #### Scenario: Creator opens a form builder in an organization workspace 96 + - **WHEN** an authenticated creator opens a form that belongs to an organization workspace 97 + - **THEN** the system shows that organization context in the builder chrome
+10 -6
openspec/specs/creator-auth/spec.md
··· 12 12 - **THEN** the system grants access to creator functionality associated with their account 13 13 14 14 ### Requirement: Creator ownership is enforced for managed forms 15 - The system SHALL allow creators to manage only forms they own and SHALL prevent access to editing, publishing, or response review for forms owned by other creators. 15 + The system SHALL allow creators to manage personal forms they own and organization forms belonging to organizations where they are members, and SHALL prevent access to editing, publishing, response review, or response export for forms outside those accessible workspaces. 16 16 17 - #### Scenario: Creator opens an owned form 18 - - **WHEN** an authenticated creator requests a form they own 17 + #### Scenario: Creator opens a personal form they own 18 + - **WHEN** an authenticated creator requests a personal form they own 19 19 - **THEN** the system allows them to edit and manage that form 20 20 21 - #### Scenario: Creator opens another creator's form 22 - - **WHEN** an authenticated creator requests a form they do not own 23 - - **THEN** the system denies access to form management and response review for that form 21 + #### Scenario: Creator opens an organization form in a joined organization 22 + - **WHEN** an authenticated creator requests a form belonging to an organization where they are a member 23 + - **THEN** the system allows them to manage that form according to organization membership access 24 + 25 + #### Scenario: Creator opens another creator's inaccessible form 26 + - **WHEN** an authenticated creator requests a form they do not own personally and that does not belong to an organization they are part of 27 + - **THEN** the system denies access to form management, response review, and response export for that form
+11
openspec/specs/dashboard-form-views/spec.md
··· 44 44 #### Scenario: Creator opens a form from table view 45 45 - **WHEN** an authenticated creator uses a dashboard action in table view 46 46 - **THEN** the system allows direct navigation to the builder or responses for that form 47 + 48 + ### Requirement: Dashboard views are scoped to the active workspace 49 + The system SHALL scope dashboard form listings to the creator's selected personal or organization workspace. 50 + 51 + #### Scenario: Creator views personal workspace dashboard 52 + - **WHEN** an authenticated creator selects their personal workspace in the dashboard 53 + - **THEN** the system shows only forms from that personal workspace 54 + 55 + #### Scenario: Creator views organization workspace dashboard 56 + - **WHEN** an authenticated creator selects an organization workspace they belong to in the dashboard 57 + - **THEN** the system shows only forms from that organization workspace
+30
openspec/specs/organization-invites/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Organization owner can create and revoke invite links 4 + The system SHALL allow the organization owner to create invite links for that organization and SHALL allow the owner to revoke active invite links. 5 + 6 + #### Scenario: Owner creates an invite link 7 + - **WHEN** the organization owner creates an invite link 8 + - **THEN** the system issues a joinable invite link for that organization 9 + 10 + #### Scenario: Owner revokes an invite link 11 + - **WHEN** the organization owner revokes an active invite link 12 + - **THEN** the system prevents that link from being used for new joins 13 + 14 + ### Requirement: Authenticated user can join organization through a valid invite link 15 + The system SHALL allow an authenticated user to join an organization through a valid invite link managed by that organization. 16 + 17 + #### Scenario: User joins via valid invite 18 + - **WHEN** an authenticated user opens a valid organization invite link and accepts the invitation 19 + - **THEN** the system adds that user as a member of the organization 20 + 21 + #### Scenario: User opens revoked or invalid invite link 22 + - **WHEN** an authenticated user opens an invalid or revoked organization invite link 23 + - **THEN** the system does not add them to the organization 24 + 25 + ### Requirement: Existing members are not duplicated through invite acceptance 26 + The system SHALL prevent duplicate membership when a user who is already a member opens a valid invite link for the same organization. 27 + 28 + #### Scenario: Existing member opens valid invite 29 + - **WHEN** an authenticated user who already belongs to the organization accepts a valid invite link 30 + - **THEN** the system keeps a single membership record and does not create a duplicate
+37
openspec/specs/organizations/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Authenticated user can create an organization 4 + The system SHALL allow an authenticated user to create an organization and SHALL make that user the owner of the new organization. 5 + 6 + #### Scenario: User creates an organization 7 + - **WHEN** an authenticated user submits a valid organization name 8 + - **THEN** the system creates the organization and records that user as its owner 9 + 10 + ### Requirement: Organization members share form-workspace permissions 11 + The system SHALL allow every member of an organization to create and manage forms, review responses, and export responses within that organization. 12 + 13 + #### Scenario: Member works with organization forms 14 + - **WHEN** an authenticated member opens an organization workspace 15 + - **THEN** the system allows that member to use the same form-related features as any other non-owner member in that organization 16 + 17 + ### Requirement: Organization owner manages organization administration 18 + The system SHALL restrict organization rename, delete, membership management, and invite-link management to the organization owner. 19 + 20 + #### Scenario: Owner renames organization 21 + - **WHEN** the organization owner updates the organization name 22 + - **THEN** the system saves the new organization name 23 + 24 + #### Scenario: Owner removes a member 25 + - **WHEN** the organization owner removes a member from the organization 26 + - **THEN** the system revokes that member's access to the organization workspace 27 + 28 + #### Scenario: Non-owner attempts organization administration 29 + - **WHEN** a non-owner member attempts to rename, delete, manage members, or manage invite links for the organization 30 + - **THEN** the system denies the request 31 + 32 + ### Requirement: Creator UI exposes personal and organization workspaces 33 + The system SHALL present a creator with their personal workspace and any organizations they belong to as selectable workspace contexts. 34 + 35 + #### Scenario: User belongs to one or more organizations 36 + - **WHEN** an authenticated creator opens the creator experience 37 + - **THEN** the system shows the personal workspace plus each accessible organization as available workspace contexts
+8 -8
openspec/specs/response-export/spec.md
··· 1 1 ## ADDED Requirements 2 2 3 3 ### Requirement: Creator can export owned form responses in CSV and XLSX formats 4 - The system SHALL allow an authenticated creator to export responses for a form they own in CSV and XLSX formats. 4 + The system SHALL allow an authenticated creator to export responses in CSV and XLSX formats for personal forms they own and organization forms belonging to organizations where they are members. 5 5 6 - #### Scenario: Creator exports owned form responses as CSV 7 - - **WHEN** an authenticated creator requests a CSV export for a form they own 6 + #### Scenario: Creator exports personal form responses as CSV 7 + - **WHEN** an authenticated creator requests a CSV export for a personal form they own 8 8 - **THEN** the system downloads a CSV file containing that form's responses 9 9 10 - #### Scenario: Creator exports owned form responses as XLSX 11 - - **WHEN** an authenticated creator requests an XLSX export for a form they own 10 + #### Scenario: Creator exports organization form responses as XLSX 11 + - **WHEN** an authenticated creator requests an XLSX export for a form belonging to an organization where they are a member 12 12 - **THEN** the system downloads an XLSX file containing that form's responses 13 13 14 14 ### Requirement: Export requests are restricted by form ownership 15 - The system SHALL allow response exports only for forms owned by the authenticated creator and SHALL deny export access for forms owned by others. 15 + The system SHALL allow response exports only for forms in workspaces the authenticated creator can access and SHALL deny export access for forms outside those workspaces. 16 16 17 - #### Scenario: Creator exports another creator's form 18 - - **WHEN** an authenticated creator requests an export for a form they do not own 17 + #### Scenario: Creator exports inaccessible form 18 + - **WHEN** an authenticated creator requests an export for a form they do not own personally and that does not belong to an organization they are part of 19 19 - **THEN** the system denies access and does not return response data 20 20 21 21 ### Requirement: Export output uses one row per submission with stable columns
+12 -8
openspec/specs/response-review/spec.md
··· 1 1 ## ADDED Requirements 2 2 3 3 ### Requirement: Creator can list responses for owned forms 4 - The system SHALL allow an authenticated creator to view submitted responses for forms they own and SHALL prevent them from listing responses for forms owned by others. 4 + The system SHALL allow an authenticated creator to view submitted responses for personal forms they own and organization forms belonging to organizations where they are members, and SHALL prevent them from listing responses for forms outside those accessible workspaces. 5 5 6 - #### Scenario: Creator opens responses for an owned form 7 - - **WHEN** an authenticated creator opens the responses view for a form they own 6 + #### Scenario: Creator opens responses for a personal form they own 7 + - **WHEN** an authenticated creator opens the responses view for a personal form they own 8 8 - **THEN** the system displays the submitted responses for that form 9 9 10 - #### Scenario: Creator opens responses for another creator's form 11 - - **WHEN** an authenticated creator opens the responses view for a form they do not own 10 + #### Scenario: Creator opens responses for an organization form in a joined organization 11 + - **WHEN** an authenticated creator opens the responses view for a form belonging to an organization where they are a member 12 + - **THEN** the system displays the submitted responses for that form 13 + 14 + #### Scenario: Creator opens responses for an inaccessible form 15 + - **WHEN** an authenticated creator opens the responses view for a form they do not own personally and that does not belong to an organization they are part of 12 16 - **THEN** the system denies access to that form's submitted responses 13 17 14 18 ### Requirement: Creator can inspect a single response in form order ··· 23 27 - **THEN** the system shows only answerable block responses while preserving the form order context for the submission 24 28 25 29 ### Requirement: Creator can start response exports from the responses view 26 - The system SHALL present response export actions in the responses view for a form owned by the authenticated creator. 30 + The system SHALL present response export actions in the responses view for a form in a workspace the authenticated creator can access. 27 31 28 - #### Scenario: Creator opens responses for an owned form 29 - - **WHEN** an authenticated creator opens the responses view for a form they own 32 + #### Scenario: Creator opens responses for an accessible form 33 + - **WHEN** an authenticated creator opens the responses view for a form in a workspace they can access 30 34 - **THEN** the system displays available export actions for the supported response export formats
+1
package.json
··· 33 33 "@dnd-kit/sortable": "^10.0.0", 34 34 "@dnd-kit/utilities": "^3.2.2", 35 35 "@prisma/client": "6", 36 + "@radix-ui/react-select": "^2.2.6", 36 37 "class-variance-authority": "^0.7.1", 37 38 "clsx": "^2.1.1", 38 39 "framer-motion": "^12.38.0",
+3
prisma/migrations/20260409070902_add_user_name_parts/migration.sql
··· 1 + ALTER TABLE "User" 2 + ADD COLUMN IF NOT EXISTS "firstName" TEXT, 3 + ADD COLUMN IF NOT EXISTS "secondName" TEXT;
+75
prisma/migrations/20260409085139_introduce_organizations/migration.sql
··· 1 + -- CreateEnum 2 + CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'MEMBER'); 3 + 4 + -- AlterTable 5 + ALTER TABLE "Form" ADD COLUMN "organizationId" TEXT; 6 + 7 + -- CreateTable 8 + CREATE TABLE "Organization" ( 9 + "id" TEXT NOT NULL, 10 + "name" TEXT NOT NULL, 11 + "ownerId" TEXT NOT NULL, 12 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 + "updatedAt" TIMESTAMP(3) NOT NULL, 14 + 15 + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") 16 + ); 17 + 18 + -- CreateTable 19 + CREATE TABLE "OrganizationMembership" ( 20 + "id" TEXT NOT NULL, 21 + "organizationId" TEXT NOT NULL, 22 + "userId" TEXT NOT NULL, 23 + "role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER', 24 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 + 26 + CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id") 27 + ); 28 + 29 + -- CreateTable 30 + CREATE TABLE "OrganizationInviteLink" ( 31 + "id" TEXT NOT NULL, 32 + "organizationId" TEXT NOT NULL, 33 + "createdById" TEXT NOT NULL, 34 + "token" TEXT NOT NULL, 35 + "revokedAt" TIMESTAMP(3), 36 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 37 + 38 + CONSTRAINT "OrganizationInviteLink_pkey" PRIMARY KEY ("id") 39 + ); 40 + 41 + -- CreateIndex 42 + CREATE INDEX "Organization_ownerId_idx" ON "Organization"("ownerId"); 43 + 44 + -- CreateIndex 45 + CREATE INDEX "OrganizationMembership_userId_idx" ON "OrganizationMembership"("userId"); 46 + 47 + -- CreateIndex 48 + CREATE UNIQUE INDEX "OrganizationMembership_organizationId_userId_key" ON "OrganizationMembership"("organizationId", "userId"); 49 + 50 + -- CreateIndex 51 + CREATE UNIQUE INDEX "OrganizationInviteLink_token_key" ON "OrganizationInviteLink"("token"); 52 + 53 + -- CreateIndex 54 + CREATE INDEX "OrganizationInviteLink_organizationId_createdAt_idx" ON "OrganizationInviteLink"("organizationId", "createdAt" DESC); 55 + 56 + -- CreateIndex 57 + CREATE INDEX "Form_organizationId_updatedAt_idx" ON "Form"("organizationId", "updatedAt" DESC); 58 + 59 + -- AddForeignKey 60 + ALTER TABLE "Form" ADD CONSTRAINT "Form_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; 61 + 62 + -- AddForeignKey 63 + ALTER TABLE "Organization" ADD CONSTRAINT "Organization_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 64 + 65 + -- AddForeignKey 66 + ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; 67 + 68 + -- AddForeignKey 69 + ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 70 + 71 + -- AddForeignKey 72 + ALTER TABLE "OrganizationInviteLink" ADD CONSTRAINT "OrganizationInviteLink_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; 73 + 74 + -- AddForeignKey 75 + ALTER TABLE "OrganizationInviteLink" ADD CONSTRAINT "OrganizationInviteLink_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+73 -20
prisma/schema.prisma
··· 20 20 MULTIPLE_CHOICE 21 21 } 22 22 23 + enum OrganizationMemberRole { 24 + OWNER 25 + MEMBER 26 + } 27 + 23 28 model User { 24 - id String @id @default(cuid()) 25 - name String? 26 - email String? @unique 27 - emailVerified DateTime? 28 - image String? 29 - accounts Account[] 30 - sessions Session[] 31 - forms Form[] 32 - createdAt DateTime @default(now()) 33 - updatedAt DateTime @updatedAt 29 + id String @id @default(cuid()) 30 + name String? 31 + firstName String? 32 + secondName String? 33 + email String? @unique 34 + emailVerified DateTime? 35 + image String? 36 + accounts Account[] 37 + sessions Session[] 38 + forms Form[] 39 + organizationMemberships OrganizationMembership[] 40 + ownedOrganizations Organization[] @relation("OrganizationOwner") 41 + createdInviteLinks OrganizationInviteLink[] @relation("OrganizationInviteLinkCreator") 42 + createdAt DateTime @default(now()) 43 + updatedAt DateTime @updatedAt 34 44 } 35 45 36 46 model Account { ··· 72 82 } 73 83 74 84 model Form { 75 - id String @id @default(cuid()) 85 + id String @id @default(cuid()) 76 86 userId String 77 - title String @default("Untitled form") 78 - description String @default("") 79 - completionTitle String @default("Thanks for taking the time.") 80 - completionMessage String @default("Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.") 87 + organizationId String? 88 + title String @default("Untitled form") 89 + description String @default("") 90 + completionTitle String @default("Thanks for taking the time.") 91 + completionMessage String @default("Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.") 81 92 completionLinkLabel String? 82 93 completionLinkUrl String? 83 - slug String @unique 84 - status FormStatus @default(DRAFT) 85 - user User @relation(fields: [userId], references: [id], onDelete: Cascade) 94 + slug String @unique 95 + status FormStatus @default(DRAFT) 96 + user User @relation(fields: [userId], references: [id], onDelete: Cascade) 97 + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) 86 98 blocks FormBlock[] 87 99 responses Response[] 88 - createdAt DateTime @default(now()) 89 - updatedAt DateTime @updatedAt 100 + createdAt DateTime @default(now()) 101 + updatedAt DateTime @updatedAt 90 102 91 103 @@index([userId, updatedAt(sort: Desc)]) 104 + @@index([organizationId, updatedAt(sort: Desc)]) 92 105 } 93 106 94 107 model FormBlock { ··· 117 130 118 131 @@index([formId, submittedAt(sort: Desc)]) 119 132 } 133 + 134 + model Organization { 135 + id String @id @default(cuid()) 136 + name String 137 + ownerId String 138 + owner User @relation("OrganizationOwner", fields: [ownerId], references: [id], onDelete: Cascade) 139 + memberships OrganizationMembership[] 140 + inviteLinks OrganizationInviteLink[] 141 + forms Form[] 142 + createdAt DateTime @default(now()) 143 + updatedAt DateTime @updatedAt 144 + 145 + @@index([ownerId]) 146 + } 147 + 148 + model OrganizationMembership { 149 + id String @id @default(cuid()) 150 + organizationId String 151 + userId String 152 + role OrganizationMemberRole @default(MEMBER) 153 + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) 154 + user User @relation(fields: [userId], references: [id], onDelete: Cascade) 155 + createdAt DateTime @default(now()) 156 + 157 + @@unique([organizationId, userId]) 158 + @@index([userId]) 159 + } 160 + 161 + model OrganizationInviteLink { 162 + id String @id @default(cuid()) 163 + organizationId String 164 + createdById String 165 + token String @unique 166 + revokedAt DateTime? 167 + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) 168 + createdBy User @relation("OrganizationInviteLinkCreator", fields: [createdById], references: [id], onDelete: Cascade) 169 + createdAt DateTime @default(now()) 170 + 171 + @@index([organizationId, createdAt(sort: Desc)]) 172 + }