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
+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>
);
}
+63 -1
View File
@@ -53,16 +53,52 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
const post = <T,>(path: string, payload?: unknown) =>
req<T>(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
const put = <T,>(path: string, payload?: unknown) =>
req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
// --- types ---------------------------------------------------------------
export type Deployment = "cloud" | "self_hosted";
export type Tier = "free" | "professional" | "self_hosted";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
/*
* Two role vocabularies, same three words. AccountRole governs the HQ account:
* who may invite, create instances and grant access. InstanceRole is the role a
* projected user holds INSIDE one instance. A person can be an account member
* and an instance owner at once — that is normal, not a mistake.
*/
export type AccountRole = "owner" | "admin" | "member";
export type InstanceRole = "owner" | "admin" | "member";
export interface Session {
kind: "staff" | "customer";
email: string;
account_id?: string;
account_role?: AccountRole;
}
export interface AccountUser {
user_id: string;
account_id: string;
email: string;
account_role: AccountRole;
verified_at?: string | null;
hq_sync_failed_at?: string | null;
created_at: string;
}
export interface InstanceMember {
member_id: string;
account_id: string;
instance_id: string;
customer_user_id: string;
control_user_id: string;
role: InstanceRole;
email: string;
created_at: string;
}
export interface Limits {
@@ -183,7 +219,9 @@ export const api = {
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
post<{ pending: boolean }>("/auth/signup", payload),
verify: (token: string) =>
req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
req<{ verified: boolean; needs_password?: boolean }>(
`/auth/verify?token=${encodeURIComponent(token)}`,
),
account: () => req<AccountResponse>("/api/account"),
link: (instance_id: string, name: string) =>
@@ -196,6 +234,30 @@ export const api = {
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
accountUsers: () => req<AccountUser[]>("/api/account/users"),
invite: (email: string, role: AccountRole) =>
post<{ invited: boolean }>("/api/account/users", { email, role }),
setAccountRole: (userId: string, role: AccountRole) =>
put<{ ok: boolean }>(`/api/account/users/${userId}/role`, { role }),
removeAccountUser: (userId: string) =>
del<{ deleted: boolean }>(`/api/account/users/${userId}`),
changePassword: (current_password: string, new_password: string) =>
put<{ updated: boolean; propagation_pending: boolean }>("/api/account/password", {
current_password,
new_password,
}),
acceptInvite: (token: string, password: string) =>
post<{ accepted: boolean }>("/auth/accept-invite", { token, password }),
members: (instanceId: string) =>
req<InstanceMember[]>(`/api/instances/${instanceId}/members`),
grantMember: (instanceId: string, user_id: string, role: InstanceRole) =>
post<InstanceMember>(`/api/instances/${instanceId}/members`, { user_id, role }),
setMemberRole: (instanceId: string, userId: string, role: InstanceRole) =>
put<{ ok: boolean }>(`/api/instances/${instanceId}/members/${userId}/role`, { role }),
revokeMember: (instanceId: string, userId: string) =>
del<{ revoked: boolean }>(`/api/instances/${instanceId}/members/${userId}`),
staff: {
accounts: (q?: string) =>
req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),