Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/app/api/room-invitations/[invitationId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import {
getRoomInvitation,
respondToRoomInvitation,
} from "@/lib/supabase-rooms";
import { NextResponse } from "next/server";

export async function POST(
req: Request,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.name)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const invitation = await getRoomInvitation(invitationId);
if (!invitation)
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 }
);
if (invitation.github_username !== session.user.name)
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (invitation.status !== "pending")
return NextResponse.json(
{ error: "Invitation already responded to" },
{ status: 409 }
);

const { action } = await req.json();
if (action !== "accept" && action !== "decline")
return NextResponse.json(
{ error: 'action must be "accept" or "decline"' },
{ status: 400 }
);

const updated = await respondToRoomInvitation(
invitationId,
action === "accept"
);
return NextResponse.json({ success: true, status: updated?.status });
}
13 changes: 13 additions & 0 deletions src/app/api/room-invitations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getPendingInvitationsForUser } from "@/lib/supabase-rooms";
import { NextResponse } from "next/server";

export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.name)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const invitations = await getPendingInvitationsForUser(session.user.name);
return NextResponse.json({ invitations });
}
65 changes: 49 additions & 16 deletions src/app/api/rooms/[roomId]/invite/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getRoomById, getRoomMembers, addRoomMember } from '@/lib/supabase-rooms';
import { NextResponse } from 'next/server';
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import {
getRoomById,
getRoomMembers,
createRoomInvitation,
notifyRoomInvitation,
} from "@/lib/supabase-rooms";
import { NextResponse } from "next/server";

export async function POST(
req: Request,
Expand All @@ -10,32 +15,60 @@ export async function POST(
const { roomId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.name)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const room = await getRoomById(roomId, session.user.name);
if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
if (!room) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!room.is_owner)
return NextResponse.json({ error: 'Only the room owner can invite' }, { status: 403 });
return NextResponse.json(
{ error: "Only the room owner can invite" },
{ status: 403 }
);
const { github_username } = await req.json();
if (!github_username?.trim())
return NextResponse.json({ error: 'github_username required' }, { status: 400 });
return NextResponse.json(
{ error: "github_username required" },
{ status: 400 }
);
// GitHub usernames: 1-39 chars, alphanumeric + hyphens, no leading/trailing hyphen
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(github_username))
return NextResponse.json({ error: 'Invalid GitHub username' }, { status: 400 });
return NextResponse.json(
{ error: "Invalid GitHub username" },
{ status: 400 }
);
const ghRes = await fetch(`https://api.github.com/users/${github_username}`, {
headers: {
Accept: 'application/vnd.github+json',
Accept: "application/vnd.github+json",
...(process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {}),
},
});
if (ghRes.status === 404)
return NextResponse.json({ error: `GitHub user "${github_username}" does not exist` }, { status: 404 });
return NextResponse.json(
{ error: `GitHub user "${github_username}" does not exist` },
{ status: 404 }
);
if (!ghRes.ok)
return NextResponse.json({ error: 'Could not verify GitHub user' }, { status: 502 });
return NextResponse.json(
{ error: "Could not verify GitHub user" },
{ status: 502 }
);
const members = await getRoomMembers(roomId);
if (members.some((m) => m.github_username === github_username))
return NextResponse.json({ error: 'User is already a member' }, { status: 409 });
await addRoomMember(roomId, github_username);
return NextResponse.json({ success: true });
}
return NextResponse.json(
{ error: "User is already a member" },
{ status: 409 }
);
try {
await createRoomInvitation(roomId, github_username, session.user.name);
} catch (error: any) {
if (error?.code === "23505")
return NextResponse.json(
{ error: "An invitation is already pending for this user" },
{ status: 409 }
);
throw error;
}
await notifyRoomInvitation(github_username, room.name, session.user.name);
return NextResponse.json({ success: true, status: "pending" });
}
3 changes: 3 additions & 0 deletions src/app/rooms/RoomsListClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import type { CollaborationRoom } from '@/types/rooms';
import CreateRoomModal from '@/components/rooms/CreateRoomModal';
import PendingInvitations from '@/components/rooms/PendingInvitations';

