Files
vantage/adminsite/components/MembersPanel.tsx
T
mrhid6 a20e165ac2
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 37s
feat: Revamp instance page
2026-08-11 10:32:59 +01:00

250 lines
13 KiB
TypeScript

"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<InstanceRole, string> = {
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<InstanceRole>("member");
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(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 (
<Panel title="Who can sign in" meta={rows.length ? `${rows.length} ${rows.length === 1 ? "person" : "people"}` : undefined} bodyless>
<div className="grid gap-3 px-4 pb-4 pt-3.5">
<p className="text-[0.84rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
{error && (
<p role="alert" className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
{error}
</p>
)}
</div>
{rows.length === 0 ? (
<EmptyState
title="Nobody else can sign in yet."
body={canManage ? "Add someone from your account below and a user is created for them inside this instance." : "An owner or admin can grant access."}
/>
) : (
<ul className="grid border-t border-rule-soft">
{/*
* 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) => (
<li
key={m.member_id}
className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-rule-soft px-4 py-3 last:border-b-0 sm:grid-cols-[auto_1fr_auto]"
>
<span aria-hidden className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.62rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0 break-all font-mono text-[0.84rem] sm:truncate sm:break-normal">{m.email}</span>
<div className="col-span-2 flex flex-wrap items-center gap-2 sm:col-span-1 sm:flex-nowrap sm:justify-end">
{canManage ? (
<label className="shrink-0">
<span className="sr-only">Role for {m.email}</span>
<select
value={m.role}
title={ROLE_GRANTS[m.role]}
onChange={(e) => changeRole.mutate({ uid: m.customer_user_id, role: e.target.value as InstanceRole })}
className={SELECT_QUIET}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
) : (
<span className="shrink-0 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-3">{m.role}</span>
)}
{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 ? (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="text-[0.8rem] text-ink-2">Revoke access?</span>
<button
type="button"
className="rounded border border-expired px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-expired hover:bg-expired hover:text-panel disabled:opacity-50"
disabled={revoke.isPending}
onClick={() => revoke.mutate(m.customer_user_id)}
>
{revoke.isPending ? "Revoking…" : "Revoke"}
</button>
<button type="button" className="font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:text-ink" onClick={() => setConfirming(null)}>
Keep
</button>
</span>
) : (
/* Quiet until intent: a row that is mostly read
should not carry a permanently red control. */
<button
type="button"
className="shrink-0 rounded border border-transparent px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:border-expired hover:text-expired"
onClick={() => {
setError(null);
setConfirming(m.customer_user_id);
}}
>
Revoke<span className="sr-only"> access for {m.email}</span>
</button>
))}
</div>
</li>
))}
</ul>
)}
{canManage && (
<div className="grid gap-3 border-t border-rule bg-panel-2 px-4 py-3.5">
{/* 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. */}
<form
className="grid gap-3 sm:flex sm:flex-wrap sm:items-end"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (selected) grant.mutate();
}}
>
<label className="grid min-w-0 gap-1.5 sm:flex-1">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Grant access to</span>
<select value={selected} onChange={(e) => setSelected(e.target.value)} className={SELECT} disabled={candidates.length === 0}>
<option value="">{candidates.length === 0 ? "Everyone already has access" : "Choose a person…"}</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
{p.email}
</option>
))}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">As</span>
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className={SELECT}>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={!selected || grant.isPending} className="w-full justify-center sm:w-auto">
{grant.isPending ? "Granting…" : "Grant access"}
</Button>
</form>
{/* The chosen rank explains itself, rather than leaving three
words to be guessed at. */}
<p className="text-[0.8rem] text-ink-3">
<span className="font-mono uppercase tracking-[0.08em]">{role}</span> {ROLE_GRANTS[role]}
</p>
{pending > 0 && (
<p className="text-[0.8rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet, and cannot be granted access until they do.
</p>
)}
</div>
)}
</Panel>
);
}