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:
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)}` : ""}`),
|
||||
|
||||
Reference in New Issue
Block a user