feat: add organization-related models and update schemas for organization management

This commit is contained in:
Abhimanyu Saharan
2026-02-08 21:16:26 +05:30
parent 8422b0ca01
commit e03125a382
86 changed files with 8673 additions and 628 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -8,14 +8,13 @@ from datetime import datetime, timezone
from typing import Any, cast from typing import Any, cast
from uuid import UUID 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 sqlalchemy import asc, desc, func
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse 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_member
from app.core.auth import AuthContext
from app.core.time import utcnow from app.core.time import utcnow
from app.db.pagination import paginate from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session 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.models.tasks import Task
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import get_active_membership, list_accessible_board_ids
router = APIRouter(prefix="/activity", tags=["activity"]) router = APIRouter(prefix="/activity", tags=["activity"])
@@ -112,6 +112,17 @@ async def list_activity(
statement = select(ActivityEvent) statement = select(ActivityEvent)
if actor.actor_type == "agent" and actor.agent: if actor.actor_type == "agent" and actor.agent:
statement = statement.where(ActivityEvent.agent_id == actor.agent.id) 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))) statement = statement.order_by(desc(col(ActivityEvent.created_at)))
return await paginate(session, statement) return await paginate(session, statement)
@@ -123,7 +134,7 @@ async def list_activity(
async def list_task_comment_feed( async def list_task_comment_feed(
board_id: UUID | None = Query(default=None), board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_member),
) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]: ) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]:
statement = ( statement = (
select(ActivityEvent, Task, Board, Agent) select(ActivityEvent, Task, Board, Agent)
@@ -134,8 +145,15 @@ async def list_task_comment_feed(
.where(func.length(func.trim(col(ActivityEvent.message))) > 0) .where(func.length(func.trim(col(ActivityEvent.message))) > 0)
.order_by(desc(col(ActivityEvent.created_at))) .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 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) 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]: def _transform(items: Sequence[Any]) -> Sequence[Any]:
rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items) rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items)
@@ -149,9 +167,14 @@ async def stream_task_comment_feed(
request: Request, request: Request,
board_id: UUID | None = Query(default=None), board_id: UUID | None = Query(default=None),
since: str | 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: ) -> EventSourceResponse:
since_dt = _parse_since(since) or utcnow() 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_ids: set[UUID] = set()
seen_queue: deque[UUID] = deque() seen_queue: deque[UUID] = deque()
@@ -161,7 +184,13 @@ async def stream_task_comment_feed(
if await request.is_disconnected(): if await request.is_disconnected():
break break
async with async_session_maker() as session: 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: for event, task, board, agent in rows:
event_id = event.id event_id = event.id
if event_id in seen_ids: if event_id in seen_ids:

View File

@@ -334,7 +334,6 @@ async def list_task_comments(
return await tasks_api.list_task_comments( return await tasks_api.list_task_comments(
task=task, task=task,
session=session, session=session,
actor=_actor(agent_ctx),
) )

View File

@@ -14,9 +14,9 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse 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.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.core.time import utcnow
from app.db.pagination import paginate from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session 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.agents import Agent
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.organizations import Organization
from app.models.tasks import Task from app.models.tasks import Task
from app.models.users import User
from app.schemas.agents import ( from app.schemas.agents import (
AgentCreate, AgentCreate,
AgentHeartbeat, AgentHeartbeat,
@@ -43,6 +45,14 @@ from app.services.agent_provisioning import (
provision_agent, provision_agent,
provision_main_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"]) 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)}" 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: if not board_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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) board = await session.get(Board, board_id)
if board is None: if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") 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 return board
@@ -111,6 +129,11 @@ async def _require_gateway(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_id is invalid", 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: if not gateway.main_session_key:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -206,6 +229,42 @@ async def _fetch_agent_events(
return list(await session.exec(statement)) 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: def _record_heartbeat(session: AsyncSession, agent: Agent) -> None:
record_activity( record_activity(
session, session,
@@ -245,13 +304,28 @@ async def list_agents(
board_id: UUID | None = Query(default=None), board_id: UUID | None = Query(default=None),
gateway_id: UUID | None = Query(default=None), gateway_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_admin),
) -> DefaultLimitOffsetPage[AgentRead]: ) -> DefaultLimitOffsetPage[AgentRead]:
main_session_keys = await _get_gateway_main_session_keys(session) 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: if board_id is not None:
statement = statement.where(col(Agent.board_id) == board_id) statement = statement.where(col(Agent.board_id) == board_id)
if gateway_id is not None: 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( statement = statement.join(Board, col(Agent.board_id) == col(Board.id)).where(
col(Board.gateway_id) == gateway_id col(Board.gateway_id) == gateway_id
) )
@@ -269,10 +343,15 @@ async def stream_agents(
request: Request, request: Request,
board_id: UUID | None = Query(default=None), board_id: UUID | None = Query(default=None),
since: str | 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: ) -> EventSourceResponse:
since_dt = _parse_since(since) or utcnow() since_dt = _parse_since(since) or utcnow()
last_seen = since_dt 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]]: async def event_generator() -> AsyncIterator[dict[str, str]]:
nonlocal last_seen nonlocal last_seen
@@ -280,7 +359,13 @@ async def stream_agents(
if await request.is_disconnected(): if await request.is_disconnected():
break break
async with async_session_maker() as session: 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 = ( main_session_keys = (
await _get_gateway_main_session_keys(session) if agents else set() await _get_gateway_main_session_keys(session) if agents else set()
) )
@@ -301,6 +386,10 @@ async def create_agent(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> AgentRead: ) -> 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 actor.actor_type == "agent":
if not actor.agent or not actor.agent.is_board_lead: if not actor.agent or not actor.agent.is_board_lead:
raise HTTPException( raise HTTPException(
@@ -319,7 +408,12 @@ async def create_agent(
) )
payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id}) 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) gateway, client_config = await _require_gateway(session, board)
data = payload.model_dump() data = payload.model_dump()
requested_name = (data.get("name") or "").strip() requested_name = (data.get("name") or "").strip()
@@ -436,11 +530,12 @@ async def create_agent(
async def get_agent( async def get_agent(
agent_id: str, agent_id: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_admin),
) -> AgentRead: ) -> AgentRead:
agent = await session.get(Agent, agent_id) agent = await session.get(Agent, agent_id)
if agent is None: if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 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) main_session_keys = await _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys) return _to_agent_read(_with_computed_status(agent), main_session_keys)
@@ -451,18 +546,28 @@ async def update_agent(
payload: AgentUpdate, payload: AgentUpdate,
force: bool = False, force: bool = False,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> AgentRead: ) -> AgentRead:
agent = await session.get(Agent, agent_id) agent = await session.get(Agent, agent_id)
if agent is None: if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 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) updates = payload.model_dump(exclude_unset=True)
make_main = updates.pop("is_gateway_main", None) 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: if "status" in updates:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="status is controlled by agent heartbeat", 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: if not updates and not force and make_main is None:
main_session_keys = await _get_gateway_main_session_keys(session) main_session_keys = await _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys) 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) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 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: if payload.status:
agent.status = payload.status agent.status = payload.status
elif agent.status == "provisioning": elif agent.status == "provisioning":
@@ -664,7 +774,16 @@ async def heartbeat_or_create_agent(
if agent is None: if agent is None:
if actor.actor_type == "agent": if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 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) gateway, client_config = await _require_gateway(session, board)
agent = Agent( agent = Agent(
name=payload.name, name=payload.name,
@@ -724,6 +843,9 @@ async def heartbeat_or_create_agent(
except Exception as exc: # pragma: no cover - unexpected provisioning errors except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_instruction_failure(session, agent, str(exc), "provision") _record_instruction_failure(session, agent, str(exc), "provision")
await session.commit() 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: elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
elif agent.agent_token_hash is None and actor.actor_type == "user": 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.commit()
await session.refresh(agent) await session.refresh(agent)
try: 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) gateway, client_config = await _require_gateway(session, board)
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision") await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
await _send_wakeup_message(agent, client_config, verb="provisioned") 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") _record_instruction_failure(session, agent, str(exc), "provision")
await session.commit() await session.commit()
elif not agent.openclaw_session_id: 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) gateway, client_config = await _require_gateway(session, board)
session_key, session_error = await _ensure_gateway_session(agent.name, client_config) session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
agent.openclaw_session_id = session_key agent.openclaw_session_id = session_key
@@ -804,11 +936,12 @@ async def heartbeat_or_create_agent(
async def delete_agent( async def delete_agent(
agent_id: str, agent_id: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_admin),
) -> OkResponse: ) -> OkResponse:
agent = await session.get(Agent, agent_id) agent = await session.get(Agent, agent_id)
if agent is None: if agent is None:
return OkResponse() 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) board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
gateway, client_config = await _require_gateway(session, board) gateway, client_config = await _require_gateway(session, board)

View File

