Skip to content
Open
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
124 changes: 124 additions & 0 deletions frontend/src/components/ProjectLockedModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useState } from "react";
import { AlertTriangle, ArrowLeft, UserCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface ProjectLockedModalProps {
lockedByEmail: string;
lockedAt?: string;
onTakeOver: () => void;
onGoBack: () => void;
className?: string;
}

export function ProjectLockedModal({
lockedByEmail,
lockedAt,
onTakeOver,
onGoBack,
className,
}: ProjectLockedModalProps) {
const [confirmTakeover, setConfirmTakeover] = useState(false);

const formatLockedTime = (isoString?: string) => {
if (!isoString) return null;
try {
const date = new Date(isoString);
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
} catch {
return null;
}
};

const lockedTime = formatLockedTime(lockedAt);

return (
<div
className={cn(
"fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm",
className
)}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="bg-amber-50 border-b border-amber-100 px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
<UserCheck className="w-5 h-5 text-amber-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
Project in use
</h2>
<p className="text-sm text-gray-600">
Someone else is currently editing
</p>
</div>
</div>
</div>

{/* Content */}
<div className="px-6 py-5 space-y-4">
<p className="text-gray-700">
This project is being edited by{" "}
<span className="font-medium text-gray-900">{lockedByEmail}</span>
{lockedTime && (
<span className="text-gray-500"> since {lockedTime}</span>
)}
.
</p>

{!confirmTakeover ? (
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button
variant="outline"
onClick={onGoBack}
className="flex-1 gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go back
</Button>
<Button
variant="default"
onClick={() => setConfirmTakeover(true)}
className="flex-1"
>
Take over session
</Button>
</div>
) : (
<div className="space-y-3 pt-2">
<div className="flex items-start gap-2 p-3 bg-amber-50 rounded-md border border-amber-200">
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
Taking over will disconnect{" "}
<span className="font-medium">{lockedByEmail}</span> from the
project. They may lose unsaved changes.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<Button
variant="outline"
onClick={() => setConfirmTakeover(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onTakeOver}
className="flex-1"
>
Take over anyway
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
204 changes: 204 additions & 0 deletions frontend/src/components/ProjectMembers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useState, useCallback, useEffect, type FormEvent } from "react";
import { Loader2, UserPlus, X, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AGENT_CONFIG } from "../config/agent";
import { cn } from "@/lib/utils";

interface ProjectMember {
user_sub: string | null;
user_email: string;
added_at: string | null;
}

interface ProjectMembersProps {
projectId: string;
className?: string;
}

export function ProjectMembers({ projectId, className }: ProjectMembersProps) {
const [members, setMembers] = useState<ProjectMember[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newEmail, setNewEmail] = useState("");
const [adding, setAdding] = useState(false);
const [removingEmail, setRemovingEmail] = useState<string | null>(null);

const fetchMembers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(
`${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members`,
{ credentials: "include" }
);
if (!res.ok) {
throw new Error("Failed to load members");
}
const data = await res.json();
setMembers(data.members || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load members");
} finally {
setLoading(false);
}
}, [projectId]);

useEffect(() => {
fetchMembers();
}, [fetchMembers]);

const handleAddMember = async (e: FormEvent) => {
e.preventDefault();
const email = newEmail.trim().toLowerCase();
if (!email || !email.includes("@")) return;

try {
setAdding(true);
setError(null);
const res = await fetch(
`${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to add member");
}
setNewEmail("");
await fetchMembers();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add member");
} finally {
setAdding(false);
}
};

const handleRemoveMember = async (userSub: string | null, userEmail: string) => {
if (members.length <= 1) return;

try {
setRemovingEmail(userEmail);
setError(null);
// Use by-email endpoint for pending members (null user_sub), otherwise by user_sub
const endpoint = userSub
? `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members/${encodeURIComponent(userSub)}`
: `${AGENT_CONFIG.HTTP_URL}api/projects/${projectId}/members/by-email/${encodeURIComponent(userEmail)}`;
const res = await fetch(endpoint, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to remove member");
}
await fetchMembers();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to remove member");
} finally {
setRemovingEmail(null);
}
};

const canRemove = members.length > 1;

return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-700">
<Users className="w-4 h-4" />
<span>People with access</span>
</div>

{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
) : (
<>
{/* Member list */}
<div className="space-y-2">
{members.map((member) => (
<div
key={member.user_email}
className="flex items-center justify-between gap-3 px-3 py-2 bg-gray-50 rounded-md border border-gray-200"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-sm font-medium text-gray-600 shrink-0">
{member.user_email.charAt(0).toUpperCase()}
</div>
<span className="text-sm text-gray-700 truncate">
{member.user_email}
</span>
{!member.user_sub && (
<span className="text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
Pending
</span>
)}
</div>
<button
type="button"
onClick={() =>
handleRemoveMember(member.user_sub, member.user_email)
}
disabled={!canRemove || removingEmail === member.user_email}
className={cn(
"p-1.5 rounded-md transition-colors shrink-0",
canRemove
? "hover:bg-red-100 text-gray-400 hover:text-red-600"
: "text-gray-300 cursor-not-allowed"
)}
title={
!canRemove
? "Cannot remove the last member"
: "Remove member"
}
>
{removingEmail === member.user_email ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<X className="w-4 h-4" />
)}
</button>
</div>
))}
</div>

{/* Add member form */}
<form onSubmit={handleAddMember} className="flex gap-2">
<Input
type="email"
placeholder="Add by email..."
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={adding}
className="flex-1"
/>
<Button
type="submit"
disabled={adding || !newEmail.trim() || !newEmail.includes("@")}
size="default"
>
{adding ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<UserPlus className="w-4 h-4" />
)}
<span className="sr-only sm:not-sr-only sm:ml-1">Add</span>
</Button>
</form>

{/* Error message */}
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-md">
{error}
</p>
)}
</>
)}
</div>
);
}
61 changes: 61 additions & 0 deletions frontend/src/components/SessionClaimedModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { UserX } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface SessionClaimedModalProps {
claimedByEmail?: string;
onDismiss: () => void;
className?: string;
}

export function SessionClaimedModal({
claimedByEmail,
onDismiss,
className,
}: SessionClaimedModalProps) {
return (
<div
className={cn(
"fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm",
className
)}
>
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="bg-red-50 border-b border-red-100 px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<UserX className="w-5 h-5 text-red-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
Session ended
</h2>
<p className="text-sm text-gray-600">
Another user took over this project
</p>
</div>
</div>
</div>

{/* Content */}
<div className="px-6 py-5 space-y-4">
<p className="text-gray-700">
Your editing session was taken over
{claimedByEmail && (
<>
{" "}
by <span className="font-medium text-gray-900">{claimedByEmail}</span>
</>
)}
. You'll be redirected to the project list.
</p>

<Button onClick={onDismiss} className="w-full">
Go to projects
</Button>
</div>
</div>
</div>
);
}
Loading