250 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|