feat(adminsite): people, instance members and one password

The members panel is absent for self-hosted instances rather than disabled:
the backend refuses those, and a panel rendering controls the server will
reject is a panel that lies.

/auth/me now reports the caller's account role, so the UI hides what the
backend would refuse rather than discovering it in an error toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 16:38:21 +01:00
co-authored by Claude Opus 5
parent a05a74cf4d
commit 2d12669f9b
11 changed files with 622 additions and 10 deletions
+178
View File
@@ -0,0 +1,178 @@
"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 } from "@/components/Button";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
/*
* 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.
*/
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 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: refresh,
onError: fail,
});
const myRole = session?.account_role;
const canManage = myRole === "owner" || myRole === "admin";
const granted = new Set((members.data ?? []).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>
</div>
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
<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>
) : (
<span className="font-mono text-[0.82rem]">{m.role}</span>
)}
{canManage && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (confirm(`Remove ${m.email} from this instance?`))
revoke.mutate(m.customer_user_id);
}}
>
Remove
</button>
)}
</span>
</li>
))}
{members.data?.length === 0 && (
<li className="text-ink-2">Nobody has been added yet.</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>
)}
{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>
)}
</section>
);
}