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)/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} +

+ )} + + + + + + + + + + + {(users.data ?? []).map((u) => { + const isSelf = u.email === session?.email; + return ( + + + + + + + ); + })} + +
EmailAccount roleStatus +
+ {u.email} + {isSelf && (you)} + + {canManage && !isSelf ? ( + + ) : ( + + {u.account_role} + + )} + + {u.verified_at ? "Active" : "Invitation pending"} + + {canManage && !isSelf && ( + + )} +
+ + {canManage && ( +
{ + e.preventDefault(); + setError(null); + if (email.trim()) invite.mutate(); + }} + > +

Invite someone

+ setEmail(e.target.value)} + required + hint="They choose their own password from the emailed link. Nothing happens until they open it." + /> + +

+ An account role is not access to an instance. Give them that on the + instance itself. +

+ + + )} +
+ ); +} 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 ( +
+
+

People

+

+ Everyone on this account. Owners and admins can invite people and grant them + access to instances; billing stays with owners. +

+
+ +
+ ); +} 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 ( +
{ + e.preventDefault(); + setError(null); + accept.mutate(); + }} + > +

Choose a password

+

+ This password signs you into Vantage HQ and into every instance you are given + access to. Nobody who invited you can see it. +

+ setPassword(e.target.value)} + required + minLength={12} + hint="At least 12 characters." + error={error ?? undefined} + /> + + + ); +} + +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 ? ( + + ) : ( + {m.role} + )} + {canManage && ( + + )} + +
  • + ))} + {members.data?.length === 0 && ( +
  • Nobody has been added yet.
  • + )} +
+ + {canManage && ( +
{ + e.preventDefault(); + setError(null); + if (selected) grant.mutate(); + }} + > + + + +
+ )} + + {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)}` : ""}`),