From 17eeac45b3dad754cd79c037e5deb4937556d6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20David=20Rinc=C3=B3n=20L=C3=B3pez?= Date: Wed, 22 Jul 2026 14:39:37 -0500 Subject: [PATCH 1/2] fix(teams): replace the invite surface with a create-team CTA in personal spaces Since C8, inviting from a personal space answers 403 personal_space by design, but the client still rendered the Organization > People "Add someone" form there: it gated only on the multiplayer capability plus owner/admin role, and every user is owner of their personal space. Users invited, hit the wall, and saw a generic red failure toast (8/8 production invite failures on 2026-07-22). Gate on the gateway's new server-truth signal instead: GET /v1/capabilities now returns spaceKind ("personal" | "team") for the active space, refetched on every space switch exactly like role. Additive and read tolerantly: - spaceKind "personal": the People tab body becomes a "create a team to invite people" CTA that opens the existing CreateTeamDialog (which switches into the new team on success, so the real invite surface appears right after); the Admin index People row caption follows. - spaceKind "team": unchanged (roster + add row / read-only admin notice). - spaceKind absent (older gateway): unchanged, so hosted team users on a stale gateway never lose the invite surface. Changes: additive Capabilities.spaceKind in ui/engine-client, a pure isPersonalSpace gate in app/src/lib/org-roles.ts beside the other caps-only role gates, the People tab branch + PeopleCreateTeamCta component, and en/es/pt copy under teams:people.personal + org.index.rows.peoplePersonal. The gateway stays the sole enforcer; this only stops offering a dead end. --- .../components/organization/admin-index.tsx | 13 +++++++- .../components/organization/members-tab.tsx | 19 +++++++++++ .../organization/organization-view.tsx | 3 +- .../organization/people-create-team-cta.tsx | 31 ++++++++++++++++++ app/src/lib/org-roles.ts | 17 ++++++++++ app/src/locales/en/teams.json | 6 ++++ app/src/locales/es/teams.json | 6 ++++ app/src/locales/pt/teams.json | 6 ++++ app/tests/org-roles.test.ts | 32 +++++++++++++++++++ ui/engine-client/src/types.ts | 11 +++++++ 10 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 app/src/components/organization/people-create-team-cta.tsx diff --git a/app/src/components/organization/admin-index.tsx b/app/src/components/organization/admin-index.tsx index 4ff3daef6..469c196e3 100644 --- a/app/src/components/organization/admin-index.tsx +++ b/app/src/components/organization/admin-index.tsx @@ -7,6 +7,12 @@ import type { OrgTabId } from "./org-view-model"; interface AdminIndexProps { /** The sections visible for this caller + space, from `orgTabIds`. */ visibleIds: readonly OrgTabId[]; + /** + * True when the active space is the caller's personal one (C8 `spaceKind`). + * A personal space is non-invitable, so the People row's caption reads as + * the create-a-team path instead of promising invites the gateway rejects. + */ + personalSpace?: boolean; /** Roster size from the loaded `GET /org`; undefined while it loads. */ memberCount?: number; onSelect: (id: OrgTabId) => void; @@ -27,6 +33,7 @@ interface AdminIndexProps { */ export function AdminIndex({ visibleIds, + personalSpace = false, memberCount, onSelect, }: AdminIndexProps) { @@ -46,7 +53,11 @@ export function AdminIndex({ + + + ); + } + return (
{canManage ? ( diff --git a/app/src/components/organization/organization-view.tsx b/app/src/components/organization/organization-view.tsx index 995a4e996..25eb18e31 100644 --- a/app/src/components/organization/organization-view.tsx +++ b/app/src/components/organization/organization-view.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useOrg } from "../../hooks/queries"; import { useCapabilities } from "../../hooks/use-capabilities"; import { analytics } from "../../lib/analytics"; -import { canSeeBillingTab } from "../../lib/org-roles"; +import { canSeeBillingTab, isPersonalSpace } from "../../lib/org-roles"; import { isTeamWorkspace } from "../../lib/space-id"; import { useWorkspaceStore } from "../../stores/workspaces"; import { AdminDetailScreen } from "./admin-detail-screen"; @@ -98,6 +98,7 @@ export function OrganizationView() {
diff --git a/app/src/components/organization/people-create-team-cta.tsx b/app/src/components/organization/people-create-team-cta.tsx new file mode 100644 index 000000000..7ee3792fa --- /dev/null +++ b/app/src/components/organization/people-create-team-cta.tsx @@ -0,0 +1,31 @@ +import { Button } from "@houston-ai/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CreateTeamDialog } from "../shell/create-team-dialog"; + +/** + * The People body for a PERSONAL space (C8). A personal space is non-invitable + * (the gateway answers `403 personal_space` on any member-add), so instead of + * an "Add someone" form that can only fail we offer the one path that works: + * create a team and invite people there. `CreateTeamDialog` switches straight + * into the new team on success, capabilities refetch with `spaceKind: "team"`, + * and the People tab re-renders as the real roster + invite surface — the + * user's invite journey continues without a dead end. + */ +export function PeopleCreateTeamCta() { + const { t } = useTranslation("teams"); + const [createOpen, setCreateOpen] = useState(false); + + return ( +
+

+ {t("people.personal.title")} +

+

{t("people.personal.body")}

+ + +
+ ); +} diff --git a/app/src/lib/org-roles.ts b/app/src/lib/org-roles.ts index 17f23a3d6..71e22ac23 100644 --- a/app/src/lib/org-roles.ts +++ b/app/src/lib/org-roles.ts @@ -27,6 +27,23 @@ export function hasSpaces(caps: Capabilities | null | undefined): boolean { return caps?.spaces === true; } +/** + * Is the ACTIVE space the caller's personal one (C8 `spaceKind`)? A personal + * space is non-invitable — the gateway answers `403 personal_space` on any + * member-add — so the People/invite surface swaps to the create-a-team path + * when this is true (every user is `owner` of their personal space, so the + * role gates alone cannot tell it apart from a team). TOLERANT READER: true + * only when the host explicitly advertises `spaceKind: "personal"`; a gateway + * that predates the field omits it and this stays false, so hosted team users + * on a stale gateway keep the invite surface unchanged. The gateway is the + * sole enforcer; this only routes an affordance. + */ +export function isPersonalSpace( + caps: Capabilities | null | undefined, +): boolean { + return caps?.spaceKind === "personal"; +} + /** * The caller's org role, or null in single-player mode. A multiplayer host * always advertises a role; treat a missing one as the least-privileged `user` diff --git a/app/src/locales/en/teams.json b/app/src/locales/en/teams.json index b682e9e69..99fcade57 100644 --- a/app/src/locales/en/teams.json +++ b/app/src/locales/en/teams.json @@ -91,6 +91,7 @@ }, "rows": { "people": "Invite teammates, set roles, remove members.", + "peoplePersonal": "It's just you here. Create a team to invite people.", "activity": "What happened in this workspace, newest first.", "usage": "How much each agent and person is messaging.", "billing": "Seats, plan, and payment." @@ -166,6 +167,11 @@ }, "people": { "adminNotice": "You can view your team. Only the owner can add or remove people.", + "personal": { + "title": "Create a team to invite people", + "body": "It's just you here for now. Teams let you invite people, share agents, and work together.", + "cta": "Create a team" + }, "add": { "title": "Add someone", "subtitle": "Invite a teammate by email. If they don't have Houston yet, we'll send an invitation.", diff --git a/app/src/locales/es/teams.json b/app/src/locales/es/teams.json index 6217c9831..870662def 100644 --- a/app/src/locales/es/teams.json +++ b/app/src/locales/es/teams.json @@ -91,6 +91,7 @@ }, "rows": { "people": "Invita compañeros, asigna roles y quita miembros.", + "peoplePersonal": "Por ahora solo estás tú aquí. Crea un equipo para invitar a personas.", "activity": "Lo que pasó en este espacio de trabajo, lo más reciente primero.", "usage": "Cuántos mensajes envía cada agente y persona.", "billing": "Asientos, plan y pago." @@ -166,6 +167,11 @@ }, "people": { "adminNotice": "Puedes ver a tu equipo. Solo el propietario puede agregar o quitar personas.", + "personal": { + "title": "Crea un equipo para invitar a personas", + "body": "Por ahora solo estás tú aquí. Los equipos te permiten invitar a personas, compartir agentes y trabajar en conjunto.", + "cta": "Crear un equipo" + }, "add": { "title": "Agregar a alguien", "subtitle": "Invita a un compañero por correo. Si aún no tiene Houston, le enviaremos una invitación.", diff --git a/app/src/locales/pt/teams.json b/app/src/locales/pt/teams.json index 8d9471a5a..e013ab1a9 100644 --- a/app/src/locales/pt/teams.json +++ b/app/src/locales/pt/teams.json @@ -91,6 +91,7 @@ }, "rows": { "people": "Convide colegas, defina funções e remova membros.", + "peoplePersonal": "Por enquanto é só você aqui. Crie uma equipe para convidar pessoas.", "activity": "O que aconteceu neste espaço de trabalho, do mais recente ao mais antigo.", "usage": "Quantas mensagens cada agente e pessoa está enviando.", "billing": "Assentos, plano e pagamento." @@ -166,6 +167,11 @@ }, "people": { "adminNotice": "Você pode ver sua equipe. Apenas o proprietário pode adicionar ou remover pessoas.", + "personal": { + "title": "Crie uma equipe para convidar pessoas", + "body": "Por enquanto é só você aqui. As equipes permitem convidar pessoas, compartilhar agentes e trabalhar em conjunto.", + "cta": "Criar uma equipe" + }, "add": { "title": "Adicionar alguém", "subtitle": "Convide um colega por e-mail. Se ainda não tiver o Houston, enviaremos um convite.", diff --git a/app/tests/org-roles.test.ts b/app/tests/org-roles.test.ts index 2c98875ae..143259bab 100644 --- a/app/tests/org-roles.test.ts +++ b/app/tests/org-roles.test.ts @@ -10,6 +10,7 @@ import { canSeeMembers, GRANTABLE_ROLES, isMultiplayer, + isPersonalSpace, orgRole, } from "../src/lib/org-roles.ts"; @@ -136,6 +137,37 @@ describe("canSeeBillingTab (C8)", () => { }); }); +describe("isPersonalSpace (C8 spaceKind)", () => { + const hosted = (spaceKind?: Capabilities["spaceKind"]): Capabilities => + caps({ + multiplayer: true, + role: "owner", + teams: true, + spaces: true, + spaceKind, + }); + + it("personal: the host explicitly says the active space is personal", () => { + // Every user is `owner` of their personal space, so the role gates alone + // cannot tell it apart from a team — only the explicit spaceKind can. + strictEqual(isPersonalSpace(hosted("personal")), true); + }); + + it("team: the invite surface stays", () => { + strictEqual(isPersonalSpace(hosted("team")), false); + }); + + it("absent (older gateway): never hides the invite surface", () => { + // Tolerant reader — a gateway that predates spaceKind omits it, and hosted + // team users there MUST keep today's members/invite surface unchanged. + strictEqual(isPersonalSpace(hosted(undefined)), false); + strictEqual(isPersonalSpace(multiplayer("owner")), false); + strictEqual(isPersonalSpace(caps()), false); + strictEqual(isPersonalSpace(null), false); + strictEqual(isPersonalSpace(undefined), false); + }); +}); + describe("grantable roles", () => { it("owner is never grantable from the UI", () => { deepStrictEqual([...GRANTABLE_ROLES], ["admin", "user"]); diff --git a/ui/engine-client/src/types.ts b/ui/engine-client/src/types.ts index 9c7fa7f1d..0ce7dc936 100644 --- a/ui/engine-client/src/types.ts +++ b/ui/engine-client/src/types.ts @@ -111,6 +111,17 @@ export interface Capabilities { * stays "create a local workspace"). The gateway is the sole enforcer. */ spaces?: boolean; + /** + * The kind of the ACTIVE space (C8). A `personal` space is non-invitable — + * the gateway answers `403 personal_space` on any member-add — while a + * `team` space is the invitable per-seat product. Server truth for the + * members/invite surface, re-fetched on every space switch exactly like + * `role` (the switch drops the query cache). Additive: absent on gateways + * that predate it, and readers MUST treat absent as "unknown" and keep the + * pre-spaceKind behavior so hosted team users on a stale gateway never lose + * the invite surface. The gateway is the sole enforcer either way. + */ + spaceKind?: "personal" | "team"; /** * Whether this deployment can wake routines on external Composio events (C9 * event-driven routines). Requires a Composio project key AND a public webhook From 55aee932d82dccd86bd9773fa4a4da797448c81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20David=20Rinc=C3=B3n=20L=C3=B3pez?= Date: Wed, 22 Jul 2026 14:40:47 -0500 Subject: [PATCH 2/2] docs(teams): record the spaceKind capability and the personal-space People CTA --- knowledge-base/teams.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/knowledge-base/teams.md b/knowledge-base/teams.md index fe3254857..7deca857d 100644 --- a/knowledge-base/teams.md +++ b/knowledge-base/teams.md @@ -39,6 +39,17 @@ Two flags on `/v1/capabilities` (`Capabilities` in `ui/engine-client`): seat billing). Absent/false on desktop/self-host, where the switcher's create action stays "create a local workspace". Read via `hasSpaces(caps)` (`app/src/lib/org-roles.ts`). See the **Spaces** section below. +- **`spaceKind?: "personal" | "team"`** — the ACTIVE space's kind, re-fetched + on every space switch exactly like `role`. The server-truth signal the + members/invite surface gates on: a personal space is non-invitable (every + member-add answers `403 personal_space`), and every user is `owner` of their + personal space, so the role gates alone can't tell it apart from a team. + Read via `isPersonalSpace(caps)` (`app/src/lib/org-roles.ts`) — a TOLERANT + reader: true only on an explicit `"personal"`, so a gateway that predates + the field keeps today's surface (a stale gateway never hides a team's invite + surface). When personal, the Organization > People body is replaced by a + create-a-team CTA (`people-create-team-cta.tsx`) and the Admin index People + caption follows (`org.index.rows.peoplePersonal`). Optional so every existing single-player/self-host profile stays valid. @@ -445,6 +456,9 @@ restores members. - `caps.spaces` = the whole surface feature-detect (`hasSpaces`). - `caps.role` is the ACTIVE space's role; re-fetched on every switch (cache drop). +- `caps.spaceKind` is the ACTIVE space's kind (`isPersonalSpace`); same + refetch-on-switch contract. Personal = non-invitable → the People surface + swaps to the create-team CTA (see **Feature detection**). - Growth beats, all Spaces-gated: an onboarding "invite your team" finish card (`onboarding/missions/onboarding-flow.ts` `showsInviteTeamCard`), a space-switcher tour step, and the personal-space person-filter teaser on the @@ -566,7 +580,12 @@ hides the "Add models" list, all copy passed in. ## Invites, members, audit, usage - **Invites**: `addOrgMember(email, role)` → `POST /org/members` (targets the - ACTIVE space; `403 personal_space` on a personal one). A known user is added + ACTIVE space; `403 personal_space` on a personal one — which is why the + People tab never renders the add form in a personal space: `MembersTab` + branches on `isPersonalSpace(caps)` (`caps.spaceKind`) and shows the + create-a-team CTA instead, whose `CreateTeamDialog` switches into the new + team on success so the real invite surface appears right after). A known + user is added directly (`AddOrgMemberResult.userId`); an unknown email creates a pending invite and the host answers **202 `{invited:true}`**. `OrgInvite` rows surface on `GET /org` for owner/admin; `deleteOrgInvite` revokes (owner only).