Skip to content

Commit f9d185d

Browse files
committed
Rescind invitations
1 parent 75fcb78 commit f9d185d

7 files changed

Lines changed: 146 additions & 5 deletions

File tree

app/(app)/admin/page.tsx

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

3-
import { useEffect } from "react";
3+
import { useEffect, useState } from "react";
44
import { useRouter } from "next/navigation";
55
import { useAuth } from "@/lib/auth/context";
66
import { InviteForm } from "@/components/admin/InviteForm";
77
import { MemberList } from "@/components/admin/MemberList";
8+
import { InvitationList } from "@/components/admin/InvitationList";
89

910
export default function AdminPage() {
1011
const { user, isAdmin, loading } = useAuth();
1112
const router = useRouter();
13+
const [inviteRefreshKey, setInviteRefreshKey] = useState(0);
1214

1315
useEffect(() => {
1416
if (!loading && !isAdmin) {
@@ -19,9 +21,10 @@ export default function AdminPage() {
1921
if (loading || !isAdmin || !user) return null;
2022

2123
return (
22-
<div className="space-y-6">
24+
<div className="space-y-10">
2325
<h1 className="text-2xl font-semibold">Admin</h1>
24-
<InviteForm />
26+
<InviteForm onSuccess={() => setInviteRefreshKey((k) => k + 1)} />
27+
<InvitationList refreshKey={inviteRefreshKey} />
2528
<MemberList currentUid={user.uid} />
2629
</div>
2730
);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { adminDb } from "@/lib/firebase/admin";
3+
import { getTokens } from "next-firebase-auth-edge";
4+
import { authConfig } from "@/lib/firebase/auth-edge";
5+
import { deleteInvitation } from "@/lib/firestore/invitations";
6+
7+
export async function DELETE(
8+
request: NextRequest,
9+
{ params }: { params: Promise<{ code: string }> }
10+
) {
11+
try {
12+
const tokens = await getTokens(request.cookies, authConfig);
13+
if (!tokens) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14+
15+
const userDoc = await adminDb.collection("users").doc(tokens.decodedToken.uid).get();
16+
if (userDoc.data()?.role !== "admin") {
17+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
18+
}
19+
20+
const { code } = await params;
21+
await deleteInvitation(code);
22+
return NextResponse.json({ ok: true });
23+
} catch (error) {
24+
console.error("Delete invitation error:", error);
25+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
26+
}
27+
}

app/api/invitations/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Resend } from "resend";
44
import { Timestamp } from "firebase-admin/firestore";
55
import { getTokens } from "next-firebase-auth-edge";
66
import { authConfig } from "@/lib/firebase/auth-edge";
7+
import { listInvitations } from "@/lib/firestore/invitations";
78

89
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
910
const isDev = process.env.NODE_ENV === "development";
@@ -41,6 +42,24 @@ async function sendInviteEmail(params: {
4142
});
4243
}
4344

45+
export async function GET(request: NextRequest) {
46+
try {
47+
const tokens = await getTokens(request.cookies, authConfig);
48+
if (!tokens) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
49+
50+
const inviterDoc = await adminDb.collection("users").doc(tokens.decodedToken.uid).get();
51+
if (inviterDoc.data()?.role !== "admin") {
52+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
53+
}
54+
55+
const invitations = await listInvitations();
56+
return NextResponse.json(invitations);
57+
} catch (error) {
58+
console.error("List invitations error:", error);
59+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
60+
}
61+
}
62+
4463
export async function POST(request: NextRequest) {
4564
try {
4665
const tokens = await getTokens(request.cookies, authConfig);

app/api/posts/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
6969
const usersSnap = await adminDb.collection("users").get();
7070
const subscribers = usersSnap.docs
7171
.map((d) => d.data())
72-
.filter((u) => u.emailNotifications !== false && u.email);
72+
.filter((u) => u.emailNotifications === true && u.email);
7373

7474
if (subscribers.length > 0) {
7575
await sendPostNotifications(subscribers as { email: string }[], title, description);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { Invitation } from "@/types";
5+
import { Button } from "@/components/ui/button";
6+
import { Badge } from "@/components/ui/badge";
7+
import { toast } from "sonner";
8+
9+
export function InvitationList({ refreshKey }: { refreshKey?: number }) {
10+
const [invitations, setInvitations] = useState<Invitation[]>([]);
11+
const [loading, setLoading] = useState(true);
12+
const [revoking, setRevoking] = useState<string | null>(null);
13+
14+
useEffect(() => {
15+
setLoading(true);
16+
fetch("/api/invitations")
17+
.then((r) => (r.ok ? r.json() : []))
18+
.then(setInvitations)
19+
.catch(console.error)
20+
.finally(() => setLoading(false));
21+
}, [refreshKey]);
22+
23+
const rescind = async (code: string, name: string) => {
24+
setRevoking(code);
25+
try {
26+
const res = await fetch(`/api/invitations/${code}`, { method: "DELETE" });
27+
if (!res.ok) throw new Error("Failed");
28+
setInvitations((prev) => prev.filter((i) => i.code !== code));
29+
toast.success(`Invitation to ${name} rescinded.`);
30+
} catch {
31+
toast.error("Failed to rescind invitation.");
32+
} finally {
33+
setRevoking(null);
34+
}
35+
};
36+
37+
return (
38+
<div>
39+
<h2 className="text-lg font-semibold mb-1">Sent invitations</h2>
40+
<p className="text-sm text-muted-foreground mb-4">
41+
Pending invitations can be rescinded to invalidate the signup link.
42+
</p>
43+
{loading ? (
44+
<p className="text-sm text-muted-foreground">Loading…</p>
45+
) : invitations.length === 0 ? (
46+
<p className="text-sm text-muted-foreground">No invitations sent yet.</p>
47+
) : (
48+
<div className="divide-y rounded-md border max-w-2xl">
49+
{invitations.map((inv) => (
50+
<div key={inv.code} className="flex items-center justify-between px-4 py-3 gap-4">
51+
<div className="min-w-0">
52+
<p className="font-medium truncate">
53+
{inv.firstName} {inv.lastName}
54+
</p>
55+
<p className="text-sm text-muted-foreground truncate">{inv.email}</p>
56+
<p className="text-xs text-muted-foreground mt-0.5">
57+
Sent {new Date(inv.sentAt).toLocaleDateString()}
58+
</p>
59+
</div>
60+
<div className="flex items-center gap-3 shrink-0">
61+
{inv.usedAt ? (
62+
<Badge variant="secondary">Accepted</Badge>
63+
) : (
64+
<>
65+
<Badge variant="outline">Pending</Badge>
66+
<button
67+
className="text-sm underline text-muted-foreground hover:text-foreground disabled:opacity-50"
68+
disabled={revoking === inv.code}
69+
onClick={() => rescind(inv.code, `${inv.firstName} ${inv.lastName}`)}
70+
>
71+
{revoking === inv.code ? "Cancelling…" : "Cancel"}
72+
</button>
73+
</>
74+
)}
75+
</div>
76+
</div>
77+
))}
78+
</div>
79+
)}
80+
</div>
81+
);
82+
}

components/admin/InviteForm.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const schema = z.object({
1717

1818
type FormData = z.infer<typeof schema>;
1919

20-
export function InviteForm() {
20+
export function InviteForm({ onSuccess }: { onSuccess?: () => void }) {
2121
const [loading, setLoading] = useState(false);
2222

2323
const {
@@ -41,6 +41,7 @@ export function InviteForm() {
4141
}
4242
toast.success(`Invitation sent to ${data.email}`);
4343
reset();
44+
onSuccess?.();
4445
} catch (err) {
4546
toast.error(err instanceof Error ? err.message : "Failed to send invitation.");
4647
} finally {

lib/firestore/invitations.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ export async function getInvitationByEmail(email: string): Promise<Invitation |
2525
export async function markInvitationUsed(code: string): Promise<void> {
2626
await adminDb.collection("invitations").doc(code).update({ usedAt: FieldValue.serverTimestamp() });
2727
}
28+
29+
export async function listInvitations(): Promise<Invitation[]> {
30+
const snap = await adminDb.collection("invitations").orderBy("sentAt", "desc").get();
31+
return snap.docs.map((d) => serializeInvitation(d.data()));
32+
}
33+
34+
export async function deleteInvitation(code: string): Promise<void> {
35+
await adminDb.collection("invitations").doc(code).delete();
36+
}

0 commit comments

Comments
 (0)