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:
parent
af91f3d5fc
commit
ab873f7f08
16 changed files with 583 additions and 39 deletions
|
|
@ -18,6 +18,7 @@ model User {
|
||||||
displayName String?
|
displayName String?
|
||||||
avatarUrl String @default("")
|
avatarUrl String @default("")
|
||||||
role String @default("viewer")
|
role String @default("viewer")
|
||||||
|
activeOrgId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -77,6 +78,7 @@ model Organization {
|
||||||
type String @default("church")
|
type String @default("church")
|
||||||
timezone String @default("America/Chicago")
|
timezone String @default("America/Chicago")
|
||||||
settings Json?
|
settings Json?
|
||||||
|
allowedDomains String[] @default([])
|
||||||
onboardingComplete Boolean @default(false)
|
onboardingComplete Boolean @default(false)
|
||||||
onboardingStep Int @default(0)
|
onboardingStep Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ function SignupForm() {
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{token
|
{token
|
||||||
? "Complete your account setup"
|
? "Complete your account setup"
|
||||||
: "You need an invitation to sign up"}
|
: "Get started with Echo OCR"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
88
src/app/(auth)/workspace-setup/page.tsx
Normal file
88
src/app/(auth)/workspace-setup/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { toast } from "sonner";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
const ORG_TYPES = [
|
const ORG_TYPES = [
|
||||||
|
{ value: "personal", label: "Personal" },
|
||||||
{ value: "church", label: "Church" },
|
{ value: "church", label: "Church" },
|
||||||
{ value: "ministry", label: "Ministry" },
|
{ value: "ministry", label: "Ministry" },
|
||||||
{ value: "nonprofit", label: "Nonprofit" },
|
{ value: "nonprofit", label: "Nonprofit" },
|
||||||
|
|
@ -35,6 +36,7 @@ type OrgData = {
|
||||||
slug: string;
|
slug: string;
|
||||||
type: string;
|
type: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
allowedDomains: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrganizationSettingsPage() {
|
export default function OrganizationSettingsPage() {
|
||||||
|
|
@ -42,6 +44,7 @@ export default function OrganizationSettingsPage() {
|
||||||
const [org, setOrg] = React.useState<OrgData | null>(null);
|
const [org, setOrg] = React.useState<OrgData | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [domainInput, setDomainInput] = React.useState("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetch("/api/org")
|
fetch("/api/org")
|
||||||
|
|
@ -64,6 +67,7 @@ export default function OrganizationSettingsPage() {
|
||||||
name: org.name,
|
name: org.name,
|
||||||
type: org.type,
|
type: org.type,
|
||||||
timezone: org.timezone,
|
timezone: org.timezone,
|
||||||
|
allowedDomains: org.allowedDomains ?? [],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -154,6 +158,98 @@ export default function OrganizationSettingsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export async function POST(req: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let role = "viewer";
|
let inviteRole = "viewer";
|
||||||
let organizationId: string | null = null;
|
let inviteOrgId: string | null = null;
|
||||||
|
|
||||||
if (inviteToken) {
|
if (inviteToken) {
|
||||||
const invitation = await prisma.invitation.findUnique({
|
const invitation = await prisma.invitation.findUnique({
|
||||||
|
|
@ -54,17 +54,8 @@ export async function POST(req: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
role = invitation.role;
|
inviteRole = invitation.role;
|
||||||
organizationId = invitation.organizationId;
|
inviteOrgId = invitation.organizationId;
|
||||||
} else {
|
|
||||||
const orgCount = await prisma.organization.count();
|
|
||||||
if (orgCount > 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Registration requires an invitation" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
role = "owner";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
@ -74,25 +65,52 @@ export async function POST(req: NextRequest) {
|
||||||
email,
|
email,
|
||||||
displayName,
|
displayName,
|
||||||
hashedPassword,
|
hashedPassword,
|
||||||
role,
|
role: "viewer",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (organizationId) {
|
if (inviteOrgId) {
|
||||||
await prisma.orgMember.create({
|
await prisma.orgMember.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId,
|
organizationId: inviteOrgId,
|
||||||
role,
|
role: inviteRole,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (inviteToken) {
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { activeOrgId: inviteOrgId },
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.invitation.update({
|
await prisma.invitation.update({
|
||||||
where: { token: inviteToken },
|
where: { token: inviteToken },
|
||||||
data: { acceptedAt: new Date() },
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
return NextResponse.json({ nextStep: 2, orgId: org.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
53
src/app/api/org/create-personal/route.ts
Normal file
53
src/app/api/org/create-personal/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
37
src/app/api/org/list/route.ts
Normal file
37
src/app/api/org/list/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ export async function GET() {
|
||||||
slug: true,
|
slug: true,
|
||||||
type: true,
|
type: true,
|
||||||
timezone: true,
|
timezone: true,
|
||||||
|
allowedDomains: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -62,15 +63,20 @@ export async function PUT(req: NextRequest) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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) {
|
if (name) {
|
||||||
data.name = name;
|
data.name = name;
|
||||||
data.slug = slugify(name);
|
data.slug = slugify(name);
|
||||||
}
|
}
|
||||||
if (type) data.type = type;
|
if (type) data.type = type;
|
||||||
if (timezone) data.timezone = timezone;
|
if (timezone) data.timezone = timezone;
|
||||||
|
if (Array.isArray(allowedDomains)) {
|
||||||
|
data.allowedDomains = allowedDomains
|
||||||
|
.map((d: string) => d.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.organization.update({
|
await prisma.organization.update({
|
||||||
where: { id: session.user.orgId },
|
where: { id: session.user.orgId },
|
||||||
|
|
|
||||||
38
src/app/api/org/switch/route.ts
Normal file
38
src/app/api/org/switch/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
34
src/auth.ts
34
src/auth.ts
|
|
@ -53,17 +53,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user, trigger }) {
|
||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id;
|
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({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: { id: token.id as string },
|
where: { id: token.id as string },
|
||||||
include: {
|
include: {
|
||||||
memberships: {
|
memberships: {
|
||||||
take: 1,
|
|
||||||
include: {
|
include: {
|
||||||
organization: {
|
organization: {
|
||||||
select: { id: true, name: true, onboardingComplete: true },
|
select: { id: true, name: true, onboardingComplete: true },
|
||||||
|
|
@ -78,13 +78,28 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
token.displayName = dbUser.displayName ?? undefined;
|
token.displayName = dbUser.displayName ?? undefined;
|
||||||
token.avatarUrl = dbUser.avatarUrl ?? undefined;
|
token.avatarUrl = dbUser.avatarUrl ?? undefined;
|
||||||
token.isEmailVerified = !!dbUser.emailVerified;
|
token.isEmailVerified = !!dbUser.emailVerified;
|
||||||
if (dbUser.memberships[0]) {
|
|
||||||
token.orgId = dbUser.memberships[0].organizationId;
|
const memberships = dbUser.memberships;
|
||||||
token.orgName = dbUser.memberships[0].organization.name;
|
let activeMembership = memberships.find(
|
||||||
token.orgRole = dbUser.memberships[0].role;
|
(m) => m.organizationId === dbUser.activeOrgId
|
||||||
token.onboardingComplete =
|
);
|
||||||
dbUser.memberships[0].organization.onboardingComplete ?? false;
|
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.avatarUrl = token.avatarUrl as string | undefined;
|
||||||
session.user.isEmailVerified = token.isEmailVerified ?? false;
|
session.user.isEmailVerified = token.isEmailVerified ?? false;
|
||||||
session.user.onboardingComplete = token.onboardingComplete ?? false;
|
session.user.onboardingComplete = token.onboardingComplete ?? false;
|
||||||
|
session.user.hasOrg = (token.hasOrg as boolean) ?? false;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
138
src/components/layout/org-switcher.tsx
Normal file
138
src/components/layout/org-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { OrgSwitcher } from "@/components/layout/org-switcher";
|
||||||
|
|
||||||
const STORAGE_KEY = "echo-sidebar-collapsed";
|
const STORAGE_KEY = "echo-sidebar-collapsed";
|
||||||
|
|
||||||
|
|
@ -143,6 +144,15 @@ export function Sidebar() {
|
||||||
collapsed ? "w-16" : "w-60"
|
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">
|
<ScrollArea className="flex-1">
|
||||||
<nav className={cn("flex flex-col gap-1 p-3", collapsed && "px-2")}>
|
<nav className={cn("flex flex-col gap-1 p-3", collapsed && "px-2")}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export type UserRole = "admin" | "reviewer" | "viewer" | "editor" | "owner";
|
export type UserRole = "admin" | "reviewer" | "viewer" | "editor" | "owner";
|
||||||
|
|
||||||
|
|
@ -33,8 +34,10 @@ type UserProfileContextValue = {
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
orgName?: string;
|
orgName?: string;
|
||||||
|
hasOrg: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
switchOrg: (orgId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserProfileContext = React.createContext<UserProfileContextValue | null>(null);
|
const UserProfileContext = React.createContext<UserProfileContextValue | null>(null);
|
||||||
|
|
@ -62,7 +65,8 @@ function saveLocalProfile(profile: Partial<UserProfile>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
|
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 [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
|
||||||
const [mounted, setMounted] = React.useState(false);
|
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 role: UserRole = (session?.user?.role as UserRole) ?? "viewer";
|
||||||
const orgId = session?.user?.orgId;
|
const orgId = session?.user?.orgId;
|
||||||
const orgName = session?.user?.orgName;
|
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(
|
const value = React.useMemo(
|
||||||
() => ({ 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, isAuthenticated, loading]
|
[profile, updateProfile, initials, userId, role, orgId, orgName, hasOrg, isAuthenticated, loading, switchOrg]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return <>{children}</>;
|
if (!mounted) return <>{children}</>;
|
||||||
|
|
@ -132,8 +150,10 @@ export function useUserProfile() {
|
||||||
role: "viewer" as UserRole,
|
role: "viewer" as UserRole,
|
||||||
orgId: undefined,
|
orgId: undefined,
|
||||||
orgName: undefined,
|
orgName: undefined,
|
||||||
|
hasOrg: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
switchOrg: async () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ const publicPaths = [
|
||||||
"/api/ftp-watch/poll",
|
"/api/ftp-watch/poll",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const workspaceSetupExemptPaths = [
|
||||||
|
"/workspace-setup",
|
||||||
|
"/onboarding",
|
||||||
|
"/api/onboarding",
|
||||||
|
"/api/org/create-personal",
|
||||||
|
"/api/org/list",
|
||||||
|
"/api/auth",
|
||||||
|
];
|
||||||
|
|
||||||
const onboardingExemptPaths = [
|
const onboardingExemptPaths = [
|
||||||
"/onboarding",
|
"/onboarding",
|
||||||
"/api/onboarding",
|
"/api/onboarding",
|
||||||
|
|
@ -67,8 +76,14 @@ export async function middleware(req: NextRequest) {
|
||||||
const hasOrg = !!token.orgId;
|
const hasOrg = !!token.orgId;
|
||||||
const onboardingDone = token.onboardingComplete === true;
|
const onboardingDone = token.onboardingComplete === true;
|
||||||
|
|
||||||
if (!hasOrg && !isOnboardingExempt(pathname)) {
|
function isWorkspaceSetupExempt(p: string) {
|
||||||
return NextResponse.redirect(new URL("/onboarding", req.url));
|
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)) {
|
if (hasOrg && !onboardingDone && !isOnboardingExempt(pathname)) {
|
||||||
|
|
|
||||||
2
src/types/next-auth.d.ts
vendored
2
src/types/next-auth.d.ts
vendored
|
|
@ -14,6 +14,7 @@ declare module "next-auth" {
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
isEmailVerified?: boolean;
|
isEmailVerified?: boolean;
|
||||||
onboardingComplete?: boolean;
|
onboardingComplete?: boolean;
|
||||||
|
hasOrg?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,5 +34,6 @@ declare module "next-auth/jwt" {
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
isEmailVerified?: boolean;
|
isEmailVerified?: boolean;
|
||||||
onboardingComplete?: boolean;
|
onboardingComplete?: boolean;
|
||||||
|
hasOrg?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue