Skip to content

Commit 67fe256

Browse files
committed
Auth/signup overhaul: unified org UI w/ combobox; join flow keeps users signed out; pending join blocks sign-in; approved org sign-in works; restricted flow deletes user on sign-out; Suspense wrappers for useSearchParams; removed old sign-in pages; fixed API handler types and lints
1 parent 13cf268 commit 67fe256

25 files changed

Lines changed: 1046 additions & 635 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { NextRequest, NextResponse } from "next/server"
2+
import { getServerSession } from "next-auth"
3+
import { authOptions } from "@/lib/auth"
4+
import { prisma } from "@/lib/prisma"
5+
6+
// POST /api/org/join-requests/[id]/decision
7+
// body: { decision: "APPROVE" | "DECLINE" }
8+
export async function POST(
9+
req: NextRequest,
10+
context: { params: Promise<{ id: string }> }
11+
) {
12+
try {
13+
const session = await getServerSession(authOptions)
14+
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
15+
16+
const { id } = await context.params
17+
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 })
18+
19+
const body = (await req.json().catch(() => null)) as { decision?: "APPROVE" | "DECLINE" } | null
20+
const decision = body?.decision
21+
if (decision !== "APPROVE" && decision !== "DECLINE") {
22+
return NextResponse.json({ error: "Invalid decision" }, { status: 400 })
23+
}
24+
25+
// Load request and org
26+
const request = await prisma.organizationJoinRequest.findUnique({
27+
where: { id },
28+
select: { id: true, status: true, organizationId: true, userId: true },
29+
})
30+
if (!request) return NextResponse.json({ error: "Not found" }, { status: 404 })
31+
if (request.status !== "PENDING") return NextResponse.json({ error: "Already decided" }, { status: 400 })
32+
33+
// Authorization: approver must be member or owner-by-email of this org
34+
const organizationId = request.organizationId
35+
let member = await prisma.organizationMember.findFirst({ where: { organizationId, userId: session.user.id } })
36+
if (!member) {
37+
const [user, org] = await Promise.all([
38+
prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true } }),
39+
prisma.organization.findUnique({ where: { id: organizationId }, select: { email: true, contactEmail: true } }),
40+
])
41+
const isOwnerByEmail = !!(user?.email && org && (org.email === user.email || org.contactEmail === user.email))
42+
if (!isOwnerByEmail) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
43+
// Optional: backfill membership so future checks pass
44+
member = await prisma.organizationMember.upsert({
45+
where: { organizationId_userId: { organizationId, userId: session.user.id } },
46+
create: { organizationId, userId: session.user.id },
47+
update: {},
48+
})
49+
}
50+
51+
if (decision === "DECLINE") {
52+
await prisma.organizationJoinRequest.update({ where: { id }, data: { status: "DECLINED", decidedAt: new Date() } })
53+
return NextResponse.json({ ok: true })
54+
}
55+
56+
// APPROVE
57+
await prisma.$transaction(async (tx) => {
58+
// Create membership
59+
await tx.organizationMember.upsert({
60+
where: { organizationId_userId: { organizationId, userId: request.userId } },
61+
create: { organizationId, userId: request.userId },
62+
update: {},
63+
})
64+
// Promote to ORGANIZATION and mark profile complete
65+
await tx.user.update({ where: { id: request.userId }, data: { role: "ORGANIZATION", profileComplete: true } })
66+
// Mark request approved
67+
await tx.organizationJoinRequest.update({ where: { id }, data: { status: "APPROVED", decidedAt: new Date() } })
68+
})
69+
70+
return NextResponse.json({ ok: true })
71+
} catch (e) {
72+
console.error("POST /api/org/join-requests/[id]/decision error", e)
73+
return NextResponse.json({ error: "Server error" }, { status: 500 })
74+
}
75+
}

