diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go
index 17bd46b..2daa622 100644
--- a/admin/internal/api/customer.go
+++ b/admin/internal/api/customer.go
@@ -57,11 +57,15 @@ func getMe(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
return
}
- c.JSON(http.StatusOK, gin.H{
- "kind": s.Kind,
- "email": s.Email,
- "account_id": s.AccountID,
- })
+ out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
+ if s.Kind == auth.KindCustomer {
+ var u models.CustomerUser
+ if err := db.Admin("customer_users").FindOne(c.Request.Context(),
+ bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
+ out["account_role"] = u.AccountRole
+ }
+ }
+ c.JSON(http.StatusOK, out)
}
func getAccount(c *gin.Context) {
diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx
index 6cac19c..243263f 100644
--- a/adminsite/app/(customer)/instances/[id]/page.tsx
+++ b/adminsite/app/(customer)/instances/[id]/page.tsx
@@ -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() {
) : (
No licence has been issued for this instance yet.
)}
+
+ {instance.deployment === "cloud" ? (
+
+ ) : (
+
+ Users for this install are managed inside it, in Settings → Instance. We do not
+ have access to your own deployment.
+
+ )}
);
}
diff --git a/adminsite/app/(customer)/instances/new/CreateForm.tsx b/adminsite/app/(customer)/instances/new/CreateForm.tsx
index 5e8afb6..3da6b75 100644
--- a/adminsite/app/(customer)/instances/new/CreateForm.tsx
+++ b/adminsite/app/(customer)/instances/new/CreateForm.tsx
@@ -53,8 +53,8 @@ export function CreateForm() {
/>
- 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.
diff --git a/adminsite/app/(customer)/layout.tsx b/adminsite/app/(customer)/layout.tsx
index edc19f6..e7391b4 100644
--- a/adminsite/app/(customer)/layout.tsx
+++ b/adminsite/app/(customer)/layout.tsx
@@ -17,6 +17,12 @@ export default function CustomerLayout({ children }: { children: React.ReactNode
Billing
+
+ People
+
+
+ Settings
+
{children}
diff --git a/adminsite/app/(customer)/settings/page.tsx b/adminsite/app/(customer)/settings/page.tsx
new file mode 100644
index 0000000..9bc2886
--- /dev/null
+++ b/adminsite/app/(customer)/settings/page.tsx
@@ -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(null);
+ const [done, setDone] = useState(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 (
+
+ );
+}
diff --git a/adminsite/app/(customer)/users/InvitePanel.tsx b/adminsite/app/(customer)/users/InvitePanel.tsx
new file mode 100644
index 0000000..e7ef665
--- /dev/null
+++ b/adminsite/app/(customer)/users/InvitePanel.tsx
@@ -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("member");
+ const [error, setError] = useState(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 ;
+
+ const myRole = session?.account_role;
+ const canManage = myRole === "owner" || myRole === "admin";
+ const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ Email
+ Account role
+ Status
+
+
+
+
+ {(users.data ?? []).map((u) => {
+ const isSelf = u.email === session?.email;
+ return (
+
+
+ {u.email}
+ {isSelf && (you) }
+
+
+ {canManage && !isSelf ? (
+
+ 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) => (
+
+ {r}
+
+ ))}
+
+ ) : (
+
+ {u.account_role}
+
+ )}
+
+
+ {u.verified_at ? "Active" : "Invitation pending"}
+
+
+ {canManage && !isSelf && (
+ {
+ if (
+ confirm(
+ `Remove ${u.email}? They lose access to every instance on this account.`,
+ )
+ )
+ remove.mutate(u.user_id);
+ }}
+ >
+ Remove
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {canManage && (
+
+ )}
+
+ );
+}
diff --git a/adminsite/app/(customer)/users/page.tsx b/adminsite/app/(customer)/users/page.tsx
new file mode 100644
index 0000000..e3dccb4
--- /dev/null
+++ b/adminsite/app/(customer)/users/page.tsx
@@ -0,0 +1,16 @@
+import { InvitePanel } from "./InvitePanel";
+
+export default function UsersPage() {
+ return (
+
+ );
+}
diff --git a/adminsite/app/accept-invite/page.tsx b/adminsite/app/accept-invite/page.tsx
new file mode 100644
index 0000000..c5e138b
--- /dev/null
+++ b/adminsite/app/accept-invite/page.tsx
@@ -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(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 That link is missing its token.
;
+ if (done)
+ return (
+
+
You're in
+
Sign in with your email address and new password.
+
+ Sign in
+
+
+ );
+
+ return (
+
+ );
+}
+
+export default function AcceptInvitePage() {
+ return (
+
+ Loading…}>
+
+
+
+ );
+}
diff --git a/adminsite/app/verify/page.tsx b/adminsite/app/verify/page.tsx
index 6f6d45a..56d9dd2 100644
--- a/adminsite/app/verify/page.tsx
+++ b/adminsite/app/verify/page.tsx
@@ -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 ;
+
if (!token)
return (
("member");
+ const [error, setError] = useState(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 (
+
+
+
Who can sign in
+
+ Each person here has a real user inside this instance and signs in with their
+ Vantage HQ password.
+
+
+
+ {error && {error}
}
+
+
+ {(members.data ?? []).map((m) => (
+
+ {m.email}
+
+ {canManage ? (
+
+ 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) => (
+
+ {r}
+
+ ))}
+
+ ) : (
+ {m.role}
+ )}
+ {canManage && (
+ {
+ if (confirm(`Remove ${m.email} from this instance?`))
+ revoke.mutate(m.customer_user_id);
+ }}
+ >
+ Remove
+
+ )}
+
+
+ ))}
+ {members.data?.length === 0 && (
+ Nobody has been added yet.
+ )}
+
+
+ {canManage && (
+
+ )}
+
+ {canManage && pending > 0 && (
+
+ {pending} invited {pending === 1 ? "person has" : "people have"} not accepted
+ yet and cannot be added until they do.
+
+ )}
+
+ );
+}
diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts
index 6698909..0260fc4 100644
--- a/adminsite/lib/api.ts
+++ b/adminsite/lib/api.ts
@@ -53,16 +53,52 @@ async function req(path: string, init?: RequestInit): Promise {
const post = (path: string, payload?: unknown) =>
req(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
+const put = (path: string, payload?: unknown) =>
+ req(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
+
+const del = (path: string) => req(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("/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("/api/subscriptions"),
+ accountUsers: () => req("/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(`/api/instances/${instanceId}/members`),
+ grantMember: (instanceId: string, user_id: string, role: InstanceRole) =>
+ post(`/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(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),