interface Props {
initialRooms: CollaborationRoom[];
Expand Down Expand Up @@ -32,6 +33,8 @@ export default function RoomsListClient({ initialRooms, currentUser }: Props) {
</button>
</div>

<PendingInvitations />

{/* Room cards */}
{rooms.length === 0 ? (
<div className="text-center py-20 text-gray-400">
Expand Down
100 changes: 56 additions & 44 deletions src/components/rooms/InviteModal.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,88 @@
'use client';
"use client";

import { useState } from 'react';
import { useState } from "react";

interface Props {
roomId: string;
onClose: () => void;
onInvited: (username: string) => void;
}

export default function InviteModal({ roomId, onClose, onInvited }: Props) {
const [username, setUsername] = useState('');
const [username, setUsername] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const [sent, setSent] = useState(false);
async function handleInvite(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);

const res = await fetch(`/api/rooms/${roomId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ github_username: username.trim() }),
});

const data = await res.json();
setLoading(false);

if (!res.ok) {
setError(data.error ?? 'Invite failed');
setError(data.error ?? "Invite failed");
return;
}

onInvited(username.trim());
onClose();
setSent(true);
}

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h2 className="text-lg font-semibold mb-4">Invite by GitHub Username</h2>

<form onSubmit={handleInvite} className="space-y-4">
<input
autoFocus
className="w-full border rounded-lg px-3 py-2 text-sm dark:bg-gray-800 dark:border-gray-700"
placeholder="github-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>

{error && <p className="text-red-500 text-sm">{error}</p>}

<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !username.trim()}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Inviting…' : 'Send Invite'}
</button>
<h2 className="text-lg font-semibold mb-4">
Invite by GitHub Username
</h2>
{sent ? (
<div className="space-y-4">
<p className="text-sm text-green-600 dark:text-green-400">
Invitation sent to <strong>{username.trim()}</strong>.
They&apos;ll join once they accept it.
</p>
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700"
>
Done
</button>
</div>
</div>
</form>
) : (
<form onSubmit={handleInvite} className="space-y-4">
<input
autoFocus
className="w-full border rounded-lg px-3 py-2 text-sm dark:bg-gray-800 dark:border-gray-700"
placeholder="github-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !username.trim()}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Inviting…" : "Send Invite"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
}
38 changes: 23 additions & 15 deletions src/components/rooms/MembersPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';
"use client";

import { useState } from 'react';
import type { RoomMember } from '@/types/rooms';
import InviteModal from './InviteModal';
import { useState } from "react";
import type { RoomMember } from "@/types/rooms";
import InviteModal from "./InviteModal";

interface Props {
roomId: string;
Expand All @@ -12,7 +12,13 @@ interface Props {
onMemberRemoved: (username: string) => void;
}

export default function MembersPanel({ roomId, members, isOwner, onMemberAdded, onMemberRemoved }: Props) {
export default function MembersPanel({
roomId,
members,
isOwner,
onMemberAdded,
onMemberRemoved,
}: Props) {
const [showInvite, setShowInvite] = useState(false);
const [removingUsername, setRemovingUsername] = useState<string | null>(null);

Expand All @@ -22,16 +28,16 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
try {
const res = await fetch(
`/api/rooms/${roomId}/members/${encodeURIComponent(username)}`,
{ method: 'DELETE' }
{ method: "DELETE" }
);
if (res.ok) {
onMemberRemoved(username);
} else {
const data = await res.json().catch(() => ({}));
alert((data as { error?: string }).error ?? 'Failed to remove member');
alert((data as { error?: string }).error ?? "Failed to remove member");
}
} catch {
alert('Network error. Please try again.');
alert("Network error. Please try again.");
} finally {
setRemovingUsername(null);
}
Expand Down Expand Up @@ -64,18 +70,20 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
/>
<div className="min-w-0 flex-1">
<p className="text-sm truncate">{m.github_username}</p>
{m.role === 'owner' && (
<span className="text-[10px] text-yellow-600 dark:text-yellow-400">owner</span>
{m.role === "owner" && (
<span className="text-[10px] text-yellow-600 dark:text-yellow-400">
owner
</span>
)}
</div>
{isOwner && m.role !== 'owner' && (
{isOwner && m.role !== "owner" && (
<button
onClick={() => handleRemove(m.github_username)}
disabled={removingUsername === m.github_username}
aria-label={`Remove ${m.github_username}`}
className="shrink-0 text-[10px] text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-40"
>
{removingUsername === m.github_username ? '…' : '✕'}
{removingUsername === m.github_username ? "…" : "✕"}
</button>
)}
</div>
Expand All @@ -86,9 +94,9 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
<InviteModal
roomId={roomId}
onClose={() => setShowInvite(false)}
onInvited={(username) => {
onMemberAdded(username);
setShowInvite(false);
onInvited={() => {
// Invitation is pending, not an actual membership — don't add to
// the members list until the invitee accepts.
}}
/>
)}
Expand Down
Loading
Loading