From e03125a38288a6d468ea1e17235389eb9181fe7e Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 8 Feb 2026 21:16:26 +0530 Subject: [PATCH] feat: add organization-related models and update schemas for organization management --- .../050c16fde00e_backfill_invite_access.py | 89 + .../1f2a3b4c5d6e_add_organizations.py | 259 ++ .../versions/2c7b1c4d9e10_merge_heads.py | 24 + ...6e1c9b2f7a4d_add_active_organization_id.py | 70 + backend/app/api/activity.py | 41 +- backend/app/api/agent.py | 1 - backend/app/api/agents.py | 161 +- backend/app/api/approvals.py | 27 +- backend/app/api/board_group_memory.py | 87 +- backend/app/api/board_groups.py | 111 +- backend/app/api/board_memory.py | 22 +- backend/app/api/board_onboarding.py | 20 +- backend/app/api/boards.py | 83 +- backend/app/api/deps.py | 99 +- backend/app/api/gateway.py | 43 +- backend/app/api/gateways.py | 28 +- backend/app/api/metrics.py | 105 +- backend/app/api/organizations.py | 403 +++ backend/app/api/tasks.py | 39 +- backend/app/core/auth.py | 6 + backend/app/main.py | 2 + backend/app/models/__init__.py | 10 + backend/app/models/board_groups.py | 1 + backend/app/models/boards.py | 1 + backend/app/models/gateways.py | 1 + .../app/models/organization_board_access.py | 30 + .../organization_invite_board_access.py | 30 + backend/app/models/organization_invites.py | 27 + backend/app/models/organization_members.py | 29 + backend/app/models/organizations.py | 19 + backend/app/models/users.py | 3 + backend/app/schemas/__init__.py | 22 + backend/app/schemas/board_groups.py | 1 + backend/app/schemas/boards.py | 1 + backend/app/schemas/gateways.py | 1 + backend/app/schemas/organizations.py | 100 + backend/app/services/organizations.py | 464 ++++ frontend/src/api/generated/agent/agent.ts | 444 ++++ .../src/api/generated/model/boardGroupRead.ts | 1 + frontend/src/api/generated/model/boardRead.ts | 1 + .../src/api/generated/model/gatewayRead.ts | 1 + frontend/src/api/generated/model/index.ts | 22 + ...TypeVarCustomizedOrganizationInviteRead.ts | 17 + ...TypeVarCustomizedOrganizationMemberRead.ts | 17 + ...tesApiV1OrganizationsMeInvitesGetParams.ts | 18 + ...ersApiV1OrganizationsMeMembersGetParams.ts | 18 + .../model/organizationActiveUpdate.ts | 10 + .../model/organizationBoardAccessRead.ts | 15 + .../model/organizationBoardAccessSpec.ts | 12 + .../api/generated/model/organizationCreate.ts | 10 + .../model/organizationInviteAccept.ts | 10 + .../model/organizationInviteCreate.ts | 15 + .../generated/model/organizationInviteRead.ts | 21 + .../generated/model/organizationListItem.ts | 13 + .../model/organizationMemberAccessUpdate.ts | 13 + .../generated/model/organizationMemberRead.ts | 21 + .../model/organizationMemberUpdate.ts | 10 + .../api/generated/model/organizationRead.ts | 13 + .../generated/model/organizationUserRead.ts | 13 + ...earchApiV1SoulsDirectorySearchGetParams.ts | 18 + .../api/generated/model/soulUpdateRequest.ts | 12 + .../model/soulsDirectoryMarkdownResponse.ts | 12 + .../model/soulsDirectorySearchResponse.ts | 11 + .../generated/model/soulsDirectorySoulRef.ts | 13 + .../generated/organizations/organizations.ts | 2329 +++++++++++++++++ .../souls-directory/souls-directory.ts | 727 +++++ frontend/src/app/agents/[agentId]/page.tsx | 36 +- frontend/src/app/agents/new/page.tsx | 44 +- frontend/src/app/agents/page.tsx | 46 +- .../src/app/board-groups/[groupId]/page.tsx | 105 +- .../src/app/boards/[boardId]/edit/page.tsx | 41 +- frontend/src/app/boards/[boardId]/page.tsx | 651 +++-- frontend/src/app/boards/new/page.tsx | 200 +- frontend/src/app/boards/page.tsx | 20 +- .../app/gateways/[gatewayId]/edit/page.tsx | 54 +- .../src/app/gateways/[gatewayId]/page.tsx | 32 +- frontend/src/app/gateways/new/page.tsx | 52 +- frontend/src/app/gateways/page.tsx | 46 +- frontend/src/app/invite/page.tsx | 141 + frontend/src/app/organization/page.tsx | 1131 ++++++++ frontend/src/components/BoardChatComposer.tsx | 8 +- .../src/components/molecules/TaskCard.tsx | 8 +- .../components/organisms/DashboardSidebar.tsx | 83 +- .../src/components/organisms/OrgSwitcher.tsx | 240 ++ .../src/components/organisms/TaskBoard.tsx | 25 +- .../components/templates/DashboardShell.tsx | 41 +- 86 files changed, 8673 insertions(+), 628 deletions(-) create mode 100644 backend/alembic/versions/050c16fde00e_backfill_invite_access.py create mode 100644 backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py create mode 100644 backend/alembic/versions/2c7b1c4d9e10_merge_heads.py create mode 100644 backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py create mode 100644 backend/app/api/organizations.py create mode 100644 backend/app/models/organization_board_access.py create mode 100644 backend/app/models/organization_invite_board_access.py create mode 100644 backend/app/models/organization_invites.py create mode 100644 backend/app/models/organization_members.py create mode 100644 backend/app/models/organizations.py create mode 100644 backend/app/schemas/organizations.py create mode 100644 backend/app/services/organizations.py create mode 100644 frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationInviteRead.ts create mode 100644 frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationMemberRead.ts create mode 100644 frontend/src/api/generated/model/listOrgInvitesApiV1OrganizationsMeInvitesGetParams.ts create mode 100644 frontend/src/api/generated/model/listOrgMembersApiV1OrganizationsMeMembersGetParams.ts create mode 100644 frontend/src/api/generated/model/organizationActiveUpdate.ts create mode 100644 frontend/src/api/generated/model/organizationBoardAccessRead.ts create mode 100644 frontend/src/api/generated/model/organizationBoardAccessSpec.ts create mode 100644 frontend/src/api/generated/model/organizationCreate.ts create mode 100644 frontend/src/api/generated/model/organizationInviteAccept.ts create mode 100644 frontend/src/api/generated/model/organizationInviteCreate.ts create mode 100644 frontend/src/api/generated/model/organizationInviteRead.ts create mode 100644 frontend/src/api/generated/model/organizationListItem.ts create mode 100644 frontend/src/api/generated/model/organizationMemberAccessUpdate.ts create mode 100644 frontend/src/api/generated/model/organizationMemberRead.ts create mode 100644 frontend/src/api/generated/model/organizationMemberUpdate.ts create mode 100644 frontend/src/api/generated/model/organizationRead.ts create mode 100644 frontend/src/api/generated/model/organizationUserRead.ts create mode 100644 frontend/src/api/generated/model/searchApiV1SoulsDirectorySearchGetParams.ts create mode 100644 frontend/src/api/generated/model/soulUpdateRequest.ts create mode 100644 frontend/src/api/generated/model/soulsDirectoryMarkdownResponse.ts create mode 100644 frontend/src/api/generated/model/soulsDirectorySearchResponse.ts create mode 100644 frontend/src/api/generated/model/soulsDirectorySoulRef.ts create mode 100644 frontend/src/api/generated/organizations/organizations.ts create mode 100644 frontend/src/api/generated/souls-directory/souls-directory.ts create mode 100644 frontend/src/app/invite/page.tsx create mode 100644 frontend/src/app/organization/page.tsx create mode 100644 frontend/src/components/organisms/OrgSwitcher.tsx diff --git a/backend/alembic/versions/050c16fde00e_backfill_invite_access.py b/backend/alembic/versions/050c16fde00e_backfill_invite_access.py new file mode 100644 index 00000000..6842d15f --- /dev/null +++ b/backend/alembic/versions/050c16fde00e_backfill_invite_access.py @@ -0,0 +1,89 @@ +"""backfill_invite_access + +Revision ID: 050c16fde00e +Revises: 2c7b1c4d9e10 +Create Date: 2026-02-08 20:07:14.621575 + +""" +from __future__ import annotations + +from datetime import datetime +import uuid + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '050c16fde00e' +down_revision = '2c7b1c4d9e10' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + now = datetime.utcnow() + rows = bind.execute( + sa.text( + """ + SELECT + m.id AS member_id, + iba.board_id AS board_id, + iba.can_read AS can_read, + iba.can_write AS can_write + FROM organization_invites i + JOIN organization_invite_board_access iba + ON iba.organization_invite_id = i.id + JOIN organization_members m + ON m.user_id = i.accepted_by_user_id + AND m.organization_id = i.organization_id + WHERE i.accepted_at IS NOT NULL + """ + ) + ).fetchall() + + for row in rows: + can_write = bool(row.can_write) + can_read = bool(row.can_read or row.can_write) + bind.execute( + sa.text( + """ + INSERT INTO organization_board_access ( + id, + organization_member_id, + board_id, + can_read, + can_write, + created_at, + updated_at + ) + VALUES ( + :id, + :member_id, + :board_id, + :can_read, + :can_write, + :now, + :now + ) + ON CONFLICT (organization_member_id, board_id) DO UPDATE + SET + can_read = organization_board_access.can_read OR EXCLUDED.can_read, + can_write = organization_board_access.can_write OR EXCLUDED.can_write, + updated_at = EXCLUDED.updated_at + """ + ), + { + "id": uuid.uuid4(), + "member_id": row.member_id, + "board_id": row.board_id, + "can_read": can_read, + "can_write": can_write, + "now": now, + }, + ) + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py b/backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py new file mode 100644 index 00000000..1b682b5e --- /dev/null +++ b/backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py @@ -0,0 +1,259 @@ +"""add organizations + +Revision ID: 1f2a3b4c5d6e +Revises: 9f0c4fb2a7b8 +Create Date: 2026-02-07 +""" + +from __future__ import annotations + +from datetime import datetime +import uuid + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "1f2a3b4c5d6e" +down_revision = "9f0c4fb2a7b8" +branch_labels = None +depends_on = None + + +DEFAULT_ORG_NAME = "Personal" + + +def upgrade() -> None: + op.create_table( + "organizations", + sa.Column("id", sa.UUID(), primary_key=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint("name", name="uq_organizations_name"), + ) + op.create_index("ix_organizations_name", "organizations", ["name"]) + + op.create_table( + "organization_members", + sa.Column("id", sa.UUID(), primary_key=True, nullable=False), + sa.Column("organization_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("role", sa.String(), nullable=False, server_default="member"), + sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_members_org"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="fk_org_members_user"), + sa.UniqueConstraint( + "organization_id", + "user_id", + name="uq_organization_members_org_user", + ), + ) + op.create_index("ix_org_members_org", "organization_members", ["organization_id"]) + op.create_index("ix_org_members_user", "organization_members", ["user_id"]) + op.create_index("ix_org_members_role", "organization_members", ["role"]) + + op.create_table( + "organization_board_access", + sa.Column("id", sa.UUID(), primary_key=True, nullable=False), + sa.Column("organization_member_id", sa.UUID(), nullable=False), + sa.Column("board_id", sa.UUID(), nullable=False), + sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["organization_member_id"], + ["organization_members.id"], + name="fk_org_board_access_member", + ), + sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_board_access_board"), + sa.UniqueConstraint( + "organization_member_id", + "board_id", + name="uq_org_board_access_member_board", + ), + ) + op.create_index( + "ix_org_board_access_member", + "organization_board_access", + ["organization_member_id"], + ) + op.create_index( + "ix_org_board_access_board", + "organization_board_access", + ["board_id"], + ) + + op.create_table( + "organization_invites", + sa.Column("id", sa.UUID(), primary_key=True, nullable=False), + sa.Column("organization_id", sa.UUID(), nullable=False), + sa.Column("invited_email", sa.String(), nullable=False), + sa.Column("token", sa.String(), nullable=False), + sa.Column("role", sa.String(), nullable=False, server_default="member"), + sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("created_by_user_id", sa.UUID(), nullable=True), + sa.Column("accepted_by_user_id", sa.UUID(), nullable=True), + sa.Column("accepted_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_invites_org"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_org_invites_creator"), + sa.ForeignKeyConstraint(["accepted_by_user_id"], ["users.id"], name="fk_org_invites_acceptor"), + sa.UniqueConstraint("token", name="uq_org_invites_token"), + ) + op.create_index("ix_org_invites_org", "organization_invites", ["organization_id"]) + op.create_index("ix_org_invites_email", "organization_invites", ["invited_email"]) + op.create_index("ix_org_invites_token", "organization_invites", ["token"]) + + op.create_table( + "organization_invite_board_access", + sa.Column("id", sa.UUID(), primary_key=True, nullable=False), + sa.Column("organization_invite_id", sa.UUID(), nullable=False), + sa.Column("board_id", sa.UUID(), nullable=False), + sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["organization_invite_id"], + ["organization_invites.id"], + name="fk_org_invite_access_invite", + ), + sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_invite_access_board"), + sa.UniqueConstraint( + "organization_invite_id", + "board_id", + name="uq_org_invite_board_access_invite_board", + ), + ) + op.create_index( + "ix_org_invite_access_invite", + "organization_invite_board_access", + ["organization_invite_id"], + ) + op.create_index( + "ix_org_invite_access_board", + "organization_invite_board_access", + ["board_id"], + ) + + op.add_column("boards", sa.Column("organization_id", sa.UUID(), nullable=True)) + op.add_column("board_groups", sa.Column("organization_id", sa.UUID(), nullable=True)) + op.add_column("gateways", sa.Column("organization_id", sa.UUID(), nullable=True)) + + op.create_index("ix_boards_organization_id", "boards", ["organization_id"]) + op.create_index("ix_board_groups_organization_id", "board_groups", ["organization_id"]) + op.create_index("ix_gateways_organization_id", "gateways", ["organization_id"]) + + op.create_foreign_key( + "fk_boards_organization_id", + "boards", + "organizations", + ["organization_id"], + ["id"], + ) + op.create_foreign_key( + "fk_board_groups_organization_id", + "board_groups", + "organizations", + ["organization_id"], + ["id"], + ) + op.create_foreign_key( + "fk_gateways_organization_id", + "gateways", + "organizations", + ["organization_id"], + ["id"], + ) + + bind = op.get_bind() + now = datetime.utcnow() + org_id = uuid.uuid4() + bind.execute( + sa.text( + "INSERT INTO organizations (id, name, created_at, updated_at) VALUES (:id, :name, :now, :now)" + ), + {"id": org_id, "name": DEFAULT_ORG_NAME, "now": now}, + ) + + bind.execute( + sa.text("UPDATE boards SET organization_id = :org_id"), + {"org_id": org_id}, + ) + bind.execute( + sa.text("UPDATE board_groups SET organization_id = :org_id"), + {"org_id": org_id}, + ) + bind.execute( + sa.text("UPDATE gateways SET organization_id = :org_id"), + {"org_id": org_id}, + ) + + user_rows = list(bind.execute(sa.text("SELECT id FROM users"))) + for row in user_rows: + user_id = row[0] + bind.execute( + sa.text( + """ + INSERT INTO organization_members + (id, organization_id, user_id, role, all_boards_read, all_boards_write, created_at, updated_at) + VALUES + (:id, :org_id, :user_id, :role, :all_read, :all_write, :now, :now) + """ + ), + { + "id": uuid.uuid4(), + "org_id": org_id, + "user_id": user_id, + "role": "owner", + "all_read": True, + "all_write": True, + "now": now, + }, + ) + + op.alter_column("boards", "organization_id", nullable=False) + op.alter_column("board_groups", "organization_id", nullable=False) + op.alter_column("gateways", "organization_id", nullable=False) + + +def downgrade() -> None: + op.drop_constraint("fk_gateways_organization_id", "gateways", type_="foreignkey") + op.drop_constraint("fk_board_groups_organization_id", "board_groups", type_="foreignkey") + op.drop_constraint("fk_boards_organization_id", "boards", type_="foreignkey") + + op.drop_index("ix_gateways_organization_id", table_name="gateways") + op.drop_index("ix_board_groups_organization_id", table_name="board_groups") + op.drop_index("ix_boards_organization_id", table_name="boards") + + op.drop_column("gateways", "organization_id") + op.drop_column("board_groups", "organization_id") + op.drop_column("boards", "organization_id") + + op.drop_index("ix_org_invite_access_board", table_name="organization_invite_board_access") + op.drop_index("ix_org_invite_access_invite", table_name="organization_invite_board_access") + op.drop_table("organization_invite_board_access") + + op.drop_index("ix_org_invites_token", table_name="organization_invites") + op.drop_index("ix_org_invites_email", table_name="organization_invites") + op.drop_index("ix_org_invites_org", table_name="organization_invites") + op.drop_table("organization_invites") + + op.drop_index("ix_org_board_access_board", table_name="organization_board_access") + op.drop_index("ix_org_board_access_member", table_name="organization_board_access") + op.drop_table("organization_board_access") + + op.drop_index("ix_org_members_role", table_name="organization_members") + op.drop_index("ix_org_members_user", table_name="organization_members") + op.drop_index("ix_org_members_org", table_name="organization_members") + op.drop_table("organization_members") + + op.drop_index("ix_organizations_name", table_name="organizations") + op.drop_table("organizations") diff --git a/backend/alembic/versions/2c7b1c4d9e10_merge_heads.py b/backend/alembic/versions/2c7b1c4d9e10_merge_heads.py new file mode 100644 index 00000000..64aba225 --- /dev/null +++ b/backend/alembic/versions/2c7b1c4d9e10_merge_heads.py @@ -0,0 +1,24 @@ +"""merge heads + +Revision ID: 2c7b1c4d9e10 +Revises: 1f2a3b4c5d6e, af403671a8c4 +Create Date: 2026-02-07 +""" + +from __future__ import annotations + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2c7b1c4d9e10" +down_revision = ("1f2a3b4c5d6e", "af403671a8c4") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py b/backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py new file mode 100644 index 00000000..5badd6ea --- /dev/null +++ b/backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py @@ -0,0 +1,70 @@ +"""add active organization to users + +Revision ID: 6e1c9b2f7a4d +Revises: 050c16fde00e +Create Date: 2026-02-08 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "6e1c9b2f7a4d" +down_revision = "050c16fde00e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("active_organization_id", sa.UUID(), nullable=True), + ) + op.create_index( + "ix_users_active_organization_id", + "users", + ["active_organization_id"], + ) + op.create_foreign_key( + "fk_users_active_organization", + "users", + "organizations", + ["active_organization_id"], + ["id"], + ) + + bind = op.get_bind() + rows = bind.execute( + sa.text( + """ + SELECT user_id, organization_id + FROM organization_members + ORDER BY user_id, created_at ASC + """ + ) + ).fetchall() + seen: set[str] = set() + for row in rows: + user_id = str(row.user_id) + if user_id in seen: + continue + seen.add(user_id) + bind.execute( + sa.text( + """ + UPDATE users + SET active_organization_id = :org_id + WHERE id = :user_id + AND active_organization_id IS NULL + """ + ), + {"org_id": row.organization_id, "user_id": row.user_id}, + ) + + +def downgrade() -> None: + op.drop_constraint("fk_users_active_organization", "users", type_="foreignkey") + op.drop_index("ix_users_active_organization_id", table_name="users") + op.drop_column("users", "active_organization_id") diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index 37947aff..8b8f0993 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -8,14 +8,13 @@ from datetime import datetime, timezone from typing import Any, cast from uuid import UUID -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy import asc, desc, func from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from sse_starlette.sse import EventSourceResponse -from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent -from app.core.auth import AuthContext +from app.api.deps import ActorContext, require_admin_or_agent, require_org_member from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import async_session_maker, get_session @@ -25,6 +24,7 @@ from app.models.boards import Board from app.models.tasks import Task from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead from app.schemas.pagination import DefaultLimitOffsetPage +from app.services.organizations import get_active_membership, list_accessible_board_ids router = APIRouter(prefix="/activity", tags=["activity"]) @@ -112,6 +112,17 @@ async def list_activity( statement = select(ActivityEvent) if actor.actor_type == "agent" and actor.agent: statement = statement.where(ActivityEvent.agent_id == actor.agent.id) + elif actor.actor_type == "user" and actor.user: + member = await get_active_membership(session, actor.user) + if member is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + board_ids = await list_accessible_board_ids(session, member=member, write=False) + if not board_ids: + statement = statement.where(col(ActivityEvent.id).is_(None)) + else: + statement = statement.join(Task, col(ActivityEvent.task_id) == col(Task.id)).where( + col(Task.board_id).in_(board_ids) + ) statement = statement.order_by(desc(col(ActivityEvent.created_at))) return await paginate(session, statement) @@ -123,7 +134,7 @@ async def list_activity( async def list_task_comment_feed( board_id: UUID | None = Query(default=None), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_member), ) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]: statement = ( select(ActivityEvent, Task, Board, Agent) @@ -134,8 +145,15 @@ async def list_task_comment_feed( .where(func.length(func.trim(col(ActivityEvent.message))) > 0) .order_by(desc(col(ActivityEvent.created_at))) ) + board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) if board_id is not None: + if board_id not in set(board_ids): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) statement = statement.where(col(Task.board_id) == board_id) + elif board_ids: + statement = statement.where(col(Task.board_id).in_(board_ids)) + else: + statement = statement.where(col(Task.id).is_(None)) def _transform(items: Sequence[Any]) -> Sequence[Any]: rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items) @@ -149,9 +167,14 @@ async def stream_task_comment_feed( request: Request, board_id: UUID | None = Query(default=None), since: str | None = Query(default=None), - auth: AuthContext = Depends(require_admin_auth), + session: AsyncSession = Depends(get_session), + ctx=Depends(require_org_member), ) -> EventSourceResponse: since_dt = _parse_since(since) or utcnow() + board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) + allowed_ids = set(board_ids) + if board_id is not None and board_id not in allowed_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) seen_ids: set[UUID] = set() seen_queue: deque[UUID] = deque() @@ -161,7 +184,13 @@ async def stream_task_comment_feed( if await request.is_disconnected(): break async with async_session_maker() as session: - rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id) + if board_id is not None: + rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id) + elif allowed_ids: + rows = await _fetch_task_comment_events(session, last_seen) + rows = [row for row in rows if row[1].board_id in allowed_ids] + else: + rows = [] for event, task, board, agent in rows: event_id = event.id if event_id in seen_ids: diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 5c6d5b09..9fbc0271 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -334,7 +334,6 @@ async def list_task_comments( return await tasks_api.list_task_comments( task=task, session=session, - actor=_actor(agent_ctx), ) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 0c1ae08b..6d8e5f1e 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -14,9 +14,9 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from sse_starlette.sse import EventSourceResponse -from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent +from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin from app.core.agent_tokens import generate_agent_token, hash_agent_token -from app.core.auth import AuthContext +from app.core.auth import AuthContext, get_auth_context from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import async_session_maker, get_session @@ -26,7 +26,9 @@ from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway +from app.models.organizations import Organization from app.models.tasks import Task +from app.models.users import User from app.schemas.agents import ( AgentCreate, AgentHeartbeat, @@ -43,6 +45,14 @@ from app.services.agent_provisioning import ( provision_agent, provision_main_agent, ) +from app.services.organizations import ( + OrganizationContext, + get_active_membership, + has_board_access, + is_org_admin, + list_accessible_board_ids, + require_board_access, +) router = APIRouter(prefix="/agents", tags=["agents"]) @@ -85,7 +95,13 @@ def _workspace_path(agent_name: str, workspace_root: str | None) -> str: return f"{root}/workspace-{_slugify(agent_name)}" -async def _require_board(session: AsyncSession, board_id: UUID | str | None) -> Board: +async def _require_board( + session: AsyncSession, + board_id: UUID | str | None, + *, + user: object | None = None, + write: bool = False, +) -> Board: if not board_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -94,6 +110,8 @@ async def _require_board(session: AsyncSession, board_id: UUID | str | None) -> board = await session.get(Board, board_id) if board is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") + if user is not None: + await require_board_access(session, user=user, board=board, write=write) # type: ignore[arg-type] return board @@ -111,6 +129,11 @@ async def _require_gateway( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Board gateway_id is invalid", ) + if gateway.organization_id != board.organization_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is invalid", + ) if not gateway.main_session_key: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -206,6 +229,42 @@ async def _fetch_agent_events( return list(await session.exec(statement)) +async def _require_user_context( + session: AsyncSession, user: User | None +) -> OrganizationContext: + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + member = await get_active_membership(session, user) + if member is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + organization = await session.get(Organization, member.organization_id) + if organization is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return OrganizationContext(organization=organization, member=member) + + +async def _require_agent_access( + session: AsyncSession, + *, + agent: Agent, + ctx, + write: bool, +) -> None: + if agent.board_id is None: + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + gateway = await _find_gateway_for_main_session(session, agent.openclaw_session_id) + if gateway is None or gateway.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return + + board = await session.get(Board, agent.board_id) + if board is None or board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not await has_board_access(session, member=ctx.member, board=board, write=write): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + def _record_heartbeat(session: AsyncSession, agent: Agent) -> None: record_activity( session, @@ -245,13 +304,28 @@ async def list_agents( board_id: UUID | None = Query(default=None), gateway_id: UUID | None = Query(default=None), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_admin), ) -> DefaultLimitOffsetPage[AgentRead]: main_session_keys = await _get_gateway_main_session_keys(session) - statement = select(Agent) + board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) + if board_id is not None and board_id not in set(board_ids): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if not board_ids: + statement = select(Agent).where(col(Agent.id).is_(None)) + else: + base_filter = col(Agent.board_id).in_(board_ids) + if is_org_admin(ctx.member): + gateway_keys = select(Gateway.main_session_key).where( + col(Gateway.organization_id) == ctx.organization.id + ) + base_filter = or_(base_filter, col(Agent.openclaw_session_id).in_(gateway_keys)) + statement = select(Agent).where(base_filter) if board_id is not None: statement = statement.where(col(Agent.board_id) == board_id) if gateway_id is not None: + gateway = await session.get(Gateway, gateway_id) + if gateway is None or gateway.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) statement = statement.join(Board, col(Agent.board_id) == col(Board.id)).where( col(Board.gateway_id) == gateway_id ) @@ -269,10 +343,15 @@ async def stream_agents( request: Request, board_id: UUID | None = Query(default=None), since: str | None = Query(default=None), - auth: AuthContext = Depends(require_admin_auth), + session: AsyncSession = Depends(get_session), + ctx=Depends(require_org_admin), ) -> EventSourceResponse: since_dt = _parse_since(since) or utcnow() last_seen = since_dt + board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) + allowed_ids = set(board_ids) + if board_id is not None and board_id not in allowed_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) async def event_generator() -> AsyncIterator[dict[str, str]]: nonlocal last_seen @@ -280,7 +359,13 @@ async def stream_agents( if await request.is_disconnected(): break async with async_session_maker() as session: - agents = await _fetch_agent_events(session, board_id, last_seen) + if board_id is not None: + agents = await _fetch_agent_events(session, board_id, last_seen) + elif allowed_ids: + agents = await _fetch_agent_events(session, None, last_seen) + agents = [agent for agent in agents if agent.board_id in allowed_ids] + else: + agents = [] main_session_keys = ( await _get_gateway_main_session_keys(session) if agents else set() ) @@ -301,6 +386,10 @@ async def create_agent( session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> AgentRead: + if actor.actor_type == "user": + ctx = await _require_user_context(session, actor.user) + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if actor.actor_type == "agent": if not actor.agent or not actor.agent.is_board_lead: raise HTTPException( @@ -319,7 +408,12 @@ async def create_agent( ) payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id}) - board = await _require_board(session, payload.board_id) + board = await _require_board( + session, + payload.board_id, + user=actor.user if actor.actor_type == "user" else None, + write=actor.actor_type == "user", + ) gateway, client_config = await _require_gateway(session, board) data = payload.model_dump() requested_name = (data.get("name") or "").strip() @@ -436,11 +530,12 @@ async def create_agent( async def get_agent( agent_id: str, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_admin), ) -> AgentRead: agent = await session.get(Agent, agent_id) if agent is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await _require_agent_access(session, agent=agent, ctx=ctx, write=False) main_session_keys = await _get_gateway_main_session_keys(session) return _to_agent_read(_with_computed_status(agent), main_session_keys) @@ -451,18 +546,28 @@ async def update_agent( payload: AgentUpdate, force: bool = False, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + auth: AuthContext = Depends(get_auth_context), + ctx=Depends(require_org_admin), ) -> AgentRead: agent = await session.get(Agent, agent_id) if agent is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await _require_agent_access(session, agent=agent, ctx=ctx, write=True) updates = payload.model_dump(exclude_unset=True) make_main = updates.pop("is_gateway_main", None) + if make_main is True and not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if "status" in updates: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="status is controlled by agent heartbeat", ) + if "board_id" in updates and updates["board_id"] is not None: + new_board = await _require_board(session, updates["board_id"]) + if new_board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not await has_board_access(session, member=ctx.member, board=new_board, write=True): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if not updates and not force and make_main is None: main_session_keys = await _get_gateway_main_session_keys(session) return _to_agent_read(_with_computed_status(agent), main_session_keys) @@ -628,6 +733,11 @@ async def heartbeat_agent( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if actor.actor_type == "user": + ctx = await _require_user_context(session, actor.user) + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + await _require_agent_access(session, agent=agent, ctx=ctx, write=True) if payload.status: agent.status = payload.status elif agent.status == "provisioning": @@ -664,7 +774,16 @@ async def heartbeat_or_create_agent( if agent is None: if actor.actor_type == "agent": raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - board = await _require_board(session, payload.board_id) + if actor.actor_type == "user": + ctx = await _require_user_context(session, actor.user) + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + board = await _require_board( + session, + payload.board_id, + user=actor.user, + write=True, + ) gateway, client_config = await _require_gateway(session, board) agent = Agent( name=payload.name, @@ -724,6 +843,9 @@ async def heartbeat_or_create_agent( except Exception as exc: # pragma: no cover - unexpected provisioning errors _record_instruction_failure(session, agent, str(exc), "provision") await session.commit() + elif actor.actor_type == "user": + ctx = await _require_user_context(session, actor.user) + await _require_agent_access(session, agent=agent, ctx=ctx, write=True) elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) elif agent.agent_token_hash is None and actor.actor_type == "user": @@ -737,7 +859,12 @@ async def heartbeat_or_create_agent( await session.commit() await session.refresh(agent) try: - board = await _require_board(session, str(agent.board_id) if agent.board_id else None) + board = await _require_board( + session, + str(agent.board_id) if agent.board_id else None, + user=actor.user if actor.actor_type == "user" else None, + write=actor.actor_type == "user", + ) gateway, client_config = await _require_gateway(session, board) await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision") await _send_wakeup_message(agent, client_config, verb="provisioned") @@ -767,7 +894,12 @@ async def heartbeat_or_create_agent( _record_instruction_failure(session, agent, str(exc), "provision") await session.commit() elif not agent.openclaw_session_id: - board = await _require_board(session, str(agent.board_id) if agent.board_id else None) + board = await _require_board( + session, + str(agent.board_id) if agent.board_id else None, + user=actor.user if actor.actor_type == "user" else None, + write=actor.actor_type == "user", + ) gateway, client_config = await _require_gateway(session, board) session_key, session_error = await _ensure_gateway_session(agent.name, client_config) agent.openclaw_session_id = session_key @@ -804,11 +936,12 @@ async def heartbeat_or_create_agent( async def delete_agent( agent_id: str, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_admin), ) -> OkResponse: agent = await session.get(Agent, agent_id) if agent is None: return OkResponse() + await _require_agent_access(session, agent=agent, ctx=ctx, write=True) board = await _require_board(session, str(agent.board_id) if agent.board_id else None) gateway, client_config = await _require_gateway(session, board) diff --git a/backend/app/api/approvals.py b/backend/app/api/approvals.py index 13855c6b..dcb99aa6 100644 --- a/backend/app/api/approvals.py +++ b/backend/app/api/approvals.py @@ -12,8 +12,13 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from sse_starlette.sse import EventSourceResponse -from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent -from app.core.auth import AuthContext +from app.api.deps import ( + ActorContext, + get_board_for_actor_read, + get_board_for_actor_write, + get_board_for_user_write, + require_admin_or_agent, +) from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import async_session_maker, get_session @@ -88,13 +93,10 @@ async def _fetch_approval_events( @router.get("", response_model=DefaultLimitOffsetPage[ApprovalRead]) async def list_approvals( status_filter: ApprovalStatus | None = Query(default=None, alias="status"), - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> DefaultLimitOffsetPage[ApprovalRead]: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) statement = select(Approval).where(col(Approval.board_id) == board.id) if status_filter: statement = statement.where(col(Approval.status) == status_filter) @@ -105,13 +107,10 @@ async def list_approvals( @router.get("/stream") async def stream_approvals( request: Request, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), actor: ActorContext = Depends(require_admin_or_agent), since: str | None = Query(default=None), ) -> EventSourceResponse: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) since_dt = _parse_since(since) or utcnow() last_seen = since_dt @@ -180,13 +179,10 @@ async def stream_approvals( @router.post("", response_model=ApprovalRead) async def create_approval( payload: ApprovalCreate, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_write), session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> Approval: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) task_id = payload.task_id or _extract_task_id(payload.payload) approval = Approval( board_id=board.id, @@ -208,9 +204,8 @@ async def create_approval( async def update_approval( approval_id: str, payload: ApprovalUpdate, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_user_write), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), ) -> Approval: approval = await session.get(Approval, approval_id) if approval is None or approval.board_id != board.id: diff --git a/backend/app/api/board_group_memory.py b/backend/app/api/board_group_memory.py index 59471998..66379ac4 100644 --- a/backend/app/api/board_group_memory.py +++ b/backend/app/api/board_group_memory.py @@ -12,8 +12,13 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from sse_starlette.sse import EventSourceResponse -from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent -from app.core.auth import AuthContext +from app.api.deps import ( + ActorContext, + get_board_for_actor_read, + get_board_for_actor_write, + require_admin_or_agent, + require_org_member, +) from app.core.config import settings from app.core.time import utcnow from app.db.pagination import paginate @@ -25,8 +30,16 @@ from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup from app.models.boards import Board from app.models.gateways import Gateway +from app.models.users import User from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead from app.schemas.pagination import DefaultLimitOffsetPage +from app.services.organizations import ( + OrganizationContext, + is_org_admin, + list_accessible_board_ids, + member_all_boards_read, + member_all_boards_write, +) from app.services.mentions import extract_mentions, matches_agent_mention router = APIRouter(tags=["board-group-memory"]) @@ -96,6 +109,38 @@ async def _fetch_memory_events( return list(await session.exec(statement)) +async def _require_group_access( + session: AsyncSession, + *, + group_id: UUID, + ctx: OrganizationContext, + write: bool, +) -> BoardGroup: + group = await session.get(BoardGroup, group_id) + if group is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if group.organization_id != ctx.member.organization_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + if write and member_all_boards_write(ctx.member): + return group + if not write and member_all_boards_read(ctx.member): + return group + + board_ids = list( + await session.exec(select(Board.id).where(col(Board.board_group_id) == group_id)) + ) + if not board_ids: + if is_org_admin(ctx.member): + return group + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + allowed_ids = await list_accessible_board_ids(session, member=ctx.member, write=write) + if not set(board_ids).intersection(set(allowed_ids)): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return group + + async def _notify_group_memory_targets( *, session: AsyncSession, @@ -193,11 +238,9 @@ async def list_board_group_memory( group_id: UUID, is_chat: bool | None = Query(default=None), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx: OrganizationContext = Depends(require_org_member), ) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]: - group = await session.get(BoardGroup, group_id) - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await _require_group_access(session, group_id=group_id, ctx=ctx, write=False) statement = ( select(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id) # Old/invalid rows (empty/whitespace-only content) can exist; exclude them to @@ -217,11 +260,9 @@ async def stream_board_group_memory( since: str | None = Query(default=None), is_chat: bool | None = Query(default=None), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx: OrganizationContext = Depends(require_org_member), ) -> EventSourceResponse: - group = await session.get(BoardGroup, group_id) - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await _require_group_access(session, group_id=group_id, ctx=ctx, write=False) since_dt = _parse_since(since) or utcnow() last_seen = since_dt @@ -252,13 +293,12 @@ async def create_board_group_memory( group_id: UUID, payload: BoardGroupMemoryCreate, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx: OrganizationContext = Depends(require_org_member), ) -> BoardGroupMemory: - group = await session.get(BoardGroup, group_id) - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + group = await _require_group_access(session, group_id=group_id, ctx=ctx, write=True) - actor = ActorContext(actor_type="user", user=auth.user) + user = await session.get(User, ctx.member.user_id) + actor = ActorContext(actor_type="user", user=user) tags = set(payload.tags or []) is_chat = "chat" in tags mentions = extract_mentions(payload.content) @@ -287,13 +327,9 @@ async def create_board_group_memory( @board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead]) async def list_board_group_memory_for_board( is_chat: bool | None = Query(default=None), - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), session: AsyncSession = Depends(get_session), - actor: ActorContext = Depends(require_admin_or_agent), ) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) group_id = board.board_group_id if group_id is None: statement = select(BoardGroupMemory).where(col(BoardGroupMemory.id).is_(None)) @@ -314,14 +350,10 @@ async def list_board_group_memory_for_board( @board_router.get("/stream") async def stream_board_group_memory_for_board( request: Request, - board: Board = Depends(get_board_or_404), - actor: ActorContext = Depends(require_admin_or_agent), + board: Board = Depends(get_board_for_actor_read), since: str | None = Query(default=None), is_chat: bool | None = Query(default=None), ) -> EventSourceResponse: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) group_id = board.board_group_id since_dt = _parse_since(since) or utcnow() last_seen = since_dt @@ -354,13 +386,10 @@ async def stream_board_group_memory_for_board( @board_router.post("", response_model=BoardGroupMemoryRead) async def create_board_group_memory_for_board( payload: BoardGroupMemoryCreate, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_write), session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> BoardGroupMemory: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) group_id = board.board_group_id if group_id is None: raise HTTPException( diff --git a/backend/app/api/board_groups.py b/backend/app/api/board_groups.py index 364cc70f..ccd80d19 100644 --- a/backend/app/api/board_groups.py +++ b/backend/app/api/board_groups.py @@ -9,8 +9,7 @@ from sqlalchemy import delete, func, update from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent -from app.core.auth import AuthContext +from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin, require_org_member from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate @@ -29,6 +28,14 @@ from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.view_models import BoardGroupSnapshot from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats from app.services.board_group_snapshot import build_group_snapshot +from app.services.organizations import ( + board_access_filter, + get_member, + is_org_admin, + list_accessible_board_ids, + member_all_boards_read, + member_all_boards_write, +) router = APIRouter(prefix="/board-groups", tags=["board-groups"]) @@ -38,12 +45,56 @@ def _slugify(value: str) -> str: return slug or uuid4().hex +async def _require_group_access( + session: AsyncSession, + *, + group_id: UUID, + member, + write: bool, +) -> BoardGroup: + group = await session.get(BoardGroup, group_id) + if group is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if group.organization_id != member.organization_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + if write and member_all_boards_write(member): + return group + if not write and member_all_boards_read(member): + return group + + board_ids = list( + await session.exec(select(Board.id).where(col(Board.board_group_id) == group_id)) + ) + if not board_ids: + if is_org_admin(member): + return group + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + allowed_ids = await list_accessible_board_ids(session, member=member, write=write) + if not set(board_ids).intersection(set(allowed_ids)): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return group + + @router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead]) async def list_board_groups( session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_member), ) -> DefaultLimitOffsetPage[BoardGroupRead]: - statement = select(BoardGroup).order_by(func.lower(col(BoardGroup.name)).asc()) + if member_all_boards_read(ctx.member): + statement = select(BoardGroup).where( + col(BoardGroup.organization_id) == ctx.organization.id + ) + else: + accessible_boards = select(Board.board_group_id).where( + board_access_filter(ctx.member, write=False) + ) + statement = select(BoardGroup).where( + col(BoardGroup.organization_id) == ctx.organization.id, + col(BoardGroup.id).in_(accessible_boards), + ) + statement = statement.order_by(func.lower(col(BoardGroup.name)).asc()) return await paginate(session, statement) @@ -51,11 +102,12 @@ async def list_board_groups( async def create_board_group( payload: BoardGroupCreate, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_admin), ) -> BoardGroup: data = payload.model_dump() if not (data.get("slug") or "").strip(): data["slug"] = _slugify(data.get("name") or "") + data["organization_id"] = ctx.organization.id return await crud.create(session, BoardGroup, **data) @@ -63,12 +115,9 @@ async def create_board_group( async def get_board_group( group_id: UUID, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_member), ) -> BoardGroup: - group = await session.get(BoardGroup, group_id) - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - return group + return await _require_group_access(session, group_id=group_id, member=ctx.member, write=False) @router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot) @@ -77,20 +126,22 @@ async def get_board_group_snapshot( include_done: bool = False, per_board_task_limit: int = 5, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_member), ) -> BoardGroupSnapshot: - group = await session.get(BoardGroup, group_id) - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=False) if per_board_task_limit < 0: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) - return await build_group_snapshot( + snapshot = await build_group_snapshot( session, group=group, exclude_board_id=None, include_done=include_done, per_board_task_limit=per_board_task_limit, ) + if not member_all_boards_read(ctx.member) and snapshot.boards: + allowed_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=False)) + snapshot.boards = [item for item in snapshot.boards if item.board.id in allowed_ids] + return snapshot @router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult) @@ -104,7 +155,23 @@ async def apply_board_group_heartbeat( if group is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if actor.actor_type == "agent": + if actor.actor_type == "user": + if actor.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + member = await get_member( + session, + user_id=actor.user.id, + organization_id=group.organization_id, + ) + if member is None or not is_org_admin(member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + await _require_group_access( + session, + group_id=group_id, + member=member, + write=True, + ) + elif actor.actor_type == "agent": agent = actor.agent if agent is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) @@ -188,11 +255,9 @@ async def update_board_group( payload: BoardGroupUpdate, group_id: UUID, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_admin), ) -> BoardGroup: - group = await session.get(BoardGroup, group_id) - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True) updates = payload.model_dump(exclude_unset=True) if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip(): updates["slug"] = _slugify(updates.get("name") or group.name) @@ -206,11 +271,9 @@ async def update_board_group( async def delete_board_group( group_id: UUID, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_admin), ) -> OkResponse: - group = await session.get(BoardGroup, group_id) - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True) # Boards reference groups, so clear the FK first to keep deletes simple. await session.execute( diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py index 3d9271cd..03c0a74e 100644 --- a/backend/app/api/board_memory.py +++ b/backend/app/api/board_memory.py @@ -12,7 +12,12 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from sse_starlette.sse import EventSourceResponse -from app.api.deps import ActorContext, get_board_or_404, require_admin_or_agent +from app.api.deps import ( + ActorContext, + get_board_for_actor_read, + get_board_for_actor_write, + require_admin_or_agent, +) from app.core.config import settings from app.core.time import utcnow from app.db.pagination import paginate @@ -178,13 +183,10 @@ async def _notify_chat_targets( @router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead]) async def list_board_memory( is_chat: bool | None = Query(default=None), - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> DefaultLimitOffsetPage[BoardMemoryRead]: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) statement = ( select(BoardMemory).where(col(BoardMemory.board_id) == board.id) # Old/invalid rows (empty/whitespace-only content) can exist; exclude them to @@ -200,14 +202,11 @@ async def list_board_memory( @router.get("/stream") async def stream_board_memory( request: Request, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), actor: ActorContext = Depends(require_admin_or_agent), since: str | None = Query(default=None), is_chat: bool | None = Query(default=None), ) -> EventSourceResponse: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) since_dt = _parse_since(since) or utcnow() last_seen = since_dt @@ -236,13 +235,10 @@ async def stream_board_memory( @router.post("", response_model=BoardMemoryRead) async def create_board_memory( payload: BoardMemoryCreate, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_write), session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> BoardMemory: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) is_chat = payload.tags is not None and "chat" in payload.tags source = payload.source if is_chat and not source: diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py index 0b63cd43..1fc0cb86 100644 --- a/backend/app/api/board_onboarding.py +++ b/backend/app/api/board_onboarding.py @@ -9,7 +9,14 @@ from pydantic import ValidationError from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent +from app.api.deps import ( + ActorContext, + get_board_for_user_read, + get_board_for_user_write, + get_board_or_404, + require_admin_auth, + require_admin_or_agent, +) from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext from app.core.config import settings @@ -136,9 +143,8 @@ async def _ensure_lead_agent( @router.get("", response_model=BoardOnboardingRead) async def get_onboarding( - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_user_read), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), ) -> BoardOnboardingSession: onboarding = ( await session.exec( @@ -155,9 +161,8 @@ async def get_onboarding( @router.post("/start", response_model=BoardOnboardingRead) async def start_onboarding( payload: BoardOnboardingStart, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_user_write), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), ) -> BoardOnboardingSession: onboarding = ( await session.exec( @@ -239,9 +244,8 @@ async def start_onboarding( @router.post("/answer", response_model=BoardOnboardingRead) async def answer_onboarding( payload: BoardOnboardingAnswer, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_user_write), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), ) -> BoardOnboardingSession: onboarding = ( await session.exec( @@ -342,7 +346,7 @@ async def agent_onboarding_update( @router.post("/confirm", response_model=BoardRead) async def confirm_onboarding( payload: BoardOnboardingConfirm, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_user_write), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), ) -> Board: diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 62de2fff..b236de0f 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -8,8 +8,13 @@ from sqlalchemy import delete, func from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent -from app.core.auth import AuthContext +from app.api.deps import ( + get_board_for_actor_read, + get_board_for_user_read, + get_board_for_user_write, + require_org_admin, + require_org_member, +) from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate @@ -38,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot from app.services.board_group_snapshot import build_board_group_snapshot from app.services.board_snapshot import build_board_snapshot +from app.services.organizations import board_access_filter router = APIRouter(prefix="/boards", tags=["boards"]) @@ -53,40 +59,66 @@ def _build_session_key(agent_name: str) -> str: return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main" -async def _require_gateway(session: AsyncSession, gateway_id: object) -> Gateway: +async def _require_gateway( + session: AsyncSession, + gateway_id: object, + *, + organization_id: UUID | None = None, +) -> Gateway: gateway = await crud.get_by_id(session, Gateway, gateway_id) if gateway is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="gateway_id is invalid", ) + if organization_id is not None and gateway.organization_id != organization_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="gateway_id is invalid", + ) return gateway async def _require_gateway_for_create( payload: BoardCreate, + ctx=Depends(require_org_admin), session: AsyncSession = Depends(get_session), ) -> Gateway: - return await _require_gateway(session, payload.gateway_id) + return await _require_gateway(session, payload.gateway_id, organization_id=ctx.organization.id) -async def _require_board_group(session: AsyncSession, board_group_id: object) -> BoardGroup: +async def _require_board_group( + session: AsyncSession, + board_group_id: object, + *, + organization_id: UUID | None = None, +) -> BoardGroup: group = await crud.get_by_id(session, BoardGroup, board_group_id) if group is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="board_group_id is invalid", ) + if organization_id is not None and group.organization_id != organization_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_group_id is invalid", + ) return group async def _require_board_group_for_create( payload: BoardCreate, + ctx=Depends(require_org_admin), session: AsyncSession = Depends(get_session), ) -> BoardGroup | None: if payload.board_group_id is None: return None - return await _require_board_group(session, payload.board_group_id) + return await _require_board_group( + session, + payload.board_group_id, + organization_id=ctx.organization.id, + ) async def _apply_board_update( @@ -97,9 +129,13 @@ async def _apply_board_update( ) -> Board: updates = payload.model_dump(exclude_unset=True) if "gateway_id" in updates: - await _require_gateway(session, updates["gateway_id"]) + await _require_gateway(session, updates["gateway_id"], organization_id=board.organization_id) if "board_group_id" in updates and updates["board_group_id"] is not None: - await _require_board_group(session, updates["board_group_id"]) + await _require_board_group( + session, + updates["board_group_id"], + organization_id=board.organization_id, + ) for key, value in updates.items(): setattr(board, key, value) if updates.get("board_type") == "goal": @@ -182,9 +218,9 @@ async def list_boards( gateway_id: UUID | None = Query(default=None), board_group_id: UUID | None = Query(default=None), session: AsyncSession = Depends(get_session), - actor: ActorContext = Depends(require_admin_or_agent), + ctx=Depends(require_org_member), ) -> DefaultLimitOffsetPage[BoardRead]: - statement = select(Board) + statement = select(Board).where(board_access_filter(ctx.member, write=False)) if gateway_id is not None: statement = statement.where(col(Board.gateway_id) == gateway_id) if board_group_id is not None: @@ -199,28 +235,25 @@ async def create_board( _gateway: Gateway = Depends(_require_gateway_for_create), _board_group: BoardGroup | None = Depends(_require_board_group_for_create), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_admin), ) -> Board: - return await crud.create(session, Board, **payload.model_dump()) + data = payload.model_dump() + data["organization_id"] = ctx.organization.id + return await crud.create(session, Board, **data) @router.get("/{board_id}", response_model=BoardRead) def get_board( - board: Board = Depends(get_board_or_404), - actor: ActorContext = Depends(require_admin_or_agent), + board: Board = Depends(get_board_for_user_read), ) -> Board: return board @router.get("/{board_id}/snapshot", response_model=BoardSnapshot) async def get_board_snapshot( - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), session: AsyncSession = Depends(get_session), - actor: ActorContext = Depends(require_admin_or_agent), ) -> BoardSnapshot: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) return await build_board_snapshot(session, board) @@ -229,13 +262,9 @@ async def get_board_group_snapshot( include_self: bool = Query(default=False), include_done: bool = Query(default=False), per_board_task_limit: int = Query(default=5, ge=0, le=100), - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), session: AsyncSession = Depends(get_session), - actor: ActorContext = Depends(require_admin_or_agent), ) -> BoardGroupSnapshot: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) return await build_board_group_snapshot( session, board=board, @@ -249,8 +278,7 @@ async def get_board_group_snapshot( async def update_board( payload: BoardUpdate, session: AsyncSession = Depends(get_session), - board: Board = Depends(get_board_or_404), - auth: AuthContext = Depends(require_admin_auth), + board: Board = Depends(get_board_for_user_write), ) -> Board: return await _apply_board_update(payload=payload, session=session, board=board) @@ -258,8 +286,7 @@ async def update_board( @router.delete("/{board_id}", response_model=OkResponse) async def delete_board( session: AsyncSession = Depends(get_session), - board: Board = Depends(get_board_or_404), - auth: AuthContext = Depends(require_admin_auth), + board: Board = Depends(get_board_for_user_write), ) -> OkResponse: agents = list(await session.exec(select(Agent).where(Agent.board_id == board.id))) task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id))) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 5106f140..6f9b135f 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -13,6 +13,14 @@ from app.models.agents import Agent from app.models.boards import Board from app.models.tasks import Task from app.models.users import User +from app.models.organizations import Organization +from app.services.organizations import ( + OrganizationContext, + ensure_member_for_user, + get_active_membership, + is_org_admin, + require_board_access, +) from app.services.admin_access import require_admin @@ -40,6 +48,31 @@ def require_admin_or_agent( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) +async def require_org_member( + auth: AuthContext = Depends(get_auth_context), + session: AsyncSession = Depends(get_session), +) -> OrganizationContext: + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + member = await get_active_membership(session, auth.user) + if member is None: + member = await ensure_member_for_user(session, auth.user) + if member is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + organization = await session.get(Organization, member.organization_id) + if organization is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return OrganizationContext(organization=organization, member=member) + + +async def require_org_admin( + ctx: OrganizationContext = Depends(require_org_member), +) -> OrganizationContext: + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return ctx + + async def get_board_or_404( board_id: str, session: AsyncSession = Depends(get_session), @@ -50,9 +83,73 @@ async def get_board_or_404( return board +async def get_board_for_actor_read( + board_id: str, + session: AsyncSession = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> Board: + board = await session.get(Board, board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if actor.actor_type == "agent": + if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return board + if actor.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(session, user=actor.user, board=board, write=False) + return board + + +async def get_board_for_actor_write( + board_id: str, + session: AsyncSession = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> Board: + board = await session.get(Board, board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if actor.actor_type == "agent": + if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return board + if actor.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(session, user=actor.user, board=board, write=True) + return board + + +async def get_board_for_user_read( + board_id: str, + session: AsyncSession = Depends(get_session), + auth: AuthContext = Depends(get_auth_context), +) -> Board: + board = await session.get(Board, board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(session, user=auth.user, board=board, write=False) + return board + + +async def get_board_for_user_write( + board_id: str, + session: AsyncSession = Depends(get_session), + auth: AuthContext = Depends(get_auth_context), +) -> Board: + board = await session.get(Board, board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(session, user=auth.user, board=board, write=True) + return board + + async def get_task_or_404( task_id: str, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), session: AsyncSession = Depends(get_session), ) -> Task: task = await session.get(Task, task_id) diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index cda089db..cbed4bbb 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel.ext.asyncio.session import AsyncSession +from app.api.deps import require_org_admin from app.core.auth import AuthContext, get_auth_context from app.db.session import get_session from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig @@ -20,6 +21,7 @@ from app.integrations.openclaw_gateway_protocol import ( ) from app.models.boards import Board from app.models.gateways import Gateway +from app.services.organizations import OrganizationContext, require_board_access from app.schemas.common import OkResponse from app.schemas.gateway_api import ( GatewayCommandsResponse, @@ -40,6 +42,8 @@ async def _resolve_gateway( gateway_url: str | None, gateway_token: str | None, gateway_main_session_key: str | None, + *, + user: object | None = None, ) -> tuple[Board | None, GatewayClientConfig, str | None]: if gateway_url: return ( @@ -55,6 +59,8 @@ async def _resolve_gateway( board = await session.get(Board, board_id) if board is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") + if isinstance(user, object) and user is not None: + await require_board_access(session, user=user, board=board, write=False) # type: ignore[arg-type] if not board.gateway_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -79,9 +85,16 @@ async def _resolve_gateway( async def _require_gateway( - session: AsyncSession, board_id: str | None + session: AsyncSession, board_id: str | None, *, user: object | None = None ) -> tuple[Board, GatewayClientConfig, str | None]: - board, config, main_session = await _resolve_gateway(session, board_id, None, None, None) + board, config, main_session = await _resolve_gateway( + session, + board_id, + None, + None, + None, + user=user, + ) if board is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -95,6 +108,7 @@ async def gateways_status( params: GatewayResolveQuery = Depends(), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(get_auth_context), + ctx: OrganizationContext = Depends(require_org_admin), ) -> GatewaysStatusResponse: board, config, main_session = await _resolve_gateway( session, @@ -102,7 +116,10 @@ async def gateways_status( params.gateway_url, params.gateway_token, params.gateway_main_session_key, + user=auth.user, ) + if board is not None and board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) try: sessions = await openclaw_call("sessions.list", config=config) if isinstance(sessions, dict): @@ -136,6 +153,7 @@ async def list_gateway_sessions( board_id: str | None = Query(default=None), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(get_auth_context), + ctx: OrganizationContext = Depends(require_org_admin), ) -> GatewaySessionsResponse: board, config, main_session = await _resolve_gateway( session, @@ -143,7 +161,10 @@ async def list_gateway_sessions( None, None, None, + user=auth.user, ) + if board is not None and board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) try: sessions = await openclaw_call("sessions.list", config=config) except OpenClawGatewayError as exc: @@ -175,6 +196,7 @@ async def get_gateway_session( board_id: str | None = Query(default=None), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(get_auth_context), + ctx: OrganizationContext = Depends(require_org_admin), ) -> GatewaySessionResponse: board, config, main_session = await _resolve_gateway( session, @@ -182,7 +204,10 @@ async def get_gateway_session( None, None, None, + user=auth.user, ) + if board is not None and board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) try: sessions = await openclaw_call("sessions.list", config=config) except OpenClawGatewayError as exc: @@ -220,8 +245,11 @@ async def get_session_history( board_id: str | None = Query(default=None), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(get_auth_context), + ctx: OrganizationContext = Depends(require_org_admin), ) -> GatewaySessionHistoryResponse: - _, config, _ = await _require_gateway(session, board_id) + board, config, _ = await _require_gateway(session, board_id, user=auth.user) + if board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) try: history = await get_chat_history(session_id, config=config) except OpenClawGatewayError as exc: @@ -238,8 +266,14 @@ async def send_gateway_session_message( board_id: str | None = Query(default=None), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(get_auth_context), + ctx: OrganizationContext = Depends(require_org_admin), ) -> OkResponse: - board, config, main_session = await _require_gateway(session, board_id) + board, config, main_session = await _require_gateway(session, board_id, user=auth.user) + if board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(session, user=auth.user, board=board, write=True) try: if main_session and session_id == main_session: await ensure_session(main_session, config=config, label="Main Agent") @@ -252,6 +286,7 @@ async def send_gateway_session_message( @router.get("/commands", response_model=GatewayCommandsResponse) async def gateway_commands( auth: AuthContext = Depends(get_auth_context), + _ctx: OrganizationContext = Depends(require_org_admin), ) -> GatewayCommandsResponse: return GatewayCommandsResponse( protocol_version=PROTOCOL_VERSION, diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index 7808f38d..f6dd48fd 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.deps import require_admin_auth +from app.api.deps import require_org_admin from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext, get_auth_context from app.core.time import utcnow @@ -131,9 +131,13 @@ async def _ensure_main_agent( @router.get("", response_model=DefaultLimitOffsetPage[GatewayRead]) async def list_gateways( session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(get_auth_context), + ctx=Depends(require_org_admin), ) -> DefaultLimitOffsetPage[GatewayRead]: - statement = select(Gateway).order_by(col(Gateway.created_at).desc()) + statement = ( + select(Gateway) + .where(col(Gateway.organization_id) == ctx.organization.id) + .order_by(col(Gateway.created_at).desc()) + ) return await paginate(session, statement) @@ -142,8 +146,10 @@ async def create_gateway( payload: GatewayCreate, session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(get_auth_context), + ctx=Depends(require_org_admin), ) -> Gateway: data = payload.model_dump() + data["organization_id"] = ctx.organization.id gateway = Gateway.model_validate(data) session.add(gateway) await session.commit() @@ -156,10 +162,10 @@ async def create_gateway( async def get_gateway( gateway_id: UUID, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(get_auth_context), + ctx=Depends(require_org_admin), ) -> Gateway: gateway = await session.get(Gateway, gateway_id) - if gateway is None: + if gateway is None or gateway.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") return gateway @@ -170,9 +176,10 @@ async def update_gateway( payload: GatewayUpdate, session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(get_auth_context), + ctx=Depends(require_org_admin), ) -> Gateway: gateway = await session.get(Gateway, gateway_id) - if gateway is None: + if gateway is None or gateway.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") previous_name = gateway.name previous_session_key = gateway.main_session_key @@ -202,10 +209,11 @@ async def sync_gateway_templates( force_bootstrap: bool = Query(default=False), board_id: UUID | None = Query(default=None), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + auth: AuthContext = Depends(get_auth_context), + ctx=Depends(require_org_admin), ) -> GatewayTemplatesSyncResult: gateway = await session.get(Gateway, gateway_id) - if gateway is None: + if gateway is None or gateway.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") return await sync_gateway_templates_service( session, @@ -223,10 +231,10 @@ async def sync_gateway_templates( async def delete_gateway( gateway_id: UUID, session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(get_auth_context), + ctx=Depends(require_org_admin), ) -> OkResponse: gateway = await session.get(Gateway, gateway_id) - if gateway is None: + if gateway is None or gateway.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") await session.delete(gateway) await session.commit() diff --git a/backend/app/api/metrics.py b/backend/app/api/metrics.py index 916ed0d6..9cfe04a3 100644 --- a/backend/app/api/metrics.py +++ b/backend/app/api/metrics.py @@ -3,14 +3,14 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta from typing import Literal +from uuid import UUID from fastapi import APIRouter, Depends, Query from sqlalchemy import DateTime, case, cast, func from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.deps import require_admin_auth -from app.core.auth import AuthContext +from app.api.deps import require_org_member from app.core.time import utcnow from app.db.session import get_session from app.models.activity_events import ActivityEvent @@ -26,6 +26,7 @@ from app.schemas.metrics import ( DashboardWipRangeSeries, DashboardWipSeriesSet, ) +from app.services.organizations import list_accessible_board_ids router = APIRouter(prefix="/metrics", tags=["metrics"]) @@ -113,22 +114,29 @@ def _wip_series_from_mapping( ) -async def _query_throughput(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries: +async def _query_throughput( + session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID] +) -> DashboardRangeSeries: bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") statement = ( select(bucket_col, func.count()) .where(col(Task.status) == "review") .where(col(Task.updated_at) >= range_spec.start) .where(col(Task.updated_at) <= range_spec.end) - .group_by(bucket_col) - .order_by(bucket_col) + ) + if not board_ids: + return _series_from_mapping(range_spec, {}) + statement = ( + statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col) ) results = (await session.exec(statement)).all() mapping = {row[0]: float(row[1]) for row in results} return _series_from_mapping(range_spec, mapping) -async def _query_cycle_time(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries: +async def _query_cycle_time( + session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID] +) -> DashboardRangeSeries: bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") in_progress = cast(Task.in_progress_at, DateTime) duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0 @@ -138,15 +146,20 @@ async def _query_cycle_time(session: AsyncSession, range_spec: RangeSpec) -> Das .where(col(Task.in_progress_at).is_not(None)) .where(col(Task.updated_at) >= range_spec.start) .where(col(Task.updated_at) <= range_spec.end) - .group_by(bucket_col) - .order_by(bucket_col) + ) + if not board_ids: + return _series_from_mapping(range_spec, {}) + statement = ( + statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col) ) results = (await session.exec(statement)).all() mapping = {row[0]: float(row[1] or 0) for row in results} return _series_from_mapping(range_spec, mapping) -async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries: +async def _query_error_rate( + session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID] +) -> DashboardRangeSeries: bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label("bucket") error_case = case( ( @@ -157,10 +170,14 @@ async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> Das ) statement = ( select(bucket_col, func.sum(error_case), func.count()) + .join(Task, col(ActivityEvent.task_id) == col(Task.id)) .where(col(ActivityEvent.created_at) >= range_spec.start) .where(col(ActivityEvent.created_at) <= range_spec.end) - .group_by(bucket_col) - .order_by(bucket_col) + ) + if not board_ids: + return _series_from_mapping(range_spec, {}) + statement = ( + statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col) ) results = (await session.exec(statement)).all() mapping: dict[datetime, float] = {} @@ -172,7 +189,9 @@ async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> Das return _series_from_mapping(range_spec, mapping) -async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardWipRangeSeries: +async def _query_wip( + session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID] +) -> DashboardWipRangeSeries: bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") inbox_case = case((col(Task.status) == "inbox", 1), else_=0) progress_case = case((col(Task.status) == "in_progress", 1), else_=0) @@ -186,8 +205,11 @@ async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardW ) .where(col(Task.updated_at) >= range_spec.start) .where(col(Task.updated_at) <= range_spec.end) - .group_by(bucket_col) - .order_by(bucket_col) + ) + if not board_ids: + return _wip_series_from_mapping(range_spec, {}) + statement = ( + statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col) ) results = (await session.exec(statement)).all() mapping: dict[datetime, dict[str, int]] = {} @@ -200,7 +222,7 @@ async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardW return _wip_series_from_mapping(range_spec, mapping) -async def _median_cycle_time_7d(session: AsyncSession) -> float | None: +async def _median_cycle_time_7d(session: AsyncSession, board_ids: list[UUID]) -> float | None: now = utcnow() start = now - timedelta(days=7) in_progress = cast(Task.in_progress_at, DateTime) @@ -212,6 +234,9 @@ async def _median_cycle_time_7d(session: AsyncSession) -> float | None: .where(col(Task.updated_at) >= start) .where(col(Task.updated_at) <= now) ) + if not board_ids: + return None + statement = statement.where(col(Task.board_id).in_(board_ids)) value = (await session.exec(statement)).one_or_none() if value is None: return None @@ -222,7 +247,9 @@ async def _median_cycle_time_7d(session: AsyncSession) -> float | None: return float(value) -async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float: +async def _error_rate_kpi( + session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID] +) -> float: error_case = case( ( col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN), @@ -232,9 +259,13 @@ async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float ) statement = ( select(func.sum(error_case), func.count()) + .join(Task, col(ActivityEvent.task_id) == col(Task.id)) .where(col(ActivityEvent.created_at) >= range_spec.start) .where(col(ActivityEvent.created_at) <= range_spec.end) ) + if not board_ids: + return 0.0 + statement = statement.where(col(Task.board_id).in_(board_ids)) result = (await session.exec(statement)).one_or_none() if result is None: return 0.0 @@ -244,18 +275,27 @@ async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float return (error_count / total_count) * 100 if total_count > 0 else 0.0 -async def _active_agents(session: AsyncSession) -> int: +async def _active_agents(session: AsyncSession, board_ids: list[UUID]) -> int: threshold = utcnow() - OFFLINE_AFTER statement = select(func.count()).where( col(Agent.last_seen_at).is_not(None), col(Agent.last_seen_at) >= threshold, ) + if not board_ids: + return 0 + statement = statement.where(col(Agent.board_id).in_(board_ids)) result = (await session.exec(statement)).one() return int(result) -async def _tasks_in_progress(session: AsyncSession) -> int: - statement = select(func.count()).where(col(Task.status) == "in_progress") +async def _tasks_in_progress(session: AsyncSession, board_ids: list[UUID]) -> int: + if not board_ids: + return 0 + statement = ( + select(func.count()) + .where(col(Task.status) == "in_progress") + .where(col(Task.board_id).in_(board_ids)) + ) result = (await session.exec(statement)).one() return int(result) @@ -264,41 +304,42 @@ async def _tasks_in_progress(session: AsyncSession) -> int: async def dashboard_metrics( range: Literal["24h", "7d"] = Query(default="24h"), session: AsyncSession = Depends(get_session), - auth: AuthContext = Depends(require_admin_auth), + ctx=Depends(require_org_member), ) -> DashboardMetrics: primary = _resolve_range(range) comparison = _comparison_range(range) + board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) - throughput_primary = await _query_throughput(session, primary) - throughput_comparison = await _query_throughput(session, comparison) + throughput_primary = await _query_throughput(session, primary, board_ids) + throughput_comparison = await _query_throughput(session, comparison, board_ids) throughput = DashboardSeriesSet( primary=throughput_primary, comparison=throughput_comparison, ) - cycle_time_primary = await _query_cycle_time(session, primary) - cycle_time_comparison = await _query_cycle_time(session, comparison) + cycle_time_primary = await _query_cycle_time(session, primary, board_ids) + cycle_time_comparison = await _query_cycle_time(session, comparison, board_ids) cycle_time = DashboardSeriesSet( primary=cycle_time_primary, comparison=cycle_time_comparison, ) - error_rate_primary = await _query_error_rate(session, primary) - error_rate_comparison = await _query_error_rate(session, comparison) + error_rate_primary = await _query_error_rate(session, primary, board_ids) + error_rate_comparison = await _query_error_rate(session, comparison, board_ids) error_rate = DashboardSeriesSet( primary=error_rate_primary, comparison=error_rate_comparison, ) - wip_primary = await _query_wip(session, primary) - wip_comparison = await _query_wip(session, comparison) + wip_primary = await _query_wip(session, primary, board_ids) + wip_comparison = await _query_wip(session, comparison, board_ids) wip = DashboardWipSeriesSet( primary=wip_primary, comparison=wip_comparison, ) kpis = DashboardKpis( - active_agents=await _active_agents(session), - tasks_in_progress=await _tasks_in_progress(session), - error_rate_pct=await _error_rate_kpi(session, primary), - median_cycle_time_hours_7d=await _median_cycle_time_7d(session), + active_agents=await _active_agents(session, board_ids), + tasks_in_progress=await _tasks_in_progress(session, board_ids), + error_rate_pct=await _error_rate_kpi(session, primary, board_ids), + median_cycle_time_hours_7d=await _median_cycle_time_7d(session, board_ids), ) return DashboardMetrics( diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py new file mode 100644 index 00000000..73940f00 --- /dev/null +++ b/backend/app/api/organizations.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import secrets +from typing import Any, Sequence +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.deps import require_org_admin, require_org_member +from app.core.auth import AuthContext, get_auth_context +from app.core.time import utcnow +from app.db.pagination import paginate +from app.db.session import get_session +from app.models.boards import Board +from app.models.organization_board_access import OrganizationBoardAccess +from app.models.organization_invite_board_access import OrganizationInviteBoardAccess +from app.models.organization_invites import OrganizationInvite +from app.models.organization_members import OrganizationMember +from app.models.organizations import Organization +from app.models.users import User +from app.schemas.organizations import ( + OrganizationActiveUpdate, + OrganizationCreate, + OrganizationInviteAccept, + OrganizationInviteCreate, + OrganizationInviteRead, + OrganizationListItem, + OrganizationMemberAccessUpdate, + OrganizationMemberRead, + OrganizationMemberUpdate, + OrganizationBoardAccessRead, + OrganizationRead, + OrganizationUserRead, +) +from app.schemas.pagination import DefaultLimitOffsetPage +from app.services.organizations import ( + OrganizationContext, + accept_invite, + apply_invite_to_member, + apply_invite_board_access, + apply_member_access_update, + get_active_membership, + get_member, + is_org_admin, + normalize_invited_email, + normalize_role, + set_active_organization, +) + +router = APIRouter(prefix="/organizations", tags=["organizations"]) + + +def _member_to_read(member: OrganizationMember, user: User | None) -> OrganizationMemberRead: + model = OrganizationMemberRead.model_validate(member, from_attributes=True) + if user is not None: + model.user = OrganizationUserRead.model_validate(user, from_attributes=True) + return model + + +@router.post("", response_model=OrganizationRead) +async def create_organization( + payload: OrganizationCreate, + session: AsyncSession = Depends(get_session), + auth: AuthContext = Depends(get_auth_context), +) -> OrganizationRead: + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + name = payload.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + existing = ( + await session.exec( + select(Organization).where(func.lower(col(Organization.name)) == name.lower()) + ) + ).first() + if existing is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + + now = utcnow() + org = Organization(name=name, created_at=now, updated_at=now) + session.add(org) + await session.flush() + + member = OrganizationMember( + organization_id=org.id, + user_id=auth.user.id, + role="owner", + all_boards_read=True, + all_boards_write=True, + created_at=now, + updated_at=now, + ) + session.add(member) + await session.flush() + await set_active_organization(session, user=auth.user, organization_id=org.id) + await session.commit() + await session.refresh(org) + return OrganizationRead.model_validate(org, from_attributes=True) + + +@router.get("/me/list", response_model=list[OrganizationListItem]) +async def list_my_organizations( + session: AsyncSession = Depends(get_session), + auth: AuthContext = Depends(get_auth_context), +) -> list[OrganizationListItem]: + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + await get_active_membership(session, auth.user) + db_user = await session.get(User, auth.user.id) + active_id = db_user.active_organization_id if db_user else auth.user.active_organization_id + + statement = ( + select(Organization, OrganizationMember) + .join(OrganizationMember, col(OrganizationMember.organization_id) == col(Organization.id)) + .where(col(OrganizationMember.user_id) == auth.user.id) + .order_by(func.lower(col(Organization.name)).asc()) + ) + rows = list(await session.exec(statement)) + return [ + OrganizationListItem( + id=org.id, + name=org.name, + role=member.role, + is_active=org.id == active_id, + ) + for org, member in rows + ] + + +@router.patch("/me/active", response_model=OrganizationRead) +async def set_active_org( + payload: OrganizationActiveUpdate, + session: AsyncSession = Depends(get_session), + auth: AuthContext = Depends(get_auth_context), +) -> OrganizationRead: + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + member = await set_active_organization( + session, user=auth.user, organization_id=payload.organization_id + ) + organization = await session.get(Organization, member.organization_id) + if organization is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return OrganizationRead.model_validate(organization, from_attributes=True) + + +@router.get("/me", response_model=OrganizationRead) +async def get_my_org(ctx: OrganizationContext = Depends(require_org_member)) -> OrganizationRead: + return OrganizationRead.model_validate(ctx.organization, from_attributes=True) + + +@router.get("/me/member", response_model=OrganizationMemberRead) +async def get_my_membership( + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_member), +) -> OrganizationMemberRead: + user = await session.get(User, ctx.member.user_id) + access_rows = list( + await session.exec( + select(OrganizationBoardAccess).where( + col(OrganizationBoardAccess.organization_member_id) == ctx.member.id + ) + ) + ) + model = _member_to_read(ctx.member, user) + model.board_access = [ + OrganizationBoardAccessRead.model_validate(row, from_attributes=True) # type: ignore[name-defined] + for row in access_rows + ] + return model + + +@router.get("/me/members", response_model=DefaultLimitOffsetPage[OrganizationMemberRead]) +async def list_org_members( + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_member), +) -> DefaultLimitOffsetPage[OrganizationMemberRead]: + statement = ( + select(OrganizationMember, User) + .join(User, col(User.id) == col(OrganizationMember.user_id)) + .where(col(OrganizationMember.organization_id) == ctx.organization.id) + .order_by(func.lower(col(User.email)).asc(), col(User.name).asc()) + ) + + def _transform(items: Sequence[Any]) -> Sequence[Any]: + output: list[OrganizationMemberRead] = [] + for member, user in items: + output.append(_member_to_read(member, user)) + return output + + return await paginate(session, statement, transformer=_transform) + + +@router.get("/me/members/{member_id}", response_model=OrganizationMemberRead) +async def get_org_member( + member_id: UUID, + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_member), +) -> OrganizationMemberRead: + member = await session.get(OrganizationMember, member_id) + if member is None or member.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not is_org_admin(ctx.member) and member.user_id != ctx.member.user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + user = await session.get(User, member.user_id) + access_rows = list( + await session.exec( + select(OrganizationBoardAccess).where( + col(OrganizationBoardAccess.organization_member_id) == member.id + ) + ) + ) + model = _member_to_read(member, user) + model.board_access = [ + OrganizationBoardAccessRead.model_validate(row, from_attributes=True) # type: ignore[name-defined] + for row in access_rows + ] + return model + + +@router.patch("/me/members/{member_id}", response_model=OrganizationMemberRead) +async def update_org_member( + member_id: UUID, + payload: OrganizationMemberUpdate, + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_admin), +) -> OrganizationMemberRead: + member = await session.get(OrganizationMember, member_id) + if member is None or member.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + updates = payload.model_dump(exclude_unset=True) + if "role" in updates and updates["role"] is not None: + member.role = normalize_role(updates["role"]) + member.updated_at = utcnow() + session.add(member) + await session.commit() + await session.refresh(member) + user = await session.get(User, member.user_id) + return _member_to_read(member, user) + + +@router.put("/me/members/{member_id}/access", response_model=OrganizationMemberRead) +async def update_member_access( + member_id: UUID, + payload: OrganizationMemberAccessUpdate, + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_admin), +) -> OrganizationMemberRead: + member = await session.get(OrganizationMember, member_id) + if member is None or member.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + board_ids = {entry.board_id for entry in payload.board_access} + if board_ids: + valid_board_ids = set( + await session.exec( + select(Board.id) + .where(col(Board.id).in_(board_ids)) + .where(col(Board.organization_id) == ctx.organization.id) + ) + ) + if valid_board_ids != board_ids: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + + await apply_member_access_update(session, member=member, update=payload) + await session.commit() + await session.refresh(member) + user = await session.get(User, member.user_id) + return _member_to_read(member, user) + + +@router.get("/me/invites", response_model=DefaultLimitOffsetPage[OrganizationInviteRead]) +async def list_org_invites( + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_admin), +) -> DefaultLimitOffsetPage[OrganizationInviteRead]: + statement = ( + select(OrganizationInvite) + .where(col(OrganizationInvite.organization_id) == ctx.organization.id) + .where(col(OrganizationInvite.accepted_at).is_(None)) + .order_by(col(OrganizationInvite.created_at).desc()) + ) + return await paginate(session, statement) + + +@router.post("/me/invites", response_model=OrganizationInviteRead) +async def create_org_invite( + payload: OrganizationInviteCreate, + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_admin), +) -> OrganizationInviteRead: + email = normalize_invited_email(payload.invited_email) + if not email: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + + existing_user = ( + await session.exec( + select(User).where(func.lower(col(User.email)) == email) + ) + ).first() + if existing_user is not None: + existing_member = await get_member( + session, + user_id=existing_user.id, + organization_id=ctx.organization.id, + ) + if existing_member is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + + token = secrets.token_urlsafe(24) + invite = OrganizationInvite( + organization_id=ctx.organization.id, + invited_email=email, + token=token, + role=normalize_role(payload.role), + all_boards_read=payload.all_boards_read, + all_boards_write=payload.all_boards_write, + created_by_user_id=ctx.member.user_id, + created_at=utcnow(), + updated_at=utcnow(), + ) + session.add(invite) + await session.flush() + + board_ids = {entry.board_id for entry in payload.board_access} + if board_ids: + valid_board_ids = set( + await session.exec( + select(Board.id) + .where(col(Board.id).in_(board_ids)) + .where(col(Board.organization_id) == ctx.organization.id) + ) + ) + if valid_board_ids != board_ids: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + await apply_invite_board_access(session, invite=invite, entries=payload.board_access) + await session.commit() + await session.refresh(invite) + return OrganizationInviteRead.model_validate(invite, from_attributes=True) + + +@router.delete("/me/invites/{invite_id}", response_model=OrganizationInviteRead) +async def revoke_org_invite( + invite_id: UUID, + session: AsyncSession = Depends(get_session), + ctx: OrganizationContext = Depends(require_org_admin), +) -> OrganizationInviteRead: + invite = await session.get(OrganizationInvite, invite_id) + if invite is None or invite.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await session.execute( + OrganizationInviteBoardAccess.__table__.delete().where( + col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id + ) + ) + await session.delete(invite) + await session.commit() + return OrganizationInviteRead.model_validate(invite, from_attributes=True) + + +@router.post("/invites/accept", response_model=OrganizationMemberRead) +async def accept_org_invite( + payload: OrganizationInviteAccept, + session: AsyncSession = Depends(get_session), + auth: AuthContext = Depends(get_auth_context), +) -> OrganizationMemberRead: + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + invite = ( + await session.exec( + select(OrganizationInvite) + .where(col(OrganizationInvite.token) == payload.token) + .where(col(OrganizationInvite.accepted_at).is_(None)) + ) + ).first() + if invite is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if invite.invited_email and auth.user.email: + if normalize_invited_email(invite.invited_email) != normalize_invited_email(auth.user.email): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + existing = await get_member( + session, + user_id=auth.user.id, + organization_id=invite.organization_id, + ) + if existing is None: + member = await accept_invite(session, invite, auth.user) + else: + await apply_invite_to_member(session, member=existing, invite=invite) + invite.accepted_by_user_id = auth.user.id + invite.accepted_at = utcnow() + invite.updated_at = utcnow() + session.add(invite) + await session.commit() + member = existing + + user = await session.get(User, member.user_id) + return _member_to_read(member, user) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index fdacdd19..f3fd2ca1 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -17,7 +17,8 @@ from sse_starlette.sse import EventSourceResponse from app.api.deps import ( ActorContext, - get_board_or_404, + get_board_for_actor_read, + get_board_for_user_write, get_task_or_404, require_admin_auth, require_admin_or_agent, @@ -42,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.services.activity_log import record_activity from app.services.mentions import extract_mentions, matches_agent_mention +from app.services.organizations import require_board_access from app.services.task_dependencies import ( blocked_by_dependency_ids, dependency_ids_by_task_id, @@ -442,7 +444,7 @@ async def _notify_lead_on_task_unassigned( @router.get("/stream") async def stream_tasks( request: Request, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), actor: ActorContext = Depends(require_admin_or_agent), since: str | None = Query(default=None), ) -> EventSourceResponse: @@ -525,13 +527,10 @@ async def list_tasks( status_filter: str | None = Query(default=None, alias="status"), assigned_agent_id: UUID | None = None, unassigned: bool | None = None, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_actor_read), session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> DefaultLimitOffsetPage[TaskRead]: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and actor.agent.board_id != board.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) statement = select(Task).where(Task.board_id == board.id) if status_filter: statuses = [s.strip() for s in status_filter.split(",") if s.strip()] @@ -586,7 +585,7 @@ async def list_tasks( @router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}}) async def create_task( payload: TaskCreate, - board: Board = Depends(get_board_or_404), + board: Board = Depends(get_board_for_user_write), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), ) -> TaskRead: @@ -669,6 +668,11 @@ async def update_task( detail="Task board_id is required.", ) board_id = task.board_id + if actor.actor_type == "user" and actor.user is not None: + board = await session.get(Board, board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await require_board_access(session, user=actor.user, board=board, write=True) previous_status = task.status previous_assigned = task.assigned_agent_id @@ -978,6 +982,14 @@ async def delete_task( task: Task = Depends(get_task_or_404), auth: AuthContext = Depends(require_admin_auth), ) -> OkResponse: + if task.board_id is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + board = await session.get(Board, task.board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(session, user=auth.user, board=board, write=True) await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id)) await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id)) await session.execute(delete(Approval).where(col(Approval.task_id) == task.id)) @@ -998,11 +1010,7 @@ async def delete_task( async def list_task_comments( task: Task = Depends(get_task_or_404), session: AsyncSession = Depends(get_session), - actor: ActorContext = Depends(require_admin_or_agent), ) -> DefaultLimitOffsetPage[TaskCommentRead]: - if actor.actor_type == "agent" and actor.agent: - if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) statement = ( select(ActivityEvent) .where(col(ActivityEvent.task_id) == task.id) @@ -1019,6 +1027,13 @@ async def create_task_comment( session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), ) -> ActivityEvent: + if task.board_id is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + if actor.actor_type == "user" and actor.user is not None: + board = await session.get(Board, task.board_id) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await require_board_access(session, user=actor.user, board=board, write=True) if actor.actor_type == "agent" and actor.agent: if actor.agent.is_board_lead and task.status != "review": if not await _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task( @@ -1030,8 +1045,6 @@ async def create_task_comment( "Board leads can only comment during review, when mentioned, or on tasks they created." ), ) - if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) event = ActivityEvent( event_type="task.comment", message=payload.message, diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index d30085f4..bf48255e 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -97,6 +97,9 @@ async def get_auth_context( clerk_user_id=clerk_user_id, defaults=defaults, ) + from app.services.organizations import ensure_member_for_user + + await ensure_member_for_user(session, user) return AuthContext( actor_type="user", @@ -146,6 +149,9 @@ async def get_auth_context_optional( clerk_user_id=clerk_user_id, defaults=defaults, ) + from app.services.organizations import ensure_member_for_user + + await ensure_member_for_user(session, user) return AuthContext( actor_type="user", diff --git a/backend/app/main.py b/backend/app/main.py index 5808801a..c0ef0c55 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from app.api.boards import router as boards_router from app.api.gateway import router as gateway_router from app.api.gateways import router as gateways_router from app.api.metrics import router as metrics_router +from app.api.organizations import router as organizations_router from app.api.souls_directory import router as souls_directory_router from app.api.tasks import router as tasks_router from app.api.users import router as users_router @@ -75,6 +76,7 @@ api_v1.include_router(activity_router) api_v1.include_router(gateway_router) api_v1.include_router(gateways_router) api_v1.include_router(metrics_router) +api_v1.include_router(organizations_router) api_v1.include_router(souls_directory_router) api_v1.include_router(board_groups_router) api_v1.include_router(board_group_memory_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f17708bc..dd6f8fc1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,11 @@ from app.models.board_memory import BoardMemory from app.models.board_onboarding import BoardOnboardingSession from app.models.boards import Board from app.models.gateways import Gateway +from app.models.organization_board_access import OrganizationBoardAccess +from app.models.organization_invite_board_access import OrganizationInviteBoardAccess +from app.models.organization_invites import OrganizationInvite +from app.models.organization_members import OrganizationMember +from app.models.organizations import Organization from app.models.task_dependencies import TaskDependency from app.models.task_fingerprints import TaskFingerprint from app.models.tasks import Task @@ -22,6 +27,11 @@ __all__ = [ "BoardGroup", "Board", "Gateway", + "Organization", + "OrganizationMember", + "OrganizationBoardAccess", + "OrganizationInvite", + "OrganizationInviteBoardAccess", "TaskDependency", "Task", "TaskFingerprint", diff --git a/backend/app/models/board_groups.py b/backend/app/models/board_groups.py index a3089c24..d2d13e78 100644 --- a/backend/app/models/board_groups.py +++ b/backend/app/models/board_groups.py @@ -13,6 +13,7 @@ class BoardGroup(TenantScoped, table=True): __tablename__ = "board_groups" id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) name: str slug: str = Field(index=True) description: str | None = None diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 4afdefbf..4192825a 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -14,6 +14,7 @@ class Board(TenantScoped, table=True): __tablename__ = "boards" id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) name: str slug: str = Field(index=True) gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True) diff --git a/backend/app/models/gateways.py b/backend/app/models/gateways.py index ccc0bb25..1cfd62c6 100644 --- a/backend/app/models/gateways.py +++ b/backend/app/models/gateways.py @@ -12,6 +12,7 @@ class Gateway(SQLModel, table=True): __tablename__ = "gateways" id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) name: str url: str token: str | None = Field(default=None) diff --git a/backend/app/models/organization_board_access.py b/backend/app/models/organization_board_access.py new file mode 100644 index 00000000..720edd05 --- /dev/null +++ b/backend/app/models/organization_board_access.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, SQLModel + +from app.core.time import utcnow + + +class OrganizationBoardAccess(SQLModel, table=True): + __tablename__ = "organization_board_access" + __table_args__ = ( + UniqueConstraint( + "organization_member_id", + "board_id", + name="uq_org_board_access_member_board", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_member_id: UUID = Field( + foreign_key="organization_members.id", index=True + ) + board_id: UUID = Field(foreign_key="boards.id", index=True) + can_read: bool = Field(default=True) + can_write: bool = Field(default=False) + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/models/organization_invite_board_access.py b/backend/app/models/organization_invite_board_access.py new file mode 100644 index 00000000..f164df68 --- /dev/null +++ b/backend/app/models/organization_invite_board_access.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, SQLModel + +from app.core.time import utcnow + + +class OrganizationInviteBoardAccess(SQLModel, table=True): + __tablename__ = "organization_invite_board_access" + __table_args__ = ( + UniqueConstraint( + "organization_invite_id", + "board_id", + name="uq_org_invite_board_access_invite_board", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_invite_id: UUID = Field( + foreign_key="organization_invites.id", index=True + ) + board_id: UUID = Field(foreign_key="boards.id", index=True) + can_read: bool = Field(default=True) + can_write: bool = Field(default=False) + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/models/organization_invites.py b/backend/app/models/organization_invites.py new file mode 100644 index 00000000..f4247d12 --- /dev/null +++ b/backend/app/models/organization_invites.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, SQLModel + +from app.core.time import utcnow + + +class OrganizationInvite(SQLModel, table=True): + __tablename__ = "organization_invites" + __table_args__ = (UniqueConstraint("token", name="uq_org_invites_token"),) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + invited_email: str = Field(index=True) + token: str = Field(index=True) + role: str = Field(default="member", index=True) + all_boards_read: bool = Field(default=False) + all_boards_write: bool = Field(default=False) + created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True) + accepted_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True) + accepted_at: datetime | None = None + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/models/organization_members.py b/backend/app/models/organization_members.py new file mode 100644 index 00000000..3b37f64f --- /dev/null +++ b/backend/app/models/organization_members.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, SQLModel + +from app.core.time import utcnow + + +class OrganizationMember(SQLModel, table=True): + __tablename__ = "organization_members" + __table_args__ = ( + UniqueConstraint( + "organization_id", + "user_id", + name="uq_organization_members_org_user", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + user_id: UUID = Field(foreign_key="users.id", index=True) + role: str = Field(default="member", index=True) + all_boards_read: bool = Field(default=False) + all_boards_write: bool = Field(default=False) + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/models/organizations.py b/backend/app/models/organizations.py new file mode 100644 index 00000000..b306aac6 --- /dev/null +++ b/backend/app/models/organizations.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, SQLModel + +from app.core.time import utcnow + + +class Organization(SQLModel, table=True): + __tablename__ = "organizations" + __table_args__ = (UniqueConstraint("name", name="uq_organizations_name"),) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(index=True) + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/models/users.py b/backend/app/models/users.py index bad848ee..fb73c7a4 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -18,3 +18,6 @@ class User(SQLModel, table=True): notes: str | None = None context: str | None = None is_super_admin: bool = Field(default=False) + active_organization_id: UUID | None = Field( + default=None, foreign_key="organizations.id", index=True + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 06687fe6..ab93e353 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -12,6 +12,18 @@ from app.schemas.board_onboarding import ( from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate from app.schemas.metrics import DashboardMetrics +from app.schemas.organizations import ( + OrganizationActiveUpdate, + OrganizationCreate, + OrganizationInviteAccept, + OrganizationInviteCreate, + OrganizationInviteRead, + OrganizationListItem, + OrganizationMemberAccessUpdate, + OrganizationMemberRead, + OrganizationMemberUpdate, + OrganizationRead, +) from app.schemas.souls_directory import ( SoulsDirectoryMarkdownResponse, SoulsDirectorySearchResponse, @@ -43,6 +55,16 @@ __all__ = [ "GatewayRead", "GatewayUpdate", "DashboardMetrics", + "OrganizationActiveUpdate", + "OrganizationCreate", + "OrganizationInviteAccept", + "OrganizationInviteCreate", + "OrganizationInviteRead", + "OrganizationListItem", + "OrganizationMemberAccessUpdate", + "OrganizationMemberRead", + "OrganizationMemberUpdate", + "OrganizationRead", "SoulsDirectoryMarkdownResponse", "SoulsDirectorySearchResponse", "SoulsDirectorySoulRef", diff --git a/backend/app/schemas/board_groups.py b/backend/app/schemas/board_groups.py index 083bb8dc..4baf1b94 100644 --- a/backend/app/schemas/board_groups.py +++ b/backend/app/schemas/board_groups.py @@ -24,5 +24,6 @@ class BoardGroupUpdate(SQLModel): class BoardGroupRead(BoardGroupBase): id: UUID + organization_id: UUID created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py index f99cab59..6e0298ab 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -54,5 +54,6 @@ class BoardUpdate(SQLModel): class BoardRead(BoardBase): id: UUID + organization_id: UUID created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/gateways.py b/backend/app/schemas/gateways.py index 04142fba..149eb2fb 100644 --- a/backend/app/schemas/gateways.py +++ b/backend/app/schemas/gateways.py @@ -49,6 +49,7 @@ class GatewayUpdate(SQLModel): class GatewayRead(GatewayBase): id: UUID + organization_id: UUID token: str | None = None created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/organizations.py b/backend/app/schemas/organizations.py new file mode 100644 index 00000000..6617866a --- /dev/null +++ b/backend/app/schemas/organizations.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class OrganizationRead(SQLModel): + id: UUID + name: str + created_at: datetime + updated_at: datetime + + +class OrganizationCreate(SQLModel): + name: str + + +class OrganizationActiveUpdate(SQLModel): + organization_id: UUID + + +class OrganizationListItem(SQLModel): + id: UUID + name: str + role: str + is_active: bool + + +class OrganizationUserRead(SQLModel): + id: UUID + email: str | None = None + name: str | None = None + preferred_name: str | None = None + + +class OrganizationMemberRead(SQLModel): + id: UUID + organization_id: UUID + user_id: UUID + role: str + all_boards_read: bool + all_boards_write: bool + created_at: datetime + updated_at: datetime + user: OrganizationUserRead | None = None + board_access: list[OrganizationBoardAccessRead] = Field(default_factory=list) + + +class OrganizationMemberUpdate(SQLModel): + role: str | None = None + + +class OrganizationBoardAccessSpec(SQLModel): + board_id: UUID + can_read: bool = True + can_write: bool = False + + +class OrganizationBoardAccessRead(SQLModel): + id: UUID + board_id: UUID + can_read: bool + can_write: bool + created_at: datetime + updated_at: datetime + + +class OrganizationMemberAccessUpdate(SQLModel): + all_boards_read: bool = False + all_boards_write: bool = False + board_access: list[OrganizationBoardAccessSpec] = Field(default_factory=list) + + +class OrganizationInviteCreate(SQLModel): + invited_email: str + role: str = "member" + all_boards_read: bool = False + all_boards_write: bool = False + board_access: list[OrganizationBoardAccessSpec] = Field(default_factory=list) + + +class OrganizationInviteRead(SQLModel): + id: UUID + organization_id: UUID + invited_email: str + role: str + all_boards_read: bool + all_boards_write: bool + token: str + created_by_user_id: UUID | None = None + accepted_by_user_id: UUID | None = None + accepted_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class OrganizationInviteAccept(SQLModel): + token: str diff --git a/backend/app/services/organizations.py b/backend/app/services/organizations.py new file mode 100644 index 00000000..132a38f6 --- /dev/null +++ b/backend/app/services/organizations.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy import func, or_ +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.time import utcnow +from app.models.boards import Board +from app.models.organization_board_access import OrganizationBoardAccess +from app.models.organization_invite_board_access import OrganizationInviteBoardAccess +from app.models.organization_invites import OrganizationInvite +from app.models.organization_members import OrganizationMember +from app.models.organizations import Organization +from app.models.users import User +from app.schemas.organizations import OrganizationBoardAccessSpec, OrganizationMemberAccessUpdate + +DEFAULT_ORG_NAME = "Personal" +ADMIN_ROLES = {"owner", "admin"} +ROLE_RANK = {"member": 0, "admin": 1, "owner": 2} + + +@dataclass(frozen=True) +class OrganizationContext: + organization: Organization + member: OrganizationMember + + +def is_org_admin(member: OrganizationMember) -> bool: + return member.role in ADMIN_ROLES + + +async def get_default_org(session: AsyncSession) -> Organization | None: + statement = select(Organization).where(col(Organization.name) == DEFAULT_ORG_NAME) + return (await session.exec(statement)).first() + + +async def ensure_default_org(session: AsyncSession) -> Organization: + org = await get_default_org(session) + if org is not None: + return org + org = Organization(name=DEFAULT_ORG_NAME, created_at=utcnow(), updated_at=utcnow()) + session.add(org) + await session.commit() + await session.refresh(org) + return org + + +async def get_member( + session: AsyncSession, + *, + user_id: UUID, + organization_id: UUID, +) -> OrganizationMember | None: + statement = select(OrganizationMember).where( + col(OrganizationMember.organization_id) == organization_id, + col(OrganizationMember.user_id) == user_id, + ) + return (await session.exec(statement)).first() + + +async def get_first_membership(session: AsyncSession, user_id: UUID) -> OrganizationMember | None: + statement = ( + select(OrganizationMember) + .where(col(OrganizationMember.user_id) == user_id) + .order_by(col(OrganizationMember.created_at).asc()) + ) + return (await session.exec(statement)).first() + + +async def set_active_organization( + session: AsyncSession, + *, + user: User, + organization_id: UUID, +) -> OrganizationMember: + member = await get_member( + session, user_id=user.id, organization_id=organization_id + ) + if member is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access") + if user.active_organization_id != organization_id: + user.active_organization_id = organization_id + session.add(user) + await session.commit() + return member + + +async def get_active_membership( + session: AsyncSession, + user: User, +) -> OrganizationMember | None: + db_user = await session.get(User, user.id) + if db_user is None: + db_user = user + if db_user.active_organization_id: + member = await get_member( + session, + user_id=db_user.id, + organization_id=db_user.active_organization_id, + ) + if member is not None: + user.active_organization_id = db_user.active_organization_id + return member + db_user.active_organization_id = None + session.add(db_user) + await session.commit() + member = await get_first_membership(session, db_user.id) + if member is None: + return None + await set_active_organization( + session, + user=db_user, + organization_id=member.organization_id, + ) + user.active_organization_id = db_user.active_organization_id + return member + + +async def _find_pending_invite( + session: AsyncSession, + email: str, +) -> OrganizationInvite | None: + statement = ( + select(OrganizationInvite) + .where(col(OrganizationInvite.accepted_at).is_(None)) + .where(col(OrganizationInvite.invited_email) == email) + .order_by(col(OrganizationInvite.created_at).asc()) + ) + return (await session.exec(statement)).first() + + +async def accept_invite( + session: AsyncSession, + invite: OrganizationInvite, + user: User, +) -> OrganizationMember: + now = utcnow() + member = OrganizationMember( + organization_id=invite.organization_id, + user_id=user.id, + role=invite.role, + all_boards_read=invite.all_boards_read, + all_boards_write=invite.all_boards_write, + created_at=now, + updated_at=now, + ) + session.add(member) + await session.flush() + + if not (invite.all_boards_read or invite.all_boards_write): + access_rows = list( + await session.exec( + select(OrganizationInviteBoardAccess).where( + col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id + ) + ) + ) + for row in access_rows: + session.add( + OrganizationBoardAccess( + organization_member_id=member.id, + board_id=row.board_id, + can_read=row.can_read, + can_write=row.can_write, + created_at=now, + updated_at=now, + ) + ) + + invite.accepted_by_user_id = user.id + invite.accepted_at = now + invite.updated_at = now + session.add(invite) + if user.active_organization_id is None: + user.active_organization_id = invite.organization_id + session.add(user) + await session.commit() + await session.refresh(member) + return member + + +async def ensure_member_for_user(session: AsyncSession, user: User) -> OrganizationMember: + existing = await get_active_membership(session, user) + if existing is not None: + return existing + + if user.email: + invite = await _find_pending_invite(session, user.email) + if invite is not None: + return await accept_invite(session, invite, user) + + org = await ensure_default_org(session) + now = utcnow() + member_count = ( + await session.exec( + select(func.count()) + .where(col(OrganizationMember.organization_id) == org.id) + ) + ).one() + is_first = int(member_count or 0) == 0 + member = OrganizationMember( + organization_id=org.id, + user_id=user.id, + role="owner" if is_first else "member", + all_boards_read=is_first, + all_boards_write=is_first, + created_at=now, + updated_at=now, + ) + user.active_organization_id = org.id + session.add(user) + session.add(member) + await session.commit() + await session.refresh(member) + return member + + +def member_all_boards_read(member: OrganizationMember) -> bool: + return member.all_boards_read or member.all_boards_write + + +def member_all_boards_write(member: OrganizationMember) -> bool: + return member.all_boards_write + + +async def has_board_access( + session: AsyncSession, + *, + member: OrganizationMember, + board: Board, + write: bool, +) -> bool: + if member.organization_id != board.organization_id: + return False + if write: + if member_all_boards_write(member): + return True + else: + if member_all_boards_read(member): + return True + statement = select(OrganizationBoardAccess).where( + col(OrganizationBoardAccess.organization_member_id) == member.id, + col(OrganizationBoardAccess.board_id) == board.id, + ) + access = (await session.exec(statement)).first() + if access is None: + return False + if write: + return bool(access.can_write) + return bool(access.can_read or access.can_write) + + +async def require_board_access( + session: AsyncSession, + *, + user: User, + board: Board, + write: bool, +) -> OrganizationMember: + member = await get_member(session, user_id=user.id, organization_id=board.organization_id) + if member is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access") + if not await has_board_access(session, member=member, board=board, write=write): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Board access denied") + return member + + +def board_access_filter(member: OrganizationMember, *, write: bool) -> object: + if write and member_all_boards_write(member): + return col(Board.organization_id) == member.organization_id + if not write and member_all_boards_read(member): + return col(Board.organization_id) == member.organization_id + access_stmt = select(OrganizationBoardAccess.board_id).where( + col(OrganizationBoardAccess.organization_member_id) == member.id + ) + if write: + access_stmt = access_stmt.where(col(OrganizationBoardAccess.can_write).is_(True)) + else: + access_stmt = access_stmt.where( + or_( + col(OrganizationBoardAccess.can_read).is_(True), + col(OrganizationBoardAccess.can_write).is_(True), + ) + ) + return col(Board.id).in_(access_stmt) + + +async def list_accessible_board_ids( + session: AsyncSession, + *, + member: OrganizationMember, + write: bool, +) -> list[UUID]: + if (write and member_all_boards_write(member)) or ( + not write and member_all_boards_read(member) + ): + ids = await session.exec( + select(Board.id).where(col(Board.organization_id) == member.organization_id) + ) + return list(ids) + + access_stmt = select(OrganizationBoardAccess.board_id).where( + col(OrganizationBoardAccess.organization_member_id) == member.id + ) + if write: + access_stmt = access_stmt.where(col(OrganizationBoardAccess.can_write).is_(True)) + else: + access_stmt = access_stmt.where( + or_( + col(OrganizationBoardAccess.can_read).is_(True), + col(OrganizationBoardAccess.can_write).is_(True), + ) + ) + board_ids = await session.exec(access_stmt) + return list(board_ids) + + +async def apply_member_access_update( + session: AsyncSession, + *, + member: OrganizationMember, + update: OrganizationMemberAccessUpdate, +) -> None: + now = utcnow() + member.all_boards_read = update.all_boards_read + member.all_boards_write = update.all_boards_write + member.updated_at = now + session.add(member) + + await session.execute( + OrganizationBoardAccess.__table__.delete().where( + col(OrganizationBoardAccess.organization_member_id) == member.id + ) + ) + + if update.all_boards_read or update.all_boards_write: + return + + rows: list[OrganizationBoardAccess] = [] + for entry in update.board_access: + rows.append( + OrganizationBoardAccess( + organization_member_id=member.id, + board_id=entry.board_id, + can_read=entry.can_read, + can_write=entry.can_write, + created_at=now, + updated_at=now, + ) + ) + session.add_all(rows) + + +async def apply_invite_board_access( + session: AsyncSession, + *, + invite: OrganizationInvite, + entries: Iterable[OrganizationBoardAccessSpec], +) -> None: + await session.execute( + OrganizationInviteBoardAccess.__table__.delete().where( + col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id + ) + ) + if invite.all_boards_read or invite.all_boards_write: + return + now = utcnow() + rows: list[OrganizationInviteBoardAccess] = [] + for entry in entries: + rows.append( + OrganizationInviteBoardAccess( + organization_invite_id=invite.id, + board_id=entry.board_id, + can_read=entry.can_read, + can_write=entry.can_write, + created_at=now, + updated_at=now, + ) + ) + session.add_all(rows) + + +def normalize_invited_email(email: str) -> str: + return email.strip().lower() + + +def normalize_role(role: str) -> str: + return role.strip().lower() or "member" + + +def _role_rank(role: str | None) -> int: + if not role: + return 0 + return ROLE_RANK.get(role, 0) + + +async def apply_invite_to_member( + session: AsyncSession, + *, + member: OrganizationMember, + invite: OrganizationInvite, +) -> None: + now = utcnow() + member_changed = False + invite_role = normalize_role(invite.role or "member") + if _role_rank(invite_role) > _role_rank(member.role): + member.role = invite_role + member_changed = True + + if invite.all_boards_read or invite.all_boards_write: + member.all_boards_read = ( + member.all_boards_read or invite.all_boards_read or invite.all_boards_write + ) + member.all_boards_write = member.all_boards_write or invite.all_boards_write + member_changed = True + if member_changed: + member.updated_at = now + session.add(member) + return + + access_rows = list( + await session.exec( + select(OrganizationInviteBoardAccess).where( + col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id + ) + ) + ) + for row in access_rows: + existing = ( + await session.exec( + select(OrganizationBoardAccess).where( + col(OrganizationBoardAccess.organization_member_id) == member.id, + col(OrganizationBoardAccess.board_id) == row.board_id, + ) + ) + ).first() + can_write = bool(row.can_write) + can_read = bool(row.can_read or row.can_write) + if existing is None: + session.add( + OrganizationBoardAccess( + organization_member_id=member.id, + board_id=row.board_id, + can_read=can_read, + can_write=can_write, + created_at=now, + updated_at=now, + ) + ) + else: + existing.can_read = bool(existing.can_read or can_read) + existing.can_write = bool(existing.can_write or can_write) + existing.updated_at = now + session.add(existing) + + if member_changed: + member.updated_at = now + session.add(member) diff --git a/frontend/src/api/generated/agent/agent.ts b/frontend/src/api/generated/agent/agent.ts index 5f8ec79e..13c61f9c 100644 --- a/frontend/src/api/generated/agent/agent.ts +++ b/frontend/src/api/generated/agent/agent.ts @@ -53,6 +53,7 @@ import type { ListTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams, ListTasksApiV1AgentBoardsBoardIdTasksGetParams, OkResponse, + SoulUpdateRequest, TaskCommentCreate, TaskCommentRead, TaskCreate, @@ -3035,6 +3036,449 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = < queryClient, ); }; +/** + * @summary Get Agent Soul + */ +export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 = + { + data: string; + status: 200; + }; + +export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess = + getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 & { + headers: Headers; + }; +export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError = + getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 & { + headers: Headers; + }; + +export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse = + | getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess + | getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError; + +export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetUrl = ( + boardId: string, + agentId: string, +) => { + return `/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`; +}; + +export const getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet = async ( + boardId: string, + agentId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetUrl( + boardId, + agentId, + ), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryKey = + (boardId: string, agentId: string) => { + return [`/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`] as const; + }; + +export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryOptions = + < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, + >( + boardId: string, + agentId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryKey( + boardId, + agentId, + ); + + const queryFn: QueryFunction< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + > + > = ({ signal }) => + getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet( + boardId, + agentId, + { signal, ...requestOptions }, + ); + + return { + queryKey, + queryFn, + enabled: !!(boardId && agentId), + ...queryOptions, + } as UseQueryOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + TData + > & { queryKey: DataTag }; + }; + +export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryResult = + NonNullable< + Awaited< + ReturnType + > + >; +export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryError = + HTTPValidationError; + +export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + boardId: string, + agentId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + boardId: string, + agentId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + boardId: string, + agentId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Agent Soul + */ + +export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + boardId: string, + agentId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryOptions( + boardId, + agentId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Update Agent Soul + */ +export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse200 = + { + data: OkResponse; + status: 200; + }; + +export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseSuccess = + updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse200 & { + headers: Headers; + }; +export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseError = + updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse422 & { + headers: Headers; + }; + +export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse = + | updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseSuccess + | updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseError; + +export const getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutUrl = + (boardId: string, agentId: string) => { + return `/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`; + }; + +export const updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut = + async ( + boardId: string, + agentId: string, + soulUpdateRequest: SoulUpdateRequest, + options?: RequestInit, + ): Promise => { + return customFetch( + getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutUrl( + boardId, + agentId, + ), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(soulUpdateRequest), + }, + ); + }; + +export const getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut + > + >, + TError, + { boardId: string; agentId: string; data: SoulUpdateRequest }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut + > + >, + TError, + { boardId: string; agentId: string; data: SoulUpdateRequest }, + TContext + > => { + const mutationKey = [ + "updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut + > + >, + { boardId: string; agentId: string; data: SoulUpdateRequest } + > = (props) => { + const { boardId, agentId, data } = props ?? {}; + + return updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut( + boardId, + agentId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut + > + > + >; +export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationBody = + SoulUpdateRequest; +export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationError = + HTTPValidationError; + +/** + * @summary Update Agent Soul + */ +export const useUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut + > + >, + TError, + { boardId: string; agentId: string; data: SoulUpdateRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType< + typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut + > + >, + TError, + { boardId: string; agentId: string; data: SoulUpdateRequest }, + TContext +> => { + return useMutation( + getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationOptions( + options, + ), + queryClient, + ); +}; /** * @summary Ask User Via Gateway Main */ diff --git a/frontend/src/api/generated/model/boardGroupRead.ts b/frontend/src/api/generated/model/boardGroupRead.ts index e2df33b4..a1e8f514 100644 --- a/frontend/src/api/generated/model/boardGroupRead.ts +++ b/frontend/src/api/generated/model/boardGroupRead.ts @@ -10,6 +10,7 @@ export interface BoardGroupRead { slug: string; description?: string | null; id: string; + organization_id: string; created_at: string; updated_at: string; } diff --git a/frontend/src/api/generated/model/boardRead.ts b/frontend/src/api/generated/model/boardRead.ts index e964b87b..f1706fed 100644 --- a/frontend/src/api/generated/model/boardRead.ts +++ b/frontend/src/api/generated/model/boardRead.ts @@ -18,6 +18,7 @@ export interface BoardRead { goal_confirmed?: boolean; goal_source?: string | null; id: string; + organization_id: string; created_at: string; updated_at: string; } diff --git a/frontend/src/api/generated/model/gatewayRead.ts b/frontend/src/api/generated/model/gatewayRead.ts index 90afd15a..d6b12431 100644 --- a/frontend/src/api/generated/model/gatewayRead.ts +++ b/frontend/src/api/generated/model/gatewayRead.ts @@ -11,6 +11,7 @@ export interface GatewayRead { main_session_key: string; workspace_root: string; id: string; + organization_id: string; token?: string | null; created_at: string; updated_at: string; diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index e65b09fc..26ab4015 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -131,6 +131,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardRead"; export * from "./limitOffsetPageTypeVarCustomizedGatewayRead"; +export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead"; +export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead"; export * from "./limitOffsetPageTypeVarCustomizedTaskCommentRead"; export * from "./limitOffsetPageTypeVarCustomizedTaskRead"; export * from "./listActivityApiV1ActivityGetParams"; @@ -147,6 +149,8 @@ export * from "./listBoardsApiV1AgentBoardsGetParams"; export * from "./listBoardsApiV1BoardsGetParams"; export * from "./listGatewaysApiV1GatewaysGetParams"; export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams"; +export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams"; +export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams"; export * from "./listSessionsApiV1GatewaySessionsGet200"; export * from "./listSessionsApiV1GatewaySessionsGetParams"; export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams"; @@ -155,11 +159,29 @@ export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams" export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams"; export * from "./listTasksApiV1BoardsBoardIdTasksGetParams"; export * from "./okResponse"; +export * from "./organizationActiveUpdate"; +export * from "./organizationBoardAccessRead"; +export * from "./organizationBoardAccessSpec"; +export * from "./organizationCreate"; +export * from "./organizationInviteAccept"; +export * from "./organizationInviteCreate"; +export * from "./organizationInviteRead"; +export * from "./organizationListItem"; +export * from "./organizationMemberAccessUpdate"; +export * from "./organizationMemberRead"; +export * from "./organizationMemberUpdate"; +export * from "./organizationRead"; +export * from "./organizationUserRead"; export * from "./readyzReadyzGet200"; +export * from "./searchApiV1SoulsDirectorySearchGetParams"; export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams"; export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost200"; export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostBody"; export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParams"; +export * from "./soulsDirectoryMarkdownResponse"; +export * from "./soulsDirectorySearchResponse"; +export * from "./soulsDirectorySoulRef"; +export * from "./soulUpdateRequest"; export * from "./streamAgentsApiV1AgentsStreamGetParams"; export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams"; export * from "./streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGetParams"; diff --git a/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationInviteRead.ts b/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationInviteRead.ts new file mode 100644 index 00000000..c97838f4 --- /dev/null +++ b/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationInviteRead.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { OrganizationInviteRead } from "./organizationInviteRead"; + +export interface LimitOffsetPageTypeVarCustomizedOrganizationInviteRead { + items: OrganizationInviteRead[]; + /** @minimum 0 */ + total: number; + /** @minimum 1 */ + limit: number; + /** @minimum 0 */ + offset: number; +} diff --git a/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationMemberRead.ts b/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationMemberRead.ts new file mode 100644 index 00000000..bd6e4738 --- /dev/null +++ b/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedOrganizationMemberRead.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { OrganizationMemberRead } from "./organizationMemberRead"; + +export interface LimitOffsetPageTypeVarCustomizedOrganizationMemberRead { + items: OrganizationMemberRead[]; + /** @minimum 0 */ + total: number; + /** @minimum 1 */ + limit: number; + /** @minimum 0 */ + offset: number; +} diff --git a/frontend/src/api/generated/model/listOrgInvitesApiV1OrganizationsMeInvitesGetParams.ts b/frontend/src/api/generated/model/listOrgInvitesApiV1OrganizationsMeInvitesGetParams.ts new file mode 100644 index 00000000..0d4c9c55 --- /dev/null +++ b/frontend/src/api/generated/model/listOrgInvitesApiV1OrganizationsMeInvitesGetParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type ListOrgInvitesApiV1OrganizationsMeInvitesGetParams = { + /** + * @minimum 1 + * @maximum 200 + */ + limit?: number; + /** + * @minimum 0 + */ + offset?: number; +}; diff --git a/frontend/src/api/generated/model/listOrgMembersApiV1OrganizationsMeMembersGetParams.ts b/frontend/src/api/generated/model/listOrgMembersApiV1OrganizationsMeMembersGetParams.ts new file mode 100644 index 00000000..0f24bddd --- /dev/null +++ b/frontend/src/api/generated/model/listOrgMembersApiV1OrganizationsMeMembersGetParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type ListOrgMembersApiV1OrganizationsMeMembersGetParams = { + /** + * @minimum 1 + * @maximum 200 + */ + limit?: number; + /** + * @minimum 0 + */ + offset?: number; +}; diff --git a/frontend/src/api/generated/model/organizationActiveUpdate.ts b/frontend/src/api/generated/model/organizationActiveUpdate.ts new file mode 100644 index 00000000..bf2a1ba4 --- /dev/null +++ b/frontend/src/api/generated/model/organizationActiveUpdate.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationActiveUpdate { + organization_id: string; +} diff --git a/frontend/src/api/generated/model/organizationBoardAccessRead.ts b/frontend/src/api/generated/model/organizationBoardAccessRead.ts new file mode 100644 index 00000000..9fc841f0 --- /dev/null +++ b/frontend/src/api/generated/model/organizationBoardAccessRead.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationBoardAccessRead { + id: string; + board_id: string; + can_read: boolean; + can_write: boolean; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/api/generated/model/organizationBoardAccessSpec.ts b/frontend/src/api/generated/model/organizationBoardAccessSpec.ts new file mode 100644 index 00000000..49371849 --- /dev/null +++ b/frontend/src/api/generated/model/organizationBoardAccessSpec.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationBoardAccessSpec { + board_id: string; + can_read?: boolean; + can_write?: boolean; +} diff --git a/frontend/src/api/generated/model/organizationCreate.ts b/frontend/src/api/generated/model/organizationCreate.ts new file mode 100644 index 00000000..2eeb1fdd --- /dev/null +++ b/frontend/src/api/generated/model/organizationCreate.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationCreate { + name: string; +} diff --git a/frontend/src/api/generated/model/organizationInviteAccept.ts b/frontend/src/api/generated/model/organizationInviteAccept.ts new file mode 100644 index 00000000..54384c7c --- /dev/null +++ b/frontend/src/api/generated/model/organizationInviteAccept.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationInviteAccept { + token: string; +} diff --git a/frontend/src/api/generated/model/organizationInviteCreate.ts b/frontend/src/api/generated/model/organizationInviteCreate.ts new file mode 100644 index 00000000..8a45ef35 --- /dev/null +++ b/frontend/src/api/generated/model/organizationInviteCreate.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { OrganizationBoardAccessSpec } from "./organizationBoardAccessSpec"; + +export interface OrganizationInviteCreate { + invited_email: string; + role?: string; + all_boards_read?: boolean; + all_boards_write?: boolean; + board_access?: OrganizationBoardAccessSpec[]; +} diff --git a/frontend/src/api/generated/model/organizationInviteRead.ts b/frontend/src/api/generated/model/organizationInviteRead.ts new file mode 100644 index 00000000..7e5d5831 --- /dev/null +++ b/frontend/src/api/generated/model/organizationInviteRead.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationInviteRead { + id: string; + organization_id: string; + invited_email: string; + role: string; + all_boards_read: boolean; + all_boards_write: boolean; + token: string; + created_by_user_id?: string | null; + accepted_by_user_id?: string | null; + accepted_at?: string | null; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/api/generated/model/organizationListItem.ts b/frontend/src/api/generated/model/organizationListItem.ts new file mode 100644 index 00000000..12e8626d --- /dev/null +++ b/frontend/src/api/generated/model/organizationListItem.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationListItem { + id: string; + name: string; + role: string; + is_active: boolean; +} diff --git a/frontend/src/api/generated/model/organizationMemberAccessUpdate.ts b/frontend/src/api/generated/model/organizationMemberAccessUpdate.ts new file mode 100644 index 00000000..2436482a --- /dev/null +++ b/frontend/src/api/generated/model/organizationMemberAccessUpdate.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { OrganizationBoardAccessSpec } from "./organizationBoardAccessSpec"; + +export interface OrganizationMemberAccessUpdate { + all_boards_read?: boolean; + all_boards_write?: boolean; + board_access?: OrganizationBoardAccessSpec[]; +} diff --git a/frontend/src/api/generated/model/organizationMemberRead.ts b/frontend/src/api/generated/model/organizationMemberRead.ts new file mode 100644 index 00000000..5fba6c8a --- /dev/null +++ b/frontend/src/api/generated/model/organizationMemberRead.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { OrganizationBoardAccessRead } from "./organizationBoardAccessRead"; +import type { OrganizationUserRead } from "./organizationUserRead"; + +export interface OrganizationMemberRead { + id: string; + organization_id: string; + user_id: string; + role: string; + all_boards_read: boolean; + all_boards_write: boolean; + created_at: string; + updated_at: string; + user?: OrganizationUserRead | null; + board_access?: OrganizationBoardAccessRead[]; +} diff --git a/frontend/src/api/generated/model/organizationMemberUpdate.ts b/frontend/src/api/generated/model/organizationMemberUpdate.ts new file mode 100644 index 00000000..8ed18173 --- /dev/null +++ b/frontend/src/api/generated/model/organizationMemberUpdate.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationMemberUpdate { + role?: string | null; +} diff --git a/frontend/src/api/generated/model/organizationRead.ts b/frontend/src/api/generated/model/organizationRead.ts new file mode 100644 index 00000000..f9f89f15 --- /dev/null +++ b/frontend/src/api/generated/model/organizationRead.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationRead { + id: string; + name: string; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/api/generated/model/organizationUserRead.ts b/frontend/src/api/generated/model/organizationUserRead.ts new file mode 100644 index 00000000..1cdf8207 --- /dev/null +++ b/frontend/src/api/generated/model/organizationUserRead.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface OrganizationUserRead { + id: string; + email?: string | null; + name?: string | null; + preferred_name?: string | null; +} diff --git a/frontend/src/api/generated/model/searchApiV1SoulsDirectorySearchGetParams.ts b/frontend/src/api/generated/model/searchApiV1SoulsDirectorySearchGetParams.ts new file mode 100644 index 00000000..2999eb77 --- /dev/null +++ b/frontend/src/api/generated/model/searchApiV1SoulsDirectorySearchGetParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type SearchApiV1SoulsDirectorySearchGetParams = { + /** + * @minLength 0 + */ + q?: string; + /** + * @minimum 1 + * @maximum 100 + */ + limit?: number; +}; diff --git a/frontend/src/api/generated/model/soulUpdateRequest.ts b/frontend/src/api/generated/model/soulUpdateRequest.ts new file mode 100644 index 00000000..0a04a175 --- /dev/null +++ b/frontend/src/api/generated/model/soulUpdateRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface SoulUpdateRequest { + content: string; + source_url?: string | null; + reason?: string | null; +} diff --git a/frontend/src/api/generated/model/soulsDirectoryMarkdownResponse.ts b/frontend/src/api/generated/model/soulsDirectoryMarkdownResponse.ts new file mode 100644 index 00000000..af2cb8a5 --- /dev/null +++ b/frontend/src/api/generated/model/soulsDirectoryMarkdownResponse.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface SoulsDirectoryMarkdownResponse { + handle: string; + slug: string; + content: string; +} diff --git a/frontend/src/api/generated/model/soulsDirectorySearchResponse.ts b/frontend/src/api/generated/model/soulsDirectorySearchResponse.ts new file mode 100644 index 00000000..8b6c49e0 --- /dev/null +++ b/frontend/src/api/generated/model/soulsDirectorySearchResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { SoulsDirectorySoulRef } from "./soulsDirectorySoulRef"; + +export interface SoulsDirectorySearchResponse { + items: SoulsDirectorySoulRef[]; +} diff --git a/frontend/src/api/generated/model/soulsDirectorySoulRef.ts b/frontend/src/api/generated/model/soulsDirectorySoulRef.ts new file mode 100644 index 00000000..a93f7e79 --- /dev/null +++ b/frontend/src/api/generated/model/soulsDirectorySoulRef.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface SoulsDirectorySoulRef { + handle: string; + slug: string; + page_url: string; + raw_md_url: string; +} diff --git a/frontend/src/api/generated/organizations/organizations.ts b/frontend/src/api/generated/organizations/organizations.ts new file mode 100644 index 00000000..bea68794 --- /dev/null +++ b/frontend/src/api/generated/organizations/organizations.ts @@ -0,0 +1,2329 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + HTTPValidationError, + LimitOffsetPageTypeVarCustomizedOrganizationInviteRead, + LimitOffsetPageTypeVarCustomizedOrganizationMemberRead, + ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, + ListOrgMembersApiV1OrganizationsMeMembersGetParams, + OrganizationActiveUpdate, + OrganizationCreate, + OrganizationInviteAccept, + OrganizationInviteCreate, + OrganizationInviteRead, + OrganizationListItem, + OrganizationMemberAccessUpdate, + OrganizationMemberRead, + OrganizationMemberUpdate, + OrganizationRead, +} from ".././model"; + +import { customFetch } from "../../mutator"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * @summary Create Organization + */ +export type createOrganizationApiV1OrganizationsPostResponse200 = { + data: OrganizationRead; + status: 200; +}; + +export type createOrganizationApiV1OrganizationsPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type createOrganizationApiV1OrganizationsPostResponseSuccess = + createOrganizationApiV1OrganizationsPostResponse200 & { + headers: Headers; + }; +export type createOrganizationApiV1OrganizationsPostResponseError = + createOrganizationApiV1OrganizationsPostResponse422 & { + headers: Headers; + }; + +export type createOrganizationApiV1OrganizationsPostResponse = + | createOrganizationApiV1OrganizationsPostResponseSuccess + | createOrganizationApiV1OrganizationsPostResponseError; + +export const getCreateOrganizationApiV1OrganizationsPostUrl = () => { + return `/api/v1/organizations`; +}; + +export const createOrganizationApiV1OrganizationsPost = async ( + organizationCreate: OrganizationCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getCreateOrganizationApiV1OrganizationsPostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(organizationCreate), + }, + ); +}; + +export const getCreateOrganizationApiV1OrganizationsPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: OrganizationCreate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: OrganizationCreate }, + TContext +> => { + const mutationKey = ["createOrganizationApiV1OrganizationsPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: OrganizationCreate } + > = (props) => { + const { data } = props ?? {}; + + return createOrganizationApiV1OrganizationsPost(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateOrganizationApiV1OrganizationsPostMutationResult = + NonNullable< + Awaited> + >; +export type CreateOrganizationApiV1OrganizationsPostMutationBody = + OrganizationCreate; +export type CreateOrganizationApiV1OrganizationsPostMutationError = + HTTPValidationError; + +/** + * @summary Create Organization + */ +export const useCreateOrganizationApiV1OrganizationsPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: OrganizationCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: OrganizationCreate }, + TContext +> => { + return useMutation( + getCreateOrganizationApiV1OrganizationsPostMutationOptions(options), + queryClient, + ); +}; +/** + * @summary List My Organizations + */ +export type listMyOrganizationsApiV1OrganizationsMeListGetResponse200 = { + data: OrganizationListItem[]; + status: 200; +}; + +export type listMyOrganizationsApiV1OrganizationsMeListGetResponseSuccess = + listMyOrganizationsApiV1OrganizationsMeListGetResponse200 & { + headers: Headers; + }; +export type listMyOrganizationsApiV1OrganizationsMeListGetResponse = + listMyOrganizationsApiV1OrganizationsMeListGetResponseSuccess; + +export const getListMyOrganizationsApiV1OrganizationsMeListGetUrl = () => { + return `/api/v1/organizations/me/list`; +}; + +export const listMyOrganizationsApiV1OrganizationsMeListGet = async ( + options?: RequestInit, +): Promise => { + return customFetch( + getListMyOrganizationsApiV1OrganizationsMeListGetUrl(), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListMyOrganizationsApiV1OrganizationsMeListGetQueryKey = () => { + return [`/api/v1/organizations/me/list`] as const; +}; + +export const getListMyOrganizationsApiV1OrganizationsMeListGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListMyOrganizationsApiV1OrganizationsMeListGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listMyOrganizationsApiV1OrganizationsMeListGet({ + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListMyOrganizationsApiV1OrganizationsMeListGetQueryResult = + NonNullable< + Awaited> + >; +export type ListMyOrganizationsApiV1OrganizationsMeListGetQueryError = unknown; + +export function useListMyOrganizationsApiV1OrganizationsMeListGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListMyOrganizationsApiV1OrganizationsMeListGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListMyOrganizationsApiV1OrganizationsMeListGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List My Organizations + */ + +export function useListMyOrganizationsApiV1OrganizationsMeListGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListMyOrganizationsApiV1OrganizationsMeListGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Set Active Org + */ +export type setActiveOrgApiV1OrganizationsMeActivePatchResponse200 = { + data: OrganizationRead; + status: 200; +}; + +export type setActiveOrgApiV1OrganizationsMeActivePatchResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type setActiveOrgApiV1OrganizationsMeActivePatchResponseSuccess = + setActiveOrgApiV1OrganizationsMeActivePatchResponse200 & { + headers: Headers; + }; +export type setActiveOrgApiV1OrganizationsMeActivePatchResponseError = + setActiveOrgApiV1OrganizationsMeActivePatchResponse422 & { + headers: Headers; + }; + +export type setActiveOrgApiV1OrganizationsMeActivePatchResponse = + | setActiveOrgApiV1OrganizationsMeActivePatchResponseSuccess + | setActiveOrgApiV1OrganizationsMeActivePatchResponseError; + +export const getSetActiveOrgApiV1OrganizationsMeActivePatchUrl = () => { + return `/api/v1/organizations/me/active`; +}; + +export const setActiveOrgApiV1OrganizationsMeActivePatch = async ( + organizationActiveUpdate: OrganizationActiveUpdate, + options?: RequestInit, +): Promise => { + return customFetch( + getSetActiveOrgApiV1OrganizationsMeActivePatchUrl(), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(organizationActiveUpdate), + }, + ); +}; + +export const getSetActiveOrgApiV1OrganizationsMeActivePatchMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: OrganizationActiveUpdate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: OrganizationActiveUpdate }, + TContext +> => { + const mutationKey = ["setActiveOrgApiV1OrganizationsMeActivePatch"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: OrganizationActiveUpdate } + > = (props) => { + const { data } = props ?? {}; + + return setActiveOrgApiV1OrganizationsMeActivePatch(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SetActiveOrgApiV1OrganizationsMeActivePatchMutationResult = + NonNullable< + Awaited> + >; +export type SetActiveOrgApiV1OrganizationsMeActivePatchMutationBody = + OrganizationActiveUpdate; +export type SetActiveOrgApiV1OrganizationsMeActivePatchMutationError = + HTTPValidationError; + +/** + * @summary Set Active Org + */ +export const useSetActiveOrgApiV1OrganizationsMeActivePatch = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: OrganizationActiveUpdate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: OrganizationActiveUpdate }, + TContext +> => { + return useMutation( + getSetActiveOrgApiV1OrganizationsMeActivePatchMutationOptions(options), + queryClient, + ); +}; +/** + * @summary Get My Org + */ +export type getMyOrgApiV1OrganizationsMeGetResponse200 = { + data: OrganizationRead; + status: 200; +}; + +export type getMyOrgApiV1OrganizationsMeGetResponseSuccess = + getMyOrgApiV1OrganizationsMeGetResponse200 & { + headers: Headers; + }; +export type getMyOrgApiV1OrganizationsMeGetResponse = + getMyOrgApiV1OrganizationsMeGetResponseSuccess; + +export const getGetMyOrgApiV1OrganizationsMeGetUrl = () => { + return `/api/v1/organizations/me`; +}; + +export const getMyOrgApiV1OrganizationsMeGet = async ( + options?: RequestInit, +): Promise => { + return customFetch( + getGetMyOrgApiV1OrganizationsMeGetUrl(), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetMyOrgApiV1OrganizationsMeGetQueryKey = () => { + return [`/api/v1/organizations/me`] as const; +}; + +export const getGetMyOrgApiV1OrganizationsMeGetQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getGetMyOrgApiV1OrganizationsMeGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getMyOrgApiV1OrganizationsMeGet({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetMyOrgApiV1OrganizationsMeGetQueryResult = NonNullable< + Awaited> +>; +export type GetMyOrgApiV1OrganizationsMeGetQueryError = unknown; + +export function useGetMyOrgApiV1OrganizationsMeGet< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetMyOrgApiV1OrganizationsMeGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetMyOrgApiV1OrganizationsMeGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get My Org + */ + +export function useGetMyOrgApiV1OrganizationsMeGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getGetMyOrgApiV1OrganizationsMeGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get My Membership + */ +export type getMyMembershipApiV1OrganizationsMeMemberGetResponse200 = { + data: OrganizationMemberRead; + status: 200; +}; + +export type getMyMembershipApiV1OrganizationsMeMemberGetResponseSuccess = + getMyMembershipApiV1OrganizationsMeMemberGetResponse200 & { + headers: Headers; + }; +export type getMyMembershipApiV1OrganizationsMeMemberGetResponse = + getMyMembershipApiV1OrganizationsMeMemberGetResponseSuccess; + +export const getGetMyMembershipApiV1OrganizationsMeMemberGetUrl = () => { + return `/api/v1/organizations/me/member`; +}; + +export const getMyMembershipApiV1OrganizationsMeMemberGet = async ( + options?: RequestInit, +): Promise => { + return customFetch( + getGetMyMembershipApiV1OrganizationsMeMemberGetUrl(), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetMyMembershipApiV1OrganizationsMeMemberGetQueryKey = () => { + return [`/api/v1/organizations/me/member`] as const; +}; + +export const getGetMyMembershipApiV1OrganizationsMeMemberGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetMyMembershipApiV1OrganizationsMeMemberGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getMyMembershipApiV1OrganizationsMeMemberGet({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetMyMembershipApiV1OrganizationsMeMemberGetQueryResult = + NonNullable< + Awaited> + >; +export type GetMyMembershipApiV1OrganizationsMeMemberGetQueryError = unknown; + +export function useGetMyMembershipApiV1OrganizationsMeMemberGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetMyMembershipApiV1OrganizationsMeMemberGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetMyMembershipApiV1OrganizationsMeMemberGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get My Membership + */ + +export function useGetMyMembershipApiV1OrganizationsMeMemberGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetMyMembershipApiV1OrganizationsMeMemberGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List Org Members + */ +export type listOrgMembersApiV1OrganizationsMeMembersGetResponse200 = { + data: LimitOffsetPageTypeVarCustomizedOrganizationMemberRead; + status: 200; +}; + +export type listOrgMembersApiV1OrganizationsMeMembersGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type listOrgMembersApiV1OrganizationsMeMembersGetResponseSuccess = + listOrgMembersApiV1OrganizationsMeMembersGetResponse200 & { + headers: Headers; + }; +export type listOrgMembersApiV1OrganizationsMeMembersGetResponseError = + listOrgMembersApiV1OrganizationsMeMembersGetResponse422 & { + headers: Headers; + }; + +export type listOrgMembersApiV1OrganizationsMeMembersGetResponse = + | listOrgMembersApiV1OrganizationsMeMembersGetResponseSuccess + | listOrgMembersApiV1OrganizationsMeMembersGetResponseError; + +export const getListOrgMembersApiV1OrganizationsMeMembersGetUrl = ( + params?: ListOrgMembersApiV1OrganizationsMeMembersGetParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/organizations/me/members?${stringifiedParams}` + : `/api/v1/organizations/me/members`; +}; + +export const listOrgMembersApiV1OrganizationsMeMembersGet = async ( + params?: ListOrgMembersApiV1OrganizationsMeMembersGetParams, + options?: RequestInit, +): Promise => { + return customFetch( + getListOrgMembersApiV1OrganizationsMeMembersGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey = ( + params?: ListOrgMembersApiV1OrganizationsMeMembersGetParams, +) => { + return [ + `/api/v1/organizations/me/members`, + ...(params ? [params] : []), + ] as const; +}; + +export const getListOrgMembersApiV1OrganizationsMeMembersGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgMembersApiV1OrganizationsMeMembersGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listOrgMembersApiV1OrganizationsMeMembersGet(params, { + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListOrgMembersApiV1OrganizationsMeMembersGetQueryResult = + NonNullable< + Awaited> + >; +export type ListOrgMembersApiV1OrganizationsMeMembersGetQueryError = + HTTPValidationError; + +export function useListOrgMembersApiV1OrganizationsMeMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: undefined | ListOrgMembersApiV1OrganizationsMeMembersGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListOrgMembersApiV1OrganizationsMeMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgMembersApiV1OrganizationsMeMembersGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListOrgMembersApiV1OrganizationsMeMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgMembersApiV1OrganizationsMeMembersGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Org Members + */ + +export function useListOrgMembersApiV1OrganizationsMeMembersGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgMembersApiV1OrganizationsMeMembersGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListOrgMembersApiV1OrganizationsMeMembersGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get Org Member + */ +export type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse200 = { + data: OrganizationMemberRead; + status: 200; +}; + +export type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponseSuccess = + getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse200 & { + headers: Headers; + }; +export type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponseError = + getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse422 & { + headers: Headers; + }; + +export type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse = + | getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponseSuccess + | getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponseError; + +export const getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetUrl = ( + memberId: string, +) => { + return `/api/v1/organizations/me/members/${memberId}`; +}; + +export const getOrgMemberApiV1OrganizationsMeMembersMemberIdGet = async ( + memberId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetUrl(memberId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey = ( + memberId: string, +) => { + return [`/api/v1/organizations/me/members/${memberId}`] as const; +}; + +export const getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryOptions = + < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, + >( + memberId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getOrgMemberApiV1OrganizationsMeMembersMemberIdGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey(memberId); + + const queryFn: QueryFunction< + Awaited< + ReturnType + > + > = ({ signal }) => + getOrgMemberApiV1OrganizationsMeMembersMemberIdGet(memberId, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!memberId, + ...queryOptions, + } as UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > & { queryKey: DataTag }; + }; + +export type GetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryResult = + NonNullable< + Awaited< + ReturnType + > + >; +export type GetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryError = + HTTPValidationError; + +export function useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + memberId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getOrgMemberApiV1OrganizationsMeMembersMemberIdGet + > + >, + TError, + Awaited< + ReturnType< + typeof getOrgMemberApiV1OrganizationsMeMembersMemberIdGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + memberId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getOrgMemberApiV1OrganizationsMeMembersMemberIdGet + > + >, + TError, + Awaited< + ReturnType< + typeof getOrgMemberApiV1OrganizationsMeMembersMemberIdGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + memberId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Org Member + */ + +export function useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + memberId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryOptions( + memberId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Update Org Member + */ +export type updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponse200 = + { + data: OrganizationMemberRead; + status: 200; + }; + +export type updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponseSuccess = + updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponse200 & { + headers: Headers; + }; +export type updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponseError = + updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponse422 & { + headers: Headers; + }; + +export type updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponse = + | updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponseSuccess + | updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchResponseError; + +export const getUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchUrl = ( + memberId: string, +) => { + return `/api/v1/organizations/me/members/${memberId}`; +}; + +export const updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch = async ( + memberId: string, + organizationMemberUpdate: OrganizationMemberUpdate, + options?: RequestInit, +): Promise => { + return customFetch( + getUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchUrl(memberId), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(organizationMemberUpdate), + }, + ); +}; + +export const getUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch + > + >, + TError, + { memberId: string; data: OrganizationMemberUpdate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { memberId: string; data: OrganizationMemberUpdate }, + TContext + > => { + const mutationKey = [ + "updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch + > + >, + { memberId: string; data: OrganizationMemberUpdate } + > = (props) => { + const { memberId, data } = props ?? {}; + + return updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch( + memberId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type UpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchMutationResult = + NonNullable< + Awaited< + ReturnType + > + >; +export type UpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchMutationBody = + OrganizationMemberUpdate; +export type UpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchMutationError = + HTTPValidationError; + +/** + * @summary Update Org Member + */ +export const useUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch + > + >, + TError, + { memberId: string; data: OrganizationMemberUpdate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + TError, + { memberId: string; data: OrganizationMemberUpdate }, + TContext +> => { + return useMutation( + getUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatchMutationOptions( + options, + ), + queryClient, + ); +}; +/** + * @summary Update Member Access + */ +export type updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponse200 = + { + data: OrganizationMemberRead; + status: 200; + }; + +export type updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponseSuccess = + updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponse200 & { + headers: Headers; + }; +export type updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponseError = + updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponse422 & { + headers: Headers; + }; + +export type updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponse = + + | updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponseSuccess + | updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutResponseError; + +export const getUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutUrl = + (memberId: string) => { + return `/api/v1/organizations/me/members/${memberId}/access`; + }; + +export const updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut = + async ( + memberId: string, + organizationMemberAccessUpdate: OrganizationMemberAccessUpdate, + options?: RequestInit, + ): Promise => { + return customFetch( + getUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutUrl( + memberId, + ), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(organizationMemberAccessUpdate), + }, + ); + }; + +export const getUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut + > + >, + TError, + { memberId: string; data: OrganizationMemberAccessUpdate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut + > + >, + TError, + { memberId: string; data: OrganizationMemberAccessUpdate }, + TContext + > => { + const mutationKey = [ + "updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut + > + >, + { memberId: string; data: OrganizationMemberAccessUpdate } + > = (props) => { + const { memberId, data } = props ?? {}; + + return updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut( + memberId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type UpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut + > + > + >; +export type UpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutMutationBody = + OrganizationMemberAccessUpdate; +export type UpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutMutationError = + HTTPValidationError; + +/** + * @summary Update Member Access + */ +export const useUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut + > + >, + TError, + { memberId: string; data: OrganizationMemberAccessUpdate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, + ): UseMutationResult< + Awaited< + ReturnType< + typeof updateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut + > + >, + TError, + { memberId: string; data: OrganizationMemberAccessUpdate }, + TContext + > => { + return useMutation( + getUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPutMutationOptions( + options, + ), + queryClient, + ); + }; +/** + * @summary List Org Invites + */ +export type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse200 = { + data: LimitOffsetPageTypeVarCustomizedOrganizationInviteRead; + status: 200; +}; + +export type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type listOrgInvitesApiV1OrganizationsMeInvitesGetResponseSuccess = + listOrgInvitesApiV1OrganizationsMeInvitesGetResponse200 & { + headers: Headers; + }; +export type listOrgInvitesApiV1OrganizationsMeInvitesGetResponseError = + listOrgInvitesApiV1OrganizationsMeInvitesGetResponse422 & { + headers: Headers; + }; + +export type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse = + | listOrgInvitesApiV1OrganizationsMeInvitesGetResponseSuccess + | listOrgInvitesApiV1OrganizationsMeInvitesGetResponseError; + +export const getListOrgInvitesApiV1OrganizationsMeInvitesGetUrl = ( + params?: ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/organizations/me/invites?${stringifiedParams}` + : `/api/v1/organizations/me/invites`; +}; + +export const listOrgInvitesApiV1OrganizationsMeInvitesGet = async ( + params?: ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, + options?: RequestInit, +): Promise => { + return customFetch( + getListOrgInvitesApiV1OrganizationsMeInvitesGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey = ( + params?: ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, +) => { + return [ + `/api/v1/organizations/me/invites`, + ...(params ? [params] : []), + ] as const; +}; + +export const getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listOrgInvitesApiV1OrganizationsMeInvitesGet(params, { + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListOrgInvitesApiV1OrganizationsMeInvitesGetQueryResult = + NonNullable< + Awaited> + >; +export type ListOrgInvitesApiV1OrganizationsMeInvitesGetQueryError = + HTTPValidationError; + +export function useListOrgInvitesApiV1OrganizationsMeInvitesGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: undefined | ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListOrgInvitesApiV1OrganizationsMeInvitesGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListOrgInvitesApiV1OrganizationsMeInvitesGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Org Invites + */ + +export function useListOrgInvitesApiV1OrganizationsMeInvitesGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListOrgInvitesApiV1OrganizationsMeInvitesGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Create Org Invite + */ +export type createOrgInviteApiV1OrganizationsMeInvitesPostResponse200 = { + data: OrganizationInviteRead; + status: 200; +}; + +export type createOrgInviteApiV1OrganizationsMeInvitesPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type createOrgInviteApiV1OrganizationsMeInvitesPostResponseSuccess = + createOrgInviteApiV1OrganizationsMeInvitesPostResponse200 & { + headers: Headers; + }; +export type createOrgInviteApiV1OrganizationsMeInvitesPostResponseError = + createOrgInviteApiV1OrganizationsMeInvitesPostResponse422 & { + headers: Headers; + }; + +export type createOrgInviteApiV1OrganizationsMeInvitesPostResponse = + | createOrgInviteApiV1OrganizationsMeInvitesPostResponseSuccess + | createOrgInviteApiV1OrganizationsMeInvitesPostResponseError; + +export const getCreateOrgInviteApiV1OrganizationsMeInvitesPostUrl = () => { + return `/api/v1/organizations/me/invites`; +}; + +export const createOrgInviteApiV1OrganizationsMeInvitesPost = async ( + organizationInviteCreate: OrganizationInviteCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getCreateOrgInviteApiV1OrganizationsMeInvitesPostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(organizationInviteCreate), + }, + ); +}; + +export const getCreateOrgInviteApiV1OrganizationsMeInvitesPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: OrganizationInviteCreate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited>, + TError, + { data: OrganizationInviteCreate }, + TContext + > => { + const mutationKey = ["createOrgInviteApiV1OrganizationsMeInvitesPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType + >, + { data: OrganizationInviteCreate } + > = (props) => { + const { data } = props ?? {}; + + return createOrgInviteApiV1OrganizationsMeInvitesPost( + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type CreateOrgInviteApiV1OrganizationsMeInvitesPostMutationResult = + NonNullable< + Awaited> + >; +export type CreateOrgInviteApiV1OrganizationsMeInvitesPostMutationBody = + OrganizationInviteCreate; +export type CreateOrgInviteApiV1OrganizationsMeInvitesPostMutationError = + HTTPValidationError; + +/** + * @summary Create Org Invite + */ +export const useCreateOrgInviteApiV1OrganizationsMeInvitesPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: OrganizationInviteCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: OrganizationInviteCreate }, + TContext +> => { + return useMutation( + getCreateOrgInviteApiV1OrganizationsMeInvitesPostMutationOptions(options), + queryClient, + ); +}; +/** + * @summary Revoke Org Invite + */ +export type revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponse200 = + { + data: OrganizationInviteRead; + status: 200; + }; + +export type revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponseSuccess = + revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponse200 & { + headers: Headers; + }; +export type revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponseError = + revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponse422 & { + headers: Headers; + }; + +export type revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponse = + | revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponseSuccess + | revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteResponseError; + +export const getRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteUrl = ( + inviteId: string, +) => { + return `/api/v1/organizations/me/invites/${inviteId}`; +}; + +export const revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete = async ( + inviteId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteUrl(inviteId), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete + > + >, + TError, + { inviteId: string }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete + > + >, + TError, + { inviteId: string }, + TContext + > => { + const mutationKey = [ + "revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete + > + >, + { inviteId: string } + > = (props) => { + const { inviteId } = props ?? {}; + + return revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete( + inviteId, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type RevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete + > + > + >; + +export type RevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteMutationError = + HTTPValidationError; + +/** + * @summary Revoke Org Invite + */ +export const useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof revokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete + > + >, + TError, + { inviteId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + TError, + { inviteId: string }, + TContext +> => { + return useMutation( + getRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDeleteMutationOptions( + options, + ), + queryClient, + ); +}; +/** + * @summary Accept Org Invite + */ +export type acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponse200 = { + data: OrganizationMemberRead; + status: 200; +}; + +export type acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponseSuccess = + acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponse200 & { + headers: Headers; + }; +export type acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponseError = + acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponse422 & { + headers: Headers; + }; + +export type acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponse = + | acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponseSuccess + | acceptOrgInviteApiV1OrganizationsInvitesAcceptPostResponseError; + +export const getAcceptOrgInviteApiV1OrganizationsInvitesAcceptPostUrl = () => { + return `/api/v1/organizations/invites/accept`; +}; + +export const acceptOrgInviteApiV1OrganizationsInvitesAcceptPost = async ( + organizationInviteAccept: OrganizationInviteAccept, + options?: RequestInit, +): Promise => { + return customFetch( + getAcceptOrgInviteApiV1OrganizationsInvitesAcceptPostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(organizationInviteAccept), + }, + ); +}; + +export const getAcceptOrgInviteApiV1OrganizationsInvitesAcceptPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: OrganizationInviteAccept }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: OrganizationInviteAccept }, + TContext + > => { + const mutationKey = ["acceptOrgInviteApiV1OrganizationsInvitesAcceptPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType + >, + { data: OrganizationInviteAccept } + > = (props) => { + const { data } = props ?? {}; + + return acceptOrgInviteApiV1OrganizationsInvitesAcceptPost( + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type AcceptOrgInviteApiV1OrganizationsInvitesAcceptPostMutationResult = + NonNullable< + Awaited< + ReturnType + > + >; +export type AcceptOrgInviteApiV1OrganizationsInvitesAcceptPostMutationBody = + OrganizationInviteAccept; +export type AcceptOrgInviteApiV1OrganizationsInvitesAcceptPostMutationError = + HTTPValidationError; + +/** + * @summary Accept Org Invite + */ +export const useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: OrganizationInviteAccept }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + TError, + { data: OrganizationInviteAccept }, + TContext +> => { + return useMutation( + getAcceptOrgInviteApiV1OrganizationsInvitesAcceptPostMutationOptions( + options, + ), + queryClient, + ); +}; diff --git a/frontend/src/api/generated/souls-directory/souls-directory.ts b/frontend/src/api/generated/souls-directory/souls-directory.ts new file mode 100644 index 00000000..d775353b --- /dev/null +++ b/frontend/src/api/generated/souls-directory/souls-directory.ts @@ -0,0 +1,727 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + HTTPValidationError, + SearchApiV1SoulsDirectorySearchGetParams, + SoulsDirectoryMarkdownResponse, + SoulsDirectorySearchResponse, +} from ".././model"; + +import { customFetch } from "../../mutator"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * @summary Search + */ +export type searchApiV1SoulsDirectorySearchGetResponse200 = { + data: SoulsDirectorySearchResponse; + status: 200; +}; + +export type searchApiV1SoulsDirectorySearchGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type searchApiV1SoulsDirectorySearchGetResponseSuccess = + searchApiV1SoulsDirectorySearchGetResponse200 & { + headers: Headers; + }; +export type searchApiV1SoulsDirectorySearchGetResponseError = + searchApiV1SoulsDirectorySearchGetResponse422 & { + headers: Headers; + }; + +export type searchApiV1SoulsDirectorySearchGetResponse = + | searchApiV1SoulsDirectorySearchGetResponseSuccess + | searchApiV1SoulsDirectorySearchGetResponseError; + +export const getSearchApiV1SoulsDirectorySearchGetUrl = ( + params?: SearchApiV1SoulsDirectorySearchGetParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/souls-directory/search?${stringifiedParams}` + : `/api/v1/souls-directory/search`; +}; + +export const searchApiV1SoulsDirectorySearchGet = async ( + params?: SearchApiV1SoulsDirectorySearchGetParams, + options?: RequestInit, +): Promise => { + return customFetch( + getSearchApiV1SoulsDirectorySearchGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getSearchApiV1SoulsDirectorySearchGetQueryKey = ( + params?: SearchApiV1SoulsDirectorySearchGetParams, +) => { + return [ + `/api/v1/souls-directory/search`, + ...(params ? [params] : []), + ] as const; +}; + +export const getSearchApiV1SoulsDirectorySearchGetQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: SearchApiV1SoulsDirectorySearchGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getSearchApiV1SoulsDirectorySearchGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + searchApiV1SoulsDirectorySearchGet(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type SearchApiV1SoulsDirectorySearchGetQueryResult = NonNullable< + Awaited> +>; +export type SearchApiV1SoulsDirectorySearchGetQueryError = HTTPValidationError; + +export function useSearchApiV1SoulsDirectorySearchGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | SearchApiV1SoulsDirectorySearchGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useSearchApiV1SoulsDirectorySearchGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: SearchApiV1SoulsDirectorySearchGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useSearchApiV1SoulsDirectorySearchGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: SearchApiV1SoulsDirectorySearchGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Search + */ + +export function useSearchApiV1SoulsDirectorySearchGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: SearchApiV1SoulsDirectorySearchGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getSearchApiV1SoulsDirectorySearchGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get Markdown + */ +export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse200 = { + data: SoulsDirectoryMarkdownResponse; + status: 200; +}; + +export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseSuccess = + getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse200 & { + headers: Headers; + }; +export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseError = + getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse422 & { + headers: Headers; + }; + +export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse = + | getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseSuccess + | getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseError; + +export const getGetMarkdownApiV1SoulsDirectoryHandleSlugGetUrl = ( + handle: string, + slug: string, +) => { + return `/api/v1/souls-directory/${handle}/${slug}`; +}; + +export const getMarkdownApiV1SoulsDirectoryHandleSlugGet = async ( + handle: string, + slug: string, + options?: RequestInit, +): Promise => { + return customFetch( + getGetMarkdownApiV1SoulsDirectoryHandleSlugGetUrl(handle, slug), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryKey = ( + handle: string, + slug: string, +) => { + return [`/api/v1/souls-directory/${handle}/${slug}`] as const; +}; + +export const getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryKey(handle, slug); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getMarkdownApiV1SoulsDirectoryHandleSlugGet(handle, slug, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!(handle && slug), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryResult = + NonNullable< + Awaited> + >; +export type GetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryError = + HTTPValidationError; + +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Markdown + */ + +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryOptions( + handle, + slug, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get Markdown + */ +export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse200 = { + data: SoulsDirectoryMarkdownResponse; + status: 200; +}; + +export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseSuccess = + getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse200 & { + headers: Headers; + }; +export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseError = + getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse422 & { + headers: Headers; + }; + +export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse = + | getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseSuccess + | getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseError; + +export const getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetUrl = ( + handle: string, + slug: string, +) => { + return `/api/v1/souls-directory/${handle}/${slug}.md`; +}; + +export const getMarkdownApiV1SoulsDirectoryHandleSlugMdGet = async ( + handle: string, + slug: string, + options?: RequestInit, +): Promise => { + return customFetch( + getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetUrl(handle, slug), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryKey = ( + handle: string, + slug: string, +) => { + return [`/api/v1/souls-directory/${handle}/${slug}.md`] as const; +}; + +export const getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryKey(handle, slug); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getMarkdownApiV1SoulsDirectoryHandleSlugMdGet(handle, slug, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!(handle && slug), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryResult = + NonNullable< + Awaited> + >; +export type GetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryError = + HTTPValidationError; + +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Markdown + */ + +export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + handle: string, + slug: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryOptions( + handle, + slug, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx index eca7caba..e2daf9f7 100644 --- a/frontend/src/app/agents/[agentId]/page.tsx +++ b/frontend/src/app/agents/[agentId]/page.tsx @@ -22,6 +22,10 @@ import { type listBoardsApiV1BoardsGetResponse, useListBoardsApiV1BoardsGet, } from "@/api/generated/boards/boards"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import type { ActivityEventRead, AgentRead, @@ -80,6 +84,20 @@ export default function AgentDetailPage() { const agentIdParam = params?.agentId; const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam; + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; + const [deleteOpen, setDeleteOpen] = useState(false); const [deleteError, setDeleteError] = useState(null); @@ -88,7 +106,7 @@ export default function AgentDetailPage() { ApiError >(agentId ?? "", { query: { - enabled: Boolean(isSignedIn && agentId), + enabled: Boolean(isSignedIn && isAdmin && agentId), refetchInterval: 30_000, refetchOnMount: "always", retry: false, @@ -102,7 +120,7 @@ export default function AgentDetailPage() { { limit: 200 }, { query: { - enabled: Boolean(isSignedIn), + enabled: Boolean(isSignedIn && isAdmin), refetchInterval: 30_000, retry: false, }, @@ -114,7 +132,7 @@ export default function AgentDetailPage() { ApiError >(undefined, { query: { - enabled: Boolean(isSignedIn), + enabled: Boolean(isSignedIn && isAdmin), refetchInterval: 60_000, refetchOnMount: "always", retry: false, @@ -186,7 +204,14 @@ export default function AgentDetailPage() { -
+ {!isAdmin ? ( +
+
+ Only organization owners and admins can access agents. +
+
+ ) : ( +

@@ -371,7 +396,8 @@ export default function AgentDetailPage() { Agent not found.

)} -
+
+ )} diff --git a/frontend/src/app/agents/new/page.tsx b/frontend/src/app/agents/new/page.tsx index 3c25878f..57caa47f 100644 --- a/frontend/src/app/agents/new/page.tsx +++ b/frontend/src/app/agents/new/page.tsx @@ -13,6 +13,10 @@ import { useListBoardsApiV1BoardsGet, } from "@/api/generated/boards/boards"; import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import type { BoardRead } from "@/api/generated/model"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; @@ -80,6 +84,20 @@ export default function NewAgentPage() { const router = useRouter(); const { isSignedIn } = useAuth(); + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; + const [name, setName] = useState(""); const [boardId, setBoardId] = useState(""); const [heartbeatEvery, setHeartbeatEvery] = useState("10m"); @@ -95,7 +113,7 @@ export default function NewAgentPage() { ApiError >(undefined, { query: { - enabled: Boolean(isSignedIn), + enabled: Boolean(isSignedIn && isAdmin), refetchOnMount: "always", }, }); @@ -182,15 +200,20 @@ export default function NewAgentPage() {
-
-
-

- Basic configuration -

-
+ {!isAdmin ? ( +
+ Only organization owners and admins can create agents. +
+ ) : ( + +
+

+ Basic configuration +

+
+ )}
diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index d3e30e90..f83a52a4 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -42,6 +42,10 @@ import { getListBoardsApiV1BoardsGetQueryKey, useListBoardsApiV1BoardsGet, } from "@/api/generated/boards/boards"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import type { AgentRead } from "@/api/generated/model"; const parseTimestamp = (value?: string | null) => { @@ -88,6 +92,20 @@ export default function AgentsPage() { const queryClient = useQueryClient(); const router = useRouter(); + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; + const [sorting, setSorting] = useState([ { id: "name", desc: false }, ]); @@ -102,7 +120,7 @@ export default function AgentsPage() { ApiError >(undefined, { query: { - enabled: Boolean(isSignedIn), + enabled: Boolean(isSignedIn && isAdmin), refetchInterval: 30_000, refetchOnMount: "always", }, @@ -113,7 +131,7 @@ export default function AgentsPage() { ApiError >(undefined, { query: { - enabled: Boolean(isSignedIn), + enabled: Boolean(isSignedIn && isAdmin), refetchInterval: 15_000, refetchOnMount: "always", }, @@ -323,9 +341,15 @@ export default function AgentsPage() {
-
-
- + {!isAdmin ? ( +
+ Only organization owners and admins can access agents. +
+ ) : ( + <> +
+
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -409,11 +433,13 @@ export default function AgentsPage() { - {agentsQuery.error ? ( -

- {agentsQuery.error.message} -

- ) : null} + {agentsQuery.error ? ( +

+ {agentsQuery.error.message} +

+ ) : null} + + )} diff --git a/frontend/src/app/board-groups/[groupId]/page.tsx b/frontend/src/app/board-groups/[groupId]/page.tsx index cf59731a..187e9494 100644 --- a/frontend/src/app/board-groups/[groupId]/page.tsx +++ b/frontend/src/app/board-groups/[groupId]/page.tsx @@ -27,9 +27,14 @@ import { streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet, useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet, } from "@/api/generated/board-group-memory/board-group-memory"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import type { BoardGroupHeartbeatApplyResult, BoardGroupMemoryRead, + OrganizationMemberRead, } from "@/api/generated/model"; import type { BoardGroupBoardSnapshot } from "@/api/generated/model"; import { Markdown } from "@/components/atoms/Markdown"; @@ -96,6 +101,18 @@ const priorityTone = (value?: string | null) => { const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) => snapshot.task_counts?.[key] ?? 0; +const canWriteGroupBoards = ( + member: OrganizationMemberRead | null, + boardIds: Set, +) => { + if (!member) return false; + if (member.all_boards_write) return true; + if (!member.board_access || boardIds.size === 0) return false; + return member.board_access.some( + (access) => access.can_write && boardIds.has(access.board_id), + ); +}; + function GroupChatMessageCard({ message }: { message: BoardGroupMemoryRead }) { return (
@@ -215,6 +232,34 @@ export default function BoardGroupDetailPage() { snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null; const group = snapshot?.group ?? null; const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]); + const boardIdSet = useMemo(() => { + const ids = new Set(); + boards.forEach((item) => { + if (item.board?.id) { + ids.add(item.board.id); + } + }); + return ids; + }, [boards]); + + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + }, + }); + + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member?.role === "admin" || member?.role === "owner"; + const canWriteGroup = useMemo( + () => canWriteGroupBoards(member, boardIdSet), + [boardIdSet, member], + ); + const canManageHeartbeat = Boolean(isAdmin && canWriteGroup); const chatHistoryQuery = useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet< @@ -554,6 +599,10 @@ export default function BoardGroupDetailPage() { setChatError("Sign in to send messages."); return false; } + if (!canWriteGroup) { + setChatError("Read-only access. You cannot post group messages."); + return false; + } const trimmed = content.trim(); if (!trimmed) return false; @@ -583,7 +632,7 @@ export default function BoardGroupDetailPage() { setIsChatSending(false); } }, - [chatBroadcast, groupId, isSignedIn, mergeChatMessages], + [canWriteGroup, chatBroadcast, groupId, isSignedIn, mergeChatMessages], ); const sendGroupNote = useCallback( @@ -592,6 +641,10 @@ export default function BoardGroupDetailPage() { setNoteSendError("Sign in to post."); return false; } + if (!canWriteGroup) { + setNoteSendError("Read-only access. You cannot post notes."); + return false; + } const trimmed = content.trim(); if (!trimmed) return false; @@ -621,7 +674,7 @@ export default function BoardGroupDetailPage() { setIsNoteSending(false); } }, - [groupId, isSignedIn, mergeNotesMessages, notesBroadcast], + [canWriteGroup, groupId, isSignedIn, mergeNotesMessages, notesBroadcast], ); const applyHeartbeat = useCallback(async () => { @@ -629,6 +682,10 @@ export default function BoardGroupDetailPage() { setHeartbeatApplyError("Sign in to apply."); return; } + if (!canManageHeartbeat) { + setHeartbeatApplyError("Read-only access. You cannot change agent pace."); + return; + } const trimmed = heartbeatEvery.trim(); if (!trimmed) { setHeartbeatApplyError("Heartbeat cadence is required."); @@ -653,7 +710,7 @@ export default function BoardGroupDetailPage() { } finally { setIsHeartbeatApplying(false); } - }, [groupId, heartbeatEvery, includeBoardLeads, isSignedIn]); + }, [canManageHeartbeat, groupId, heartbeatEvery, includeBoardLeads, isSignedIn]); return ( @@ -793,7 +850,9 @@ export default function BoardGroupDetailPage() { heartbeatEvery === value ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900", + !canManageHeartbeat && "opacity-50 cursor-not-allowed", )} + disabled={!canManageHeartbeat} onClick={() => { setHeartbeatAmount(String(preset.amount)); setHeartbeatUnit(preset.unit); @@ -812,19 +871,25 @@ export default function BoardGroupDetailPage() { heartbeatEvery ? "border-slate-200" : "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100", + !canManageHeartbeat && "opacity-60 cursor-not-allowed", )} placeholder="10" inputMode="numeric" type="number" min={1} step={1} + disabled={!canManageHeartbeat} /> setEditStatus(value as TaskStatus)} - disabled={!selectedTask || isSavingTask} + disabled={!selectedTask || isSavingTask || !canWrite} > @@ -3096,7 +3260,7 @@ export default function BoardDetailPage() { + setName(event.target.value)} - placeholder="e.g. Release operations" - disabled={isLoading} - /> + {!isAdmin ? ( +
+ Only organization owners and admins can create boards. +
+ ) : ( +
+
+
+
+ + setName(event.target.value)} + placeholder="e.g. Release operations" + disabled={isLoading} + /> +
+
+ + +
-
- - + +
+
+ + +

+ Optional. Groups increase cross-board visibility. +

+
-
-
- - -

- Optional. Groups increase cross-board visibility. + {gateways.length === 0 ? ( +

+

+ No gateways available. Create one in{" "} + + Gateways + {" "} + to continue.

+ ) : null} + + {errorMessage ? ( +

{errorMessage}

+ ) : null} + +
+ +
-
- - {gateways.length === 0 ? ( -
-

- No gateways available. Create one in{" "} - - Gateways - {" "} - to continue. -

-
- ) : null} - - {errorMessage ? ( -

{errorMessage}

- ) : null} - -
- - -
+ )}
diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx index be6cc70b..324e5841 100644 --- a/frontend/src/app/boards/page.tsx +++ b/frontend/src/app/boards/page.tsx @@ -25,6 +25,10 @@ import { type listBoardGroupsApiV1BoardGroupsGetResponse, useListBoardGroupsApiV1BoardGroupsGet, } from "@/api/generated/board-groups/board-groups"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import type { BoardGroupRead, BoardRead } from "@/api/generated/model"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; @@ -56,6 +60,20 @@ const compactId = (value: string) => export default function BoardsPage() { const { isSignedIn } = useAuth(); const queryClient = useQueryClient(); + + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; const [deleteTarget, setDeleteTarget] = useState(null); const boardsKey = getListBoardsApiV1BoardsGetQueryKey(); @@ -264,7 +282,7 @@ export default function BoardsPage() { {boards.length === 1 ? "" : "s"} total.

- {boards.length > 0 ? ( + {boards.length > 0 && isAdmin ? ( ({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; + const [name, setName] = useState(undefined); const [gatewayUrl, setGatewayUrl] = useState(undefined); const [gatewayToken, setGatewayToken] = useState( @@ -77,7 +95,7 @@ export default function EditGatewayPage() { ApiError >(gatewayId ?? "", { query: { - enabled: Boolean(isSignedIn && gatewayId), + enabled: Boolean(isSignedIn && isAdmin && gatewayId), refetchOnMount: "always", retry: false, }, @@ -230,21 +248,26 @@ export default function EditGatewayPage() {
-
-
- - setName(event.target.value)} - placeholder="Primary gateway" - disabled={isLoading} - /> + {!isAdmin ? ( +
+ Only organization owners and admins can edit gateways.
+ ) : ( + +
+ + setName(event.target.value)} + placeholder="Primary gateway" + disabled={isLoading} + /> +
@@ -361,6 +384,7 @@ export default function EditGatewayPage() {
+ )}
diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx index 4e04786e..9fa10156 100644 --- a/frontend/src/app/gateways/[gatewayId]/page.tsx +++ b/frontend/src/app/gateways/[gatewayId]/page.tsx @@ -18,6 +18,10 @@ import { type listAgentsApiV1AgentsGetResponse, useListAgentsApiV1AgentsGet, } from "@/api/generated/agents/agents"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; @@ -49,12 +53,26 @@ export default function GatewayDetailPage() { ? gatewayIdParam[0] : gatewayIdParam; + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; + const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet< getGatewayApiV1GatewaysGatewayIdGetResponse, ApiError >(gatewayId ?? "", { query: { - enabled: Boolean(isSignedIn && gatewayId), + enabled: Boolean(isSignedIn && isAdmin && gatewayId), refetchInterval: 30_000, }, }); @@ -67,7 +85,7 @@ export default function GatewayDetailPage() { ApiError >(gatewayId ? { gateway_id: gatewayId } : undefined, { query: { - enabled: Boolean(isSignedIn && gatewayId), + enabled: Boolean(isSignedIn && isAdmin && gatewayId), refetchInterval: 15_000, }, }); @@ -85,7 +103,7 @@ export default function GatewayDetailPage() { ApiError >(statusParams, { query: { - enabled: Boolean(isSignedIn && statusParams), + enabled: Boolean(isSignedIn && isAdmin && statusParams), refetchInterval: 15_000, }, }); @@ -142,7 +160,7 @@ export default function GatewayDetailPage() { > Back to gateways - {gatewayId ? ( + {isAdmin && gatewayId ? (
- {gatewayQuery.isLoading ? ( + {!isAdmin ? ( +
+ Only organization owners and admins can access gateways. +
+ ) : gatewayQuery.isLoading ? (
Loading gateway…
diff --git a/frontend/src/app/gateways/new/page.tsx b/frontend/src/app/gateways/new/page.tsx index 1483b571..df201ad6 100644 --- a/frontend/src/app/gateways/new/page.tsx +++ b/frontend/src/app/gateways/new/page.tsx @@ -13,6 +13,10 @@ import { gatewaysStatusApiV1GatewaysStatusGet, useCreateGatewayApiV1GatewaysPost, } from "@/api/generated/gateways/gateways"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; @@ -42,6 +46,20 @@ export default function NewGatewayPage() { const { isSignedIn } = useAuth(); const router = useRouter(); + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; + const [name, setName] = useState(""); const [gatewayUrl, setGatewayUrl] = useState(""); const [gatewayToken, setGatewayToken] = useState(""); @@ -191,21 +209,26 @@ export default function NewGatewayPage() {
-
-
- - setName(event.target.value)} - placeholder="Primary gateway" - disabled={isLoading} - /> + {!isAdmin ? ( +
+ Only organization owners and admins can create gateways.
+ ) : ( + +
+ + setName(event.target.value)} + placeholder="Primary gateway" + disabled={isLoading} + /> +
@@ -320,6 +343,7 @@ export default function NewGatewayPage() {
+ )}
diff --git a/frontend/src/app/gateways/page.tsx b/frontend/src/app/gateways/page.tsx index b852f4a5..cc5821a8 100644 --- a/frontend/src/app/gateways/page.tsx +++ b/frontend/src/app/gateways/page.tsx @@ -35,6 +35,10 @@ import { useDeleteGatewayApiV1GatewaysGatewayIdDelete, useListGatewaysApiV1GatewaysGet, } from "@/api/generated/gateways/gateways"; +import { + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + useGetMyMembershipApiV1OrganizationsMeMemberGet, +} from "@/api/generated/organizations/organizations"; import type { GatewayRead } from "@/api/generated/model"; const truncate = (value?: string | null, max = 24) => { @@ -58,6 +62,20 @@ const formatTimestamp = (value?: string | null) => { export default function GatewaysPage() { const { isSignedIn } = useAuth(); const queryClient = useQueryClient(); + + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; const [sorting, setSorting] = useState([ { id: "name", desc: false }, ]); @@ -69,7 +87,7 @@ export default function GatewaysPage() { ApiError >(undefined, { query: { - enabled: Boolean(isSignedIn), + enabled: Boolean(isSignedIn && isAdmin), refetchInterval: 30_000, refetchOnMount: "always", }, @@ -240,7 +258,7 @@ export default function GatewaysPage() { Manage OpenClaw gateway connections used by boards

- {gateways.length > 0 ? ( + {isAdmin && gateways.length > 0 ? (
-
-
-
+ {!isAdmin ? ( +
+ Only organization owners and admins can access gateways. +
+ ) : ( + <> +
+
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -347,11 +371,13 @@ export default function GatewaysPage() { - {gatewaysQuery.error ? ( -

- {gatewaysQuery.error.message} -

- ) : null} + {gatewaysQuery.error ? ( +

+ {gatewaysQuery.error.message} +

+ ) : null} + + )} diff --git a/frontend/src/app/invite/page.tsx b/frontend/src/app/invite/page.tsx new file mode 100644 index 00000000..7d0a7b2e --- /dev/null +++ b/frontend/src/app/invite/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; + +import { ApiError } from "@/api/mutator"; +import { useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost } from "@/api/generated/organizations/organizations"; +import { BrandMark } from "@/components/atoms/BrandMark"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +export default function InvitePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { isSignedIn } = useAuth(); + + const tokenFromQuery = (searchParams.get("token") ?? "").trim(); + const [token, setToken] = useState(tokenFromQuery); + const [error, setError] = useState(null); + const [accepted, setAccepted] = useState(false); + + useEffect(() => { + setToken(tokenFromQuery); + }, [tokenFromQuery]); + + const acceptInviteMutation = + useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost({ + mutation: { + onSuccess: (result) => { + if (result.status === 200) { + setAccepted(true); + setError(null); + setTimeout(() => router.push("/organization"), 800); + } + }, + onError: (err) => { + setError(err.message || "Unable to accept invite."); + }, + }, + }); + + const handleAccept = (event?: React.FormEvent) => { + event?.preventDefault(); + if (!isSignedIn) return; + const trimmed = token.trim(); + if (!trimmed) { + setError("Invite token is required."); + return; + } + setError(null); + acceptInviteMutation.mutate({ data: { token: trimmed } }); + }; + + const isSubmitting = acceptInviteMutation.isPending; + const isReady = Boolean(token.trim()); + const helperText = useMemo(() => { + if (accepted) { + return "Invite accepted. Redirecting to your organization…"; + } + if (!token.trim()) { + return "Paste the invite token or open the invite link you were sent."; + } + return "Accept the invite to join the organization."; + }, [accepted, token]); + + return ( +
+
+
+ +
+
+ +
+
+
+

+ Organization Invite +

+

+ Join your team in OpenClaw +

+

{helperText}

+
+ +
+ + setToken(event.target.value)} + placeholder="Paste invite token" + disabled={accepted || isSubmitting} + /> + + {error ? ( +
+ {error} +
+ ) : null} + + +
+

Sign in to accept your invite.

+ + + +
+
+ + +
+ + + +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/organization/page.tsx b/frontend/src/app/organization/page.tsx new file mode 100644 index 00000000..88deebab --- /dev/null +++ b/frontend/src/app/organization/page.tsx @@ -0,0 +1,1131 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useEffect, useMemo, useState } from "react"; + +import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; +import { useQueryClient } from "@tanstack/react-query"; +import { Building2, Copy, UserPlus, Users } from "lucide-react"; + +import { ApiError } from "@/api/mutator"; +import { + type listBoardsApiV1BoardsGetResponse, + useListBoardsApiV1BoardsGet, +} from "@/api/generated/boards/boards"; +import { + type getMyOrgApiV1OrganizationsMeGetResponse, + type getMyMembershipApiV1OrganizationsMeMemberGetResponse, + type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse, + type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse, + type listOrgMembersApiV1OrganizationsMeMembersGetResponse, + getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey, + getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey, + getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey, + useCreateOrgInviteApiV1OrganizationsMeInvitesPost, + useGetMyOrgApiV1OrganizationsMeGet, + useGetMyMembershipApiV1OrganizationsMeMemberGet, + useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet, + useListOrgInvitesApiV1OrganizationsMeInvitesGet, + useListOrgMembersApiV1OrganizationsMeMembersGet, + useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete, + useUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut, + useUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch, +} from "@/api/generated/organizations/organizations"; +import type { + BoardRead, + OrganizationBoardAccessSpec, + OrganizationInviteRead, + OrganizationMemberRead, +} from "@/api/generated/model"; +import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DashboardShell } from "@/components/templates/DashboardShell"; +import { cn } from "@/lib/utils"; + +const formatTimestamp = (value?: string | null) => { + if (!value) return "—"; + const date = new Date(`${value}${value.endsWith("Z") ? "" : "Z"}`); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +type AccessScope = "all" | "custom"; + +type BoardAccessState = Record; + +const buildAccessList = (access: BoardAccessState): OrganizationBoardAccessSpec[] => + Object.entries(access) + .filter(([, entry]) => entry.read || entry.write) + .map(([boardId, entry]) => ({ + board_id: boardId, + can_read: entry.read || entry.write, + can_write: entry.write, + })); + +const summarizeAccess = (allRead: boolean, allWrite: boolean) => { + if (allRead || allWrite) { + if (allRead && allWrite) return "All boards: read + write"; + if (allWrite) return "All boards: write"; + return "All boards: read"; + } + return "Selected boards"; +}; + +const roleBadgeVariant = (role: string) => { + if (role === "admin" || role === "owner") return "accent" as const; + return "outline" as const; +}; + +const defaultBoardAccess: BoardAccessState = {}; + +const initialsFrom = (value?: string | null) => { + if (!value) return "?"; + const parts = value.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); +}; + +function BoardAccessEditor({ + boards, + scope, + onScopeChange, + allRead, + allWrite, + onAllReadChange, + onAllWriteChange, + access, + onAccessChange, + disabled, + emptyMessage, +}: { + boards: BoardRead[]; + scope: AccessScope; + onScopeChange: (scope: AccessScope) => void; + allRead: boolean; + allWrite: boolean; + onAllReadChange: (next: boolean) => void; + onAllWriteChange: (next: boolean) => void; + access: BoardAccessState; + onAccessChange: (next: BoardAccessState) => void; + disabled?: boolean; + emptyMessage?: string; +}) { + const handleAllReadToggle = () => { + if (disabled) return; + const next = !allRead; + onAllReadChange(next); + if (!next && allWrite) { + onAllWriteChange(false); + } + }; + + const handleAllWriteToggle = () => { + if (disabled) return; + const next = !allWrite; + onAllWriteChange(next); + if (next && !allRead) { + onAllReadChange(true); + } + }; + + const updateBoardAccess = ( + boardId: string, + next: { read: boolean; write: boolean }, + ) => { + onAccessChange({ + ...access, + [boardId]: { + read: next.read || next.write, + write: next.write, + }, + }); + }; + + const handleBoardReadToggle = (boardId: string) => { + if (disabled) return; + const current = access[boardId] ?? { read: false, write: false }; + const nextRead = !current.read; + const nextWrite = nextRead ? current.write : false; + updateBoardAccess(boardId, { read: nextRead, write: nextWrite }); + }; + + const handleBoardWriteToggle = (boardId: string) => { + if (disabled) return; + const current = access[boardId] ?? { read: false, write: false }; + const nextWrite = !current.write; + const nextRead = nextWrite ? true : current.read; + updateBoardAccess(boardId, { read: nextRead, write: nextWrite }); + }; + + return ( +
+
+

+ Board access +

+
+ + +
+
+ + {scope === "all" ? ( +
+ + + + Write access implies read permissions. + +
+ ) : ( +
+ {boards.length === 0 ? ( +
+ {emptyMessage ?? "No boards available yet."} +
+ ) : ( +
+
+ + + + + + + + + {boards.map((board) => { + const entry = access[board.id] ?? { + read: false, + write: false, + }; + return ( + + + + + + ); + })} + +
BoardReadWrite
+
+ {board.name} +
+
+ {board.slug} +
+
+ handleBoardReadToggle(board.id)} + disabled={disabled} + /> + + handleBoardWriteToggle(board.id)} + disabled={disabled} + /> +
+
+ )} +
+ )} +
+ ); +} + +export default function OrganizationPage() { + const { isSignedIn } = useAuth(); + const queryClient = useQueryClient(); + + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [inviteScope, setInviteScope] = useState("all"); + const [inviteAllRead, setInviteAllRead] = useState(true); + const [inviteAllWrite, setInviteAllWrite] = useState(false); + const [inviteAccess, setInviteAccess] = useState( + defaultBoardAccess, + ); + const [inviteError, setInviteError] = useState(null); + const [copiedInviteId, setCopiedInviteId] = useState(null); + + const [accessDialogOpen, setAccessDialogOpen] = useState(false); + const [activeMemberId, setActiveMemberId] = useState(null); + const [accessScope, setAccessScope] = useState("all"); + const [accessAllRead, setAccessAllRead] = useState(false); + const [accessAllWrite, setAccessAllWrite] = useState(false); + const [accessRole, setAccessRole] = useState("member"); + const [accessMap, setAccessMap] = useState( + defaultBoardAccess, + ); + const [accessError, setAccessError] = useState(null); + + const orgQuery = useGetMyOrgApiV1OrganizationsMeGet< + getMyOrgApiV1OrganizationsMeGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + }, + }); + + const membersQuery = useListOrgMembersApiV1OrganizationsMeMembersGet< + listOrgMembersApiV1OrganizationsMeMembersGetResponse, + ApiError + >( + { limit: 200 }, + { + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + }, + }, + ); + + const boardsQuery = useListBoardsApiV1BoardsGet< + listBoardsApiV1BoardsGetResponse, + ApiError + >( + { limit: 200 }, + { + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + }, + }, + ); + + const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< + getMyMembershipApiV1OrganizationsMeMemberGetResponse, + ApiError + >({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + }, + }); + + const isAdmin = + membershipQuery.data?.status === 200 && + (membershipQuery.data.data.role === "admin" || + membershipQuery.data.data.role === "owner"); + + const invitesQuery = useListOrgInvitesApiV1OrganizationsMeInvitesGet< + listOrgInvitesApiV1OrganizationsMeInvitesGetResponse, + ApiError + >( + { limit: 200 }, + { + query: { + enabled: Boolean(isSignedIn && isAdmin), + refetchOnMount: "always", + retry: false, + }, + }, + ); + + const members = useMemo(() => { + if (membersQuery.data?.status !== 200) return []; + return membersQuery.data.data.items ?? []; + }, [membersQuery.data]); + + const invites = useMemo(() => { + if (invitesQuery.data?.status !== 200) return []; + return invitesQuery.data.data.items ?? []; + }, [invitesQuery.data]); + + const boards = useMemo(() => { + if (boardsQuery.data?.status !== 200) return []; + return boardsQuery.data.data.items ?? []; + }, [boardsQuery.data]); + + const memberDetailsQuery = + useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet< + getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse, + ApiError + >(activeMemberId ?? "", { + query: { + enabled: Boolean(activeMemberId && accessDialogOpen), + }, + }); + + const createInviteMutation = useCreateOrgInviteApiV1OrganizationsMeInvitesPost< + ApiError + >({ + mutation: { + onSuccess: (result) => { + if (result.status === 200) { + setInviteEmail(""); + setInviteRole("member"); + setInviteScope("all"); + setInviteAllRead(true); + setInviteAllWrite(false); + setInviteAccess(defaultBoardAccess); + setInviteError(null); + queryClient.invalidateQueries({ + queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey({ + limit: 200, + }), + }); + setInviteDialogOpen(false); + } + }, + onError: (err) => { + setInviteError(err.message || "Unable to create invite."); + }, + }, + }); + + const revokeInviteMutation = + useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete({ + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey({ + limit: 200, + }), + }); + }, + }, + }); + + const updateMemberAccessMutation = + useUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut( + { + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey({ + limit: 200, + }), + }); + if (activeMemberId) { + queryClient.invalidateQueries({ + queryKey: + getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey( + activeMemberId, + ), + }); + } + }, + }, + }, + ); + + const updateMemberRoleMutation = + useUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch({ + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey({ + limit: 200, + }), + }); + }, + }, + }); + + useEffect(() => { + if (memberDetailsQuery.data?.status !== 200) return; + const member = memberDetailsQuery.data.data; + setAccessRole(member.role); + const isAll = member.all_boards_read || member.all_boards_write; + setAccessScope(isAll ? "all" : "custom"); + setAccessAllRead(member.all_boards_read); + setAccessAllWrite(member.all_boards_write); + const nextAccess: BoardAccessState = {}; + for (const entry of member.board_access ?? []) { + nextAccess[entry.board_id] = { + read: entry.can_read || entry.can_write, + write: entry.can_write, + }; + } + setAccessMap(nextAccess); + setAccessError(null); + }, [memberDetailsQuery.data]); + + useEffect(() => { + if (!accessDialogOpen) { + setActiveMemberId(null); + setAccessError(null); + } + }, [accessDialogOpen]); + + useEffect(() => { + if (!inviteDialogOpen) { + setInviteError(null); + } + }, [inviteDialogOpen]); + + const orgName = + orgQuery.data?.status === 200 + ? orgQuery.data.data.name + : "Organization"; + + const handleInviteSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!isSignedIn || !isAdmin) return; + + const trimmedEmail = inviteEmail.trim().toLowerCase(); + if (!trimmedEmail || !trimmedEmail.includes("@")) { + setInviteError("Enter a valid email address."); + return; + } + + const hasAllAccess = + inviteScope === "all" && (inviteAllRead || inviteAllWrite); + const inviteAccessList = buildAccessList(inviteAccess); + const hasCustomAccess = + inviteScope === "custom" && inviteAccessList.length > 0; + + if (!hasAllAccess && !hasCustomAccess) { + setInviteError("Select read or write access for at least one board."); + return; + } + + setInviteError(null); + createInviteMutation.mutate({ + data: { + invited_email: trimmedEmail, + role: inviteRole, + all_boards_read: inviteScope === "all" ? inviteAllRead : false, + all_boards_write: inviteScope === "all" ? inviteAllWrite : false, + board_access: inviteScope === "custom" ? inviteAccessList : [], + }, + }); + }; + + const handleCopyInvite = async (invite: OrganizationInviteRead) => { + try { + const baseUrl = + typeof window !== "undefined" ? window.location.origin : ""; + const inviteUrl = baseUrl + ? `${baseUrl}/invite?token=${invite.token}` + : invite.token; + let copied = false; + + if (typeof navigator !== "undefined" && navigator.clipboard) { + try { + await navigator.clipboard.writeText(inviteUrl); + copied = true; + } catch { + copied = false; + } + } + + if (!copied && typeof document !== "undefined") { + const textarea = document.createElement("textarea"); + textarea.value = inviteUrl; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + copied = document.execCommand("copy"); + document.body.removeChild(textarea); + } + + if (copied) { + setCopiedInviteId(invite.id); + setTimeout(() => setCopiedInviteId(null), 2000); + return; + } + + if (typeof window !== "undefined") { + window.prompt("Copy invite link:", inviteUrl); + } + } catch { + setCopiedInviteId(null); + } + }; + + const openAccessDialog = (memberId: string) => { + setActiveMemberId(memberId); + setAccessDialogOpen(true); + }; + + const handleSaveAccess = async () => { + if (!activeMemberId || !isAdmin) return; + + const hasAllAccess = + accessScope === "all" && (accessAllRead || accessAllWrite); + const accessList = buildAccessList(accessMap); + const hasCustomAccess = accessScope === "custom" && accessList.length > 0; + + if (!hasAllAccess && !hasCustomAccess) { + setAccessError("Select read or write access for at least one board."); + return; + } + + setAccessError(null); + + try { + if (memberDetailsQuery.data?.status === 200) { + const member = memberDetailsQuery.data.data; + if (member.role !== accessRole) { + await updateMemberRoleMutation.mutateAsync({ + memberId: member.id, + data: { role: accessRole }, + }); + } + } + + await updateMemberAccessMutation.mutateAsync({ + memberId: activeMemberId, + data: { + all_boards_read: accessScope === "all" ? accessAllRead : false, + all_boards_write: accessScope === "all" ? accessAllWrite : false, + board_access: accessScope === "custom" ? accessList : [], + }, + }); + + setAccessDialogOpen(false); + } catch (err) { + setAccessError( + err instanceof Error ? err.message : "Unable to update member access.", + ); + } + }; + + const memberAccessSummary = (member: OrganizationMemberRead) => + summarizeAccess(member.all_boards_read, member.all_boards_write); + + const memberDisplay = (member: OrganizationMemberRead) => { + const primary = + member.user?.name || + member.user?.preferred_name || + member.user?.email || + member.user_id; + const secondary = member.user?.email ?? "No email on file"; + return { + primary, + secondary, + initials: initialsFrom(primary), + }; + }; + + return ( + + +
+
+

+ Sign in to manage your organization. +

+ + + +
+
+
+ + +
+
+
+
+
+
+

+ Organization +

+ + + {orgName} + +
+

+ Manage members and board access across your workspace. +

+
+ + + {members.length} + {" "} + members + + + + {boards.length} + {" "} + boards + + + + {invites.length} + {" "} + pending + +
+
+ +
+
+
+ +
+
+
+
+

+ Members & invites +

+

+ Invite teammates and tune their board permissions. +

+
+
+ + {members.length + invites.length} total +
+
+
+ + + + + + + + + + + {membersQuery.isLoading ? ( + + + + ) : null} + + {members.map((member) => { + const display = memberDisplay(member); + return ( + + + + + + + ); + })} + + {isAdmin && invitesQuery.isLoading ? ( + + + + ) : null} + + {isAdmin + ? invites.map((invite) => ( + + + + + + + )) + : null} + + {!membersQuery.isLoading && + (!isAdmin || !invitesQuery.isLoading) && + members.length === 0 && + (!isAdmin || invites.length === 0) ? ( + + + + ) : null} + +
+ Member + + Status + + Access + + Actions +
+ Loading members... +
+
+
+ {display.initials} +
+
+
+ {display.primary} +
+
+ {display.secondary} +
+
+
+
+ + {member.role} + + + {memberAccessSummary(member)} + + {isAdmin ? ( + + ) : ( + + Admin only + + )} +
+ Loading invites... +
+
+
+ {initialsFrom(invite.invited_email)} +
+
+
+ {invite.invited_email} +
+
+ Invited {formatTimestamp(invite.created_at)} +
+
+
+
+
+ Pending + + {invite.role} + +
+
+ {summarizeAccess( + invite.all_boards_read, + invite.all_boards_write, + )} + +
+ + +
+
+ No members or invites yet. +
+
+
+
+
+
+ + + + + Invite a member + + Grant access to all boards or select specific workspaces. + + + + {isAdmin ? ( +
+
+
+ + setInviteEmail(event.target.value)} + placeholder="name@company.com" + type="email" + required + /> +
+
+ + +
+
+ + + + {inviteError ? ( +

{inviteError}

+ ) : null} + + + + + + + ) : ( +
+ Only organization admins can invite new members. +
+ )} +
+
+ + + + + Manage member access + + Adjust board permissions and role for this teammate. + + + + {memberDetailsQuery.isLoading ? ( +
+ Loading member access... +
+ ) : memberDetailsQuery.data?.status === 200 ? ( +
+
+

+ {memberDetailsQuery.data.data.user?.name || + memberDetailsQuery.data.data.user?.preferred_name || + memberDetailsQuery.data.data.user?.email || + memberDetailsQuery.data.data.user_id} +

+

+ {memberDetailsQuery.data.data.user?.email ?? + "No email on file"} +

+
+ +
+ + +
+ + + + {accessError ? ( +

{accessError}

+ ) : null} +
+ ) : ( +
+ Unable to load member access. +
+ )} + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/BoardChatComposer.tsx b/frontend/src/components/BoardChatComposer.tsx index 57b9633c..9ef76cc7 100644 --- a/frontend/src/components/BoardChatComposer.tsx +++ b/frontend/src/components/BoardChatComposer.tsx @@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea"; type BoardChatComposerProps = { placeholder?: string; isSending?: boolean; + disabled?: boolean; onSend: (content: string) => Promise; }; function BoardChatComposerImpl({ placeholder = "Message the board lead. Tag agents with @name.", isSending = false, + disabled = false, onSend, }: BoardChatComposerProps) { const [value, setValue] = useState(""); @@ -28,7 +30,7 @@ function BoardChatComposerImpl({ }, [isSending]); const send = useCallback(async () => { - if (isSending) return; + if (isSending || disabled) return; const trimmed = value.trim(); if (!trimmed) return; const ok = await onSend(trimmed); @@ -53,12 +55,12 @@ function BoardChatComposerImpl({ }} placeholder={placeholder} className="min-h-[120px]" - disabled={isSending} + disabled={isSending || disabled} />
diff --git a/frontend/src/components/molecules/TaskCard.tsx b/frontend/src/components/molecules/TaskCard.tsx index 0ee23b93..59a0c6a2 100644 --- a/frontend/src/components/molecules/TaskCard.tsx +++ b/frontend/src/components/molecules/TaskCard.tsx @@ -93,8 +93,10 @@ export function TaskCard({ /> ) : null}
-
-

{title}

+
+

+ {title} +

{isBlocked ? (
@@ -114,7 +116,7 @@ export function TaskCard({
) : null}
-
+
({ + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + retry: false, + }, + }); + const member = + membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; + const isAdmin = member ? ["owner", "admin"].includes(member.role) : false; const healthQuery = useHealthzHealthzGet( { query: { @@ -48,7 +65,7 @@ export function DashboardSidebar() { ? "System status unavailable" : "System degraded"; - return ( + return (