app/api/org/join-requests/route.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { NextResponse } from "next/server"
2+
import { getServerSession } from "next-auth"
3+
import { authOptions } from "@/lib/auth"
4+
import { prisma } from "@/lib/prisma"
5+
6+
// POST /api/org/join-requests
7+
// body: { organizationId: string, message?: string }
8+
export async function POST(req: Request) {
9+
try {
10+
const session = await getServerSession(authOptions)
11+
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
12+
13+
const body = (await req.json().catch(() => null)) as { organizationId?: string; message?: string } | null
14+
const organizationId = body?.organizationId
15+
const message = (body?.message || "").slice(0, 500) || undefined
16+
if (!organizationId) return NextResponse.json({ error: "Missing organizationId" }, { status: 400 })
17+
18+
// Verify org exists
19+
const org = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } })
20+
if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
21+
22+
// If already a member, no-op
23+
const existingMember = await prisma.organizationMember.findUnique({
24+
where: { organizationId_userId: { organizationId, userId: session.user.id } },
25+
})
26+
if (existingMember) return NextResponse.json({ ok: true, alreadyMember: true })
27+
28+
// If there is a pending request, ensure role is ORGANIZATION and return success
29+
const pending = await prisma.organizationJoinRequest.findFirst({
30+
where: { organizationId, userId: session.user.id, status: "PENDING" },
31+
})
32+
if (pending) {
33+
await prisma.user.update({ where: { id: session.user.id }, data: { role: "ORGANIZATION" } })
34+
return NextResponse.json({ ok: true, pending: true, id: pending.id })
35+
}
36+
37+
const created = await prisma.organizationJoinRequest.create({
38+
data: { organizationId, userId: session.user.id, message },
39+
select: { id: true, createdAt: true },
40+
})
41+
// Immediately mark the user's role as ORGANIZATION (profileComplete remains false until approved)
42+
await prisma.user.update({ where: { id: session.user.id }, data: { role: "ORGANIZATION" } })
43+
return NextResponse.json({ ok: true, id: created.id })
44+
} catch (e) {
45+
console.error("POST /api/org/join-requests error", e)
46+
return NextResponse.json({ error: "Server error" }, { status: 500 })
47+
}
48+
}
49+
50+
// GET /api/org/join-requests?organizationId=...
51+
// Returns pending join requests for an org (approvers only)
52+
export async function GET(req: Request) {
53+
try {
54+
const { searchParams } = new URL(req.url)
55+
const organizationId = searchParams.get("organizationId")
56+
if (!organizationId) return NextResponse.json({ error: "Missing organizationId" }, { status: 400 })
57+
58+
const session = await getServerSession(authOptions)
59+
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
60+
61+
// Authorization: must be a member OR owner-by-email
62+
let member = await prisma.organizationMember.findFirst({ where: { organizationId, userId: session.user.id } })
63+
if (!member) {
64+
const [user, org] = await Promise.all([
65+
prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true } }),
66+
prisma.organization.findUnique({ where: { id: organizationId }, select: { email: true, contactEmail: true } }),
67+
])
68+
const isOwnerByEmail = !!(user?.email && org && (org.email === user.email || org.contactEmail === user.email))
69+
if (!isOwnerByEmail) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
70+
// Optional: backfill membership so future checks pass
71+
member = await prisma.organizationMember.upsert({
72+
where: { organizationId_userId: { organizationId, userId: session.user.id } },
73+
create: { organizationId, userId: session.user.id },
74+
update: {},
75+
})
76+
}
77+
78+
const requests = await prisma.organizationJoinRequest.findMany({
79+
where: { organizationId, status: "PENDING" },
80+
orderBy: { createdAt: "asc" },
81+
select: {
82+
id: true,
83+
message: true,
84+
createdAt: true,
85+
user: { select: { id: true, name: true, email: true, image: true } },
86+
},
87+
})
88+
89+
return NextResponse.json({ data: requests })
90+
} catch (e) {
91+
console.error("GET /api/org/join-requests error", e)
92+
return NextResponse.json({ error: "Server error" }, { status: 500 })
93+
}
94+
}

app/api/org/settings/route.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { NextRequest, NextResponse } from "next/server"
1+
import { NextResponse } from "next/server"
22
import { getServerSession } from "next-auth"
33
import { authOptions } from "@/lib/auth"
44
import { prisma } from "@/lib/prisma"
55

