"use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { ApiError, api, type InstanceRole } from "@/lib/api"; import { useSession } from "@/lib/session"; import { Button, controlClass } from "@/components/Button"; import { EmptyState, Panel } from "@/components/Panel"; const ROLES: InstanceRole[] = ["owner", "admin", "member"]; /* * What each rank actually lets someone do, in the instance rather than in the * portal. The select used to offer three words with no statement of what they * bought — which is a permissions control that declines to explain permissions. */ const ROLE_GRANTS: Record = { owner: "Everything, including billing and deleting the instance.", admin: "Manage servers, workflows, secrets and settings.", member: "Use the instance. Cannot change settings or members.", }; const SELECT_QUIET = "rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none"; /* Same height as the Grant access button beside it — see controlClass. */ const SELECT = controlClass("bg-panel"); /* * The access roster for one instance. * * Absent entirely for self-hosted instances — the backend refuses those, and a * panel that renders controls the server will reject is a panel that lies. * * The row is a monogram and an address set in mono, because in this product an * identity IS an address, and every other identifier on the screen — the * instance UUID, the licence reference — is mono too. The role is a fact most * of the time and a control occasionally, so it is drawn as text and only grows * a border on hover or focus: the old row made the dropdown the loudest thing * in it, which is backwards for a list people mostly read. * * Granting sits in its own strip on --panel-2 rather than as a fourth row of * naked controls, so the roster reads as the record and the strip as the action. */ export function MembersPanel({ instanceId }: { instanceId: string }) { const qc = useQueryClient(); const { session } = useSession(); const [selected, setSelected] = useState(""); const [role, setRole] = useState("member"); const [error, setError] = useState(null); const [confirming, setConfirming] = useState(null); const members = useQuery({ queryKey: ["members", instanceId], queryFn: () => api.members(instanceId), }); const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers }); const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] }); const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."); const grant = useMutation({ mutationFn: () => api.grantMember(instanceId, selected, role), onSuccess: () => { setSelected(""); setRole("member"); refresh(); }, onError: fail, }); const changeRole = useMutation({ mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role), onSuccess: refresh, onError: fail, }); const revoke = useMutation({ mutationFn: (uid: string) => api.revokeMember(instanceId, uid), onSuccess: () => { setConfirming(null); refresh(); }, onError: (e) => { setConfirming(null); fail(e); }, }); const myRole = session?.account_role; const canManage = myRole === "owner" || myRole === "admin"; const rows = members.data ?? []; const granted = new Set(rows.map((m) => m.customer_user_id)); const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at); const pending = (people.data ?? []).filter((p) => !p.verified_at).length; return (

Each person here has a real user inside this instance and signs in with their Vantage HQ password.

{error && (

{error}

)}
{rows.length === 0 ? ( ) : (
    {/* * Two columns on a phone — monogram and address — with the * controls dropping to their own full-width row beneath; * three columns from sm up, controls right-aligned. As one * wrapping flex row the address competed with a select and * two buttons for 320px and lost, and the confirm step put * three more elements into the same row. */} {rows.map((m) => (
  • {m.email.slice(0, 2).toUpperCase()} {m.email}
    {canManage ? ( ) : ( {m.role} )} {canManage && /* * Confirming inline rather than through * window.confirm(), and in the row itself rather * than a dialog: it can say what revoking does, * where the eye already is. */ (confirming === m.customer_user_id ? ( Revoke access? ) : ( /* Quiet until intent: a row that is mostly read should not carry a permanently red control. */ ))}
  • ))}
)} {canManage && (
{/* Stacked and full width on a phone; one row from sm up. Three controls side by side left the person select about 90px wide, which is not enough to read an address in. */}
{ e.preventDefault(); setError(null); if (selected) grant.mutate(); }} >
{/* The chosen rank explains itself, rather than leaving three words to be guessed at. */}

{role} — {ROLE_GRANTS[role]}

{pending > 0 && (

{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet, and cannot be granted access until they do.

)}
)}
); }