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>
179 lines
7.6 KiB
TypeScript
179 lines
7.6 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 } 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>
|
|
);
|
|
}
|