66
// Load current organization's settings (name, contactEmail)
7-
export async function GET(req: NextRequest) {
7+
export async function GET() {
88
const session = await getServerSession(authOptions)
99
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
1010

@@ -36,12 +36,6 @@ export async function GET(req: NextRequest) {
3636
}
3737
if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
3838

39-
const envBase = process.env.NEXTAUTH_URL?.trim()
40-
const reqOrigin = (() => {
41-
try { return new URL(req.url).origin } catch { return null }
42-
})()
43-
const baseUrl = envBase || reqOrigin || null
44-
4539
return NextResponse.json({
4640
id: org.id,
4741
name: org.name ?? "",
@@ -52,12 +46,11 @@ export async function GET(req: NextRequest) {
5246
defaultEventLocationTemplate: org.defaultEventLocationTemplate ?? null,
5347
defaultVolunteersNeeded: org.defaultVolunteersNeeded ?? null,
5448
userEmail: userEmail,
55-
baseUrl,
5649
})
5750
}
5851

5952
// Update current organization's settings
60-
export async function PUT(req: NextRequest) {
53+
export async function PUT(req: Request) {
6154
const session = await getServerSession(authOptions)
6255
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
6356

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { NextResponse } from "next/server";
2+
import { getServerSession } from "next-auth";
3+
import { authOptions } from "@/lib/auth";
4+
import { prisma } from "@/lib/prisma";
5+
6+
export async function POST() {
7+
try {
8+
const session = await getServerSession(authOptions);
9+
const userId = session?.user?.id;
10+
if (userId) {
11+
// Delete the user and cascade to related entities per schema
12+
await prisma.user.delete({ where: { id: userId } });
13+
}
14+
return NextResponse.json({ ok: true });
15+
} catch (e) {
16+
console.error("restricted signout delete failed", e);
17+
return NextResponse.json({ ok: false }, { status: 500 });
18+
}
19+
}

app/auth/signin/SignInClient.tsx

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
"use client"
22

33
import * as React from "react"
4+
import Link from "next/link"
45
import { useSearchParams } from "next/navigation"
56
import { signIn } from "next-auth/react"
7+
import { Button } from "@/components/ui/button"
8+
import { Card, CardContent, CardHeader } from "@/components/ui/card"
9+
import { Toaster } from "@/components/ui/sonner"
10+
import { toast } from "sonner"
611

712
export default function SignInClient() {
813
const params = useSearchParams()
@@ -11,44 +16,68 @@ export default function SignInClient() {
1116
const cb = params.get("callbackUrl") || "/"
1217
const mode = params.get("mode") || undefined
1318

19+
React.useEffect(() => {
20+
const err = params.get("error")
21+
if (err === "org_pending") {
22+
toast.error("Your request is pending approval. You can sign in after an admin approves.")
23+
}
24+
}, [params])
25+
1426
return (
15-
<main className="min-h-screen flex items-center justify-center p-6">
16-
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
17-
<div className="mb-4">
18-
<h1 className="text-xl font-semibold">Sign in</h1>
19-
<p className="text-sm text-muted-foreground">
20-
{mode === "org-invite"
21-
? "Continue to join the organization with your Google account."
22-
: "Continue with your Google account."}
23-
</p>
27+
<main className="min-h-[calc(100vh-4rem)] p-6 bg-gradient-to-b from-primary/20 via-transparent to-transparent">
28+
<header className="mb-6 flex justify-end">
29+
<Button asChild variant="default">
30+
<Link href="/auth/signup">Sign up</Link>
31+
</Button>
32+
</header>
33+
<div className="min-h-[calc(100vh-10rem)] flex items-center justify-center">
34+
<div className="w-full max-w-sm">
35+
<Card>
36+
<CardHeader className="text-center space-y-1">
37+
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
38+
<p className="text-sm text-muted-foreground">
39+
{mode === "org-invite"
40+
? "Continue to join the organization with your Google account."
41+
: "Continue with your Google account."}
42+
</p>
43+
</CardHeader>
44+
<CardContent>
45+
<button
46+
type="button"
47+
disabled={loading}
48+
onClick={async () => {
49+
setLoading(true)
50+
try {
51+
try {
52+
// Clear any stale volunteer/org-intent cookies to avoid misrouting
53+
document.cookie = "signup_user=; Max-Age=0; Path=/; SameSite=Lax";
54+
document.cookie = "org_join_orgId=; Max-Age=0; Path=/; SameSite=Lax";
55+
} catch {}
56+
await signIn("google", { callbackUrl: cb })
57+
} finally {
58+
setLoading(false)
59+
}
60+
}}
61+
className="w-full inline-flex items-center justify-center gap-2 rounded-md bg-orange-500 px-4 py-2 text-white hover:bg-orange-600 disabled:opacity-50"
62+
>
63+
<span className="inline-flex h-4 w-4 items-center justify-center">
64+
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden>
65+
<path d="M21.35 11.1h-8.9v2.98h5.1c-.22 1.3-.93 2.4-1.98 3.14l3.2 2.48c1.87-1.73 2.95-4.28 2.95-7.38 0-.64-.06-1.25-.17-1.83z" />
66+
<path d="M12.45 22c2.67 0 4.91-.88 6.55-2.39l-3.2-2.48c-.89 .6-2.02 .95-3.35 .95-2.57 0-4.75-1.73-5.53-4.06H3.59v2.55A9.55 9.55 0 0 0 12.45 22z" />
67+
<path d="M6.92 13.99a5.73 5.73 0 0 1 0-3.98V7.46H3.59a9.57 9.57 0 0 0 0 9.08l3.33-2.55z" />
68+
<path d="M12.45 5.52c1.45 0 2.74 .5 3.76 1.47l2.82-2.82A9.52 9.52 0 0 0 12.45 2 9.55 9.55 0 0 0 3.59 7.46l3.33 2.55c.78-2.33 2.96-4.49 5.53-4.49z" />
69+
</svg>
70+
</span>
71+
{loading ? "Redirecting…" : "Continue with Google"}
72+
</button>
73+
<p className="mt-4 text-[11px] text-muted-foreground text-center">
74+
By continuing, you agree to our Terms and acknowledge our Privacy Policy.
75+
</p>
76+
</CardContent>
77+
</Card>
2478
</div>
25-
<button
26-
type="button"
27-
disabled={loading}
28-
onClick={async () => {
29-
setLoading(true)
30-
try {
31-
await signIn("google", { callbackUrl: cb })
32-
} finally {
33-
setLoading(false)
34-
}
35-
}}
36-
className="w-full inline-flex items-center justify-center gap-2 rounded-md bg-orange-500 px-4 py-2 text-white hover:bg-orange-600 disabled:opacity-50"
37-
>
38-
<span className="inline-flex h-4 w-4 items-center justify-center">
39-
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden>
40-
<path d="M21.35 11.1h-8.9v2.98h5.1c-.22 1.3-.93 2.4-1.98 3.14l3.2 2.48c1.87-1.73 2.95-4.28 2.95-7.38 0-.64-.06-1.25-.17-1.83z" />
41-
<path d="M12.45 22c2.67 0 4.91-.88 6.55-2.39l-3.2-2.48c-.89.6-2.02.95-3.35.95-2.57 0-4.75-1.73-5.53-4.06H3.59v2.55A9.55 9.55 0 0 0 12.45 22z" />
42-
<path d="M6.92 13.99a5.73 5.73 0 0 1 0-3.98V7.46H3.59a9.57 9.57 0 0 0 0 9.08l3.33-2.55z" />
43-
<path d="M12.45 5.52c1.45 0 2.74.5 3.76 1.47l2.82-2.82A9.52 9.52 0 0 0 12.45 2 9.55 9.55 0 0 0 3.59 7.46l3.33 2.55c.78-2.33 2.96-4.49 5.53-4.49z" />
44-
</svg>
45-
</span>
46-
{loading ? "Redirecting…" : "Continue with Google"}
47-
</button>
48-
<p className="mt-4 text-[11px] text-muted-foreground">
49-
By continuing, you agree to our Terms and acknowledge our Privacy Policy.
50-
</p>
5179
</div>
80+
<Toaster />
5281
</main>
5382
)
5483
}

app/auth/signin/cwru/page.tsx

Lines changed: 0 additions & 40 deletions
This file was deleted.

0 commit comments

Comments
 (0)