diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index c6fda4e..57e9d06 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -147,7 +147,7 @@ func main() { mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) - // mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork)) + mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork)) // Billing (network admin only; admin check happens inside each handler) mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling)) diff --git a/go/cmd/pusherservice/main.go b/go/cmd/pusherservice/main.go index 21fd9b7..9bc5cb6 100644 --- a/go/cmd/pusherservice/main.go +++ b/go/cmd/pusherservice/main.go @@ -37,8 +37,8 @@ func main() { pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // Services - authSvc := auth.NewAuthService(authRedis, nil, nil) // nil aeroSvc / fbAuth — pusher only calls GetSession - networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil) // nil aeroSvc / noop billing / nil firestore — pusher never mutates membership + authSvc := auth.NewAuthService(authRedis, nil, nil) // nil aeroSvc / fbAuth — pusher only calls GetSession + networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil) // nil aeroSvc / noop billing / nil firestore — pusher never mutates membership // Pod identity (use hostname in k8s, which is the pod name) podID, err := os.Hostname() diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 7989edc..86d9488 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -546,34 +546,28 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// RemoveMemberFromNetwork removes a member from a network +// RemoveMemberFromNetwork removes a member from a network. Admin-only. +// Admins cannot remove themselves — doing so would leave networks.admin_human_id +// dangling. Removal of a non-member is a no-op (204). func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) { - humanId, ok := middleware.HumanIdFromContext(r.Context()) + net, _, ok := h.loadNetworkForAdmin(w, r) if !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) return } - networkID := r.PathValue("id") memberHumanId := r.PathValue("humanId") - if networkID == "" || memberHumanId == "" { - http.Error(w, "network id and member humanId are required", http.StatusBadRequest) + if memberHumanId == "" { + http.Error(w, "member humanId is required", http.StatusBadRequest) return } - isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) - if err != nil { - slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - if !isMember { - http.Error(w, "access denied", http.StatusForbidden) + if memberHumanId == net.AdminHumanId { + http.Error(w, "admin cannot remove themselves", http.StatusConflict) return } - if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil { - slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId) + if err := h.networkSvc.RemoveMember(r.Context(), net.ID, memberHumanId); err != nil { + slog.Error("failed to remove member from network", "error", err, "network_id", net.ID, "memberHumanId", memberHumanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } diff --git a/js/src/api/client.ts b/js/src/api/client.ts index bb6699d..938355d 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -192,10 +192,10 @@ class ApiClient { ); } - async removeMember(networkId: string, email: string): Promise { + async removeMember(networkId: string, humanId: string): Promise { await this.requestVoid( "DELETE", - `/networks/${networkId}/members/${email}`, + `/networks/${networkId}/members/${humanId}`, ); } diff --git a/js/src/components/confirm-destructive-overlay.tsx b/js/src/components/confirm-destructive-overlay.tsx new file mode 100644 index 0000000..ffd51f5 --- /dev/null +++ b/js/src/components/confirm-destructive-overlay.tsx @@ -0,0 +1,72 @@ +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "@/components/ui/button"; + +interface ConfirmDestructiveOverlayProps { + title: string; + description: React.ReactNode; + confirmLabel: string; + pendingLabel?: string; + isPending: boolean; + onConfirm: () => void; + onClose: () => void; +} + +export function ConfirmDestructiveOverlay({ + title, + description, + confirmLabel, + pendingLabel = "Working…", + isPending, + onConfirm, + onClose, +}: ConfirmDestructiveOverlayProps) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", handler, { capture: true }); + return () => window.removeEventListener("keydown", handler, { capture: true }); + }, [onClose]); + + return createPortal( +
+
+
+
+

{title}

