Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion app/src/components/organization/admin-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +33,7 @@ interface AdminIndexProps {
*/
export function AdminIndex({
visibleIds,
personalSpace = false,
memberCount,
onSelect,
}: AdminIndexProps) {
Expand All @@ -46,7 +53,11 @@ export function AdminIndex({
<SettingsRow
icon={Users}
title={t("org.tabs.people")}
description={t("org.index.rows.people")}
description={t(
personalSpace
? "org.index.rows.peoplePersonal"
: "org.index.rows.people",
)}
value={
memberCount === undefined
? undefined
Expand Down
19 changes: 19 additions & 0 deletions app/src/components/organization/members-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useTranslation } from "react-i18next";
import { useCapabilities } from "../../hooks/use-capabilities";
import { useSession } from "../../hooks/use-session";
import { isPersonalSpace } from "../../lib/org-roles";
import type { OrgTabProps } from "./organization-view";
import { PeopleAddRow } from "./people-add-row";
import { PeopleCreateTeamCta } from "./people-create-team-cta";
import { PendingInvites } from "./people-invites";
import { PeopleRoster } from "./people-roster";

Expand All @@ -14,15 +17,31 @@ import { PeopleRoster } from "./people-roster";
* owner/admin, so it never mounts in single-player or for a plain member. All
* mutations route through hooks whose `call()` wrapper toasts on failure, so
* there are no silent failures here.
*
* In a PERSONAL space (C8 `spaceKind`) the whole membership surface is replaced
* by the create-a-team CTA: personal spaces are non-invitable (every member-add
* answers `403 personal_space`) and the roster is definitionally just the
* caller, so the add form, pending invites, and roster would only offer a dead
* end. A host that predates `spaceKind` omits it and keeps today's surface —
* see `isPersonalSpace`.
*/
export default function MembersTab({ ctx }: OrgTabProps) {
const { t } = useTranslation("teams");
const { data: session } = useSession();
const { capabilities } = useCapabilities();
const selfId = session?.uid ?? null;
const canManage = ctx.isOwner;
const members = ctx.org.members ?? [];
const invites = ctx.org.invites ?? [];

if (isPersonalSpace(capabilities)) {
return (
<div className="flex flex-col gap-8 py-6">
<PeopleCreateTeamCta />
</div>
);
}

return (
<div className="flex flex-col gap-8 py-6">
{canManage ? (
Expand Down
3 changes: 2 additions & 1 deletion app/src/components/organization/organization-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -98,6 +98,7 @@ export function OrganizationView() {
<div className="flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<AdminIndex
visibleIds={visibleIds}
personalSpace={isPersonalSpace(capabilities)}
memberCount={org?.members?.length}
onSelect={setActive}
/>
Expand Down
31 changes: 31 additions & 0 deletions app/src/components/organization/people-create-team-cta.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="rounded-2xl border border-ink/5 bg-chip p-4">
<h2 className="mb-1 text-sm font-medium text-ink">
{t("people.personal.title")}
</h2>
<p className="mb-3 text-xs text-ink-muted">{t("people.personal.body")}</p>
<Button className="rounded-full" onClick={() => setCreateOpen(true)}>
{t("people.personal.cta")}
</Button>
<CreateTeamDialog open={createOpen} onOpenChange={setCreateOpen} />
</section>
);
}
17 changes: 17 additions & 0 deletions app/src/lib/org-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions app/src/locales/en/teams.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions app/src/locales/es/teams.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions app/src/locales/pt/teams.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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.",
Expand Down
32 changes: 32 additions & 0 deletions app/tests/org-roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
canSeeMembers,
GRANTABLE_ROLES,
isMultiplayer,
isPersonalSpace,
orgRole,
} from "../src/lib/org-roles.ts";

Expand Down Expand Up @@ -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"]);
Expand Down
21 changes: 20 additions & 1 deletion knowledge-base/teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
11 changes: 11 additions & 0 deletions ui/engine-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading