diff --git a/adminsite/components/MembersPanel.tsx b/adminsite/components/MembersPanel.tsx index 8d9cde9..59a47f1 100644 --- a/adminsite/components/MembersPanel.tsx +++ b/adminsite/components/MembersPanel.tsx @@ -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 = { + 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 ( -
-
-

Who can sign in

-

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

+ +
+

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

+ {error && ( +

+ {error} +

+ )}
- {error &&

{error}

} + {rows.length === 0 ? ( + + ) : ( +
    + {rows.map((m) => ( +
  • + + {m.email.slice(0, 2).toUpperCase()} + + {m.email} -
      - {(members.data ?? []).map((m) => ( -
    • - {m.email} - {canManage ? ( - + ) : ( - {m.role} + {m.role} )} + {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 ? ( - - Revoke access? + + Revoke access? - ) : ( + /* Quiet until intent: a row that is mostly read + should not carry a permanently red control. */ ))} - -
    • - ))} - {members.data?.length === 0 &&
    • Nobody has been added yet.
    • } -
    +
  • + ))} +
+ )} {canManage && ( -
{ - e.preventDefault(); - setError(null); - if (selected) grant.mutate(); - }} - > - - - -
- )} +
+
{ + e.preventDefault(); + setError(null); + if (selected) grant.mutate(); + }} + > + + + +
- {canManage && pending > 0 && ( -

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

+ {/* 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. +

+ )} +
)} -
+ ); }