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
+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)}` : ""}`),