"use client"; import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; interface OrgMember { userId: string; email: string; displayName: string; givenName: string; familyName: string; roles: string[]; authorizationId: string; } interface Props { initialMembers: OrgMember[]; currentUserId: string; /** * Whether the viewing user can change other members' roles. True only * for customer owners. Server enforces this independently — this prop * is purely UX (don't render the control if the action would 403). */ canEditRoles: boolean; } type RoleOption = "owner" | "user"; /** * TeamList — renders the org's members. Refreshes after invites by * polling the API; the InviteForm broadcasts a `team:refresh` window * event after a successful invite so the list updates immediately * rather than waiting for the next reload. * * Slice 7 + Bug 25: owners can change other members' roles inline. * Clicking the "Change role" button on a row swaps the badge for a * dropdown + Save/Cancel pair. We deliberately don't use a modal — * the change is a single-field edit and the user already sees the row * context, so inline is faster. * * Self-row never shows the editor (server enforces too). Last-owner * demotion is enforced server-side; we surface the resulting 409 as a * row-local error rather than pre-validating client-side, because the * client doesn't know the org's full owner count without an extra * round trip. */ export function TeamList({ initialMembers, currentUserId, canEditRoles, }: Props) { const t = useTranslations("team"); const [members, setMembers] = useState(initialMembers); // Per-row editor state. `editingId` is the userId currently being // edited (only one at a time). `pendingRole` is the dropdown value. // `rowError` carries server-rejection messages keyed by userId. const [editingId, setEditingId] = useState(null); const [pendingRole, setPendingRole] = useState("user"); const [submitting, setSubmitting] = useState(false); const [rowError, setRowError] = useState>({}); useEffect(() => { function refresh() { fetch("/api/team") .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data?.members) setMembers(data.members); }) .catch(() => {}); } window.addEventListener("team:refresh", refresh); return () => window.removeEventListener("team:refresh", refresh); }, []); function startEdit(m: OrgMember) { const current = (m.roles[0] === "owner" ? "owner" : "user") as RoleOption; setEditingId(m.userId); setPendingRole(current); setRowError((e) => ({ ...e, [m.userId]: "" })); } function cancelEdit() { setEditingId(null); setSubmitting(false); } async function saveEdit(m: OrgMember) { setSubmitting(true); setRowError((e) => ({ ...e, [m.userId]: "" })); try { const res = await fetch( `/api/team/${encodeURIComponent(m.userId)}/role`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role: pendingRole }), } ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("roleUpdateFailed")); } // Optimistic update — replace the row's roles locally rather than // re-fetching the whole list. The list will eventually re-fetch // on the next `team:refresh` event anyway. setMembers((prev) => prev.map((x) => x.userId === m.userId ? { ...x, roles: [pendingRole] } : x ) ); setEditingId(null); } catch (err: any) { setRowError((e) => ({ ...e, [m.userId]: err.message })); } finally { setSubmitting(false); } } if (members.length === 0) { return (
{t("noMembers")}
); } return (
    {members.map((m) => { const isSelf = m.userId === currentUserId; const isEditing = editingId === m.userId; // Hide editor for self even when the viewer is an owner — // self-demotion is server-blocked and offering it as a UI // affordance would just produce errors. const showEditor = canEditRoles && !isSelf; const err = rowError[m.userId]; return (
  • {m.displayName || m.email} {isSelf && ( {t("you")} )}
    {m.email}
    {err && (
    {err}
    )}
    {isEditing ? ( <> ) : ( <>
    {m.roles.length === 0 && ( {t("noRole")} )} {m.roles.map((r) => ( {r} ))}
    {showEditor && ( )} )}
  • ); })}
); }