Add multi-workspace support with org switcher and open registration

- Open registration to all users (remove invite-only gate)
- Add domain auto-join: new users matching org allowedDomains get auto-added
- Track active workspace via activeOrgId on User model
- Refactor JWT callback to resolve active org from all memberships
- Add org switch, list, and create-personal API endpoints
- Add workspace-setup page for users without an org
- Build OrgSwitcher dropdown in sidebar header
- Add allowed email domains management to org settings

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-16 18:08:08 -05:00
parent af91f3d5fc
commit ab873f7f08
16 changed files with 583 additions and 39 deletions

View file

@ -18,6 +18,7 @@ model User {
displayName String?
avatarUrl String @default("")
role String @default("viewer")
activeOrgId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -77,6 +78,7 @@ model Organization {
type String @default("church")
timezone String @default("America/Chicago")
settings Json?
allowedDomains String[] @default([])
onboardingComplete Boolean @default(false)
onboardingStep Int @default(0)
createdAt DateTime @default(now())

View file

@ -90,7 +90,7 @@ function SignupForm() {
<p className="mt-1 text-sm text-muted-foreground">
{token
? "Complete your account setup"
: "You need an invitation to sign up"}
: "Get started with Echo OCR"}
</p>
</div>

View file

@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { ScanLine, Building2, Zap, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function WorkspaceSetupPage() {
const router = useRouter();
const { update: updateSession } = useSession();
const [loading, setLoading] = useState<"create" | "skip" | null>(null);
async function handleCreate() {
router.push("/onboarding");
}
async function handleSkip() {
setLoading("skip");
try {
const res = await fetch("/api/org/create-personal", { method: "POST" });
if (!res.ok) {
const data = await res.json();
console.error("Failed to create personal workspace:", data.error);
setLoading(null);
return;
}
await updateSession();
router.push("/");
} catch (err) {
console.error("Failed to create personal workspace:", err);
setLoading(null);
}
}
return (
<div className="glass-card w-full rounded-2xl p-8">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight">
Set up your workspace
</h1>
<p className="mt-1 text-center text-sm text-muted-foreground">
Create a workspace for your team, or skip to get started with a personal one.
</p>
</div>
<div className="space-y-3">
<button
onClick={handleCreate}
disabled={!!loading}
className="group relative flex w-full items-start gap-4 rounded-xl border border-border/50 p-4 text-left transition-all hover:border-primary/50 hover:bg-accent/50 disabled:opacity-50"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Building2 className="size-5" />
</div>
<div>
<p className="font-semibold">Create a Workspace</p>
<p className="mt-0.5 text-sm text-muted-foreground">
Set up your organization with locations, team members, and integrations.
</p>
</div>
</button>
<button
onClick={handleSkip}
disabled={!!loading}
className="group relative flex w-full items-start gap-4 rounded-xl border border-border/50 p-4 text-left transition-all hover:border-primary/50 hover:bg-accent/50 disabled:opacity-50"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Zap className="size-5" />
</div>
<div className="flex-1">
<p className="font-semibold">Skip for Now</p>
<p className="mt-0.5 text-sm text-muted-foreground">
We&apos;ll create a personal workspace so you can start right away.
</p>
</div>
{loading === "skip" && (
<Loader2 className="size-4 animate-spin self-center text-muted-foreground" />
)}
</button>
</div>
</div>
);
}

View file

@ -3,7 +3,7 @@
import * as React from "react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { Loader2, Save, Building2, Globe, AlertTriangle } from "lucide-react";
import { Loader2, Save, Building2, Globe, AlertTriangle, X, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -23,6 +23,7 @@ import {
} from "@/components/ui/select";
const ORG_TYPES = [
{ value: "personal", label: "Personal" },
{ value: "church", label: "Church" },
{ value: "ministry", label: "Ministry" },
{ value: "nonprofit", label: "Nonprofit" },
@ -35,6 +36,7 @@ type OrgData = {
slug: string;
type: string;
timezone: string;
allowedDomains: string[];
};
export default function OrganizationSettingsPage() {
@ -42,6 +44,7 @@ export default function OrganizationSettingsPage() {
const [org, setOrg] = React.useState<OrgData | null>(null);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [domainInput, setDomainInput] = React.useState("");
React.useEffect(() => {
fetch("/api/org")
@ -64,6 +67,7 @@ export default function OrganizationSettingsPage() {
name: org.name,
type: org.type,
timezone: org.timezone,
allowedDomains: org.allowedDomains ?? [],
}),
});
if (res.ok) {
@ -154,6 +158,98 @@ export default function OrganizationSettingsPage() {
</CardContent>
</Card>
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mail className="size-4" />
Allowed Email Domains
</CardTitle>
<CardDescription>
Users who sign up with an email at these domains will automatically join this workspace.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{org.allowedDomains?.length > 0 && (
<div className="flex flex-wrap gap-2">
{org.allowedDomains.map((domain) => (
<span
key={domain}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"
>
{domain}
<button
type="button"
onClick={() =>
setOrg((prev) =>
prev
? {
...prev,
allowedDomains: prev.allowedDomains.filter(
(d) => d !== domain
),
}
: prev
)
}
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<Input
placeholder="example.com"
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = domainInput.trim().toLowerCase();
if (val && !org.allowedDomains?.includes(val)) {
setOrg((prev) =>
prev
? {
...prev,
allowedDomains: [...(prev.allowedDomains ?? []), val],
}
: prev
);
setDomainInput("");
}
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const val = domainInput.trim().toLowerCase();
if (val && !org.allowedDomains?.includes(val)) {
setOrg((prev) =>
prev
? {
...prev,
allowedDomains: [...(prev.allowedDomains ?? []), val],
}
: prev
);
setDomainInput("");
}
}}
>
Add
</Button>
</div>
<p className="text-xs text-muted-foreground">
Type a domain and press Enter or click Add. Changes are saved when you click Save Changes.
</p>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (

View file

@ -30,8 +30,8 @@ export async function POST(req: NextRequest) {
);
}
let role = "viewer";
let organizationId: string | null = null;
let inviteRole = "viewer";
let inviteOrgId: string | null = null;
if (inviteToken) {
const invitation = await prisma.invitation.findUnique({
@ -54,17 +54,8 @@ export async function POST(req: NextRequest) {
);
}
role = invitation.role;
organizationId = invitation.organizationId;
} else {
const orgCount = await prisma.organization.count();
if (orgCount > 0) {
return NextResponse.json(
{ error: "Registration requires an invitation" },
{ status: 403 }
);
}
role = "owner";
inviteRole = invitation.role;
inviteOrgId = invitation.organizationId;
}
const hashedPassword = await bcrypt.hash(password, 12);
@ -74,23 +65,50 @@ export async function POST(req: NextRequest) {
email,
displayName,
hashedPassword,
role,
role: "viewer",
},
});
if (organizationId) {
if (inviteOrgId) {
await prisma.orgMember.create({
data: {
userId: user.id,
organizationId,
role,
organizationId: inviteOrgId,
role: inviteRole,
},
});
if (inviteToken) {
await prisma.invitation.update({
where: { token: inviteToken },
data: { acceptedAt: new Date() },
await prisma.user.update({
where: { id: user.id },
data: { activeOrgId: inviteOrgId },
});
await prisma.invitation.update({
where: { token: inviteToken },
data: { acceptedAt: new Date() },
});
}
// Domain auto-join: add user to any org whose allowedDomains match
const emailDomain = email.split("@")[1]?.toLowerCase();
if (emailDomain) {
const matchingOrgs = await prisma.organization.findMany({
where: { allowedDomains: { has: emailDomain } },
select: { id: true },
});
for (const org of matchingOrgs) {
if (org.id === inviteOrgId) continue;
await prisma.orgMember.create({
data: { userId: user.id, organizationId: org.id, role: "viewer" },
});
}
if (!user.activeOrgId && matchingOrgs.length > 0) {
const firstOrg = inviteOrgId ?? matchingOrgs[0].id;
await prisma.user.update({
where: { id: user.id },
data: { activeOrgId: firstOrg },
});
}
}

View file

@ -115,6 +115,11 @@ export async function POST(req: NextRequest) {
},
});
await prisma.user.update({
where: { id: session.user.id },
data: { activeOrgId: org.id },
});
return NextResponse.json({ nextStep: 2, orgId: org.id });
}

View file

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 48);
}
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { displayName: true, email: true },
});
const displayName = user?.displayName || user?.email?.split("@")[0] || "User";
const orgName = `${displayName}'s Workspace`;
const baseSlug = slugify(orgName);
const slug = `${baseSlug}-${session.user.id.slice(0, 6)}`;
const org = await prisma.organization.create({
data: {
name: orgName,
slug,
type: "personal",
onboardingComplete: true,
onboardingStep: 99,
},
});
await prisma.orgMember.create({
data: {
userId: session.user.id,
organizationId: org.id,
role: "owner",
},
});
await prisma.user.update({
where: { id: session.user.id },
data: { activeOrgId: org.id },
});
return NextResponse.json({ success: true, orgId: org.id });
}

View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const memberships = await prisma.orgMember.findMany({
where: { userId: session.user.id },
include: {
organization: {
select: {
id: true,
name: true,
slug: true,
type: true,
onboardingComplete: true,
},
},
},
orderBy: { createdAt: "asc" },
});
const orgs = memberships.map((m) => ({
id: m.organization.id,
name: m.organization.name,
slug: m.organization.slug,
type: m.organization.type,
role: m.role,
onboardingComplete: m.organization.onboardingComplete,
}));
return NextResponse.json({ orgs, activeOrgId: session.user.orgId });
}

View file

@ -17,6 +17,7 @@ export async function GET() {
slug: true,
type: true,
timezone: true,
allowedDomains: true,
},
});
@ -62,15 +63,20 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { name, type, timezone } = await req.json();
const { name, type, timezone, allowedDomains } = await req.json();
const data: Record<string, string> = {};
const data: Record<string, unknown> = {};
if (name) {
data.name = name;
data.slug = slugify(name);
}
if (type) data.type = type;
if (timezone) data.timezone = timezone;
if (Array.isArray(allowedDomains)) {
data.allowedDomains = allowedDomains
.map((d: string) => d.trim().toLowerCase())
.filter(Boolean);
}
await prisma.organization.update({
where: { id: session.user.orgId },

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { orgId } = await req.json();
if (!orgId) {
return NextResponse.json({ error: "orgId is required" }, { status: 400 });
}
const membership = await prisma.orgMember.findUnique({
where: {
userId_organizationId: {
userId: session.user.id,
organizationId: orgId,
},
},
});
if (!membership) {
return NextResponse.json(
{ error: "You are not a member of this organization" },
{ status: 403 }
);
}
await prisma.user.update({
where: { id: session.user.id },
data: { activeOrgId: orgId },
});
return NextResponse.json({ success: true });
}

View file

@ -53,17 +53,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
: []),
],
callbacks: {
async jwt({ token, user }) {
async jwt({ token, user, trigger }) {
if (user) {
token.id = user.id;
}
if (token.id) {
// Refresh org data on sign-in or when session update is triggered
if (token.id && (user || trigger === "update")) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
include: {
memberships: {
take: 1,
include: {
organization: {
select: { id: true, name: true, onboardingComplete: true },
@ -78,13 +78,28 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.displayName = dbUser.displayName ?? undefined;
token.avatarUrl = dbUser.avatarUrl ?? undefined;
token.isEmailVerified = !!dbUser.emailVerified;
if (dbUser.memberships[0]) {
token.orgId = dbUser.memberships[0].organizationId;
token.orgName = dbUser.memberships[0].organization.name;
token.orgRole = dbUser.memberships[0].role;
token.onboardingComplete =
dbUser.memberships[0].organization.onboardingComplete ?? false;
const memberships = dbUser.memberships;
let activeMembership = memberships.find(
(m) => m.organizationId === dbUser.activeOrgId
);
if (!activeMembership && memberships.length > 0) {
activeMembership = memberships[0];
}
if (activeMembership) {
token.orgId = activeMembership.organizationId;
token.orgName = activeMembership.organization.name;
token.orgRole = activeMembership.role;
token.onboardingComplete =
activeMembership.organization.onboardingComplete ?? false;
} else {
token.orgId = undefined;
token.orgName = undefined;
token.orgRole = undefined;
token.onboardingComplete = false;
}
token.hasOrg = memberships.length > 0;
}
}
@ -100,6 +115,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session.user.avatarUrl = token.avatarUrl as string | undefined;
session.user.isEmailVerified = token.isEmailVerified ?? false;
session.user.onboardingComplete = token.onboardingComplete ?? false;
session.user.hasOrg = (token.hasOrg as boolean) ?? false;
}
return session;
},

View file

@ -0,0 +1,138 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUserProfile } from "@/lib/user-profile";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
type OrgListItem = {
id: string;
name: string;
slug: string;
type: string;
role: string;
};
export function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
const { orgId, orgName, switchOrg } = useUserProfile();
const router = useRouter();
const [orgs, setOrgs] = React.useState<OrgListItem[]>([]);
const [loading, setLoading] = React.useState(false);
const fetchOrgs = React.useCallback(async () => {
try {
const res = await fetch("/api/org/list");
if (res.ok) {
const data = await res.json();
setOrgs(data.orgs ?? []);
}
} catch {}
}, []);
const initial = orgName
? orgName
.split(/\s+/)
.map((w: string) => w[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "?";
const trigger = (
<DropdownMenuTrigger
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-sidebar-accent/50",
collapsed && "justify-center px-0"
)}
onClick={fetchOrgs}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-bold text-primary">
{initial}
</div>
{!collapsed && (
<>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-tight text-sidebar-foreground">
{orgName || "No workspace"}
</p>
</div>
<ChevronsUpDown className="size-4 shrink-0 text-sidebar-foreground/40" />
</>
)}
</DropdownMenuTrigger>
);
return (
<DropdownMenu>
{collapsed ? (
<Tooltip>
<TooltipTrigger render={<div />}>{trigger}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{orgName || "No workspace"}
</TooltipContent>
</Tooltip>
) : (
trigger
)}
<DropdownMenuContent
side={collapsed ? "right" : "bottom"}
align="start"
sideOffset={collapsed ? 8 : 4}
className="w-64"
>
{orgs.map((org) => (
<DropdownMenuItem
key={org.id}
className="flex items-center gap-2"
disabled={loading}
onClick={async () => {
if (org.id === orgId) return;
setLoading(true);
await switchOrg(org.id);
setLoading(false);
}}
>
<div className="flex size-6 shrink-0 items-center justify-center rounded bg-primary/10 text-[10px] font-bold text-primary">
{org.name
.split(/\s+/)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<span className="flex-1 truncate">{org.name}</span>
{org.id === orgId && (
<Check className="size-4 shrink-0 text-primary" />
)}
</DropdownMenuItem>
))}
{orgs.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => router.push("/onboarding")}
>
<div className="flex size-6 items-center justify-center">
<Plus className="size-4 text-muted-foreground" />
</div>
<span>Create new workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -20,6 +20,7 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { ScrollArea } from "@/components/ui/scroll-area";
import { OrgSwitcher } from "@/components/layout/org-switcher";
const STORAGE_KEY = "echo-sidebar-collapsed";
@ -143,6 +144,15 @@ export function Sidebar() {
collapsed ? "w-16" : "w-60"
)}
>
<div
className={cn(
"border-b border-sidebar-border p-3",
collapsed && "px-2"
)}
>
<OrgSwitcher collapsed={collapsed} />
</div>
<ScrollArea className="flex-1">
<nav className={cn("flex flex-col gap-1 p-3", collapsed && "px-2")}>
{navItems.map((item) => (

View file

@ -2,6 +2,7 @@
import * as React from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
export type UserRole = "admin" | "reviewer" | "viewer" | "editor" | "owner";
@ -33,8 +34,10 @@ type UserProfileContextValue = {
role: UserRole;
orgId?: string;
orgName?: string;
hasOrg: boolean;
isAuthenticated: boolean;
loading: boolean;
switchOrg: (orgId: string) => Promise<void>;
};
const UserProfileContext = React.createContext<UserProfileContextValue | null>(null);
@ -62,7 +65,8 @@ function saveLocalProfile(profile: Partial<UserProfile>) {
}
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const { data: session, status, update: updateSession } = useSession();
const router = useRouter();
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
const [mounted, setMounted] = React.useState(false);
@ -106,10 +110,24 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
const role: UserRole = (session?.user?.role as UserRole) ?? "viewer";
const orgId = session?.user?.orgId;
const orgName = session?.user?.orgName;
const hasOrg = session?.user?.hasOrg ?? false;
const switchOrg = React.useCallback(
async (targetOrgId: string) => {
await fetch("/api/org/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orgId: targetOrgId }),
});
await updateSession();
router.refresh();
},
[updateSession, router]
);
const value = React.useMemo(
() => ({ profile, updateProfile, initials, userId, role, orgId, orgName, isAuthenticated, loading }),
[profile, updateProfile, initials, userId, role, orgId, orgName, isAuthenticated, loading]
() => ({ profile, updateProfile, initials, userId, role, orgId, orgName, hasOrg, isAuthenticated, loading, switchOrg }),
[profile, updateProfile, initials, userId, role, orgId, orgName, hasOrg, isAuthenticated, loading, switchOrg]
);
if (!mounted) return <>{children}</>;
@ -132,8 +150,10 @@ export function useUserProfile() {
role: "viewer" as UserRole,
orgId: undefined,
orgName: undefined,
hasOrg: false,
isAuthenticated: false,
loading: false,
switchOrg: async () => {},
};
}
return ctx;

View file

@ -16,6 +16,15 @@ const publicPaths = [
"/api/ftp-watch/poll",
];
const workspaceSetupExemptPaths = [
"/workspace-setup",
"/onboarding",
"/api/onboarding",
"/api/org/create-personal",
"/api/org/list",
"/api/auth",
];
const onboardingExemptPaths = [
"/onboarding",
"/api/onboarding",
@ -67,8 +76,14 @@ export async function middleware(req: NextRequest) {
const hasOrg = !!token.orgId;
const onboardingDone = token.onboardingComplete === true;
if (!hasOrg && !isOnboardingExempt(pathname)) {
return NextResponse.redirect(new URL("/onboarding", req.url));
function isWorkspaceSetupExempt(p: string) {
return workspaceSetupExemptPaths.some(
(x) => p === x || p.startsWith(x + "/")
);
}
if (!hasOrg && !isWorkspaceSetupExempt(pathname)) {
return NextResponse.redirect(new URL("/workspace-setup", req.url));
}
if (hasOrg && !onboardingDone && !isOnboardingExempt(pathname)) {

View file

@ -14,6 +14,7 @@ declare module "next-auth" {
avatarUrl?: string;
isEmailVerified?: boolean;
onboardingComplete?: boolean;
hasOrg?: boolean;
};
}
@ -33,5 +34,6 @@ declare module "next-auth/jwt" {
avatarUrl?: string;
isEmailVerified?: boolean;
onboardingComplete?: boolean;
hasOrg?: boolean;
}
}