-
Notifications
You must be signed in to change notification settings - Fork 0
Shared projects #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
F1nnM
wants to merge
16
commits into
main
Choose a base branch
from
shared-projects
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
45487bb
feat(store): add project_members table and locking columns
F1nnM 9647ecc
feat(store): add ProjectMember dataclass and CRUD functions
F1nnM b8046cf
feat(store): change access control from ownership to membership
F1nnM 44bcad1
feat(store): add session locking functions
F1nnM c4b12ae
test(store): add force-claim locking test
F1nnM d30ca04
fix(store): atomic locking, nullable user_sub, migration for existing…
F1nnM be37e78
feat(api): add member management endpoints
F1nnM 9383241
feat(ws): add session locking on INIT with force-claim support
F1nnM b373de9
feat(store): allow any member to delete/rename projects
F1nnM 2b4e819
feat(frontend): add project sharing UI and session locking modals
F1nnM afb4ae8
fix: resolve frontend-backend protocol mismatches for session locking
F1nnM 7625759
fix: address PR review feedback for project sharing
F1nnM 9fb8a7b
fix: address second round of PR review feedback
F1nnM c6fef34
Update src/runtimes/ws_server.py
F1nnM 5c0263e
Update src/runtimes/ws_server.py
F1nnM cfa460f
Update src/runtimes/ws_server.py
F1nnM File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.