Skip to content

Commit 4e88497

Browse files
committed
org settings: enable owner-only delete + export UI and add member self-deactivation; harden owner-only checks on org delete/settings/export
- Owner-only: enforce on DELETE /api/org/delete, PUT /api/org/settings, GET /api/org/export\n- UI: Enable Delete organization (owners) and Export as JSON (owners) on /org/settings\n- UI: Add 'Delete my account' for non-owner members, calls DELETE /api/user/deactivate and signs out\n- Misc: loading/disabled states and confirmations
1 parent afb8b25 commit 4e88497

4 files changed

Lines changed: 107 additions & 39 deletions

File tree

app/api/org/delete/route.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,13 @@ export async function DELETE() {
1010
const session = await getServerSession(authOptions)
1111
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
1212

13-
// Resolve org via same logic as org/settings
14-
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true, id: true } })
13+
// Resolve org by email and enforce OWNER-only (email or contactEmail match)
14+
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true } })
1515
if (!user?.email) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
16-
const org = await prisma.organization.findFirst({ where: { email: user.email }, select: { id: true } })
16+
const org = await prisma.organization.findFirst({ where: { OR: [{ email: user.email }, { contactEmail: user.email }] }, select: { id: true, email: true, contactEmail: true } })
1717
if (!org?.id) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
18-
19-
// Ensure requester is a member (avoids delete via just email match)
20-
const member = await prisma.organizationMember.findFirst({ where: { organizationId: org.id, userId: session.user.id } })
21-
if (!member) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
18+
const isOwner = org.email === user.email || org.contactEmail === user.email
19+
if (!isOwner) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
2220

2321
await prisma.organization.delete({ where: { id: org.id } })
2422
return NextResponse.json({ ok: true })

app/api/org/export/route.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,11 @@ export async function GET(req: NextRequest) {
2525
const type = searchParams.get("type") as "signups" | "volunteers" | "messages" | "account" | null
2626
if (!type) return NextResponse.json({ error: "Missing type" }, { status: 400 })
2727

28-
// Resolve org by current user's email (same as org/settings), then ensure requester is a member
29-
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true, id: true } })
28+
// Owner-only: resolve org by matching user's email to org.email or org.contactEmail
29+
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true } })
3030
if (!user?.email) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
31-
const org = await prisma.organization.findFirst({ where: { email: user.email }, select: { id: true } })
32-
if (!org?.id) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
33-
const member = await prisma.organizationMember.findFirst({ where: { organizationId: org.id, userId: session.user.id } })
34-
if (!member) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
31+
const org = await prisma.organization.findFirst({ where: { OR: [{ email: user.email }, { contactEmail: user.email }] }, select: { id: true } })
32+
if (!org?.id) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
3533

3634
let csv = ""
3735
let filename = "export.csv"

app/api/org/settings/route.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,12 @@ export async function PUT(req: Request) {
6464
const defaultVolunteersNeeded = body?.defaultVolunteersNeeded as number | undefined
6565
if (!name) return NextResponse.json({ error: "Missing name" }, { status: 400 })
6666

67-
// Resolve organization by membership first; fallback to email heuristic
68-
let orgIdResolved: string | null = null
69-
const member = await prisma.organizationMember.findFirst({ where: { userId: session.user.id }, select: { organizationId: true } })
70-
if (member?.organizationId) orgIdResolved = member.organizationId
71-
if (!orgIdResolved) {
72-
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true } })
73-
if (user?.email) {
74-
const org = await prisma.organization.findFirst({ where: { email: user.email }, select: { id: true } })
75-
orgIdResolved = org?.id ?? null
76-
}
77-
}
78-
if (!orgIdResolved) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
67+
// Owner-only: resolve org by matching user's email to org.email or org.contactEmail
68+
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { email: true } })
69+
if (!user?.email) return NextResponse.json({ error: "Organization not found" }, { status: 404 })
70+
const ownerOrg = await prisma.organization.findFirst({ where: { OR: [{ email: user.email }, { contactEmail: user.email }] }, select: { id: true } })
71+
const orgIdResolved = ownerOrg?.id ?? null
72+
if (!orgIdResolved) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
7973

