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
@@ -6,6 +6,7 @@ import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
@@ -88,6 +89,15 @@ export default function InstancePage() {
) : (
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
)}
{instance.deployment === "cloud" ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do not
have access to your own deployment.
</p>
)}
</div>
);
}
@@ -53,8 +53,8 @@ export function CreateForm() {
/>
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing one does not
change the other afterwards.
You sign in to it with this same email address and password. Changing your Vantage
HQ password changes it here too.
</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
+6
View File
@@ -17,6 +17,12 @@ export default function CustomerLayout({ children }: { children: React.ReactNode
<Link href="/billing" className="text-ink-3">
Billing
</Link>
<Link href="/users" className="text-ink-3">
People
</Link>
<Link href="/settings" className="text-ink-3">
Settings
</Link>
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
@@ -0,0 +1,75 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export default function SettingsPage() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<string | null>(null);
const change = useMutation({
mutationFn: () => api.changePassword(current, next),
onSuccess: (res) => {
setCurrent("");
setNext("");
setDone(
res.propagation_pending
? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes."
: "Password changed everywhere.",
);
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">Settings</h1>
<p className="text-ink-2">
Your password signs you in here and into every Vantage instance you belong to.
Changing it changes all of them.
</p>
</header>
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
setDone(null);
change.mutate();
}}
>
<Field
label="Current password"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
/>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
{done && <p className="text-[0.9rem] text-valid">{done}</p>}
<Button type="submit" disabled={change.isPending || next.length < 12}>
{change.isPending ? "Changing…" : "Change password"}
</Button>
</form>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
const ROLES: AccountRole[] = ["owner", "admin", "member"];
export function InvitePanel() {
const qc = useQueryClient();
const { session } = useSession();
const [email, setEmail] = useState("");
const [role, setRole] = useState<AccountRole>("member");
const [error, setError] = useState<string | null>(null);
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const invite = useMutation({
mutationFn: () => api.invite(email.trim().toLowerCase(), role),
onSuccess: () => {
setEmail("");
setRole("member");
refresh();
},
onError: fail,
});
const setRoleFor = useMutation({
mutationFn: (v: { id: string; role: AccountRole }) => api.setAccountRole(v.id, v.role),
onSuccess: refresh,
onError: fail,
});
const remove = useMutation({
mutationFn: (id: string) => api.removeAccountUser(id),
onSuccess: refresh,
onError: fail,
});
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const myRole = session?.account_role;
const canManage = myRole === "owner" || myRole === "admin";
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<div className="grid gap-6">
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
<th className="py-2">Email</th>
<th className="py-2">Account role</th>
<th className="py-2">Status</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft">
<td className="py-2.5">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="py-2.5">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="py-2.5 text-ink-2">
{u.verified_at ? "Active" : "Invitation pending"}
</td>
<td className="py-2.5 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
)
)
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{canManage && (
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<h2 className="text-xl">Invite someone</h2>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<p className="text-[0.82rem] text-ink-2">
An account role is not access to an instance. Give them that on the
instance itself.
</p>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { InvitePanel } from "./InvitePanel";
export default function UsersPage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">People</h1>
<p className="text-ink-2">
Everyone on this account. Owners and admins can invite people and grant them
access to instances; billing stays with owners.
</p>
</header>
<InvitePanel />
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
function AcceptForm() {
const token = useSearchParams().get("token") ?? "";
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
if (done)
return (
<div className="grid gap-3">
<h1 className="text-3xl">You&apos;re in</h1>
<p className="text-ink-2">Sign in with your email address and new password.</p>
<Link href="/login" className="font-semibold text-accent underline">
Sign in
</Link>
</div>
);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
>
<h1 className="text-3xl">Choose a password</h1>
<p className="text-ink-2">
This password signs you into Vantage HQ and into every instance you are given
access to. Nobody who invited you can see it.
</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12}>
{accept.isPending ? "Setting…" : "Set password"}
</Button>
</form>
);
}
export default function AcceptInvitePage() {
return (
<main className="mx-auto max-w-rail px-5 py-16">
<Suspense fallback={<p className="text-ink-3">Loading</p>}>
<AcceptForm />
</Suspense>
</main>
);
}
+14 -2
View File
@@ -1,12 +1,13 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { api } from "@/lib/api";
function Verify() {
const router = useRouter();
const token = useSearchParams().get("token") ?? "";
const { data, error, isLoading } = useQuery({
queryKey: ["verify", token],
@@ -15,6 +16,17 @@ function Verify() {
retry: false,
});
// An invitation and a verification link are the same shape, and someone will
// paste one into the other. The backend leaves an invite token unspent and
// says so; send them where they can actually finish.
const needsPassword = data?.needs_password === true;
useEffect(() => {
if (needsPassword) {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
}
}, [needsPassword, token, router]);
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
if (!token)
return (
<Message