feat: Updated members panel on instance page
This commit is contained in:
@@ -5,12 +5,41 @@ import { useState } from "react";
|
||||
import { ApiError, api, type InstanceRole } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { Button } from "@/components/Button";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* Absent entirely for self-hosted instances the backend refuses those, and a
|
||||
* 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";
|
||||
|
||||
const SELECT = "rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.84rem] text-ink focus:border-accent focus:outline-none";
|
||||
|
||||
/*
|
||||
* 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();
|
||||
@@ -58,126 +87,146 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const myRole = session?.account_role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
|
||||
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 (
|
||||
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-xl">Who can sign in</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
|
||||
<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>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
{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">
|
||||
{rows.map((m) => (
|
||||
<li key={m.member_id} className="flex flex-wrap items-center gap-3 border-b border-rule-soft px-4 py-2.5 last:border-b-0">
|
||||
<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 flex-1 truncate font-mono text-[0.84rem]">{m.email}</span>
|
||||
|
||||
<ul className="grid gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
|
||||
<span>{m.email}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{canManage ? (
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={(e) =>
|
||||
changeRole.mutate({
|
||||
uid: m.customer_user_id,
|
||||
role: e.target.value as InstanceRole,
|
||||
})
|
||||
}
|
||||
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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="font-mono text-[0.82rem]">{m.role}</span>
|
||||
<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: this is the panel's own
|
||||
* idiom, the same one ConfirmPlanChange uses,
|
||||
* and it can say what revoking actually does.
|
||||
* 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 items-center gap-2">
|
||||
<span className="text-[0.82rem] text-ink-2">Revoke access?</span>
|
||||
<span className="flex shrink-0 items-center gap-2.5">
|
||||
<span className="text-[0.8rem] text-ink-2">Revoke access?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline disabled:opacity-50"
|
||||
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 ? "Removing…" : "Remove"}
|
||||
{revoke.isPending ? "Revoking…" : "Revoke"}
|
||||
</button>
|
||||
<button type="button" className="text-[0.82rem] text-ink-2 underline" onClick={() => setConfirming(null)}>
|
||||
<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="text-[0.82rem] font-semibold text-expired underline"
|
||||
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);
|
||||
}}
|
||||
>
|
||||
Remove<span className="sr-only"> {m.email}</span>
|
||||
Revoke<span className="sr-only"> access for {m.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (selected) grant.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
<option value="">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.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" disabled={!selected || grant.isPending}>
|
||||
{grant.isPending ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<div className="grid gap-3 border-t border-rule bg-panel-2 px-4 py-3.5">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (selected) grant.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="grid min-w-0 flex-1 gap-1.5">
|
||||
<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}>
|
||||
{grant.isPending ? "Granting…" : "Grant access"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{canManage && pending > 0 && (
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
|
||||
</p>
|
||||
{/* 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>
|
||||
)}
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user