import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { canInviteOrgMembers, canManageOrg, canManageOrgDomain, } from "../../org/permissions.js"; import type { OrgInfo, OrgMember, OrgPendingInvitation, OrgRole, } from "../../org/types.js"; import { agentNativePath } from "../api-path.js"; const ORG_BASE = agentNativePath("/_agent-native/org"); async function apiFetch(path: string, init?: RequestInit) { const res = await fetch(path, { ...init, credentials: "include", headers: { "Content-Type": "application/json", ...init?.headers, }, }); if (!res.ok) { // Prefer a JSON `error` / `message` field when the server returns one, // and only fall back to the raw body for plaintext responses. Avoids // surfacing `{"error":"..."}` as the user-visible message. const text = await res.text().catch(() => ""); let message: string = res.statusText; if (text) { try { const parsed = JSON.parse(text) as { error?: string; message?: string; }; message = parsed.error ?? parsed.message ?? text; } catch { message = text; } } throw new Error(message); } return res.json(); } export function useOrg() { return useQuery({ queryKey: ["org-me"], queryFn: () => apiFetch(`${ORG_BASE}/me`), staleTime: 30_000, }); } export interface UseOrgRoleResult { org: OrgInfo | undefined; role: OrgRole | null; isOwner: boolean; canManageOrg: boolean; canInviteMembers: boolean; canManageDomain: boolean; isLoading: boolean; error: Error | null; } export function useOrgRole(): UseOrgRoleResult { const query = useOrg(); const role = query.data?.role ?? null; return { org: query.data, role, isOwner: role === "owner", canManageOrg: canManageOrg(role), canInviteMembers: canInviteOrgMembers(role), canManageDomain: canManageOrgDomain(role), isLoading: query.isLoading, error: query.error, }; } export function useOrgMembers() { // Scope the cache by active orgId so switching or creating an org forces a // fresh fetch rather than briefly showing the previous org's members. const { data: org } = useOrg(); return useQuery<{ members: OrgMember[] }>({ queryKey: ["org-members", org?.orgId ?? null], queryFn: () => apiFetch(`${ORG_BASE}/members`), staleTime: 30_000, }); } export function useOrgInvitations() { const { data: org } = useOrg(); return useQuery<{ invitations: OrgPendingInvitation[] }>({ queryKey: ["org-invitations", org?.orgId ?? null], queryFn: () => apiFetch(`${ORG_BASE}/invitations`), staleTime: 30_000, }); } // NOTE: the onSuccess handlers below `await invalidateQueries`. In // TanStack Query v5, invalidateQueries: // 1. Marks every matching query as stale, so the next mount of an // INACTIVE query (e.g. the org-members table on a settings page // the user hasn't visited yet) refetches immediately instead of // serving 30-second-stale cached data. // 2. Triggers a refetch of every ACTIVE query that matches. // 3. Returns a promise that resolves once those refetches settle. // // `await`ing therefore keeps `mutation.isPending` true through the // full read-after-write window — closing the create-org / accept- // invite race where a button could re-enable mid-refetch. We // previously tried refetchQueries here for "unambiguous semantics", // but that variant doesn't mark inactive queries stale, leaving them // to serve stale data on next mount. invalidateQueries is the right // primitive — it just needed an `await`. export function useCreateOrg() { const qc = useQueryClient(); return useMutation({ mutationFn: (name: string) => apiFetch(ORG_BASE, { method: "POST", body: JSON.stringify({ name }), }), onSuccess: async () => { // Creating an org also switches the user into it server-side, so every // org-scoped query (members, invitations, and template-level data) is // now stale. Match the broad invalidation that useSwitchOrg already does. await qc.invalidateQueries(); }, }); } export type InviteRole = "admin" | "member"; export interface InviteVars { email: string; role?: InviteRole; } export function useInviteMember() { const qc = useQueryClient(); return useMutation({ mutationFn: (vars: string | InviteVars) => { const body: { email: string; role: InviteRole } = typeof vars === "string" ? { email: vars, role: "member" } : { email: vars.email, role: vars.role ?? "member" }; return apiFetch(`${ORG_BASE}/invitations`, { method: "POST", body: JSON.stringify(body), }); }, onSuccess: async () => { await Promise.all([ qc.invalidateQueries({ queryKey: ["org-members"] }), qc.invalidateQueries({ queryKey: ["org-invitations"] }), ]); }, }); } export interface BulkInviteResult { succeeded: Array<{ id: string; email: string; role: InviteRole; status: "pending"; emailSent: boolean; emailError?: string; }>; failed: Array<{ email: string; error: string }>; total: number; } export function useBulkInviteMembers() { const qc = useQueryClient(); return useMutation({ mutationFn: (invites) => apiFetch(`${ORG_BASE}/invitations`, { method: "POST", body: JSON.stringify({ invites: invites.map((i) => ({ email: i.email, role: i.role ?? "member", })), }), }), onSuccess: async () => { await Promise.all([ qc.invalidateQueries({ queryKey: ["org-members"] }), qc.invalidateQueries({ queryKey: ["org-invitations"] }), ]); }, }); } export function useChangeMemberRole() { const qc = useQueryClient(); return useMutation< { email: string; role: InviteRole }, Error, { email: string; role: InviteRole } >({ mutationFn: ({ email, role }) => apiFetch(`${ORG_BASE}/members/${encodeURIComponent(email)}/role`, { method: "PUT", body: JSON.stringify({ role }), }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["org-members"] }); }, }); } export function useAcceptInvitation() { const qc = useQueryClient(); return useMutation({ mutationFn: (invitationId: string) => apiFetch(`${ORG_BASE}/invitations/${invitationId}/accept`, { method: "POST", }), onSuccess: async () => { // Joining/switching orgs changes all org-scoped data — invalidate // every cached query (no key filter) so each one refetches or is // marked stale for the next mount. await qc.invalidateQueries(); }, }); } export function useRemoveMember() { const qc = useQueryClient(); return useMutation({ mutationFn: (email: string) => apiFetch(`${ORG_BASE}/members/${encodeURIComponent(email)}`, { method: "DELETE", }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["org-members"] }); }, }); } export function useUpdateOrg() { const qc = useQueryClient(); return useMutation({ mutationFn: (name: string) => apiFetch(ORG_BASE, { method: "PATCH", body: JSON.stringify({ name }), }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["org-me"] }); }, }); } export function useSwitchOrg() { const qc = useQueryClient(); return useMutation({ mutationFn: (orgId: string | null) => apiFetch(`${ORG_BASE}/switch`, { method: "PUT", body: JSON.stringify({ orgId }), }), onSuccess: async () => { // Switching org changes everything scoped to AGENT_ORG_ID. await qc.invalidateQueries(); }, }); } export function useDeleteOrg() { const qc = useQueryClient(); return useMutation({ mutationFn: (name: string) => apiFetch(ORG_BASE, { method: "DELETE", body: JSON.stringify({ name }), }), onSuccess: async () => { // Deleting an org drops the user into a different active org (or none), // so every org-scoped query is stale — same reasoning as create/switch. await qc.invalidateQueries(); }, }); } export function useJoinByDomain() { const qc = useQueryClient(); return useMutation({ mutationFn: (orgId: string) => apiFetch(`${ORG_BASE}/join-by-domain`, { method: "POST", body: JSON.stringify({ orgId }), }), onSuccess: async () => { await qc.invalidateQueries(); }, }); } export function useSetOrgDomain() { const qc = useQueryClient(); return useMutation({ mutationFn: (domain: string | null) => apiFetch(`${ORG_BASE}/domain`, { method: "PUT", body: JSON.stringify({ domain }), }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["org-me"] }); }, }); } /** * Fetch the org's A2A secret on demand (owner/admin). Deliberately a separate * request from `useOrg()` so the secret only reaches the browser when the * operator asks to reveal or copy it. */ export function useRevealA2ASecret() { return useMutation<{ a2aSecret: string | null }, Error, void>({ mutationFn: () => apiFetch(`${ORG_BASE}/a2a-secret`), }); } export function useSetA2ASecret() { const qc = useQueryClient(); return useMutation< { a2aSecret: string; previousSecret: string | null }, Error, string | undefined >({ mutationFn: (secret?: string) => apiFetch(`${ORG_BASE}/a2a-secret`, { method: "PUT", body: JSON.stringify({ secret }), }), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["org-me"] }); }, }); } export interface SyncA2ASecretResult { total: number; succeeded: number; failed: number; results: Array<{ id: string; name: string; url: string; ok: boolean; status?: number; error?: string; }>; } /** * Push the org's A2A secret to every connected app so cross-app delegation * works without manual copy/paste. Optionally pass a `signSecret` to sign * the outbound JWTs with a different secret (used by the regenerate-then- * sync flow where the new secret is in DB but peers still hold the old * one). */ export function useSyncA2ASecret() { return useMutation< SyncA2ASecretResult, Error, { signSecret?: string } | void >({ mutationFn: (vars) => apiFetch(`${ORG_BASE}/a2a-secret/sync`, { method: "POST", body: JSON.stringify({ signSecret: vars && "signSecret" in vars ? vars.signSecret : undefined, }), }), }); }