@@ -12,8 +12,13 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse 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.api.deps import (
from app.core.auth import AuthContext 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.core.time import utcnow
from app.db.pagination import paginate from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session 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]) @router.get("", response_model=DefaultLimitOffsetPage[ApprovalRead])
async def list_approvals( async def list_approvals(
status_filter: ApprovalStatus | None = Query(default=None, alias="status"), 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), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[ApprovalRead]: ) -> 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) statement = select(Approval).where(col(Approval.board_id) == board.id)
if status_filter: if status_filter:
statement = statement.where(col(Approval.status) == status_filter) statement = statement.where(col(Approval.status) == status_filter)
@@ -105,13 +107,10 @@ async def list_approvals(
@router.get("/stream") @router.get("/stream")
async def stream_approvals( async def stream_approvals(
request: Request, request: Request,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None), since: str | None = Query(default=None),
) -> EventSourceResponse: ) -> 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() since_dt = _parse_since(since) or utcnow()
last_seen = since_dt last_seen = since_dt
@@ -180,13 +179,10 @@ async def stream_approvals(
@router.post("", response_model=ApprovalRead) @router.post("", response_model=ApprovalRead)
async def create_approval( async def create_approval(
payload: ApprovalCreate, payload: ApprovalCreate,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> Approval: ) -> 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) task_id = payload.task_id or _extract_task_id(payload.payload)
approval = Approval( approval = Approval(
board_id=board.id, board_id=board.id,
@@ -208,9 +204,8 @@ async def create_approval(
async def update_approval( async def update_approval(
approval_id: str, approval_id: str,
payload: ApprovalUpdate, payload: ApprovalUpdate,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Approval: ) -> Approval:
approval = await session.get(Approval, approval_id) approval = await session.get(Approval, approval_id)
if approval is None or approval.board_id != board.id: if approval is None or approval.board_id != board.id:

View File

@@ -12,8 +12,13 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse 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.api.deps import (
from app.core.auth import AuthContext 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.config import settings
from app.core.time import utcnow from app.core.time import utcnow
from app.db.pagination import paginate 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.board_groups import BoardGroup
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.users import User
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage 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 from app.services.mentions import extract_mentions, matches_agent_mention
router = APIRouter(tags=["board-group-memory"]) router = APIRouter(tags=["board-group-memory"])
@@ -96,6 +109,38 @@ async def _fetch_memory_events(
return list(await session.exec(statement)) 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( async def _notify_group_memory_targets(
*, *,
session: AsyncSession, session: AsyncSession,
@@ -193,11 +238,9 @@ async def list_board_group_memory(
group_id: UUID, group_id: UUID,
is_chat: bool | None = Query(default=None), is_chat: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx: OrganizationContext = Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]: ) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
group = await session.get(BoardGroup, group_id) await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
statement = ( statement = (
select(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id) select(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to # 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), since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None), is_chat: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx: OrganizationContext = Depends(require_org_member),
) -> EventSourceResponse: ) -> EventSourceResponse:
group = await session.get(BoardGroup, group_id) await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
since_dt = _parse_since(since) or utcnow() since_dt = _parse_since(since) or utcnow()
last_seen = since_dt last_seen = since_dt
@@ -252,13 +293,12 @@ async def create_board_group_memory(
group_id: UUID, group_id: UUID,
payload: BoardGroupMemoryCreate, payload: BoardGroupMemoryCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx: OrganizationContext = Depends(require_org_member),
) -> BoardGroupMemory: ) -> BoardGroupMemory:
group = await session.get(BoardGroup, group_id) group = await _require_group_access(session, group_id=group_id, ctx=ctx, write=True)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
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 []) tags = set(payload.tags or [])
is_chat = "chat" in tags is_chat = "chat" in tags
mentions = extract_mentions(payload.content) mentions = extract_mentions(payload.content)
@@ -287,13 +327,9 @@ async def create_board_group_memory(
@board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead]) @board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
async def list_board_group_memory_for_board( async def list_board_group_memory_for_board(
is_chat: bool | None = Query(default=None), 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), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]: ) -> 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 group_id = board.board_group_id
if group_id is None: if group_id is None:
statement = select(BoardGroupMemory).where(col(BoardGroupMemory.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") @board_router.get("/stream")
async def stream_board_group_memory_for_board( async def stream_board_group_memory_for_board(
request: Request, 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), since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None), is_chat: bool | None = Query(default=None),
) -> EventSourceResponse: ) -> 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 group_id = board.board_group_id
since_dt = _parse_since(since) or utcnow() since_dt = _parse_since(since) or utcnow()
last_seen = since_dt last_seen = since_dt
@@ -354,13 +386,10 @@ async def stream_board_group_memory_for_board(
@board_router.post("", response_model=BoardGroupMemoryRead) @board_router.post("", response_model=BoardGroupMemoryRead)
async def create_board_group_memory_for_board( async def create_board_group_memory_for_board(
payload: BoardGroupMemoryCreate, payload: BoardGroupMemoryCreate,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardGroupMemory: ) -> 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 group_id = board.board_group_id
if group_id is None: if group_id is None:
raise HTTPException( raise HTTPException(

View File

@@ -9,8 +9,7 @@ from sqlalchemy import delete, func, update
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
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, require_org_member
from app.core.auth import AuthContext
from app.core.time import utcnow from app.core.time import utcnow
from app.db import crud from app.db import crud
from app.db.pagination import paginate 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.schemas.view_models import BoardGroupSnapshot
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats 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.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"]) router = APIRouter(prefix="/board-groups", tags=["board-groups"])
@@ -38,12 +45,56 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex 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]) @router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead])
async def list_board_groups( async def list_board_groups(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardGroupRead]: ) -> 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) return await paginate(session, statement)
@@ -51,11 +102,12 @@ async def list_board_groups(
async def create_board_group( async def create_board_group(
payload: BoardGroupCreate, payload: BoardGroupCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_admin),
) -> BoardGroup: ) -> BoardGroup:
data = payload.model_dump() data = payload.model_dump()
if not (data.get("slug") or "").strip(): if not (data.get("slug") or "").strip():
data["slug"] = _slugify(data.get("name") or "") data["slug"] = _slugify(data.get("name") or "")
data["organization_id"] = ctx.organization.id
return await crud.create(session, BoardGroup, **data) return await crud.create(session, BoardGroup, **data)
@@ -63,12 +115,9 @@ async def create_board_group(
async def get_board_group( async def get_board_group(
group_id: UUID, group_id: UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_member),
) -> BoardGroup: ) -> BoardGroup:
group = await session.get(BoardGroup, group_id) return await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return group
@router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot) @router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot)
@@ -77,20 +126,22 @@ async def get_board_group_snapshot(
include_done: bool = False, include_done: bool = False,
per_board_task_limit: int = 5, per_board_task_limit: int = 5,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_member),
) -> BoardGroupSnapshot: ) -> BoardGroupSnapshot:
group = await session.get(BoardGroup, group_id) group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if per_board_task_limit < 0: if per_board_task_limit < 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return await build_group_snapshot( snapshot = await build_group_snapshot(
session, session,
group=group, group=group,
exclude_board_id=None, exclude_board_id=None,
include_done=include_done, include_done=include_done,
per_board_task_limit=per_board_task_limit, 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) @router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
@@ -104,7 +155,23 @@ async def apply_board_group_heartbeat(
if group is None: if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 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 agent = actor.agent
if agent is None: if agent is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
@@ -188,11 +255,9 @@ async def update_board_group(
payload: BoardGroupUpdate, payload: BoardGroupUpdate,
group_id: UUID, group_id: UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_admin),
) -> BoardGroup: ) -> BoardGroup:
group = await session.get(BoardGroup, group_id) group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip(): if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
updates["slug"] = _slugify(updates.get("name") or group.name) updates["slug"] = _slugify(updates.get("name") or group.name)
@@ -206,11 +271,9 @@ async def update_board_group(
async def delete_board_group( async def delete_board_group(
group_id: UUID, group_id: UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_admin),
) -> OkResponse: ) -> OkResponse:
group = await session.get(BoardGroup, group_id) group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# Boards reference groups, so clear the FK first to keep deletes simple. # Boards reference groups, so clear the FK first to keep deletes simple.
await session.execute( await session.execute(

View File

@@ -12,7 +12,12 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse 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.config import settings
from app.core.time import utcnow from app.core.time import utcnow
from app.db.pagination import paginate from app.db.pagination import paginate
@@ -178,13 +183,10 @@ async def _notify_chat_targets(
@router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead]) @router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
async def list_board_memory( async def list_board_memory(
is_chat: bool | None = Query(default=None), 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), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[BoardMemoryRead]: ) -> 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 = ( statement = (
select(BoardMemory).where(col(BoardMemory.board_id) == board.id) select(BoardMemory).where(col(BoardMemory.board_id) == board.id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to # Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
@@ -200,14 +202,11 @@ async def list_board_memory(
@router.get("/stream") @router.get("/stream")
async def stream_board_memory( async def stream_board_memory(
request: Request, request: Request,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None), since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None), is_chat: bool | None = Query(default=None),
) -> EventSourceResponse: ) -> 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() since_dt = _parse_since(since) or utcnow()
last_seen = since_dt last_seen = since_dt
@@ -236,13 +235,10 @@ async def stream_board_memory(
@router.post("", response_model=BoardMemoryRead) @router.post("", response_model=BoardMemoryRead)
async def create_board_memory( async def create_board_memory(
payload: BoardMemoryCreate, payload: BoardMemoryCreate,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardMemory: ) -> 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 is_chat = payload.tags is not None and "chat" in payload.tags
source = payload.source source = payload.source
if is_chat and not source: if is_chat and not source:

View File

@@ -9,7 +9,14 @@ from pydantic import ValidationError
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession 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.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext from app.core.auth import AuthContext
from app.core.config import settings from app.core.config import settings
@@ -136,9 +143,8 @@ async def _ensure_lead_agent(
@router.get("", response_model=BoardOnboardingRead) @router.get("", response_model=BoardOnboardingRead)
async def get_onboarding( async def get_onboarding(
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_read),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession: ) -> BoardOnboardingSession:
onboarding = ( onboarding = (
await session.exec( await session.exec(
@@ -155,9 +161,8 @@ async def get_onboarding(
@router.post("/start", response_model=BoardOnboardingRead) @router.post("/start", response_model=BoardOnboardingRead)
async def start_onboarding( async def start_onboarding(
payload: BoardOnboardingStart, payload: BoardOnboardingStart,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession: ) -> BoardOnboardingSession:
onboarding = ( onboarding = (
await session.exec( await session.exec(
@@ -239,9 +244,8 @@ async def start_onboarding(
@router.post("/answer", response_model=BoardOnboardingRead) @router.post("/answer", response_model=BoardOnboardingRead)
async def answer_onboarding( async def answer_onboarding(
payload: BoardOnboardingAnswer, payload: BoardOnboardingAnswer,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession: ) -> BoardOnboardingSession:
onboarding = ( onboarding = (
await session.exec( await session.exec(
@@ -342,7 +346,7 @@ async def agent_onboarding_update(
@router.post("/confirm", response_model=BoardRead) @router.post("/confirm", response_model=BoardRead)
async def confirm_onboarding( async def confirm_onboarding(
payload: BoardOnboardingConfirm, payload: BoardOnboardingConfirm,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> Board: ) -> Board:

View File

@@ -8,8 +8,13 @@ from sqlalchemy import delete, func
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession 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 (
from app.core.auth import AuthContext 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.core.time import utcnow
from app.db import crud from app.db import crud
from app.db.pagination import paginate 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.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
from app.services.board_group_snapshot import build_board_group_snapshot from app.services.board_group_snapshot import build_board_group_snapshot
from app.services.board_snapshot import build_board_snapshot from app.services.board_snapshot import build_board_snapshot
from app.services.organizations import board_access_filter
router = APIRouter(prefix="/boards", tags=["boards"]) 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" 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) gateway = await crud.get_by_id(session, Gateway, gateway_id)
if gateway is None: if gateway is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="gateway_id is invalid", 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 return gateway
async def _require_gateway_for_create( async def _require_gateway_for_create(
payload: BoardCreate, payload: BoardCreate,
ctx=Depends(require_org_admin),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Gateway: ) -> 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) group = await crud.get_by_id(session, BoardGroup, board_group_id)
if group is None: if group is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_group_id is invalid", 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 return group
async def _require_board_group_for_create( async def _require_board_group_for_create(
payload: BoardCreate, payload: BoardCreate,
ctx=Depends(require_org_admin),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> BoardGroup | None: ) -> BoardGroup | None:
if payload.board_group_id is None: if payload.board_group_id is None:
return 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( async def _apply_board_update(
@@ -97,9 +129,13 @@ async def _apply_board_update(
) -> Board: ) -> Board:
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
if "gateway_id" in updates: 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: 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(): for key, value in updates.items():
setattr(board, key, value) setattr(board, key, value)
if updates.get("board_type") == "goal": if updates.get("board_type") == "goal":
@@ -182,9 +218,9 @@ async def list_boards(
gateway_id: UUID | None = Query(default=None), gateway_id: UUID | None = Query(default=None),
board_group_id: UUID | None = Query(default=None), board_group_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), ctx=Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardRead]: ) -> DefaultLimitOffsetPage[BoardRead]:
statement = select(Board) statement = select(Board).where(board_access_filter(ctx.member, write=False))
if gateway_id is not None: if gateway_id is not None:
statement = statement.where(col(Board.gateway_id) == gateway_id) statement = statement.where(col(Board.gateway_id) == gateway_id)
if board_group_id is not None: if board_group_id is not None:
@@ -199,28 +235,25 @@ async def create_board(
_gateway: Gateway = Depends(_require_gateway_for_create), _gateway: Gateway = Depends(_require_gateway_for_create),
_board_group: BoardGroup | None = Depends(_require_board_group_for_create), _board_group: BoardGroup | None = Depends(_require_board_group_for_create),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_admin),
) -> Board: ) -> 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) @router.get("/{board_id}", response_model=BoardRead)
def get_board( def get_board(
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_read),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Board: ) -> Board:
return board return board
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot) @router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
async def get_board_snapshot( 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), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardSnapshot: ) -> 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) return await build_board_snapshot(session, board)
@@ -229,13 +262,9 @@ async def get_board_group_snapshot(
include_self: bool = Query(default=False), include_self: bool = Query(default=False),
include_done: bool = Query(default=False), include_done: bool = Query(default=False),
per_board_task_limit: int = Query(default=5, ge=0, le=100), 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), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardGroupSnapshot: ) -> 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( return await build_board_group_snapshot(
session, session,
board=board, board=board,
@@ -249,8 +278,7 @@ async def get_board_group_snapshot(
async def update_board( async def update_board(
payload: BoardUpdate, payload: BoardUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_write),
auth: AuthContext = Depends(require_admin_auth),
) -> Board: ) -> Board:
return await _apply_board_update(payload=payload, session=session, board=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) @router.delete("/{board_id}", response_model=OkResponse)
async def delete_board( async def delete_board(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_write),
auth: AuthContext = Depends(require_admin_auth),
) -> OkResponse: ) -> OkResponse:
agents = list(await session.exec(select(Agent).where(Agent.board_id == board.id))) 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))) task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id)))

View File

@@ -13,6 +13,14 @@ from app.models.agents import Agent
from app.models.boards import Board from app.models.boards import Board
from app.models.tasks import Task from app.models.tasks import Task
from app.models.users import User 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 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) 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( async def get_board_or_404(
board_id: str, board_id: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -50,9 +83,73 @@ async def get_board_or_404(
return board 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( async def get_task_or_404(
task_id: str, task_id: str,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Task: ) -> Task:
task = await session.get(Task, task_id) task = await session.get(Task, task_id)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel.ext.asyncio.session import AsyncSession 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.core.auth import AuthContext, get_auth_context
from app.db.session import get_session from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig 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.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.services.organizations import OrganizationContext, require_board_access
from app.schemas.common import OkResponse from app.schemas.common import OkResponse
from app.schemas.gateway_api import ( from app.schemas.gateway_api import (
GatewayCommandsResponse, GatewayCommandsResponse,
@@ -40,6 +42,8 @@ async def _resolve_gateway(
gateway_url: str | None, gateway_url: str | None,
gateway_token: str | None, gateway_token: str | None,
gateway_main_session_key: str | None, gateway_main_session_key: str | None,
*,
user: object | None = None,
) -> tuple[Board | None, GatewayClientConfig, str | None]: ) -> tuple[Board | None, GatewayClientConfig, str | None]:
if gateway_url: if gateway_url:
return ( return (
@@ -55,6 +59,8 @@ async def _resolve_gateway(
board = await session.get(Board, board_id) board = await session.get(Board, board_id)
if board is None: if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") 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: if not board.gateway_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -79,9 +85,16 @@ async def _resolve_gateway(
async def _require_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]: ) -> 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: if board is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -95,6 +108,7 @@ async def gateways_status(
params: GatewayResolveQuery = Depends(), params: GatewayResolveQuery = Depends(),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaysStatusResponse: ) -> GatewaysStatusResponse:
board, config, main_session = await _resolve_gateway( board, config, main_session = await _resolve_gateway(
session, session,
@@ -102,7 +116,10 @@ async def gateways_status(
params.gateway_url, params.gateway_url,
params.gateway_token, params.gateway_token,
params.gateway_main_session_key, 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: try:
sessions = await openclaw_call("sessions.list", config=config) sessions = await openclaw_call("sessions.list", config=config)
if isinstance(sessions, dict): if isinstance(sessions, dict):
@@ -136,6 +153,7 @@ async def list_gateway_sessions(
board_id: str | None = Query(default=None), board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaySessionsResponse: ) -> GatewaySessionsResponse:
board, config, main_session = await _resolve_gateway( board, config, main_session = await _resolve_gateway(
session, session,
@@ -143,7 +161,10 @@ async def list_gateway_sessions(
None, None,
None, 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: try:
sessions = await openclaw_call("sessions.list", config=config) sessions = await openclaw_call("sessions.list", config=config)
except OpenClawGatewayError as exc: except OpenClawGatewayError as exc:
@@ -175,6 +196,7 @@ async def get_gateway_session(
board_id: str | None = Query(default=None), board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaySessionResponse: ) -> GatewaySessionResponse:
board, config, main_session = await _resolve_gateway( board, config, main_session = await _resolve_gateway(
session, session,
@@ -182,7 +204,10 @@ async def get_gateway_session(
None, None,
None, 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: try:
sessions = await openclaw_call("sessions.list", config=config) sessions = await openclaw_call("sessions.list", config=config)
except OpenClawGatewayError as exc: except OpenClawGatewayError as exc:
@@ -220,8 +245,11 @@ async def get_session_history(
board_id: str | None = Query(default=None), board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaySessionHistoryResponse: ) -> 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: try:
history = await get_chat_history(session_id, config=config) history = await get_chat_history(session_id, config=config)
except OpenClawGatewayError as exc: except OpenClawGatewayError as exc:
@@ -238,8 +266,14 @@ async def send_gateway_session_message(
board_id: str | None = Query(default=None), board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OkResponse: ) -> 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: try:
if main_session and session_id == main_session: if main_session and session_id == main_session:
await ensure_session(main_session, config=config, label="Main Agent") 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) @router.get("/commands", response_model=GatewayCommandsResponse)
async def gateway_commands( async def gateway_commands(
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
_ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewayCommandsResponse: ) -> GatewayCommandsResponse:
return GatewayCommandsResponse( return GatewayCommandsResponse(
protocol_version=PROTOCOL_VERSION, protocol_version=PROTOCOL_VERSION,

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession 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.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext, get_auth_context from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow from app.core.time import utcnow
@@ -131,9 +131,13 @@ async def _ensure_main_agent(
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead]) @router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
async def list_gateways( async def list_gateways(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), ctx=Depends(require_org_admin),
) -> DefaultLimitOffsetPage[GatewayRead]: ) -> 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) return await paginate(session, statement)
@@ -142,8 +146,10 @@ async def create_gateway(
payload: GatewayCreate, payload: GatewayCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> Gateway: ) -> Gateway:
data = payload.model_dump() data = payload.model_dump()
data["organization_id"] = ctx.organization.id
gateway = Gateway.model_validate(data) gateway = Gateway.model_validate(data)
session.add(gateway) session.add(gateway)
await session.commit() await session.commit()
@@ -156,10 +162,10 @@ async def create_gateway(
async def get_gateway( async def get_gateway(
gateway_id: UUID, gateway_id: UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), ctx=Depends(require_org_admin),
) -> Gateway: ) -> Gateway:
gateway = await session.get(Gateway, gateway_id) 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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
return gateway return gateway
@@ -170,9 +176,10 @@ async def update_gateway(
payload: GatewayUpdate, payload: GatewayUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> Gateway: ) -> Gateway:
gateway = await session.get(Gateway, gateway_id) 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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
previous_name = gateway.name previous_name = gateway.name
previous_session_key = gateway.main_session_key previous_session_key = gateway.main_session_key
@@ -202,10 +209,11 @@ async def sync_gateway_templates(
force_bootstrap: bool = Query(default=False), force_bootstrap: bool = Query(default=False),
board_id: UUID | None = Query(default=None), board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> GatewayTemplatesSyncResult: ) -> GatewayTemplatesSyncResult:
gateway = await session.get(Gateway, gateway_id) 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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
return await sync_gateway_templates_service( return await sync_gateway_templates_service(
session, session,
@@ -223,10 +231,10 @@ async def sync_gateway_templates(
async def delete_gateway( async def delete_gateway(
gateway_id: UUID, gateway_id: UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context), ctx=Depends(require_org_admin),
) -> OkResponse: ) -> OkResponse:
gateway = await session.get(Gateway, gateway_id) 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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
await session.delete(gateway) await session.delete(gateway)
await session.commit() await session.commit()

View File

@@ -3,14 +3,14 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Literal from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import DateTime, case, cast, func from sqlalchemy import DateTime, case, cast, func
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_admin_auth from app.api.deps import require_org_member
from app.core.auth import AuthContext
from app.core.time import utcnow from app.core.time import utcnow
from app.db.session import get_session from app.db.session import get_session
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
@@ -26,6 +26,7 @@ from app.schemas.metrics import (
DashboardWipRangeSeries, DashboardWipRangeSeries,
DashboardWipSeriesSet, DashboardWipSeriesSet,
) )
from app.services.organizations import list_accessible_board_ids
router = APIRouter(prefix="/metrics", tags=["metrics"]) 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") bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
statement = ( statement = (
select(bucket_col, func.count()) select(bucket_col, func.count())
.where(col(Task.status) == "review") .where(col(Task.status) == "review")
.where(col(Task.updated_at) >= range_spec.start) .where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end) .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() results = (await session.exec(statement)).all()
mapping = {row[0]: float(row[1]) for row in results} mapping = {row[0]: float(row[1]) for row in results}
return _series_from_mapping(range_spec, mapping) 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") bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
in_progress = cast(Task.in_progress_at, DateTime) in_progress = cast(Task.in_progress_at, DateTime)
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0 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.in_progress_at).is_not(None))
.where(col(Task.updated_at) >= range_spec.start) .where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end) .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() results = (await session.exec(statement)).all()
mapping = {row[0]: float(row[1] or 0) for row in results} mapping = {row[0]: float(row[1] or 0) for row in results}
return _series_from_mapping(range_spec, mapping) 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") bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label("bucket")
error_case = case( error_case = case(
( (
@@ -157,10 +170,14 @@ async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> Das
) )
statement = ( statement = (
select(bucket_col, func.sum(error_case), func.count()) 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.start)
.where(col(ActivityEvent.created_at) <= range_spec.end) .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() results = (await session.exec(statement)).all()
mapping: dict[datetime, float] = {} 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) 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") bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
inbox_case = case((col(Task.status) == "inbox", 1), else_=0) inbox_case = case((col(Task.status) == "inbox", 1), else_=0)
progress_case = case((col(Task.status) == "in_progress", 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.start)
.where(col(Task.updated_at) <= range_spec.end) .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() results = (await session.exec(statement)).all()
mapping: dict[datetime, dict[str, int]] = {} 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) 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() now = utcnow()
start = now - timedelta(days=7) start = now - timedelta(days=7)
in_progress = cast(Task.in_progress_at, DateTime) 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) >= start)
.where(col(Task.updated_at) <= now) .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() value = (await session.exec(statement)).one_or_none()
if value is None: if value is None:
return None return None
@@ -222,7 +247,9 @@ async def _median_cycle_time_7d(session: AsyncSession) -> float | None:
return float(value) 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( error_case = case(
( (
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN), col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
@@ -232,9 +259,13 @@ async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float
) )
statement = ( statement = (
select(func.sum(error_case), func.count()) 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.start)
.where(col(ActivityEvent.created_at) <= range_spec.end) .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() result = (await session.exec(statement)).one_or_none()
if result is None: if result is None:
return 0.0 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 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 threshold = utcnow() - OFFLINE_AFTER
statement = select(func.count()).where( statement = select(func.count()).where(
col(Agent.last_seen_at).is_not(None), col(Agent.last_seen_at).is_not(None),
col(Agent.last_seen_at) >= threshold, 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() result = (await session.exec(statement)).one()
return int(result) return int(result)
async def _tasks_in_progress(session: AsyncSession) -> int: async def _tasks_in_progress(session: AsyncSession, board_ids: list[UUID]) -> int:
statement = select(func.count()).where(col(Task.status) == "in_progress") 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() result = (await session.exec(statement)).one()
return int(result) return int(result)
@@ -264,41 +304,42 @@ async def _tasks_in_progress(session: AsyncSession) -> int:
async def dashboard_metrics( async def dashboard_metrics(
range: Literal["24h", "7d"] = Query(default="24h"), range: Literal["24h", "7d"] = Query(default="24h"),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), ctx=Depends(require_org_member),
) -> DashboardMetrics: ) -> DashboardMetrics:
primary = _resolve_range(range) primary = _resolve_range(range)
comparison = _comparison_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_primary = await _query_throughput(session, primary, board_ids)
throughput_comparison = await _query_throughput(session, comparison) throughput_comparison = await _query_throughput(session, comparison, board_ids)
throughput = DashboardSeriesSet( throughput = DashboardSeriesSet(
primary=throughput_primary, primary=throughput_primary,
comparison=throughput_comparison, comparison=throughput_comparison,
) )
cycle_time_primary = await _query_cycle_time(session, primary) cycle_time_primary = await _query_cycle_time(session, primary, board_ids)
cycle_time_comparison = await _query_cycle_time(session, comparison) cycle_time_comparison = await _query_cycle_time(session, comparison, board_ids)
cycle_time = DashboardSeriesSet( cycle_time = DashboardSeriesSet(
primary=cycle_time_primary, primary=cycle_time_primary,
comparison=cycle_time_comparison, comparison=cycle_time_comparison,
) )
error_rate_primary = await _query_error_rate(session, primary) error_rate_primary = await _query_error_rate(session, primary, board_ids)
error_rate_comparison = await _query_error_rate(session, comparison) error_rate_comparison = await _query_error_rate(session, comparison, board_ids)
error_rate = DashboardSeriesSet( error_rate = DashboardSeriesSet(
primary=error_rate_primary, primary=error_rate_primary,
comparison=error_rate_comparison, comparison=error_rate_comparison,
) )
wip_primary = await _query_wip(session, primary) wip_primary = await _query_wip(session, primary, board_ids)
wip_comparison = await _query_wip(session, comparison) wip_comparison = await _query_wip(session, comparison, board_ids)
wip = DashboardWipSeriesSet( wip = DashboardWipSeriesSet(
primary=wip_primary, primary=wip_primary,
comparison=wip_comparison, comparison=wip_comparison,
) )
kpis = DashboardKpis( kpis = DashboardKpis(
active_agents=await _active_agents(session), active_agents=await _active_agents(session, board_ids),
tasks_in_progress=await _tasks_in_progress(session), tasks_in_progress=await _tasks_in_progress(session, board_ids),
error_rate_pct=await _error_rate_kpi(session, primary), error_rate_pct=await _error_rate_kpi(session, primary, board_ids),
median_cycle_time_hours_7d=await _median_cycle_time_7d(session), median_cycle_time_hours_7d=await _median_cycle_time_7d(session, board_ids),
) )
return DashboardMetrics( return DashboardMetrics(

View File

@@ -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)

View File

@@ -17,7 +17,8 @@ from sse_starlette.sse import EventSourceResponse
from app.api.deps import ( from app.api.deps import (
ActorContext, ActorContext,
get_board_or_404, get_board_for_actor_read,
get_board_for_user_write,
get_task_or_404, get_task_or_404,
require_admin_auth, require_admin_auth,
require_admin_or_agent, 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.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.mentions import extract_mentions, matches_agent_mention from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.organizations import require_board_access
from app.services.task_dependencies import ( from app.services.task_dependencies import (
blocked_by_dependency_ids, blocked_by_dependency_ids,
dependency_ids_by_task_id, dependency_ids_by_task_id,
@@ -442,7 +444,7 @@ async def _notify_lead_on_task_unassigned(
@router.get("/stream") @router.get("/stream")
async def stream_tasks( async def stream_tasks(
request: Request, request: Request,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None), since: str | None = Query(default=None),
) -> EventSourceResponse: ) -> EventSourceResponse:
@@ -525,13 +527,10 @@ async def list_tasks(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),
assigned_agent_id: UUID | None = None, assigned_agent_id: UUID | None = None,
unassigned: bool | 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), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[TaskRead]: ) -> 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) statement = select(Task).where(Task.board_id == board.id)
if status_filter: if status_filter:
statuses = [s.strip() for s in status_filter.split(",") if s.strip()] 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}}) @router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}})
async def create_task( async def create_task(
payload: TaskCreate, payload: TaskCreate,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> TaskRead: ) -> TaskRead:
@@ -669,6 +668,11 @@ async def update_task(
detail="Task board_id is required.", detail="Task board_id is required.",
) )
board_id = task.board_id 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_status = task.status
previous_assigned = task.assigned_agent_id previous_assigned = task.assigned_agent_id
@@ -978,6 +982,14 @@ async def delete_task(
task: Task = Depends(get_task_or_404), task: Task = Depends(get_task_or_404),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> OkResponse: ) -> 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(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.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)) 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( async def list_task_comments(
task: Task = Depends(get_task_or_404), task: Task = Depends(get_task_or_404),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[TaskCommentRead]: ) -> 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 = ( statement = (
select(ActivityEvent) select(ActivityEvent)
.where(col(ActivityEvent.task_id) == task.id) .where(col(ActivityEvent.task_id) == task.id)
@@ -1019,6 +1027,13 @@ async def create_task_comment(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> ActivityEvent: ) -> 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.actor_type == "agent" and actor.agent:
if actor.agent.is_board_lead and task.status != "review": 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( 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." "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 = ActivityEvent(
event_type="task.comment", event_type="task.comment",
message=payload.message, message=payload.message,

View File

@@ -97,6 +97,9 @@ async def get_auth_context(
clerk_user_id=clerk_user_id, clerk_user_id=clerk_user_id,
defaults=defaults, defaults=defaults,
) )
from app.services.organizations import ensure_member_for_user
await ensure_member_for_user(session, user)
return AuthContext( return AuthContext(
actor_type="user", actor_type="user",
@@ -146,6 +149,9 @@ async def get_auth_context_optional(
clerk_user_id=clerk_user_id, clerk_user_id=clerk_user_id,
defaults=defaults, defaults=defaults,
) )
from app.services.organizations import ensure_member_for_user
await ensure_member_for_user(session, user)
return AuthContext( return AuthContext(
actor_type="user", actor_type="user",

View File

@@ -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.gateway import router as gateway_router
from app.api.gateways import router as gateways_router from app.api.gateways import router as gateways_router
from app.api.metrics import router as metrics_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.souls_directory import router as souls_directory_router
from app.api.tasks import router as tasks_router from app.api.tasks import router as tasks_router
from app.api.users import router as users_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(gateway_router)
api_v1.include_router(gateways_router) api_v1.include_router(gateways_router)
api_v1.include_router(metrics_router) api_v1.include_router(metrics_router)
api_v1.include_router(organizations_router)
api_v1.include_router(souls_directory_router) api_v1.include_router(souls_directory_router)
api_v1.include_router(board_groups_router) api_v1.include_router(board_groups_router)
api_v1.include_router(board_group_memory_router) api_v1.include_router(board_group_memory_router)

View File

@@ -7,6 +7,11 @@ from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway 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_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task from app.models.tasks import Task
@@ -22,6 +27,11 @@ __all__ = [
"BoardGroup", "BoardGroup",
"Board", "Board",
"Gateway", "Gateway",
"Organization",
"OrganizationMember",
"OrganizationBoardAccess",
"OrganizationInvite",
"OrganizationInviteBoardAccess",
"TaskDependency", "TaskDependency",
"Task", "Task",
"TaskFingerprint", "TaskFingerprint",

View File

@@ -13,6 +13,7 @@ class BoardGroup(TenantScoped, table=True):
__tablename__ = "board_groups" __tablename__ = "board_groups"
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str name: str
slug: str = Field(index=True) slug: str = Field(index=True)
description: str | None = None description: str | None = None

View File

@@ -14,6 +14,7 @@ class Board(TenantScoped, table=True):
__tablename__ = "boards" __tablename__ = "boards"
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str name: str
slug: str = Field(index=True) slug: str = Field(index=True)
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True) gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)

View File

@@ -12,6 +12,7 @@ class Gateway(SQLModel, table=True):
__tablename__ = "gateways" __tablename__ = "gateways"
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str name: str
url: str url: str
token: str | None = Field(default=None) token: str | None = Field(default=None)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -18,3 +18,6 @@ class User(SQLModel, table=True):
notes: str | None = None notes: str | None = None
context: str | None = None context: str | None = None
is_super_admin: bool = Field(default=False) is_super_admin: bool = Field(default=False)
active_organization_id: UUID | None = Field(
default=None, foreign_key="organizations.id", index=True
)

View File

@@ -12,6 +12,18 @@ from app.schemas.board_onboarding import (
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
from app.schemas.metrics import DashboardMetrics 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 ( from app.schemas.souls_directory import (
SoulsDirectoryMarkdownResponse, SoulsDirectoryMarkdownResponse,
SoulsDirectorySearchResponse, SoulsDirectorySearchResponse,
@@ -43,6 +55,16 @@ __all__ = [
"GatewayRead", "GatewayRead",
"GatewayUpdate", "GatewayUpdate",
"DashboardMetrics", "DashboardMetrics",
"OrganizationActiveUpdate",
"OrganizationCreate",
"OrganizationInviteAccept",
"OrganizationInviteCreate",
"OrganizationInviteRead",
"OrganizationListItem",
"OrganizationMemberAccessUpdate",
"OrganizationMemberRead",
"OrganizationMemberUpdate",
"OrganizationRead",
"SoulsDirectoryMarkdownResponse", "SoulsDirectoryMarkdownResponse",
"SoulsDirectorySearchResponse", "SoulsDirectorySearchResponse",
"SoulsDirectorySoulRef", "SoulsDirectorySoulRef",

View File

@@ -24,5 +24,6 @@ class BoardGroupUpdate(SQLModel):
class BoardGroupRead(BoardGroupBase): class BoardGroupRead(BoardGroupBase):
id: UUID id: UUID
organization_id: UUID
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -54,5 +54,6 @@ class BoardUpdate(SQLModel):
class BoardRead(BoardBase): class BoardRead(BoardBase):
id: UUID id: UUID
organization_id: UUID
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -49,6 +49,7 @@ class GatewayUpdate(SQLModel):
class GatewayRead(GatewayBase): class GatewayRead(GatewayBase):
id: UUID id: UUID
organization_id: UUID
token: str | None = None token: str | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -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

View File

@@ -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)

View File

@@ -53,6 +53,7 @@ import type {
ListTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams, ListTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams,
ListTasksApiV1AgentBoardsBoardIdTasksGetParams, ListTasksApiV1AgentBoardsBoardIdTasksGetParams,
OkResponse, OkResponse,
SoulUpdateRequest,
TaskCommentCreate, TaskCommentCreate,
TaskCommentRead, TaskCommentRead,
TaskCreate, TaskCreate,
@@ -3035,6 +3036,449 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
queryClient, 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<getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse> => {
return customFetch<getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse>(
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<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
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<QueryKey, TData, TError> };
};
export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryResult =
NonNullable<
Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>
>;
export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryError =
HTTPValidationError;
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
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<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
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<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get Agent Soul
*/
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryOptions(
boardId,
agentId,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse> => {
return customFetch<updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse>(
getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutUrl(
boardId,
agentId,
),
{
...options,
method: "PUT",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(soulUpdateRequest),
},
);
};
export const getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
>
>,
TError,
{ boardId: string; agentId: string; data: SoulUpdateRequest },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): 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<typeof customFetch>;
},
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 * @summary Ask User Via Gateway Main
*/ */

View File

@@ -10,6 +10,7 @@ export interface BoardGroupRead {
slug: string; slug: string;
description?: string | null; description?: string | null;
id: string; id: string;
organization_id: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@@ -18,6 +18,7 @@ export interface BoardRead {
goal_confirmed?: boolean; goal_confirmed?: boolean;
goal_source?: string | null; goal_source?: string | null;
id: string; id: string;
organization_id: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@@ -11,6 +11,7 @@ export interface GatewayRead {
main_session_key: string; main_session_key: string;
workspace_root: string; workspace_root: string;
id: string; id: string;
organization_id: string;
token?: string | null; token?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;

View File

@@ -131,6 +131,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead"; export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead";
export * from "./limitOffsetPageTypeVarCustomizedTaskCommentRead"; export * from "./limitOffsetPageTypeVarCustomizedTaskCommentRead";
export * from "./limitOffsetPageTypeVarCustomizedTaskRead"; export * from "./limitOffsetPageTypeVarCustomizedTaskRead";
export * from "./listActivityApiV1ActivityGetParams"; export * from "./listActivityApiV1ActivityGetParams";
@@ -147,6 +149,8 @@ export * from "./listBoardsApiV1AgentBoardsGetParams";
export * from "./listBoardsApiV1BoardsGetParams"; export * from "./listBoardsApiV1BoardsGetParams";
export * from "./listGatewaysApiV1GatewaysGetParams"; export * from "./listGatewaysApiV1GatewaysGetParams";
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams"; export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";
export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams";
export * from "./listSessionsApiV1GatewaySessionsGet200"; export * from "./listSessionsApiV1GatewaySessionsGet200";
export * from "./listSessionsApiV1GatewaySessionsGetParams"; export * from "./listSessionsApiV1GatewaySessionsGetParams";
export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams"; export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams";
@@ -155,11 +159,29 @@ export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams"
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams"; export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
export * from "./listTasksApiV1BoardsBoardIdTasksGetParams"; export * from "./listTasksApiV1BoardsBoardIdTasksGetParams";
export * from "./okResponse"; 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 "./readyzReadyzGet200";
export * from "./searchApiV1SoulsDirectorySearchGetParams";
export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams"; export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost200"; export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost200";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostBody"; export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostBody";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParams"; export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParams";
export * from "./soulsDirectoryMarkdownResponse";
export * from "./soulsDirectorySearchResponse";
export * from "./soulsDirectorySoulRef";
export * from "./soulUpdateRequest";
export * from "./streamAgentsApiV1AgentsStreamGetParams"; export * from "./streamAgentsApiV1AgentsStreamGetParams";
export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams"; export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams";
export * from "./streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGetParams"; export * from "./streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGetParams";

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<T extends (...args: never) => unknown> = Parameters<T>[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<searchApiV1SoulsDirectorySearchGetResponse> => {
return customFetch<searchApiV1SoulsDirectorySearchGetResponse>(
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<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getSearchApiV1SoulsDirectorySearchGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
> = ({ signal }) =>
searchApiV1SoulsDirectorySearchGet(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type SearchApiV1SoulsDirectorySearchGetQueryResult = NonNullable<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
>;
export type SearchApiV1SoulsDirectorySearchGetQueryError = HTTPValidationError;
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params: undefined | SearchApiV1SoulsDirectorySearchGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Search
*/
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getSearchApiV1SoulsDirectorySearchGetQueryOptions(
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse> => {
return customFetch<getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse>(
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<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryKey(handle, slug);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>
> = ({ signal }) =>
getMarkdownApiV1SoulsDirectoryHandleSlugGet(handle, slug, {
signal,
...requestOptions,
});
return {
queryKey,
queryFn,
enabled: !!(handle && slug),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>
>;
export type GetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryError =
HTTPValidationError;
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get Markdown
*/
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryOptions(
handle,
slug,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse> => {
return customFetch<getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse>(
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<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryKey(handle, slug);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>>
> = ({ signal }) =>
getMarkdownApiV1SoulsDirectoryHandleSlugMdGet(handle, slug, {
signal,
...requestOptions,
});
return {
queryKey,
queryFn,
enabled: !!(handle && slug),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>>
>;
export type GetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryError =
HTTPValidationError;
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get Markdown
*/
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryOptions(
handle,
slug,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -22,6 +22,10 @@ import {
type listBoardsApiV1BoardsGetResponse, type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet, useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards"; } from "@/api/generated/boards/boards";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { import type {
ActivityEventRead, ActivityEventRead,
AgentRead, AgentRead,
@@ -80,6 +84,20 @@ export default function AgentDetailPage() {
const agentIdParam = params?.agentId; const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam; 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 [deleteOpen, setDeleteOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -88,7 +106,7 @@ export default function AgentDetailPage() {
ApiError ApiError
>(agentId ?? "", { >(agentId ?? "", {
query: { query: {
enabled: Boolean(isSignedIn && agentId), enabled: Boolean(isSignedIn && isAdmin && agentId),
refetchInterval: 30_000, refetchInterval: 30_000,
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
@@ -102,7 +120,7 @@ export default function AgentDetailPage() {
{ limit: 200 }, { limit: 200 },
{ {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000, refetchInterval: 30_000,
retry: false, retry: false,
}, },
@@ -114,7 +132,7 @@ export default function AgentDetailPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 60_000, refetchInterval: 60_000,
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
@@ -186,7 +204,14 @@ export default function AgentDetailPage() {
</SignedOut> </SignedOut>
<SignedIn> <SignedIn>
<DashboardSidebar /> <DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8"> {!isAdmin ? (
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-6 py-5 text-sm text-muted">
Only organization owners and admins can access agents.
</div>
</div>
) : (
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet"> <p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
@@ -371,7 +396,8 @@ export default function AgentDetailPage() {
Agent not found. Agent not found.
</div> </div>
)} )}
</div> </div>
)}
</SignedIn> </SignedIn>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>

View File

@@ -13,6 +13,10 @@ import {
useListBoardsApiV1BoardsGet, useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards"; } from "@/api/generated/boards/boards";
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents"; import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardRead } from "@/api/generated/model"; import type { BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -80,6 +84,20 @@ export default function NewAgentPage() {
const router = useRouter(); const router = useRouter();
const { isSignedIn } = useAuth(); 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 [name, setName] = useState("");
const [boardId, setBoardId] = useState<string>(""); const [boardId, setBoardId] = useState<string>("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m"); const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
@@ -95,7 +113,7 @@ export default function NewAgentPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always", refetchOnMount: "always",
}, },
}); });
@@ -182,15 +200,20 @@ export default function NewAgentPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
<form {!isAdmin ? (
onSubmit={handleSubmit} <div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6" Only organization owners and admins can create agents.
> </div>
<div> ) : (
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <form
Basic configuration onSubmit={handleSubmit}
</p> className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
<div className="mt-4 space-y-6"> >
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Basic configuration
</p>
<div className="mt-4 space-y-6">
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-900"> <label className="text-sm font-medium text-slate-900">
@@ -369,6 +392,7 @@ export default function NewAgentPage() {
</Button> </Button>
</div> </div>
</form> </form>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -42,6 +42,10 @@ import {
getListBoardsApiV1BoardsGetQueryKey, getListBoardsApiV1BoardsGetQueryKey,
useListBoardsApiV1BoardsGet, useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards"; } from "@/api/generated/boards/boards";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { AgentRead } from "@/api/generated/model"; import type { AgentRead } from "@/api/generated/model";
const parseTimestamp = (value?: string | null) => { const parseTimestamp = (value?: string | null) => {
@@ -88,6 +92,20 @@ export default function AgentsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); 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<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false }, { id: "name", desc: false },
]); ]);
@@ -102,7 +120,7 @@ export default function AgentsPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000, refetchInterval: 30_000,
refetchOnMount: "always", refetchOnMount: "always",
}, },
@@ -113,7 +131,7 @@ export default function AgentsPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 15_000, refetchInterval: 15_000,
refetchOnMount: "always", refetchOnMount: "always",
}, },
@@ -323,9 +341,15 @@ export default function AgentsPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> {!isAdmin ? (
<div className="overflow-x-auto"> <div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
<table className="w-full text-left text-sm"> Only organization owners and admins can access agents.
</div>
) : (
<>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500"> <thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
@@ -409,11 +433,13 @@ export default function AgentsPage() {
</div> </div>
</div> </div>
{agentsQuery.error ? ( {agentsQuery.error ? (
<p className="mt-4 text-sm text-red-500"> <p className="mt-4 text-sm text-red-500">
{agentsQuery.error.message} {agentsQuery.error.message}
</p> </p>
) : null} ) : null}
</>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -27,9 +27,14 @@ import {
streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet, streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet,
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet, useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet,
} from "@/api/generated/board-group-memory/board-group-memory"; } from "@/api/generated/board-group-memory/board-group-memory";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { import type {
BoardGroupHeartbeatApplyResult, BoardGroupHeartbeatApplyResult,
BoardGroupMemoryRead, BoardGroupMemoryRead,
OrganizationMemberRead,
} from "@/api/generated/model"; } from "@/api/generated/model";
import type { BoardGroupBoardSnapshot } from "@/api/generated/model"; import type { BoardGroupBoardSnapshot } from "@/api/generated/model";
import { Markdown } from "@/components/atoms/Markdown"; import { Markdown } from "@/components/atoms/Markdown";
@@ -96,6 +101,18 @@ const priorityTone = (value?: string | null) => {
const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) => const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) =>
snapshot.task_counts?.[key] ?? 0; snapshot.task_counts?.[key] ?? 0;
const canWriteGroupBoards = (
member: OrganizationMemberRead | null,
boardIds: Set<string>,
) => {
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 }) { function GroupChatMessageCard({ message }: { message: BoardGroupMemoryRead }) {
return ( return (
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4"> <div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
@@ -215,6 +232,34 @@ export default function BoardGroupDetailPage() {
snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null; snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null;
const group = snapshot?.group ?? null; const group = snapshot?.group ?? null;
const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]); const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]);
const boardIdSet = useMemo(() => {
const ids = new Set<string>();
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 = const chatHistoryQuery =
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet< useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet<
@@ -554,6 +599,10 @@ export default function BoardGroupDetailPage() {
setChatError("Sign in to send messages."); setChatError("Sign in to send messages.");
return false; return false;
} }
if (!canWriteGroup) {
setChatError("Read-only access. You cannot post group messages.");
return false;
}
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) return false; if (!trimmed) return false;
@@ -583,7 +632,7 @@ export default function BoardGroupDetailPage() {
setIsChatSending(false); setIsChatSending(false);
} }
}, },
[chatBroadcast, groupId, isSignedIn, mergeChatMessages], [canWriteGroup, chatBroadcast, groupId, isSignedIn, mergeChatMessages],
); );
const sendGroupNote = useCallback( const sendGroupNote = useCallback(
@@ -592,6 +641,10 @@ export default function BoardGroupDetailPage() {
setNoteSendError("Sign in to post."); setNoteSendError("Sign in to post.");
return false; return false;
} }
if (!canWriteGroup) {
setNoteSendError("Read-only access. You cannot post notes.");
return false;
}
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) return false; if (!trimmed) return false;
@@ -621,7 +674,7 @@ export default function BoardGroupDetailPage() {
setIsNoteSending(false); setIsNoteSending(false);
} }
}, },
[groupId, isSignedIn, mergeNotesMessages, notesBroadcast], [canWriteGroup, groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
); );
const applyHeartbeat = useCallback(async () => { const applyHeartbeat = useCallback(async () => {
@@ -629,6 +682,10 @@ export default function BoardGroupDetailPage() {
setHeartbeatApplyError("Sign in to apply."); setHeartbeatApplyError("Sign in to apply.");
return; return;
} }
if (!canManageHeartbeat) {
setHeartbeatApplyError("Read-only access. You cannot change agent pace.");
return;
}
const trimmed = heartbeatEvery.trim(); const trimmed = heartbeatEvery.trim();
if (!trimmed) { if (!trimmed) {
setHeartbeatApplyError("Heartbeat cadence is required."); setHeartbeatApplyError("Heartbeat cadence is required.");
@@ -653,7 +710,7 @@ export default function BoardGroupDetailPage() {
} finally { } finally {
setIsHeartbeatApplying(false); setIsHeartbeatApplying(false);
} }
}, [groupId, heartbeatEvery, includeBoardLeads, isSignedIn]); }, [canManageHeartbeat, groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
return ( return (
<DashboardShell> <DashboardShell>
@@ -793,7 +850,9 @@ export default function BoardGroupDetailPage() {
heartbeatEvery === value heartbeatEvery === value
? "bg-slate-900 text-white" ? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900", : "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
!canManageHeartbeat && "opacity-50 cursor-not-allowed",
)} )}
disabled={!canManageHeartbeat}
onClick={() => { onClick={() => {
setHeartbeatAmount(String(preset.amount)); setHeartbeatAmount(String(preset.amount));
setHeartbeatUnit(preset.unit); setHeartbeatUnit(preset.unit);
@@ -812,19 +871,25 @@ export default function BoardGroupDetailPage() {
heartbeatEvery heartbeatEvery
? "border-slate-200" ? "border-slate-200"
: "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100", : "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100",
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
)} )}
placeholder="10" placeholder="10"
inputMode="numeric" inputMode="numeric"
type="number" type="number"
min={1} min={1}
step={1} step={1}
disabled={!canManageHeartbeat}
/> />
<select <select
value={heartbeatUnit} value={heartbeatUnit}
onChange={(event) => onChange={(event) =>
setHeartbeatUnit(event.target.value as HeartbeatUnit) setHeartbeatUnit(event.target.value as HeartbeatUnit)
} }
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm" className={cn(
"h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm",
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
)}
disabled={!canManageHeartbeat}
> >
<option value="s">sec</option> <option value="s">sec</option>
<option value="m">min</option> <option value="m">min</option>
@@ -839,6 +904,7 @@ export default function BoardGroupDetailPage() {
onChange={(event) => onChange={(event) =>
setIncludeBoardLeads(event.target.checked) setIncludeBoardLeads(event.target.checked)
} }
disabled={!canManageHeartbeat}
/> />
Include leads Include leads
</label> </label>
@@ -846,11 +912,24 @@ export default function BoardGroupDetailPage() {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => void applyHeartbeat()} onClick={() => void applyHeartbeat()}
disabled={isHeartbeatApplying || !heartbeatEvery} disabled={
isHeartbeatApplying || !heartbeatEvery || !canManageHeartbeat
}
title={
canManageHeartbeat
? "Apply heartbeat"
: "Read-only access"
}
> >
{isHeartbeatApplying ? "Applying…" : "Apply"} {isHeartbeatApplying ? "Applying…" : "Apply"}
</Button> </Button>
</div> </div>
{!canManageHeartbeat ? (
<p className="text-xs text-slate-500">
Read-only access. You cannot change agent pace for this
group.
</p>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -1035,6 +1114,7 @@ export default function BoardGroupDetailPage() {
className="h-4 w-4 rounded border-slate-300 text-blue-600" className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={chatBroadcast} checked={chatBroadcast}
onChange={(event) => setChatBroadcast(event.target.checked)} onChange={(event) => setChatBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/> />
Broadcast Broadcast
</label> </label>
@@ -1072,9 +1152,14 @@ export default function BoardGroupDetailPage() {
</div> </div>
<BoardChatComposer <BoardChatComposer
placeholder="Message the whole group. Tag @lead, @name, or @all." placeholder={
canWriteGroup
? "Message the whole group. Tag @lead, @name, or @all."
: "Read-only access. Group chat is disabled."
}
isSending={isChatSending} isSending={isChatSending}
onSend={sendGroupChat} onSend={sendGroupChat}
disabled={!canWriteGroup}
/> />
</div> </div>
</div> </div>
@@ -1115,6 +1200,7 @@ export default function BoardGroupDetailPage() {
className="h-4 w-4 rounded border-slate-300 text-blue-600" className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={notesBroadcast} checked={notesBroadcast}
onChange={(event) => setNotesBroadcast(event.target.checked)} onChange={(event) => setNotesBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/> />
Broadcast Broadcast
</label> </label>
@@ -1152,9 +1238,14 @@ export default function BoardGroupDetailPage() {
</div> </div>
<BoardChatComposer <BoardChatComposer
placeholder="Post a shared note for all linked boards. Tag @lead, @name, or @all." placeholder={
canWriteGroup
? "Post a shared note for all linked boards. Tag @lead, @name, or @all."
: "Read-only access. Notes are disabled."
}
isSending={isNoteSending} isSending={isNoteSending}
onSend={sendGroupNote} onSend={sendGroupNote}
disabled={!canWriteGroup}
/> />
</div> </div>
</div> </div>

View File

@@ -22,6 +22,10 @@ import {
type listGatewaysApiV1GatewaysGetResponse, type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet, useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways"; } from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { import type {
BoardGroupRead, BoardGroupRead,
BoardRead, BoardRead,
@@ -59,6 +63,20 @@ export default function EditBoardPage() {
const boardIdParam = params?.boardId; const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
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 mainRef = useRef<HTMLElement | null>(null); const mainRef = useRef<HTMLElement | null>(null);
const [board, setBoard] = useState<BoardRead | null>(null); const [board, setBoard] = useState<BoardRead | null>(null);
@@ -130,7 +148,7 @@ export default function EditBoardPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
}, },
@@ -141,7 +159,7 @@ export default function EditBoardPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
}, },
@@ -152,7 +170,7 @@ export default function EditBoardPage() {
ApiError ApiError
>(boardId ?? "", { >(boardId ?? "", {
query: { query: {
enabled: Boolean(isSignedIn && boardId), enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
}, },
@@ -315,14 +333,20 @@ export default function EditBoardPage() {
Update board settings and gateway. Update board settings and gateway.
</p> </p>
</div> </div>
)}
</div> </div>
<div className="p-8"> <div className="p-8">
<div className="space-y-6"> {!isAdmin ? (
<form <div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
onSubmit={handleSubmit} Only organization owners and admins can edit board settings.
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm" </div>
> ) : (
<div className="space-y-6">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
{resolvedBoardType !== "general" && {resolvedBoardType !== "general" &&
baseBoard && baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? ( !(baseBoard.goal_confirmed ?? false) ? (
@@ -495,6 +519,7 @@ export default function EditBoardPage() {
</div> </div>
</form> </form>
</div> </div>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -45,6 +45,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ApiError } from "@/api/mutator";
import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents"; import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
import { import {
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet, streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
@@ -62,6 +63,10 @@ import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost, createBoardMemoryApiV1BoardsBoardIdMemoryPost,
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet, streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
} from "@/api/generated/board-memory/board-memory"; } from "@/api/generated/board-memory/board-memory";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { import {
createTaskApiV1BoardsBoardIdTasksPost, createTaskApiV1BoardsBoardIdTasksPost,
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost, createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
@@ -76,6 +81,7 @@ import type {
BoardGroupSnapshot, BoardGroupSnapshot,
BoardMemoryRead, BoardMemoryRead,
BoardRead, BoardRead,
OrganizationMemberRead,
TaskCardRead, TaskCardRead,
TaskCommentRead, TaskCommentRead,
TaskRead, TaskRead,
@@ -168,6 +174,47 @@ const formatShortTimestamp = (value: string) => {
}); });
}; };
type ToastMessage = {
id: number;
message: string;
tone: "error" | "success";
};
const formatActionError = (err: unknown, fallback: string) => {
if (err instanceof ApiError) {
if (err.status === 403) {
return "Read-only access. You do not have permission to make changes.";
}
return err.message || fallback;
}
if (err instanceof Error && err.message) {
return err.message;
}
return fallback;
};
const resolveBoardAccess = (
member: OrganizationMemberRead | null,
boardId?: string | null,
) => {
if (!member || !boardId) {
return { canRead: false, canWrite: false };
}
if (member.all_boards_write) {
return { canRead: true, canWrite: true };
}
if (member.all_boards_read) {
return { canRead: true, canWrite: false };
}
const entry = member.board_access?.find((access) => access.board_id === boardId);
if (!entry) {
return { canRead: false, canWrite: false };
}
const canWrite = Boolean(entry.can_write);
const canRead = Boolean(entry.can_read || entry.can_write);
return { canRead, canWrite };
};
const TaskCommentCard = memo(function TaskCommentCard({ const TaskCommentCard = memo(function TaskCommentCard({
comment, comment,
authorLabel, authorLabel,
@@ -322,6 +369,31 @@ export default function BoardDetailPage() {
const isPageActive = usePageActive(); const isPageActive = usePageActive();
const taskIdFromUrl = searchParams.get("taskId"); const taskIdFromUrl = searchParams.get("taskId");
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
},
});
const boardAccess = useMemo(
() =>
resolveBoardAccess(
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null,
boardId,
),
[membershipQuery.data, boardId],
);
const isOrgAdmin = useMemo(() => {
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
return member ? ["owner", "admin"].includes(member.role) : false;
}, [membershipQuery.data]);
const canWrite = boardAccess.canWrite;
const [board, setBoard] = useState<Board | null>(null); const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@@ -387,7 +459,10 @@ export default function BoardDetailPage() {
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null); const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [viewMode, setViewMode] = useState<"board" | "list">("board");
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false); const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const isLiveFeedOpenRef = useRef(false); const isLiveFeedOpenRef = useRef(false);
const toastIdRef = useRef(0);
const toastTimersRef = useRef<Record<number, number>>({});
const pushLiveFeed = useCallback((comment: TaskComment) => { const pushLiveFeed = useCallback((comment: TaskComment) => {
const alreadySeen = liveFeedRef.current.some( const alreadySeen = liveFeedRef.current.some(
(item) => item.id === comment.id, (item) => item.id === comment.id,
@@ -423,6 +498,31 @@ export default function BoardDetailPage() {
}, 2200); }, 2200);
}, []); }, []);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const timer = toastTimersRef.current[id];
if (timer !== undefined) {
window.clearTimeout(timer);
delete toastTimersRef.current[id];
}
}, []);
const pushToast = useCallback(
(message: string, tone: ToastMessage["tone"] = "error") => {
const trimmed = message.trim();
if (!trimmed) return;
const id = toastIdRef.current + 1;
toastIdRef.current = id;
setToasts((prev) => [...prev, { id, message: trimmed, tone }]);
if (typeof window !== "undefined") {
toastTimersRef.current[id] = window.setTimeout(() => {
dismissToast(id);
}, 3500);
}
},
[dismissToast],
);
useEffect(() => { useEffect(() => {
liveFeedHistoryLoadedRef.current = false; liveFeedHistoryLoadedRef.current = false;
setIsLiveFeedHistoryLoading(false); setIsLiveFeedHistoryLoading(false);
@@ -448,6 +548,17 @@ export default function BoardDetailPage() {
}; };
}, []); }, []);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
Object.values(toastTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
toastTimersRef.current = {};
};
}, []);
useEffect(() => { useEffect(() => {
if (!isLiveFeedOpen) return; if (!isLiveFeedOpen) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
@@ -1269,7 +1380,7 @@ export default function BoardDetailPage() {
useEffect(() => { useEffect(() => {
if (!isPageActive) return; if (!isPageActive) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId || !isOrgAdmin) return;
let isCancelled = false; let isCancelled = false;
const abortController = new AbortController(); const abortController = new AbortController();
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
@@ -1372,7 +1483,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout); window.clearTimeout(reconnectTimeout);
} }
}; };
}, [board, boardId, isPageActive, isSignedIn]); }, [board, boardId, isOrgAdmin, isPageActive, isSignedIn]);
const resetForm = () => { const resetForm = () => {
setTitle(""); setTitle("");
@@ -1411,9 +1522,9 @@ export default function BoardDetailPage() {
setIsDialogOpen(false); setIsDialogOpen(false);
resetForm(); resetForm();
} catch (err) { } catch (err) {
setCreateError( const message = formatActionError(err, "Something went wrong.");
err instanceof Error ? err.message : "Something went wrong.", setCreateError(message);
); pushToast(message);
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -1454,8 +1565,7 @@ export default function BoardDetailPage() {
} }
return { ok: true, error: null }; return { ok: true, error: null };
} catch (err) { } catch (err) {
const message = const message = formatActionError(err, "Unable to send message.");
err instanceof Error ? err.message : "Unable to send message.";
return { ok: false, error: message }; return { ok: false, error: message };
} }
}, },
@@ -1473,6 +1583,7 @@ export default function BoardDetailPage() {
if (!result.ok) { if (!result.ok) {
if (result.error) { if (result.error) {
setChatError(result.error); setChatError(result.error);
pushToast(result.error);
} }
return false; return false;
} }
@@ -1481,7 +1592,7 @@ export default function BoardDetailPage() {
setIsChatSending(false); setIsChatSending(false);
} }
}, },
[postBoardChatMessage], [postBoardChatMessage, pushToast],
); );
const openAgentsControlDialog = (action: "pause" | "resume") => { const openAgentsControlDialog = (action: "pause" | "resume") => {
@@ -1497,16 +1608,16 @@ export default function BoardDetailPage() {
try { try {
const result = await postBoardChatMessage(command); const result = await postBoardChatMessage(command);
if (!result.ok) { if (!result.ok) {
setAgentsControlError( const message = result.error ?? `Unable to send ${command} command.`;
result.error ?? `Unable to send ${command} command.`, setAgentsControlError(message);
); pushToast(message);
return; return;
} }
setIsAgentsControlDialogOpen(false); setIsAgentsControlDialogOpen(false);
} finally { } finally {
setIsAgentsControlSending(false); setIsAgentsControlSending(false);
} }
}, [agentsControlAction, postBoardChatMessage]); }, [agentsControlAction, postBoardChatMessage, pushToast]);
const assigneeById = useMemo(() => { const assigneeById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -1746,9 +1857,9 @@ export default function BoardDetailPage() {
setComments((prev) => [created, ...prev]); setComments((prev) => [created, ...prev]);
setNewComment(""); setNewComment("");
} catch (err) { } catch (err) {
setPostCommentError( const message = formatActionError(err, "Unable to send message.");
err instanceof Error ? err.message : "Unable to send message.", setPostCommentError(message);
); pushToast(message);
} finally { } finally {
setIsPostingComment(false); setIsPostingComment(false);
taskCommentInputRef.current?.focus(); taskCommentInputRef.current?.focus();
@@ -1830,9 +1941,9 @@ export default function BoardDetailPage() {
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
} }
} catch (err) { } catch (err) {
setSaveTaskError( const message = formatActionError(err, "Something went wrong.");
err instanceof Error ? err.message : "Something went wrong.", setSaveTaskError(message);
); pushToast(message);
} finally { } finally {
setIsSavingTask(false); setIsSavingTask(false);
} }
@@ -1863,9 +1974,9 @@ export default function BoardDetailPage() {
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
closeComments(); closeComments();
} catch (err) { } catch (err) {
setDeleteTaskError( const message = formatActionError(err, "Something went wrong.");
err instanceof Error ? err.message : "Something went wrong.", setDeleteTaskError(message);
); pushToast(message);
} finally { } finally {
setIsDeletingTask(false); setIsDeletingTask(false);
} }
@@ -1936,10 +2047,12 @@ export default function BoardDetailPage() {
); );
} catch (err) { } catch (err) {
setTasks(previousTasks); setTasks(previousTasks);
setError(err instanceof Error ? err.message : "Unable to move task."); const message = formatActionError(err, "Unable to move task.");
setError(message);
pushToast(message);
} }
}, },
[boardId, isSignedIn, taskTitleById], [boardId, isSignedIn, pushToast, taskTitleById],
); );
const agentInitials = (agent: Agent) => const agentInitials = (agent: Agent) =>
@@ -2085,6 +2198,10 @@ export default function BoardDetailPage() {
const handleApprovalDecision = useCallback( const handleApprovalDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => { async (approvalId: string, status: "approved" | "rejected") => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
if (!canWrite) {
pushToast("Read-only access. You do not have permission to update approvals.");
return;
}
setApprovalsUpdatingId(approvalId); setApprovalsUpdatingId(approvalId);
setApprovalsError(null); setApprovalsError(null);
try { try {
@@ -2102,14 +2219,14 @@ export default function BoardDetailPage() {
prev.map((item) => (item.id === approvalId ? updated : item)), prev.map((item) => (item.id === approvalId ? updated : item)),
); );
} catch (err) { } catch (err) {
setApprovalsError( const message = formatActionError(err, "Unable to update approval.");
err instanceof Error ? err.message : "Unable to update approval.", setApprovalsError(message);
); pushToast(message);
} finally { } finally {
setApprovalsUpdatingId(null); setApprovalsUpdatingId(null);
} }
}, },
[boardId, isSignedIn], [boardId, canWrite, isSignedIn, pushToast],
); );
return ( return (
@@ -2174,7 +2291,8 @@ export default function BoardDetailPage() {
onClick={() => setIsDialogOpen(true)} onClick={() => setIsDialogOpen(true)}
className="h-9 w-9 p-0" className="h-9 w-9 p-0"
aria-label="New task" aria-label="New task"
title="New task" title={canWrite ? "New task" : "Read-only access"}
disabled={!canWrite}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
@@ -2192,31 +2310,44 @@ export default function BoardDetailPage() {
</span> </span>
) : null} ) : null}
</Button> </Button>
<Button {isOrgAdmin ? (
variant="outline" <Button
onClick={() => variant="outline"
openAgentsControlDialog( onClick={() =>
isAgentsPaused ? "resume" : "pause", openAgentsControlDialog(
) isAgentsPaused ? "resume" : "pause",
} )
disabled={!isSignedIn || !boardId || isAgentsControlSending} }
className={cn( disabled={
"h-9 w-9 p-0", !isSignedIn ||
isAgentsPaused !boardId ||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800" isAgentsControlSending ||
: "", !canWrite
)} }
aria-label={ className={cn(
isAgentsPaused ? "Resume agents" : "Pause agents" "h-9 w-9 p-0",
} isAgentsPaused
title={isAgentsPaused ? "Resume agents" : "Pause agents"} ? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
> : "",
{isAgentsPaused ? ( )}
<Play className="h-4 w-4" /> aria-label={
) : ( isAgentsPaused ? "Resume agents" : "Pause agents"
<Pause className="h-4 w-4" /> }
)} title={
</Button> canWrite
? isAgentsPaused
? "Resume agents"
: "Pause agents"
: "Read-only access"
}
>
{isAgentsPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
) : null}
<Button <Button
variant="outline" variant="outline"
onClick={openBoardChat} onClick={openBoardChat}
@@ -2235,83 +2366,87 @@ export default function BoardDetailPage() {
> >
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
</Button> </Button>
<button {isOrgAdmin ? (
type="button" <button
onClick={() => router.push(`/boards/${boardId}/edit`)} type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50" onClick={() => router.push(`/boards/${boardId}/edit`)}
aria-label="Board settings" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
title="Board settings" aria-label="Board settings"
> title="Board settings"
<Settings className="h-4 w-4" /> >
</button> <Settings className="h-4 w-4" />
</button>
) : null}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="relative flex gap-6 p-6"> <div className="relative flex gap-6 p-6">
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm"> {isOrgAdmin ? (
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3"> <aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div> <div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <div>
Agents <p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
</p> Agents
<p className="text-xs text-slate-400"> </p>
{sortedAgents.length} total <p className="text-xs text-slate-400">
</p> {sortedAgents.length} total
</div> </p>
<button
type="button"
onClick={() => router.push("/agents/new")}
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
>
Add
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{sortedAgents.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
No agents assigned yet.
</div> </div>
) : ( <button
sortedAgents.map((agent) => { type="button"
const isWorking = workingAgentIds.has(agent.id); onClick={() => router.push("/agents/new")}
return ( className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
<button >
key={agent.id} Add
type="button" </button>
className={cn( </div>
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50", <div className="flex-1 space-y-2 overflow-y-auto p-3">
)} {sortedAgents.length === 0 ? (
onClick={() => router.push(`/agents/${agent.id}`)} <div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
> No agents assigned yet.
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700"> </div>
{agentAvatarLabel(agent)} ) : (
<span sortedAgents.map((agent) => {
className={cn( const isWorking = workingAgentIds.has(agent.id);
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white", return (
isWorking <button
? "bg-emerald-500" key={agent.id}
: agent.status === "online" type="button"
? "bg-green-500" className={cn(
: "bg-slate-300", "flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
)} )}
/> onClick={() => router.push(`/agents/${agent.id}`)}
</div> >
<div className="min-w-0 flex-1"> <div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
<p className="truncate text-sm font-medium text-slate-900"> {agentAvatarLabel(agent)}
{agent.name} <span
</p> className={cn(
<p className="text-[11px] text-slate-500"> "absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
{agentRoleLabel(agent)} isWorking
</p> ? "bg-emerald-500"
</div> : agent.status === "online"
</button> ? "bg-green-500"
); : "bg-slate-300",
}) )}
)} />
</div> </div>
</aside> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{agent.name}
</p>
<p className="text-[11px] text-slate-500">
{agentRoleLabel(agent)}
</p>
</div>
</button>
);
})
)}
</div>
</aside>
) : null}
<div className="min-w-0 flex-1 space-y-6"> <div className="min-w-0 flex-1 space-y-6">
{error && ( {error && (
@@ -2364,16 +2499,18 @@ export default function BoardDetailPage() {
> >
View group View group
</Button> </Button>
<Button {isOrgAdmin ? (
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => size="sm"
router.push(`/boards/${boardId}/edit`) onClick={() =>
} router.push(`/boards/${boardId}/edit`)
disabled={!boardId} }
> disabled={!boardId}
Settings >
</Button> Settings
</Button>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -2528,7 +2665,8 @@ export default function BoardDetailPage() {
<TaskBoard <TaskBoard
tasks={tasks} tasks={tasks}
onTaskSelect={openComments} onTaskSelect={openComments}
onTaskMove={handleTaskMove} onTaskMove={canWrite ? handleTaskMove : undefined}
readOnly={!canWrite}
/> />
) : ( ) : (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -2546,7 +2684,8 @@ export default function BoardDetailPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsDialogOpen(true)} onClick={() => setIsDialogOpen(true)}
disabled={isCreating} disabled={isCreating || !canWrite}
title={canWrite ? "New task" : "Read-only access"}
> >
New task New task
</Button> </Button>
@@ -2660,7 +2799,8 @@ export default function BoardDetailPage() {
type="button" type="button"
onClick={() => setIsEditDialogOpen(true)} onClick={() => setIsEditDialogOpen(true)}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50" className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
disabled={!selectedTask} disabled={!selectedTask || !canWrite}
title={canWrite ? "Edit task" : "Read-only access"}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</button> </button>
@@ -2826,7 +2966,10 @@ export default function BoardDetailPage() {
onClick={() => onClick={() =>
handleApprovalDecision(approval.id, "approved") handleApprovalDecision(approval.id, "approved")
} }
disabled={approvalsUpdatingId === approval.id} disabled={
approvalsUpdatingId === approval.id || !canWrite
}
title={canWrite ? "Approve" : "Read-only access"}
> >
Approve Approve
</Button> </Button>
@@ -2836,7 +2979,10 @@ export default function BoardDetailPage() {
onClick={() => onClick={() =>
handleApprovalDecision(approval.id, "rejected") handleApprovalDecision(approval.id, "rejected")
} }
disabled={approvalsUpdatingId === approval.id} disabled={
approvalsUpdatingId === approval.id || !canWrite
}
title={canWrite ? "Reject" : "Read-only access"}
className="border-slate-300 text-slate-700" className="border-slate-300 text-slate-700"
> >
Reject Reject
@@ -2861,22 +3007,34 @@ export default function BoardDetailPage() {
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return; if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return; if (event.shiftKey) return;
if (!canWrite) return;
event.preventDefault(); event.preventDefault();
if (isPostingComment) return; if (isPostingComment) return;
if (!newComment.trim()) return; if (!newComment.trim()) return;
void handlePostComment(); void handlePostComment();
}} }}
placeholder="Write a message for the assigned agent…" placeholder={
canWrite
? "Write a message for the assigned agent…"
: "Read-only access. Comments are disabled."
}
className="min-h-[80px] bg-white" className="min-h-[80px] bg-white"
disabled={!canWrite || isPostingComment}
/> />
{postCommentError ? ( {postCommentError ? (
<p className="text-xs text-rose-600">{postCommentError}</p> <p className="text-xs text-rose-600">{postCommentError}</p>
) : null} ) : null}
{!canWrite ? (
<p className="text-xs text-slate-500">
Read-only access. You cannot post comments on this board.
</p>
) : null}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
size="sm" size="sm"
onClick={handlePostComment} onClick={handlePostComment}
disabled={isPostingComment || !newComment.trim()} disabled={!canWrite || isPostingComment || !newComment.trim()}
title={canWrite ? "Send message" : "Read-only access"}
> >
{isPostingComment ? "Sending…" : "Send message"} {isPostingComment ? "Sending…" : "Send message"}
</Button> </Button>
@@ -2956,6 +3114,12 @@ export default function BoardDetailPage() {
<BoardChatComposer <BoardChatComposer
isSending={isChatSending} isSending={isChatSending}
onSend={handleSendChat} onSend={handleSendChat}
disabled={!canWrite}
placeholder={
canWrite
? "Message the board lead. Tag agents with @name."
: "Read-only access. Chat is disabled."
}
/> />
</div> </div>
</div> </div>
@@ -3052,7 +3216,7 @@ export default function BoardDetailPage() {
value={editTitle} value={editTitle}
onChange={(event) => setEditTitle(event.target.value)} onChange={(event) => setEditTitle(event.target.value)}
placeholder="Task title" placeholder="Task title"
disabled={!selectedTask || isSavingTask} disabled={!selectedTask || isSavingTask || !canWrite}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -3064,7 +3228,7 @@ export default function BoardDetailPage() {
onChange={(event) => setEditDescription(event.target.value)} onChange={(event) => setEditDescription(event.target.value)}
placeholder="Task details" placeholder="Task details"
className="min-h-[140px]" className="min-h-[140px]"
disabled={!selectedTask || isSavingTask} disabled={!selectedTask || isSavingTask || !canWrite}
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -3075,7 +3239,7 @@ export default function BoardDetailPage() {
<Select <Select
value={editStatus} value={editStatus}
onValueChange={(value) => setEditStatus(value as TaskStatus)} onValueChange={(value) => setEditStatus(value as TaskStatus)}
disabled={!selectedTask || isSavingTask} disabled={!selectedTask || isSavingTask || !canWrite}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select status" /> <SelectValue placeholder="Select status" />
@@ -3096,7 +3260,7 @@ export default function BoardDetailPage() {
<Select <Select
value={editPriority} value={editPriority}
onValueChange={setEditPriority} onValueChange={setEditPriority}
disabled={!selectedTask || isSavingTask} disabled={!selectedTask || isSavingTask || !canWrite}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select priority" /> <SelectValue placeholder="Select priority" />
@@ -3120,7 +3284,7 @@ export default function BoardDetailPage() {
onValueChange={(value) => onValueChange={(value) =>
setEditAssigneeId(value === "unassigned" ? "" : value) setEditAssigneeId(value === "unassigned" ? "" : value)
} }
disabled={!selectedTask || isSavingTask} disabled={!selectedTask || isSavingTask || !canWrite}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Unassigned" /> <SelectValue placeholder="Unassigned" />
@@ -3155,7 +3319,8 @@ export default function BoardDetailPage() {
disabled={ disabled={
!selectedTask || !selectedTask ||
isSavingTask || isSavingTask ||
selectedTask.status === "done" selectedTask.status === "done" ||
!canWrite
} }
emptyMessage="No other tasks found." emptyMessage="No other tasks found."
/> />
@@ -3195,8 +3360,14 @@ export default function BoardDetailPage() {
<button <button
type="button" type="button"
onClick={() => removeTaskDependency(depId)} onClick={() => removeTaskDependency(depId)}
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700" className={cn(
"rounded-full p-0.5 text-slate-500 transition",
canWrite
? "hover:bg-white hover:text-slate-700"
: "opacity-50 cursor-not-allowed",
)}
aria-label="Remove dependency" aria-label="Remove dependency"
disabled={!canWrite}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
@@ -3217,21 +3388,26 @@ export default function BoardDetailPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsDeleteDialogOpen(true)} onClick={() => setIsDeleteDialogOpen(true)}
disabled={!selectedTask || isSavingTask} disabled={!selectedTask || isSavingTask || !canWrite}
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700" className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
title={canWrite ? "Delete task" : "Read-only access"}
> >
Delete task Delete task
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={handleTaskReset} onClick={handleTaskReset}
disabled={!selectedTask || isSavingTask || !hasTaskChanges} disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
> >
Reset Reset
</Button> </Button>
<Button <Button
onClick={() => handleTaskSave(true)} onClick={() => handleTaskSave(true)}
disabled={!selectedTask || isSavingTask || !hasTaskChanges} disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
> >
{isSavingTask ? "Saving…" : "Save changes"} {isSavingTask ? "Saving…" : "Save changes"}
</Button> </Button>
@@ -3262,7 +3438,7 @@ export default function BoardDetailPage() {
</Button> </Button>
<Button <Button
onClick={handleDeleteTask} onClick={handleDeleteTask}
disabled={isDeletingTask} disabled={isDeletingTask || !canWrite}
className="bg-rose-600 text-white hover:bg-rose-700" className="bg-rose-600 text-white hover:bg-rose-700"
> >
{isDeletingTask ? "Deleting…" : "Delete task"} {isDeletingTask ? "Deleting…" : "Delete task"}
@@ -3294,6 +3470,7 @@ export default function BoardDetailPage() {
value={title} value={title}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
placeholder="e.g. Prepare launch notes" placeholder="e.g. Prepare launch notes"
disabled={!canWrite || isCreating}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -3305,13 +3482,18 @@ export default function BoardDetailPage() {
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
placeholder="Optional details" placeholder="Optional details"
className="min-h-[120px]" className="min-h-[120px]"
disabled={!canWrite || isCreating}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-strong"> <label className="text-sm font-medium text-strong">
Priority Priority
</label> </label>
<Select value={priority} onValueChange={setPriority}> <Select
value={priority}
onValueChange={setPriority}
disabled={!canWrite || isCreating}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select priority" /> <SelectValue placeholder="Select priority" />
</SelectTrigger> </SelectTrigger>
@@ -3334,77 +3516,116 @@ export default function BoardDetailPage() {
<Button variant="outline" onClick={() => setIsDialogOpen(false)}> <Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreateTask} disabled={isCreating}> <Button onClick={handleCreateTask} disabled={!canWrite || isCreating}>
{isCreating ? "Creating…" : "Create task"} {isCreating ? "Creating…" : "Create task"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog {isOrgAdmin ? (
open={isAgentsControlDialogOpen} <Dialog
onOpenChange={(nextOpen) => { open={isAgentsControlDialogOpen}
setIsAgentsControlDialogOpen(nextOpen); onOpenChange={(nextOpen) => {
if (!nextOpen) { setIsAgentsControlDialogOpen(nextOpen);
setAgentsControlError(null); if (!nextOpen) {
} setAgentsControlError(null);
}} }
> }}
<DialogContent aria-label="Agent controls"> >
<DialogHeader> <DialogContent aria-label="Agent controls">
<DialogTitle> <DialogHeader>
{agentsControlAction === "pause" <DialogTitle>
? "Pause agents" {agentsControlAction === "pause"
: "Resume agents"}
</DialogTitle>
<DialogDescription>
{agentsControlAction === "pause"
? "Send /pause to every agent on this board."
: "Send /resume to every agent on this board."}
</DialogDescription>
</DialogHeader>
{agentsControlError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{agentsControlError}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-semibold text-slate-900">What happens</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
This posts{" "}
<span className="font-mono">
{agentsControlAction === "pause" ? "/pause" : "/resume"}
</span>{" "}
to board chat.
</li>
<li>Mission Control forwards it to all agents on this board.</li>
</ul>
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsAgentsControlDialogOpen(false)}
disabled={isAgentsControlSending}
>
Cancel
</Button>
<Button
onClick={handleConfirmAgentsControl}
disabled={isAgentsControlSending}
>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"
? "Pause agents" ? "Pause agents"
: "Resume agents"} : "Resume agents"}
</Button> </DialogTitle>
</DialogFooter> <DialogDescription>
</DialogContent> {agentsControlAction === "pause"
</Dialog> ? "Send /pause to every agent on this board."
: "Send /resume to every agent on this board."}
</DialogDescription>
</DialogHeader>
{agentsControlError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{agentsControlError}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-semibold text-slate-900">What happens</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
This posts{" "}
<span className="font-mono">
{agentsControlAction === "pause" ? "/pause" : "/resume"}
</span>{" "}
to board chat.
</li>
<li>
Mission Control forwards it to all agents on this board.
</li>
</ul>
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsAgentsControlDialogOpen(false)}
disabled={isAgentsControlSending}
>
Cancel
</Button>
<Button
onClick={handleConfirmAgentsControl}
disabled={isAgentsControlSending}
>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
{toasts.length ? (
<div className="fixed bottom-6 right-6 z-[60] flex w-[320px] max-w-[90vw] flex-col gap-3">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"rounded-xl border bg-white px-4 py-3 text-sm shadow-lush",
toast.tone === "error"
? "border-rose-200 text-rose-700"
: "border-emerald-200 text-emerald-700",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1 h-2 w-2 rounded-full",
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
)}
/>
<p className="flex-1 text-sm text-slate-700">
{toast.message}
</p>
<button
type="button"
className="text-xs text-slate-400 hover:text-slate-600"
onClick={() => dismissToast(toast.id)}
>
Dismiss
</button>
</div>
</div>
))}
</div>
) : null}
{/* onboarding moved to board settings */} {/* onboarding moved to board settings */}
</DashboardShell> </DashboardShell>

View File

@@ -18,6 +18,10 @@ import {
type listGatewaysApiV1GatewaysGetResponse, type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet, useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways"; } from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardGroupRead } from "@/api/generated/model"; import type { BoardGroupRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -36,6 +40,20 @@ export default function NewBoardPage() {
const router = useRouter(); const router = useRouter();
const { isSignedIn } = useAuth(); 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 [name, setName] = useState("");
const [gatewayId, setGatewayId] = useState<string>(""); const [gatewayId, setGatewayId] = useState<string>("");
const [boardGroupId, setBoardGroupId] = useState<string>("none"); const [boardGroupId, setBoardGroupId] = useState<string>("none");
@@ -47,7 +65,7 @@ export default function NewBoardPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
}, },
@@ -58,7 +76,7 @@ export default function NewBoardPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
}, },
@@ -166,100 +184,106 @@ export default function NewBoardPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
<form {!isAdmin ? (
onSubmit={handleSubmit} <div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm" Only organization owners and admins can create boards.
> </div>
<div className="space-y-4"> ) : (
<div className="grid gap-6 md:grid-cols-2"> <form
<div className="space-y-2"> onSubmit={handleSubmit}
<label className="text-sm font-medium text-slate-900"> className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
Board name <span className="text-red-500">*</span> >
</label> <div className="space-y-4">
<Input <div className="grid gap-6 md:grid-cols-2">
value={name} <div className="space-y-2">
onChange={(event) => setName(event.target.value)} <label className="text-sm font-medium text-slate-900">
placeholder="e.g. Release operations" Board name <span className="text-red-500">*</span>
disabled={isLoading} </label>
/> <Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Release operations"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900"> <div className="grid gap-6 md:grid-cols-2">
Gateway <span className="text-red-500">*</span> <div className="space-y-2">
</label> <label className="text-sm font-medium text-slate-900">
<SearchableSelect Board group
ariaLabel="Select gateway" </label>
value={displayGatewayId} <SearchableSelect
onValueChange={setGatewayId} ariaLabel="Select board group"
options={gatewayOptions} value={boardGroupId}
placeholder="Select gateway" onValueChange={setBoardGroupId}
searchPlaceholder="Search gateways..." options={groupOptions}
emptyMessage="No gateways found." placeholder="No group"
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200" searchPlaceholder="Search groups..."
contentClassName="rounded-xl border border-slate-200 shadow-lg" emptyMessage="No groups found."
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900" triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
/> contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Optional. Groups increase cross-board visibility.
</p>
</div>
</div> </div>
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> {gateways.length === 0 ? (
<div className="space-y-2"> <div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<label className="text-sm font-medium text-slate-900"> <p>
Board group No gateways available. Create one in{" "}
</label> <Link
<SearchableSelect href="/gateways"
ariaLabel="Select board group" className="font-medium text-blue-600 hover:text-blue-700"
value={boardGroupId} >
onValueChange={setBoardGroupId} Gateways
options={groupOptions} </Link>{" "}
placeholder="No group" to continue.
searchPlaceholder="Search groups..."
emptyMessage="No groups found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Optional. Groups increase cross-board visibility.
</p> </p>
</div> </div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/boards")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !isFormReady}>
{isLoading ? "Creating…" : "Create board"}
</Button>
</div> </div>
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>
No gateways available. Create one in{" "}
<Link
href="/gateways"
className="font-medium text-blue-600 hover:text-blue-700"
>
Gateways
</Link>{" "}
to continue.
</p>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/boards")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !isFormReady}>
{isLoading ? "Creating…" : "Create board"}
</Button>
</div>
</form> </form>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -25,6 +25,10 @@ import {
type listBoardGroupsApiV1BoardGroupsGetResponse, type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet, useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups"; } 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 type { BoardGroupRead, BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -56,6 +60,20 @@ const compactId = (value: string) =>
export default function BoardsPage() { export default function BoardsPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const queryClient = useQueryClient(); 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<BoardRead | null>(null); const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
const boardsKey = getListBoardsApiV1BoardsGetQueryKey(); const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
@@ -264,7 +282,7 @@ export default function BoardsPage() {
{boards.length === 1 ? "" : "s"} total. {boards.length === 1 ? "" : "s"} total.
</p> </p>
</div> </div>
{boards.length > 0 ? ( {boards.length > 0 && isAdmin ? (
<Link <Link
href="/boards/new" href="/boards/new"
className={buttonVariants({ className={buttonVariants({

View File

@@ -15,6 +15,10 @@ import {
useGetGatewayApiV1GatewaysGatewayIdGet, useGetGatewayApiV1GatewaysGatewayIdGet,
useUpdateGatewayApiV1GatewaysGatewayIdPatch, useUpdateGatewayApiV1GatewaysGatewayIdPatch,
} from "@/api/generated/gateways/gateways"; } from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { GatewayUpdate } from "@/api/generated/model"; import type { GatewayUpdate } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -50,6 +54,20 @@ export default function EditGatewayPage() {
? gatewayIdParam[0] ? gatewayIdParam[0]
: gatewayIdParam; : 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 [name, setName] = useState<string | undefined>(undefined); const [name, setName] = useState<string | undefined>(undefined);
const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined); const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined);
const [gatewayToken, setGatewayToken] = useState<string | undefined>( const [gatewayToken, setGatewayToken] = useState<string | undefined>(
@@ -77,7 +95,7 @@ export default function EditGatewayPage() {
ApiError ApiError
>(gatewayId ?? "", { >(gatewayId ?? "", {
query: { query: {
enabled: Boolean(isSignedIn && gatewayId), enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchOnMount: "always", refetchOnMount: "always",
retry: false, retry: false,
}, },
@@ -230,21 +248,26 @@ export default function EditGatewayPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
<form {!isAdmin ? (
onSubmit={handleSubmit} <div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm" Only organization owners and admins can edit gateways.
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
</div> </div>
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -361,6 +384,7 @@ export default function EditGatewayPage() {
</Button> </Button>
</div> </div>
</form> </form>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -18,6 +18,10 @@ import {
type listAgentsApiV1AgentsGetResponse, type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet, useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents"; } from "@/api/generated/agents/agents";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -49,12 +53,26 @@ export default function GatewayDetailPage() {
? gatewayIdParam[0] ? gatewayIdParam[0]
: gatewayIdParam; : 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< const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
getGatewayApiV1GatewaysGatewayIdGetResponse, getGatewayApiV1GatewaysGatewayIdGetResponse,
ApiError ApiError
>(gatewayId ?? "", { >(gatewayId ?? "", {
query: { query: {
enabled: Boolean(isSignedIn && gatewayId), enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchInterval: 30_000, refetchInterval: 30_000,
}, },
}); });
@@ -67,7 +85,7 @@ export default function GatewayDetailPage() {
ApiError ApiError
>(gatewayId ? { gateway_id: gatewayId } : undefined, { >(gatewayId ? { gateway_id: gatewayId } : undefined, {
query: { query: {
enabled: Boolean(isSignedIn && gatewayId), enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchInterval: 15_000, refetchInterval: 15_000,
}, },
}); });
@@ -85,7 +103,7 @@ export default function GatewayDetailPage() {
ApiError ApiError
>(statusParams, { >(statusParams, {
query: { query: {
enabled: Boolean(isSignedIn && statusParams), enabled: Boolean(isSignedIn && isAdmin && statusParams),
refetchInterval: 15_000, refetchInterval: 15_000,
}, },
}); });
@@ -142,7 +160,7 @@ export default function GatewayDetailPage() {
> >
Back to gateways Back to gateways
</Button> </Button>
{gatewayId ? ( {isAdmin && gatewayId ? (
<Button <Button
onClick={() => router.push(`/gateways/${gatewayId}/edit`)} onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
> >
@@ -154,7 +172,11 @@ export default function GatewayDetailPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
{gatewayQuery.isLoading ? ( {!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can access gateways.
</div>
) : gatewayQuery.isLoading ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
Loading gateway Loading gateway
</div> </div>

View File

@@ -13,6 +13,10 @@ import {
gatewaysStatusApiV1GatewaysStatusGet, gatewaysStatusApiV1GatewaysStatusGet,
useCreateGatewayApiV1GatewaysPost, useCreateGatewayApiV1GatewaysPost,
} from "@/api/generated/gateways/gateways"; } from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -42,6 +46,20 @@ export default function NewGatewayPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const router = useRouter(); 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 [name, setName] = useState("");
const [gatewayUrl, setGatewayUrl] = useState(""); const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState(""); const [gatewayToken, setGatewayToken] = useState("");
@@ -191,21 +209,26 @@ export default function NewGatewayPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
<form {!isAdmin ? (
onSubmit={handleSubmit} <div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm" Only organization owners and admins can create gateways.
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
</div> </div>
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -320,6 +343,7 @@ export default function NewGatewayPage() {
</Button> </Button>
</div> </div>
</form> </form>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -35,6 +35,10 @@ import {
useDeleteGatewayApiV1GatewaysGatewayIdDelete, useDeleteGatewayApiV1GatewaysGatewayIdDelete,
useListGatewaysApiV1GatewaysGet, useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways"; } from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { GatewayRead } from "@/api/generated/model"; import type { GatewayRead } from "@/api/generated/model";
const truncate = (value?: string | null, max = 24) => { const truncate = (value?: string | null, max = 24) => {
@@ -58,6 +62,20 @@ const formatTimestamp = (value?: string | null) => {
export default function GatewaysPage() { export default function GatewaysPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const queryClient = useQueryClient(); 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<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false }, { id: "name", desc: false },
]); ]);
@@ -69,7 +87,7 @@ export default function GatewaysPage() {
ApiError ApiError
>(undefined, { >(undefined, {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000, refetchInterval: 30_000,
refetchOnMount: "always", refetchOnMount: "always",
}, },
@@ -240,7 +258,7 @@ export default function GatewaysPage() {
Manage OpenClaw gateway connections used by boards Manage OpenClaw gateway connections used by boards
</p> </p>
</div> </div>
{gateways.length > 0 ? ( {isAdmin && gateways.length > 0 ? (
<Link <Link
href="/gateways/new" href="/gateways/new"
className={buttonVariants({ className={buttonVariants({
@@ -256,9 +274,15 @@ export default function GatewaysPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> {!isAdmin ? (
<div className="overflow-x-auto"> <div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
<table className="w-full text-left text-sm"> Only organization owners and admins can access gateways.
</div>
) : (
<>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500"> <thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
@@ -347,11 +371,13 @@ export default function GatewaysPage() {
</div> </div>
</div> </div>
{gatewaysQuery.error ? ( {gatewaysQuery.error ? (
<p className="mt-4 text-sm text-red-500"> <p className="mt-4 text-sm text-red-500">
{gatewaysQuery.error.message} {gatewaysQuery.error.message}
</p> </p>
) : null} ) : null}
</>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -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<string | null>(null);
const [accepted, setAccepted] = useState(false);
useEffect(() => {
setToken(tokenFromQuery);
}, [tokenFromQuery]);
const acceptInviteMutation =
useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost<ApiError>({
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 (
<div className="min-h-screen bg-app text-strong">
<header className="border-b border-[color:var(--border)] bg-white">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<BrandMark />
</div>
</header>
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Organization Invite
</p>
<h1 className="text-2xl font-semibold text-strong">
Join your team in OpenClaw
</h1>
<p className="text-sm text-muted">{helperText}</p>
</div>
<div className="mt-6 flex flex-col gap-4">
<label className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Invite Token
</label>
<Input
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="Paste invite token"
disabled={accepted || isSubmitting}
/>
{error ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-600">
{error}
</div>
) : null}
<SignedOut>
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
<p>Sign in to accept your invite.</p>
<SignInButton mode="modal">
<Button size="md">Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<form className="flex flex-wrap items-center gap-3" onSubmit={handleAccept}>
<Button type="submit" size="md" disabled={!isReady || isSubmitting || accepted}>
{accepted
? "Invite accepted"
: isSubmitting
? "Accepting…"
: "Accept invite"}
</Button>
<Button
type="button"
variant="ghost"
size="md"
onClick={() => router.push("/")}
disabled={isSubmitting}
>
Go back
</Button>
</form>
</SignedIn>
</div>
</div>
</main>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea";
type BoardChatComposerProps = { type BoardChatComposerProps = {
placeholder?: string; placeholder?: string;
isSending?: boolean; isSending?: boolean;
disabled?: boolean;
onSend: (content: string) => Promise<boolean>; onSend: (content: string) => Promise<boolean>;
}; };
function BoardChatComposerImpl({ function BoardChatComposerImpl({
placeholder = "Message the board lead. Tag agents with @name.", placeholder = "Message the board lead. Tag agents with @name.",
isSending = false, isSending = false,
disabled = false,
onSend, onSend,
}: BoardChatComposerProps) { }: BoardChatComposerProps) {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
@@ -28,7 +30,7 @@ function BoardChatComposerImpl({
}, [isSending]); }, [isSending]);
const send = useCallback(async () => { const send = useCallback(async () => {
if (isSending) return; if (isSending || disabled) return;
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return; if (!trimmed) return;
const ok = await onSend(trimmed); const ok = await onSend(trimmed);
@@ -53,12 +55,12 @@ function BoardChatComposerImpl({
}} }}
placeholder={placeholder} placeholder={placeholder}
className="min-h-[120px]" className="min-h-[120px]"
disabled={isSending} disabled={isSending || disabled}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
onClick={() => void send()} onClick={() => void send()}
disabled={isSending || !value.trim()} disabled={isSending || disabled || !value.trim()}
> >
{isSending ? "Sending…" : "Send"} {isSending ? "Sending…" : "Send"}
</Button> </Button>

View File

@@ -93,8 +93,10 @@ export function TaskCard({
/> />
) : null} ) : null}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="space-y-2"> <div className="min-w-0 space-y-2">
<p className="text-sm font-medium text-slate-900">{title}</p> <p className="text-sm font-medium text-slate-900 line-clamp-2 break-words">
{title}
</p>
{isBlocked ? ( {isBlocked ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700"> <div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" /> <span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
@@ -114,7 +116,7 @@ export function TaskCard({
</div> </div>
) : null} ) : null}
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-shrink-0 flex-col items-end gap-2">
<span <span
className={cn( className={cn(
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide", "inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",

View File

@@ -8,11 +8,14 @@ import {
Bot, Bot,
CheckCircle2, CheckCircle2,
Folder, Folder,
Building2,
LayoutGrid, LayoutGrid,
Network, Network,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator"; import { ApiError } from "@/api/mutator";
import { type getMyMembershipApiV1OrganizationsMeMemberGetResponse, useGetMyMembershipApiV1OrganizationsMeMemberGet } from "@/api/generated/organizations/organizations";
import { import {
type healthzHealthzGetResponse, type healthzHealthzGetResponse,
useHealthzHealthzGet, useHealthzHealthzGet,
@@ -21,6 +24,20 @@ import { cn } from "@/lib/utils";
export function DashboardSidebar() { export function DashboardSidebar() {
const pathname = usePathname(); const pathname = usePathname();
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 healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>( const healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>(
{ {
query: { query: {
@@ -48,7 +65,7 @@ export function DashboardSidebar() {
? "System status unavailable" ? "System status unavailable"
: "System degraded"; : "System degraded";
return ( return (
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white"> <aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
<div className="flex-1 px-3 py-4"> <div className="flex-1 px-3 py-4">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500"> <p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
@@ -67,18 +84,20 @@ export function DashboardSidebar() {
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
Dashboard Dashboard
</Link> </Link>
<Link {isAdmin ? (
href="/gateways" <Link
className={cn( href="/gateways"
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition", className={cn(
pathname.startsWith("/gateways") "flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
? "bg-blue-100 text-blue-800 font-medium" pathname.startsWith("/gateways")
: "hover:bg-slate-100", ? "bg-blue-100 text-blue-800 font-medium"
)} : "hover:bg-slate-100",
> )}
<Network className="h-4 w-4" /> >
Gateways <Network className="h-4 w-4" />
</Link> Gateways
</Link>
) : null}
<Link <Link
href="/board-groups" href="/board-groups"
className={cn( className={cn(
@@ -103,6 +122,18 @@ export function DashboardSidebar() {
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
Boards Boards
</Link> </Link>
<Link
href="/organization"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/organization")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Building2 className="h-4 w-4" />
Organization
</Link>
<Link <Link
href="/approvals" href="/approvals"
className={cn( className={cn(
@@ -127,18 +158,20 @@ export function DashboardSidebar() {
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Live feed Live feed
</Link> </Link>
<Link {isAdmin ? (
href="/agents" <Link
className={cn( href="/agents"
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition", className={cn(
pathname.startsWith("/agents") "flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
? "bg-blue-100 text-blue-800 font-medium" pathname.startsWith("/agents")
: "hover:bg-slate-100", ? "bg-blue-100 text-blue-800 font-medium"
)} : "hover:bg-slate-100",
> )}
<Bot className="h-4 w-4" /> >
Agents <Bot className="h-4 w-4" />
</Link> Agents
</Link>
) : null}
</nav> </nav>
</div> </div>
<div className="border-t border-slate-200 p-4"> <div className="border-t border-slate-200 p-4">

View File

@@ -0,0 +1,240 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Building2, Plus } from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type listMyOrganizationsApiV1OrganizationsMeListGetResponse,
useCreateOrganizationApiV1OrganizationsPost,
useListMyOrganizationsApiV1OrganizationsMeListGet,
useSetActiveOrgApiV1OrganizationsMeActivePatch,
} from "@/api/generated/organizations/organizations";
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,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function OrgSwitcher() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [orgName, setOrgName] = useState("");
const [orgError, setOrgError] = useState<string | null>(null);
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
if (!("BroadcastChannel" in window)) return;
const channel = new BroadcastChannel("org-switch");
channelRef.current = channel;
return () => {
channel.close();
channelRef.current = null;
};
}, []);
const orgsQuery = useListMyOrganizationsApiV1OrganizationsMeListGet<
listMyOrganizationsApiV1OrganizationsMeListGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const orgs = orgsQuery.data?.status === 200 ? orgsQuery.data.data : [];
const activeOrg = orgs.find((item) => item.is_active) ?? null;
const orgValue = activeOrg?.id ?? "personal";
const announceOrgSwitch = (orgId: string) => {
if (typeof window === "undefined") return;
const payload = JSON.stringify({ orgId, ts: Date.now() });
try {
window.localStorage.setItem("openclaw_org_switch", payload);
} catch {
// Ignore storage failures.
}
channelRef.current?.postMessage(payload);
};
const setActiveOrgMutation =
useSetActiveOrgApiV1OrganizationsMeActivePatch<ApiError>({
mutation: {
onSuccess: (_result, variables) => {
const orgId = variables?.data?.organization_id;
if (orgId) {
announceOrgSwitch(orgId);
}
window.location.reload();
},
onError: (err) => {
setOrgError(err.message || "Unable to switch organization.");
},
},
});
const createOrgMutation = useCreateOrganizationApiV1OrganizationsPost<ApiError>(
{
mutation: {
onSuccess: () => {
setOrgName("");
setOrgError(null);
setCreateOpen(false);
queryClient.invalidateQueries({
queryKey: ["/api/v1/organizations/me/list"],
});
if (typeof window !== "undefined") {
announceOrgSwitch("new");
}
window.location.reload();
},
onError: (err) => {
setOrgError(err.message || "Unable to create organization.");
},
},
},
);
const handleOrgChange = (value: string) => {
if (value === "__create__") {
setOrgError(null);
setCreateOpen(true);
return;
}
if (!value || value === orgValue) {
return;
}
setActiveOrgMutation.mutate({
data: { organization_id: value },
});
};
const handleCreateOrg = () => {
const trimmed = orgName.trim();
if (!trimmed) {
setOrgError("Organization name is required.");
return;
}
createOrgMutation.mutate({
data: { name: trimmed },
});
};
if (!isSignedIn) {
return null;
}
return (
<div className="relative">
<Select value={orgValue} onValueChange={handleOrgChange}>
<SelectTrigger className="h-9 w-[220px] rounded-md border-slate-200 bg-white px-3 text-sm font-medium text-slate-900 shadow-none focus:ring-2 focus:ring-blue-500/30 focus:ring-offset-0">
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-slate-400" />
<SelectValue placeholder="Select organization" />
</span>
</SelectTrigger>
<SelectContent className="min-w-[220px] rounded-md border-slate-200 p-1 shadow-xl">
<div className="px-3 pb-2 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Org switcher
</div>
{orgs.length ? (
orgs.map((org) => (
<SelectItem
key={org.id}
value={org.id}
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700 data-[state=checked]:bg-slate-50 data-[state=checked]:text-slate-900 focus:bg-slate-100"
>
{org.name}
</SelectItem>
))
) : (
<SelectItem
value={orgValue}
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700"
>
Organization
</SelectItem>
)}
<SelectSeparator className="my-2" />
<SelectItem
value="__create__"
className="rounded-md py-2 pl-3 pr-3 text-sm font-medium text-slate-600 hover:text-slate-900 focus:bg-slate-100 [&>span:first-child]:hidden"
>
<span className="flex items-center gap-2">
<Plus className="h-4 w-4 text-slate-400" />
Create new org
</span>
</SelectItem>
</SelectContent>
</Select>
{orgError && !createOpen ? (
<p className="absolute left-0 top-full mt-1 text-xs text-rose-500">
{orgError}
</p>
) : null}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent aria-label="Create organization">
<DialogHeader>
<DialogTitle>Create a new organization</DialogTitle>
<DialogDescription>
This will switch you to the new organization as soon as it is
created.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<label
htmlFor="org-name"
className="text-xs font-semibold uppercase tracking-wide text-muted"
>
Organization name
</label>
<Input
id="org-name"
placeholder="Acme Robotics"
value={orgName}
onChange={(event) => setOrgName(event.target.value)}
/>
{orgError ? (
<p className="text-sm text-rose-500">{orgError}</p>
) : null}
</div>
<DialogFooter className="mt-6">
<Button
type="button"
variant="ghost"
onClick={() => setCreateOpen(false)}
>
Cancel
</Button>
<Button
type="button"
onClick={handleCreateOrg}
disabled={createOrgMutation.isPending}
>
{createOrgMutation.isPending ? "Creating..." : "Create org"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -34,6 +34,7 @@ type TaskBoardProps = {
tasks: Task[]; tasks: Task[];
onTaskSelect?: (task: Task) => void; onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>; onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
readOnly?: boolean;
}; };
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked"; type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
@@ -99,6 +100,7 @@ export const TaskBoard = memo(function TaskBoard({
tasks, tasks,
onTaskSelect, onTaskSelect,
onTaskMove, onTaskMove,
readOnly = false,
}: TaskBoardProps) { }: TaskBoardProps) {
const boardRef = useRef<HTMLDivElement | null>(null); const boardRef = useRef<HTMLDivElement | null>(null);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -268,6 +270,10 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragStart = const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => { (task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) {
event.preventDefault();
return;
}
if (task.is_blocked) { if (task.is_blocked) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -287,6 +293,7 @@ export const TaskBoard = memo(function TaskBoard({
const handleDrop = const handleDrop =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => { (status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault(); event.preventDefault();
setActiveColumn(null); setActiveColumn(null);
const raw = event.dataTransfer.getData("text/plain"); const raw = event.dataTransfer.getData("text/plain");
@@ -303,6 +310,7 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragOver = const handleDragOver =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => { (status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault(); event.preventDefault();
if (activeColumn !== status) { if (activeColumn !== status) {
setActiveColumn(status); setActiveColumn(status);
@@ -310,6 +318,7 @@ export const TaskBoard = memo(function TaskBoard({
}; };
const handleDragLeave = (status: TaskStatus) => () => { const handleDragLeave = (status: TaskStatus) => () => {
if (readOnly) return;
if (activeColumn === status) { if (activeColumn === status) {
setActiveColumn(null); setActiveColumn(null);
} }
@@ -368,11 +377,13 @@ export const TaskBoard = memo(function TaskBoard({
key={column.title} key={column.title}
className={cn( className={cn(
"kanban-column min-h-[calc(100vh-260px)]", "kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status && "ring-2 ring-slate-200", activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)} )}
onDrop={handleDrop(column.status)} onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={handleDragOver(column.status)} onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={handleDragLeave(column.status)} onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
> >
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur"> <div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -445,10 +456,10 @@ export const TaskBoard = memo(function TaskBoard({
isBlocked={task.is_blocked} isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0} blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)} onClick={() => onTaskSelect?.(task)}
draggable={!task.is_blocked} draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id} isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)} onDragStart={readOnly ? undefined : handleDragStart(task)}
onDragEnd={handleDragEnd} onDragEnd={readOnly ? undefined : handleDragEnd}
/> />
</div> </div>
))} ))}

View File

@@ -1,10 +1,12 @@
"use client"; "use client";
import { useEffect } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SignedIn, useUser } from "@/auth/clerk"; import { SignedIn, useUser } from "@/auth/clerk";
import { BrandMark } from "@/components/atoms/BrandMark"; import { BrandMark } from "@/components/atoms/BrandMark";
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
import { UserMenu } from "@/components/organisms/UserMenu"; import { UserMenu } from "@/components/organisms/UserMenu";
export function DashboardShell({ children }: { children: ReactNode }) { export function DashboardShell({ children }: { children: ReactNode }) {
@@ -12,13 +14,46 @@ export function DashboardShell({ children }: { children: ReactNode }) {
const displayName = const displayName =
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator"; user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
useEffect(() => {
if (typeof window === "undefined") return;
const handleStorage = (event: StorageEvent) => {
if (event.key !== "openclaw_org_switch" || !event.newValue) return;
window.location.reload();
};
window.addEventListener("storage", handleStorage);
let channel: BroadcastChannel | null = null;
if ("BroadcastChannel" in window) {
channel = new BroadcastChannel("org-switch");
channel.onmessage = () => {
window.location.reload();
};
}
return () => {
window.removeEventListener("storage", handleStorage);
channel?.close();
};
}, []);
return ( return (
<div className="min-h-screen bg-app text-strong"> <div className="min-h-screen bg-app text-strong">
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white shadow-sm"> <header className="sticky top-0 z-40 border-b border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between px-6 py-3"> <div className="grid grid-cols-[260px_1fr_auto] items-center gap-0 py-3">
<BrandMark /> <div className="flex items-center px-6">
<BrandMark />
</div>
<SignedIn> <SignedIn>
<div className="flex items-center gap-3"> <div className="flex items-center">
<div className="max-w-[220px]">
<OrgSwitcher />
</div>
</div>
</SignedIn>
<SignedIn>
<div className="flex items-center gap-3 px-6">
<div className="hidden text-right lg:block"> <div className="hidden text-right lg:block">
<p className="text-sm font-semibold text-slate-900"> <p className="text-sm font-semibold text-slate-900">
{displayName} {displayName}