8074
const updated = await prisma.organization.update({
8175
where: { id: orgIdResolved },

app/org/settings/page.tsx

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client"
22

33
import * as React from "react"
4+
import { signOut } from "next-auth/react"
45
import { Button } from "@/components/ui/button"
56
import { Card, CardContent } from "@/components/ui/card"
67
import { Input } from "@/components/ui/input"
@@ -15,6 +16,7 @@ export default function Page() {
1516
const [saving, setSaving] = React.useState(false)
1617
const [orgId, setOrgId] = React.useState<string | null>(null)
1718
const [orgEmail, setOrgEmail] = React.useState<string>("")
19+
const [orgContactEmail, setOrgContactEmail] = React.useState<string>("")
1820
const [userEmail, setUserEmail] = React.useState<string>("")
1921
const [members, setMembers] = React.useState<Array<{ id: string; user: { id: string; name: string | null; email: string; image: string | null } }>>([])
2022
const [pending, setPending] = React.useState<Array<{ id: string; message: string | null; createdAt: string; user: { id: string; name: string | null; email: string; image: string | null } }>>([])
@@ -42,6 +44,7 @@ export default function Page() {
4244
const [defaultLocation, setDefaultLocation] = React.useState<string>("")
4345
const [defaultHours, setDefaultHours] = React.useState<number | undefined>(2)
4446
const [defaultVolunteers, setDefaultVolunteers] = React.useState<number | undefined>(10)
47+
const [deleting, setDeleting] = React.useState(false)
4548

4649
// Simple option lists; can be expanded
4750
const timezones = React.useMemo(() => {
@@ -71,6 +74,7 @@ export default function Page() {
7174
setOrgName(json?.name ?? "")
7275
if (json?.id) setOrgId(json.id as string)
7376
setOrgEmail((json?.email as string) || "")
77+
setOrgContactEmail((json?.contactEmail as string) || "")
7478
setUserEmail((json?.userEmail as string) || "")
7579
// baseUrl no longer used
7680
setTimezone((prev) => (json?.timezone as string) || prev)
@@ -351,12 +355,33 @@ export default function Page() {
351355
<p className="text-sm text-muted-foreground">Export a full JSON snapshot of your data.</p>
352356
</div>
353357
<div className="shrink-0">
354-
<Button
355-
className="bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50 disabled:pointer-events-none"
356-
disabled
357-
>
358-
Export as JSON
359-
</Button>
358+
{(() => {
359+
const isOwner = Boolean(userEmail) && (userEmail === orgEmail || (!!orgContactEmail && userEmail === orgContactEmail))
360+
const disabled = loading || saving || !isOwner
361+
return (
362+
<Button
363+
className="bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50 disabled:pointer-events-none"
364+
disabled={disabled}
365+
onClick={async () => {
366+
try {
367+
const r = await fetch("/api/org/export?type=account")
368+
if (!r.ok) return
369+
const blob = await r.blob()
370+
const url = URL.createObjectURL(blob)
371+
const a = document.createElement("a")
372+
a.href = url
373+
a.download = `organization-${orgId || "account"}.json`
374+
document.body.appendChild(a)
375+
a.click()
376+
a.remove()
377+
URL.revokeObjectURL(url)
378+
} catch {}
379+
}}
380+
>
381+
Export as JSON
382+
</Button>
383+
)
384+
})()}
360385
</div>
361386
</div>
362387
</CardContent>
@@ -371,14 +396,67 @@ export default function Page() {
371396
<p className="text-sm text-muted-foreground">Be careful—this action is permanent.</p>
372397
</div>
373398
<div className="shrink-0">
374-
<Button
375-
variant="destructive"
376-
disabled
377-
className="disabled:opacity-50 disabled:pointer-events-none"
378-
title="Temporarily disabled"
379-
>
380-
Delete organization (disabled)
381-
</Button>
399+
{(() => {
400+
const isOwner = Boolean(userEmail) && (userEmail === orgEmail || (!!orgContactEmail && userEmail === orgContactEmail))
401+
const disabled = loading || saving || deleting
402+
if (isOwner) {
403+
return (
404+
<Button
405+
variant="destructive"
406+
disabled={disabled}
407+
className="disabled:opacity-50 disabled:pointer-events-none"
408+
title="Delete this organization"
409+
onClick={async () => {
410+
const confirmed = window.confirm("This will permanently delete the organization and all related data. Continue?")
411+
if (!confirmed) return
412+
try {
413+
setDeleting(true)
414+
const r = await fetch("/api/org/delete", { method: "DELETE" })
415+
if (!r.ok) {
416+
alert("Failed to delete organization")
417+
return
418+
}
419+
await signOut({ callbackUrl: "/" })
420+
} catch {
421+
alert("Failed to delete organization")
422+
} finally {
423+
setDeleting(false)
424+
}
425+
}}
426+
>
427+
{deleting ? "Deleting…" : "Delete organization"}
428+
</Button>
429+
)
430+
}
431+
// Non-owner: allow deleting their own account
432+
return (
433+
<Button
434+
variant="destructive"
435+
disabled={disabled}
436+
className="disabled:opacity-50 disabled:pointer-events-none"
437+
title="Delete my account"
438+
onClick={async () => {
439+
const confirmed = window.confirm("This will permanently delete your account and remove your access to this org. Continue?")
440+
if (!confirmed) return
441+
try {
442+
setDeleting(true)
443+
const r = await fetch("/api/user/deactivate", { method: "DELETE" })
444+
if (!r.ok) {
445+
alert("Failed to delete account")
446+
return
447+
}
448+
await signOut({ callbackUrl: "/" })
449+
} catch {
450+
alert("Failed to delete account")
451+
} finally {
452+
setDeleting(false)
453+
}
454+
}}
455+
>
456+
{deleting ? "Deleting…" : "Delete my account"}
457+
</Button>
458+
)
459+
})()}
382460
</div>
383461
</div>
384462
</CardContent>

0 commit comments

Comments
 (0)