+ + + Esc + {" "} + to close + +
+ +
{description}
+ +
+ + +
+
+
, + document.body, + ); +} diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx index ae6e841..01e1b86 100644 --- a/js/src/features/network-selector.tsx +++ b/js/src/features/network-selector.tsx @@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { useNetworks } from "@/hooks/use-networks"; -import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-invitations"; +import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management"; import { apiClient } from "@/api/client"; import { Progress } from "@/components/ui/progress"; import type { Network, Invitation } from "@/api/types"; diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx index f44ddbf..81edf50 100644 --- a/js/src/features/network-settings.tsx +++ b/js/src/features/network-settings.tsx @@ -15,12 +15,22 @@ import { useNetworkInvitations, useInviteMembers, useRevokeInvitation, -} from "@/hooks/use-invitations"; + useRemoveMember, +} from "@/hooks/use-member-management"; import { useAuthStore } from "@/stores/auth-store"; import { BillingSection } from "@/features/network-billing"; +import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay"; import type { Human } from "@/api/types"; -function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) { +function MemberRow({ + human, + isAdmin, + onRemove, +}: { + human: Human; + isAdmin: boolean; + onRemove?: () => void; +}) { const initials = human.email_prefix.slice(0, 2).toUpperCase(); return ( @@ -40,6 +50,17 @@ function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) { Admin )} + {onRemove && ( + + )}
); } @@ -170,6 +191,8 @@ export default function NetworkSettingsPage() { const { data: invitations } = useNetworkInvitations(networkId!); const currentUser = useAuthStore((s) => s.user); const isAdmin = currentUser?.id === network?.admin_human.id; + const [memberToRemove, setMemberToRemove] = useState(null); + const removeMember = useRemoveMember(networkId!); const billingRef = useRef(null); @@ -228,17 +251,23 @@ export default function NetworkSettingsPage() { } /> - {network?.humans.map((human, index) => ( -
- - {index < network.humans.length - 1 && ( - - )} -
- ))} + {network?.humans.map((human, index) => { + const isRowAdmin = human.id === network.admin_human.id; + const canRemove = + isAdmin && !isRowAdmin && human.id !== currentUser?.id; + return ( +
+ setMemberToRemove(human) : undefined} + /> + {index < network.humans.length - 1 && ( + + )} +
+ ); + })} {isAdmin && network && ( @@ -299,6 +328,42 @@ export default function NetworkSettingsPage() {
+ + {memberToRemove && ( + +
  • + They'll lose access to this network's streams and files within + seconds. +
  • +
  • Any content they posted stays in the network.
  • +
  • + If they're in a live huddle, they may remain until the call ends. +
  • + + } + confirmLabel="Remove" + pendingLabel="Removing…" + isPending={removeMember.isPending} + onConfirm={() => { + const target = memberToRemove; + removeMember.mutate(target.id, { + onSuccess: () => { + toast.success(`Removed ${target.email}`); + setMemberToRemove(null); + }, + onError: (err) => { + toast.error(err.message || "Failed to remove member"); + }, + }); + }} + onClose={() => { + if (!removeMember.isPending) setMemberToRemove(null); + }} + /> + )}
    ); } diff --git a/js/src/features/particles/delete-particle-overlay.tsx b/js/src/features/particles/delete-particle-overlay.tsx index 3287c5c..7f15b86 100644 --- a/js/src/features/particles/delete-particle-overlay.tsx +++ b/js/src/features/particles/delete-particle-overlay.tsx @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; +import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay"; import { softDeleteParticle } from "@/lib/firestore-particles"; import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import type { Particle } from "@/api/types"; @@ -40,55 +39,20 @@ export function DeleteParticleOverlay({ } }, [deleting, networkId, onClose, particle.id, streamId, userId]); - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - onClose(); - } - }; - window.addEventListener("keydown", handler, { capture: true }); - return () => window.removeEventListener("keydown", handler, { capture: true }); - }, [onClose]); - - return createPortal( -
    -
    -
    -
    -

    Delete this particle?

    - - - Esc - {" "} - to close - -
    - -

    + return ( + This cannot be undone. Other viewers will see a "This particle was deleted" message in its place.

    - -
    - - -
    -
    -
    , - document.body, + } + confirmLabel="Delete" + pendingLabel="Deleting…" + isPending={deleting} + onConfirm={handleDelete} + onClose={onClose} + /> ); } diff --git a/js/src/hooks/use-invitations.ts b/js/src/hooks/use-member-management.ts similarity index 84% rename from js/src/hooks/use-invitations.ts rename to js/src/hooks/use-member-management.ts index dbf1c42..fb0547f 100644 --- a/js/src/hooks/use-invitations.ts +++ b/js/src/hooks/use-member-management.ts @@ -50,3 +50,13 @@ export function useRevokeInvitation(networkId: string) { }, }); } + +export function useRemoveMember(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["networks"] }); + }, + }); +}