Merge pull request #61 from abhi1693/feat/organizations

feat(orgs): introduce organizations, invites, and board access controls
This commit is contained in:
Abhimanyu Saharan
2026-02-08 23:03:24 +05:30
committed by GitHub
91 changed files with 9691 additions and 1517 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 uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, desc, func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import ActorContext, require_admin_or_agent, require_org_member
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
@@ -25,6 +24,11 @@ from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import (
OrganizationContext,
get_active_membership,
list_accessible_board_ids,
)
router = APIRouter(prefix="/activity", tags=["activity"])
@@ -112,6 +116,17 @@ async def list_activity(
statement = select(ActivityEvent)
if actor.actor_type == "agent" and actor.agent:
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
elif actor.actor_type == "user" and actor.user:
member = await get_active_membership(session, actor.user)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
board_ids = await list_accessible_board_ids(session, member=member, write=False)
if not board_ids:
statement = statement.where(col(ActivityEvent.id).is_(None))
else:
statement = statement.join(Task, col(ActivityEvent.task_id) == col(Task.id)).where(
col(Task.board_id).in_(board_ids)
)
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
return await paginate(session, statement)
@@ -123,7 +138,7 @@ async def list_activity(
async def list_task_comment_feed(
board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_member),
) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]:
statement = (
select(ActivityEvent, Task, Board, Agent)
@@ -134,8 +149,15 @@ async def list_task_comment_feed(
.where(func.length(func.trim(col(ActivityEvent.message))) > 0)
.order_by(desc(col(ActivityEvent.created_at)))
)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
if board_id is not None:
if board_id not in set(board_ids):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = statement.where(col(Task.board_id) == board_id)
elif board_ids:
statement = statement.where(col(Task.board_id).in_(board_ids))
else:
statement = statement.where(col(Task.id).is_(None))
def _transform(items: Sequence[Any]) -> Sequence[Any]:
rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items)
@@ -149,9 +171,14 @@ async def stream_task_comment_feed(
request: Request,
board_id: UUID | None = Query(default=None),
since: str | None = Query(default=None),
auth: AuthContext = Depends(require_admin_auth),
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
) -> EventSourceResponse:
since_dt = _parse_since(since) or utcnow()
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
allowed_ids = set(board_ids)
if board_id is not None and board_id not in allowed_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
seen_ids: set[UUID] = set()
seen_queue: deque[UUID] = deque()
@@ -161,7 +188,13 @@ async def stream_task_comment_feed(
if await request.is_disconnected():
break
async with async_session_maker() as session:
rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id)
if board_id is not None:
rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id)
elif allowed_ids:
rows = await _fetch_task_comment_events(session, last_seen)
rows = [row for row in rows if row[1].board_id in allowed_ids]
else:
rows = []
for event, task, board, agent in rows:
event_id = event.id
if event_id in seen_ids:

View File

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

View File

@@ -10,13 +10,14 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, or_, update
from sqlalchemy.sql.elements import ColumnElement
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
@@ -26,7 +27,9 @@ from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organizations import Organization
from app.models.tasks import Task
from app.models.users import User
from app.schemas.agents import (
AgentCreate,
AgentHeartbeat,
@@ -43,6 +46,14 @@ from app.services.agent_provisioning import (
provision_agent,
provision_main_agent,
)
from app.services.organizations import (
OrganizationContext,
get_active_membership,
has_board_access,
is_org_admin,
list_accessible_board_ids,
require_board_access,
)
router = APIRouter(prefix="/agents", tags=["agents"])
@@ -85,7 +96,13 @@ def _workspace_path(agent_name: str, workspace_root: str | None) -> str:
return f"{root}/workspace-{_slugify(agent_name)}"
async def _require_board(session: AsyncSession, board_id: UUID | str | None) -> Board:
async def _require_board(
session: AsyncSession,
board_id: UUID | str | None,
*,
user: object | None = None,
write: bool = False,
) -> Board:
if not board_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -94,6 +111,8 @@ async def _require_board(session: AsyncSession, board_id: UUID | str | None) ->
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if user is not None:
await require_board_access(session, user=user, board=board, write=write) # type: ignore[arg-type]
return board
@@ -111,6 +130,11 @@ async def _require_gateway(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_id is invalid",
)
if gateway.organization_id != board.organization_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_id is invalid",
)
if not gateway.main_session_key:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -206,6 +230,40 @@ async def _fetch_agent_events(
return list(await session.exec(statement))
async def _require_user_context(session: AsyncSession, user: User | None) -> OrganizationContext:
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_active_membership(session, user)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
organization = await session.get(Organization, member.organization_id)
if organization is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return OrganizationContext(organization=organization, member=member)
async def _require_agent_access(
session: AsyncSession,
*,
agent: Agent,
ctx: OrganizationContext,
write: bool,
) -> None:
if agent.board_id is None:
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
gateway = await _find_gateway_for_main_session(session, agent.openclaw_session_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return
board = await session.get(Board, agent.board_id)
if board is None or board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if not await has_board_access(session, member=ctx.member, board=board, write=write):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
def _record_heartbeat(session: AsyncSession, agent: Agent) -> None:
record_activity(
session,
@@ -245,13 +303,28 @@ async def list_agents(
board_id: UUID | None = Query(default=None),
gateway_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_admin),
) -> DefaultLimitOffsetPage[AgentRead]:
main_session_keys = await _get_gateway_main_session_keys(session)
statement = select(Agent)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
if board_id is not None and board_id not in set(board_ids):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not board_ids:
statement = select(Agent).where(col(Agent.id).is_(None))
else:
base_filter: ColumnElement[bool] = col(Agent.board_id).in_(board_ids)
if is_org_admin(ctx.member):
gateway_keys = select(Gateway.main_session_key).where(
col(Gateway.organization_id) == ctx.organization.id
)
base_filter = or_(base_filter, col(Agent.openclaw_session_id).in_(gateway_keys))
statement = select(Agent).where(base_filter)
if board_id is not None:
statement = statement.where(col(Agent.board_id) == board_id)
if gateway_id is not None:
gateway = await session.get(Gateway, gateway_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
statement = statement.join(Board, col(Agent.board_id) == col(Board.id)).where(
col(Board.gateway_id) == gateway_id
)
@@ -269,10 +342,15 @@ async def stream_agents(
request: Request,
board_id: UUID | None = Query(default=None),
since: str | None = Query(default=None),
auth: AuthContext = Depends(require_admin_auth),
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> EventSourceResponse:
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
allowed_ids = set(board_ids)
if board_id is not None and board_id not in allowed_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
async def event_generator() -> AsyncIterator[dict[str, str]]:
nonlocal last_seen
@@ -280,7 +358,13 @@ async def stream_agents(
if await request.is_disconnected():
break
async with async_session_maker() as session:
agents = await _fetch_agent_events(session, board_id, last_seen)
if board_id is not None:
agents = await _fetch_agent_events(session, board_id, last_seen)
elif allowed_ids:
agents = await _fetch_agent_events(session, None, last_seen)
agents = [agent for agent in agents if agent.board_id in allowed_ids]
else:
agents = []
main_session_keys = (
await _get_gateway_main_session_keys(session) if agents else set()
)
@@ -301,6 +385,10 @@ async def create_agent(
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> AgentRead:
if actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if actor.actor_type == "agent":
if not actor.agent or not actor.agent.is_board_lead:
raise HTTPException(
@@ -319,7 +407,12 @@ async def create_agent(
)
payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id})
board = await _require_board(session, payload.board_id)
board = await _require_board(
session,
payload.board_id,
user=actor.user if actor.actor_type == "user" else None,
write=actor.actor_type == "user",
)
gateway, client_config = await _require_gateway(session, board)
data = payload.model_dump()
requested_name = (data.get("name") or "").strip()
@@ -436,11 +529,12 @@ async def create_agent(
async def get_agent(
agent_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_admin),
) -> AgentRead:
agent = await session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _require_agent_access(session, agent=agent, ctx=ctx, write=False)
main_session_keys = await _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@@ -451,18 +545,28 @@ async def update_agent(
payload: AgentUpdate,
force: bool = False,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> AgentRead:
agent = await session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
updates = payload.model_dump(exclude_unset=True)
make_main = updates.pop("is_gateway_main", None)
if make_main is True and not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in updates:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="status is controlled by agent heartbeat",
)
if "board_id" in updates and updates["board_id"] is not None:
new_board = await _require_board(session, updates["board_id"])
if new_board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if not await has_board_access(session, member=ctx.member, board=new_board, write=True):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not updates and not force and make_main is None:
main_session_keys = await _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@@ -628,6 +732,11 @@ async def heartbeat_agent(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
if payload.status:
agent.status = payload.status
elif agent.status == "provisioning":
@@ -664,7 +773,16 @@ async def heartbeat_or_create_agent(
if agent is None:
if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
board = await _require_board(session, payload.board_id)
if actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
board = await _require_board(
session,
payload.board_id,
user=actor.user,
write=True,
)
gateway, client_config = await _require_gateway(session, board)
agent = Agent(
name=payload.name,
@@ -724,50 +842,68 @@ async def heartbeat_or_create_agent(
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_instruction_failure(session, agent, str(exc), "provision")
await session.commit()
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
elif agent.agent_token_hash is None and actor.actor_type == "user":
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
agent.provision_requested_at = utcnow()
agent.provision_action = "provision"
session.add(agent)
await session.commit()
await session.refresh(agent)
try:
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
gateway, client_config = await _require_gateway(session, board)
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
await _send_wakeup_message(agent, client_config, verb="provisioned")
agent.provision_confirm_token_hash = None
agent.provision_requested_at = None
agent.provision_action = None
agent.updated_at = utcnow()
session.add(agent)
await session.commit()
record_activity(
session,
event_type="agent.provision",
message=f"Provisioned directly for {agent.name}.",
agent_id=agent.id,
)
record_activity(
session,
event_type="agent.wakeup.sent",
message=f"Wakeup message sent to {agent.name}.",
agent_id=agent.id,
)
await session.commit()
except OpenClawGatewayError as exc:
_record_instruction_failure(session, agent, str(exc), "provision")
await session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_instruction_failure(session, agent, str(exc), "provision")
await session.commit()
elif not agent.openclaw_session_id:
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
else:
if actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
if agent.agent_token_hash is None:
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
agent.provision_requested_at = utcnow()
agent.provision_action = "provision"
session.add(agent)
await session.commit()
await session.refresh(agent)
try:
board = await _require_board(
session,
str(agent.board_id) if agent.board_id else None,
user=actor.user,
write=True,
)
gateway, client_config = await _require_gateway(session, board)
await provision_agent(
agent, board, gateway, raw_token, actor.user, action="provision"
)
await _send_wakeup_message(agent, client_config, verb="provisioned")
agent.provision_confirm_token_hash = None
agent.provision_requested_at = None
agent.provision_action = None
agent.updated_at = utcnow()
session.add(agent)
await session.commit()
record_activity(
session,
event_type="agent.provision",
message=f"Provisioned directly for {agent.name}.",
agent_id=agent.id,
)
record_activity(
session,
event_type="agent.wakeup.sent",
message=f"Wakeup message sent to {agent.name}.",
agent_id=agent.id,
)
await session.commit()
except OpenClawGatewayError as exc:
_record_instruction_failure(session, agent, str(exc), "provision")
await session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_instruction_failure(session, agent, str(exc), "provision")
await session.commit()
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not agent.openclaw_session_id:
board = await _require_board(
session,
str(agent.board_id) if agent.board_id else None,
user=actor.user if actor.actor_type == "user" else None,
write=actor.actor_type == "user",
)
gateway, client_config = await _require_gateway(session, board)
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
agent.openclaw_session_id = session_key
@@ -804,11 +940,12 @@ async def heartbeat_or_create_agent(
async def delete_agent(
agent_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OkResponse:
agent = await session.get(Agent, agent_id)
if agent is None:
return OkResponse()
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
gateway, client_config = await _require_gateway(session, board)

View File

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

View File

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

View File

@@ -9,8 +9,7 @@ from sqlalchemy import delete, func, update
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin, require_org_member
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
@@ -19,6 +18,7 @@ from app.models.agents import Agent
from app.models.board_groups import BoardGroup
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organization_members import OrganizationMember
from app.schemas.board_group_heartbeat import (
BoardGroupHeartbeatApply,
BoardGroupHeartbeatApplyResult,
@@ -29,6 +29,15 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats
from app.services.board_group_snapshot import build_group_snapshot
from app.services.organizations import (
OrganizationContext,
board_access_filter,
get_member,
is_org_admin,
list_accessible_board_ids,
member_all_boards_read,
member_all_boards_write,
)
router = APIRouter(prefix="/board-groups", tags=["board-groups"])
@@ -38,12 +47,54 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex
async def _require_group_access(
session: AsyncSession,
*,
group_id: UUID,
member: OrganizationMember,
write: bool,
) -> BoardGroup:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if group.organization_id != member.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if write and member_all_boards_write(member):
return group
if not write and member_all_boards_read(member):
return group
board_ids = list(
await session.exec(select(Board.id).where(col(Board.board_group_id) == group_id))
)
if not board_ids:
if is_org_admin(member):
return group
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
allowed_ids = await list_accessible_board_ids(session, member=member, write=write)
if not set(board_ids).intersection(set(allowed_ids)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return group
@router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead])
async def list_board_groups(
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardGroupRead]:
statement = select(BoardGroup).order_by(func.lower(col(BoardGroup.name)).asc())
if member_all_boards_read(ctx.member):
statement = select(BoardGroup).where(col(BoardGroup.organization_id) == ctx.organization.id)
else:
accessible_boards = select(Board.board_group_id).where(
board_access_filter(ctx.member, write=False)
)
statement = select(BoardGroup).where(
col(BoardGroup.organization_id) == ctx.organization.id,
col(BoardGroup.id).in_(accessible_boards),
)
statement = statement.order_by(func.lower(col(BoardGroup.name)).asc())
return await paginate(session, statement)
@@ -51,11 +102,12 @@ async def list_board_groups(
async def create_board_group(
payload: BoardGroupCreate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_admin),
) -> BoardGroup:
data = payload.model_dump()
if not (data.get("slug") or "").strip():
data["slug"] = _slugify(data.get("name") or "")
data["organization_id"] = ctx.organization.id
return await crud.create(session, BoardGroup, **data)
@@ -63,12 +115,9 @@ async def create_board_group(
async def get_board_group(
group_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_member),
) -> BoardGroup:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return group
return await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
@router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot)
@@ -77,20 +126,22 @@ async def get_board_group_snapshot(
include_done: bool = False,
per_board_task_limit: int = 5,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_member),
) -> BoardGroupSnapshot:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
if per_board_task_limit < 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return await build_group_snapshot(
snapshot = await build_group_snapshot(
session,
group=group,
exclude_board_id=None,
include_done=include_done,
per_board_task_limit=per_board_task_limit,
)
if not member_all_boards_read(ctx.member) and snapshot.boards:
allowed_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=False))
snapshot.boards = [item for item in snapshot.boards if item.board.id in allowed_ids]
return snapshot
@router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
@@ -104,7 +155,23 @@ async def apply_board_group_heartbeat(
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent":
if actor.actor_type == "user":
if actor.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_member(
session,
user_id=actor.user.id,
organization_id=group.organization_id,
)
if member is None or not is_org_admin(member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
await _require_group_access(
session,
group_id=group_id,
member=member,
write=True,
)
elif actor.actor_type == "agent":
agent = actor.agent
if agent is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
@@ -188,11 +255,9 @@ async def update_board_group(
payload: BoardGroupUpdate,
group_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_admin),
) -> BoardGroup:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
updates = payload.model_dump(exclude_unset=True)
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
updates["slug"] = _slugify(updates.get("name") or group.name)
@@ -206,11 +271,9 @@ async def update_board_group(
async def delete_board_group(
group_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OkResponse:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
# Boards reference groups, so clear the FK first to keep deletes simple.
await session.execute(

View File

@@ -6,13 +6,18 @@ from collections.abc import AsyncIterator
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, get_board_or_404, require_admin_or_agent
from app.api.deps import (
ActorContext,
get_board_for_actor_read,
get_board_for_actor_write,
require_admin_or_agent,
)
from app.core.config import settings
from app.core.time import utcnow
from app.db.pagination import paginate
@@ -178,13 +183,10 @@ async def _notify_chat_targets(
@router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
async def list_board_memory(
is_chat: bool | None = Query(default=None),
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = (
select(BoardMemory).where(col(BoardMemory.board_id) == board.id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
@@ -200,14 +202,11 @@ async def list_board_memory(
@router.get("/stream")
async def stream_board_memory(
request: Request,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None),
) -> EventSourceResponse:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -236,13 +235,10 @@ async def stream_board_memory(
@router.post("", response_model=BoardMemoryRead)
async def create_board_memory(
payload: BoardMemoryCreate,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardMemory:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
is_chat = payload.tags is not None and "chat" in payload.tags
source = payload.source
if is_chat and not source:

View File

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

View File

@@ -8,8 +8,13 @@ from sqlalchemy import delete, func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import (
get_board_for_actor_read,
get_board_for_user_read,
get_board_for_user_write,
require_org_admin,
require_org_member,
)
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
@@ -38,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
from app.services.board_group_snapshot import build_board_group_snapshot
from app.services.board_snapshot import build_board_snapshot
from app.services.organizations import OrganizationContext, board_access_filter
router = APIRouter(prefix="/boards", tags=["boards"])
@@ -53,40 +59,66 @@ def _build_session_key(agent_name: str) -> str:
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
async def _require_gateway(session: AsyncSession, gateway_id: object) -> Gateway:
async def _require_gateway(
session: AsyncSession,
gateway_id: object,
*,
organization_id: UUID | None = None,
) -> Gateway:
gateway = await crud.get_by_id(session, Gateway, gateway_id)
if gateway is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="gateway_id is invalid",
)
if organization_id is not None and gateway.organization_id != organization_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="gateway_id is invalid",
)
return gateway
async def _require_gateway_for_create(
payload: BoardCreate,
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = Depends(get_session),
) -> Gateway:
return await _require_gateway(session, payload.gateway_id)
return await _require_gateway(session, payload.gateway_id, organization_id=ctx.organization.id)
async def _require_board_group(session: AsyncSession, board_group_id: object) -> BoardGroup:
async def _require_board_group(
session: AsyncSession,
board_group_id: object,
*,
organization_id: UUID | None = None,
) -> BoardGroup:
group = await crud.get_by_id(session, BoardGroup, board_group_id)
if group is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_group_id is invalid",
)
if organization_id is not None and group.organization_id != organization_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_group_id is invalid",
)
return group
async def _require_board_group_for_create(
payload: BoardCreate,
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = Depends(get_session),
) -> BoardGroup | None:
if payload.board_group_id is None:
return None
return await _require_board_group(session, payload.board_group_id)
return await _require_board_group(
session,
payload.board_group_id,
organization_id=ctx.organization.id,
)
async def _apply_board_update(
@@ -97,9 +129,15 @@ async def _apply_board_update(
) -> Board:
updates = payload.model_dump(exclude_unset=True)
if "gateway_id" in updates:
await _require_gateway(session, updates["gateway_id"])
await _require_gateway(
session, updates["gateway_id"], organization_id=board.organization_id
)
if "board_group_id" in updates and updates["board_group_id"] is not None:
await _require_board_group(session, updates["board_group_id"])
await _require_board_group(
session,
updates["board_group_id"],
organization_id=board.organization_id,
)
for key, value in updates.items():
setattr(board, key, value)
if updates.get("board_type") == "goal":
@@ -182,9 +220,9 @@ async def list_boards(
gateway_id: UUID | None = Query(default=None),
board_group_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
ctx: OrganizationContext = Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardRead]:
statement = select(Board)
statement = select(Board).where(board_access_filter(ctx.member, write=False))
if gateway_id is not None:
statement = statement.where(col(Board.gateway_id) == gateway_id)
if board_group_id is not None:
@@ -199,28 +237,25 @@ async def create_board(
_gateway: Gateway = Depends(_require_gateway_for_create),
_board_group: BoardGroup | None = Depends(_require_board_group_for_create),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_admin),
) -> Board:
return await crud.create(session, Board, **payload.model_dump())
data = payload.model_dump()
data["organization_id"] = ctx.organization.id
return await crud.create(session, Board, **data)
@router.get("/{board_id}", response_model=BoardRead)
def get_board(
board: Board = Depends(get_board_or_404),
actor: ActorContext = Depends(require_admin_or_agent),
board: Board = Depends(get_board_for_user_read),
) -> Board:
return board
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
async def get_board_snapshot(
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardSnapshot:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await build_board_snapshot(session, board)
@@ -229,13 +264,9 @@ async def get_board_group_snapshot(
include_self: bool = Query(default=False),
include_done: bool = Query(default=False),
per_board_task_limit: int = Query(default=5, ge=0, le=100),
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardGroupSnapshot:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await build_board_group_snapshot(
session,
board=board,
@@ -249,8 +280,7 @@ async def get_board_group_snapshot(
async def update_board(
payload: BoardUpdate,
session: AsyncSession = Depends(get_session),
board: Board = Depends(get_board_or_404),
auth: AuthContext = Depends(require_admin_auth),
board: Board = Depends(get_board_for_user_write),
) -> Board:
return await _apply_board_update(payload=payload, session=session, board=board)
@@ -258,8 +288,7 @@ async def update_board(
@router.delete("/{board_id}", response_model=OkResponse)
async def delete_board(
session: AsyncSession = Depends(get_session),
board: Board = Depends(get_board_or_404),
auth: AuthContext = Depends(require_admin_auth),
board: Board = Depends(get_board_for_user_write),
) -> OkResponse:
agents = list(await session.exec(select(Agent).where(Agent.board_id == board.id)))
task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id)))

View File

@@ -11,9 +11,17 @@ from app.core.auth import AuthContext, get_auth_context, get_auth_context_option
from app.db.session import get_session
from app.models.agents import Agent
from app.models.boards import Board
from app.models.organizations import Organization
from app.models.tasks import Task
from app.models.users import User
from app.services.admin_access import require_admin
from app.services.organizations import (
OrganizationContext,
ensure_member_for_user,
get_active_membership,
is_org_admin,
require_board_access,
)
def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthContext:
@@ -40,6 +48,31 @@ def require_admin_or_agent(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
async def require_org_member(
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_session),
) -> OrganizationContext:
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_active_membership(session, auth.user)
if member is None:
member = await ensure_member_for_user(session, auth.user)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
organization = await session.get(Organization, member.organization_id)
if organization is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return OrganizationContext(organization=organization, member=member)
async def require_org_admin(
ctx: OrganizationContext = Depends(require_org_member),
) -> OrganizationContext:
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return ctx
async def get_board_or_404(
board_id: str,
session: AsyncSession = Depends(get_session),
@@ -50,9 +83,73 @@ async def get_board_or_404(
return board
async def get_board_for_actor_read(
board_id: str,
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return board
if actor.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=actor.user, board=board, write=False)
return board
async def get_board_for_actor_write(
board_id: str,
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return board
if actor.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=actor.user, board=board, write=True)
return board
async def get_board_for_user_read(
board_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=False)
return board
async def get_board_for_user_write(
board_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=True)
return board
async def get_task_or_404(
task_id: str,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
) -> Task:
task = await session.get(Task, task_id)

View File

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

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_admin_auth
from app.api.deps import require_org_admin
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
@@ -25,6 +25,7 @@ from app.schemas.gateways import (
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_main_agent
from app.services.organizations import OrganizationContext
from app.services.template_sync import sync_gateway_templates as sync_gateway_templates_service
router = APIRouter(prefix="/gateways", tags=["gateways"])
@@ -131,9 +132,13 @@ async def _ensure_main_agent(
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
async def list_gateways(
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> DefaultLimitOffsetPage[GatewayRead]:
statement = select(Gateway).order_by(col(Gateway.created_at).desc())
statement = (
select(Gateway)
.where(col(Gateway.organization_id) == ctx.organization.id)
.order_by(col(Gateway.created_at).desc())
)
return await paginate(session, statement)
@@ -142,8 +147,10 @@ async def create_gateway(
payload: GatewayCreate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> Gateway:
data = payload.model_dump()
data["organization_id"] = ctx.organization.id
gateway = Gateway.model_validate(data)
session.add(gateway)
await session.commit()
@@ -156,10 +163,10 @@ async def create_gateway(
async def get_gateway(
gateway_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> Gateway:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
return gateway
@@ -170,9 +177,10 @@ async def update_gateway(
payload: GatewayUpdate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> Gateway:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
previous_name = gateway.name
previous_session_key = gateway.main_session_key
@@ -202,10 +210,11 @@ async def sync_gateway_templates(
force_bootstrap: bool = Query(default=False),
board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewayTemplatesSyncResult:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
return await sync_gateway_templates_service(
session,
@@ -223,10 +232,10 @@ async def sync_gateway_templates(
async def delete_gateway(
gateway_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OkResponse:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
await session.delete(gateway)
await session.commit()

View File

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

View File

@@ -0,0 +1,401 @@
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 delete, 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,
OrganizationBoardAccessRead,
OrganizationCreate,
OrganizationInviteAccept,
OrganizationInviteCreate,
OrganizationInviteRead,
OrganizationListItem,
OrganizationMemberAccessUpdate,
OrganizationMemberRead,
OrganizationMemberUpdate,
OrganizationRead,
OrganizationUserRead,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import (
OrganizationContext,
accept_invite,
apply_invite_board_access,
apply_invite_to_member,
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) 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) 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(
delete(OrganizationInviteBoardAccess).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 (
ActorContext,
get_board_or_404,
get_board_for_actor_read,
get_board_for_user_write,
get_task_or_404,
require_admin_auth,
require_admin_or_agent,
@@ -42,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.organizations import require_board_access
from app.services.task_dependencies import (
blocked_by_dependency_ids,
dependency_ids_by_task_id,
@@ -442,7 +444,7 @@ async def _notify_lead_on_task_unassigned(
@router.get("/stream")
async def stream_tasks(
request: Request,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
) -> EventSourceResponse:
@@ -525,13 +527,10 @@ async def list_tasks(
status_filter: str | None = Query(default=None, alias="status"),
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[TaskRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = select(Task).where(Task.board_id == board.id)
if status_filter:
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
@@ -586,7 +585,7 @@ async def list_tasks(
@router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}})
async def create_task(
payload: TaskCreate,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> TaskRead:
@@ -669,6 +668,11 @@ async def update_task(
detail="Task board_id is required.",
)
board_id = task.board_id
if actor.actor_type == "user" and actor.user is not None:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await require_board_access(session, user=actor.user, board=board, write=True)
previous_status = task.status
previous_assigned = task.assigned_agent_id
@@ -978,6 +982,14 @@ async def delete_task(
task: Task = Depends(get_task_or_404),
auth: AuthContext = Depends(require_admin_auth),
) -> OkResponse:
if task.board_id is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
board = await session.get(Board, task.board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=True)
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
await session.execute(delete(Approval).where(col(Approval.task_id) == task.id))
@@ -998,11 +1010,7 @@ async def delete_task(
async def list_task_comments(
task: Task = Depends(get_task_or_404),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[TaskCommentRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = (
select(ActivityEvent)
.where(col(ActivityEvent.task_id) == task.id)
@@ -1019,6 +1027,13 @@ async def create_task_comment(
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> ActivityEvent:
if task.board_id is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
if actor.actor_type == "user" and actor.user is not None:
board = await session.get(Board, task.board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await require_board_access(session, user=actor.user, board=board, write=True)
if actor.actor_type == "agent" and actor.agent:
if actor.agent.is_board_lead and task.status != "review":
if not await _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
@@ -1030,8 +1045,6 @@ async def create_task_comment(
"Board leads can only comment during review, when mentioned, or on tasks they created."
),
)
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
event = ActivityEvent(
event_type="task.comment",
message=payload.message,

View File

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

View File

@@ -31,6 +31,13 @@ class Settings(BaseSettings):
cors_origins: str = ""
base_url: str = ""
# Optional: local directory where the backend is allowed to write "preserved" agent
# workspace files (e.g. USER.md/SELF.md/MEMORY.md). If empty, local writes are disabled
# and provisioning relies on the gateway API.
#
# Security note: do NOT point this at arbitrary system paths in production.
local_agent_workspace_root: str = ""
# Database lifecycle
db_auto_migrate: bool = False

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.gateways import router as gateways_router
from app.api.metrics import router as metrics_router
from app.api.organizations import router as organizations_router
from app.api.souls_directory import router as souls_directory_router
from app.api.tasks import router as tasks_router
from app.api.users import router as users_router
@@ -75,6 +76,7 @@ api_v1.include_router(activity_router)
api_v1.include_router(gateway_router)
api_v1.include_router(gateways_router)
api_v1.include_router(metrics_router)
api_v1.include_router(organizations_router)
api_v1.include_router(souls_directory_router)
api_v1.include_router(board_groups_router)
api_v1.include_router(board_group_memory_router)

View File

@@ -7,6 +7,11 @@ from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.task_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task
@@ -22,6 +27,11 @@ __all__ = [
"BoardGroup",
"Board",
"Gateway",
"Organization",
"OrganizationMember",
"OrganizationBoardAccess",
"OrganizationInvite",
"OrganizationInviteBoardAccess",
"TaskDependency",
"Task",
"TaskFingerprint",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
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,28 @@
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
context: str | None = None
is_super_admin: bool = Field(default=False)
active_organization_id: UUID | None = Field(
default=None, foreign_key="organizations.id", index=True
)

View File

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

View File

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

View File

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

View File

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

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

@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
@@ -181,9 +182,22 @@ def _ensure_workspace_file(
) -> None:
if not workspace_path or not name:
return
# `gateway.workspace_root` is sometimes configured as `~/.openclaw`.
# Expand user here to avoid creating a literal `./~` directory under the backend cwd.
root = Path(workspace_path).expanduser()
# Only write to a dedicated, explicitly-configured local directory.
# Using `gateway.workspace_root` directly here is unsafe (and CodeQL correctly flags it)
# because it is a DB-backed config value.
base_root = (settings.local_agent_workspace_root or "").strip()
if not base_root:
return
base = Path(base_root).expanduser()
# Derive a stable, safe directory name from the (potentially untrusted) workspace path.
# This prevents path traversal and avoids writing to arbitrary locations.
digest = hashlib.sha256(workspace_path.encode("utf-8")).hexdigest()[:16]
root = base / f"gateway-workspace-{digest}"
# Ensure `name` is a plain filename (no path separators).
if Path(name).name != name:
return
path = root / name
if not overwrite and path.exists():
return

View File

@@ -0,0 +1,461 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import delete, func, or_
from sqlalchemy.sql.elements import ColumnElement
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) -> ColumnElement[bool]:
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(
delete(OrganizationBoardAccess).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(
delete(OrganizationInviteBoardAccess).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

@@ -134,7 +134,7 @@ async def validate_dependency_update(
normalized = _dedupe_uuid_list(depends_on_task_ids)
if task_id in normalized:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Task cannot depend on itself.",
)
if not normalized:

View File

@@ -162,6 +162,7 @@ Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` pub
If you see repeated proxy errors (often `ECONNRESET`), make sure your dev server hostname and browser URL match (e.g. `localhost` vs `127.0.0.1`), and that your origin is included in `allowedDevOrigins`.
Notes:
- Local dev should work via `http://localhost:3000` and `http://127.0.0.1:3000`.
- LAN dev should work via the configured LAN IP (e.g. `http://192.168.1.101:3000`) **only** if you bind the dev server to all interfaces (`npm run dev:lan`).
- If you bind Next to `127.0.0.1` only, remote LAN clients wont connect.

View File

@@ -3,7 +3,8 @@ import { clerkSetup } from "@clerk/testing/cypress";
export default defineConfig({
env: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
// Optional overrides.
CLERK_ORIGIN: process.env.CYPRESS_CLERK_ORIGIN,
CLERK_TEST_EMAIL: process.env.CYPRESS_CLERK_TEST_EMAIL,

View File

@@ -53,6 +53,7 @@ import type {
ListTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams,
ListTasksApiV1AgentBoardsBoardIdTasksGetParams,
OkResponse,
SoulUpdateRequest,
TaskCommentCreate,
TaskCommentRead,
TaskCreate,
@@ -3035,6 +3036,449 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
queryClient,
);
};
/**
* @summary Get Agent Soul
*/
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 =
{
data: string;
status: 200;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess =
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 & {
headers: Headers;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError =
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 & {
headers: Headers;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse =
| getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess
| getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError;
export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetUrl = (
boardId: string,
agentId: string,
) => {
return `/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`;
};
export const getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet = async (
boardId: string,
agentId: string,
options?: RequestInit,
): Promise<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
*/

View File

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

View File

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

View File

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

View File

@@ -131,6 +131,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead";
export * from "./limitOffsetPageTypeVarCustomizedTaskCommentRead";
export * from "./limitOffsetPageTypeVarCustomizedTaskRead";
export * from "./listActivityApiV1ActivityGetParams";
@@ -147,6 +149,8 @@ export * from "./listBoardsApiV1AgentBoardsGetParams";
export * from "./listBoardsApiV1BoardsGetParams";
export * from "./listGatewaysApiV1GatewaysGetParams";
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";
export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams";
export * from "./listSessionsApiV1GatewaySessionsGet200";
export * from "./listSessionsApiV1GatewaySessionsGetParams";
export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams";
@@ -155,11 +159,29 @@ export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams"
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
export * from "./listTasksApiV1BoardsBoardIdTasksGetParams";
export * from "./okResponse";
export * from "./organizationActiveUpdate";
export * from "./organizationBoardAccessRead";
export * from "./organizationBoardAccessSpec";
export * from "./organizationCreate";
export * from "./organizationInviteAccept";
export * from "./organizationInviteCreate";
export * from "./organizationInviteRead";
export * from "./organizationListItem";
export * from "./organizationMemberAccessUpdate";
export * from "./organizationMemberRead";
export * from "./organizationMemberUpdate";
export * from "./organizationRead";
export * from "./organizationUserRead";
export * from "./readyzReadyzGet200";
export * from "./searchApiV1SoulsDirectorySearchGetParams";
export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost200";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostBody";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParams";
export * from "./soulsDirectoryMarkdownResponse";
export * from "./soulsDirectorySearchResponse";
export * from "./soulsDirectorySoulRef";
export * from "./soulUpdateRequest";
export * from "./streamAgentsApiV1AgentsStreamGetParams";
export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams";
export * from "./streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGetParams";

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,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type {
ActivityEventRead,
AgentRead,
@@ -80,6 +84,20 @@ export default function AgentDetailPage() {
const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -88,7 +106,7 @@ export default function AgentDetailPage() {
ApiError
>(agentId ?? "", {
query: {
enabled: Boolean(isSignedIn && agentId),
enabled: Boolean(isSignedIn && isAdmin && agentId),
refetchInterval: 30_000,
refetchOnMount: "always",
retry: false,
@@ -102,7 +120,7 @@ export default function AgentDetailPage() {
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000,
retry: false,
},
@@ -114,7 +132,7 @@ export default function AgentDetailPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 60_000,
refetchOnMount: "always",
retry: false,
@@ -186,192 +204,203 @@ export default function AgentDetailPage() {
</SignedOut>
<SignedIn>
<DashboardSidebar />
<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="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Agents
</p>
<h1 className="text-2xl font-semibold text-strong">
{agent?.name ?? "Agent"}
</h1>
<p className="text-sm text-muted">
Review agent health, session binding, and recent activity.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push("/agents")}>
Back to agents
</Button>
{agent ? (
<Link
href={`/agents/${agent.id}/edit`}
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
) : null}
{agent ? (
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
) : null}
{!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>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Loading agent details
</div>
) : agent ? (
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Overview
</p>
<p className="mt-1 text-lg font-semibold text-strong">
{agent.name}
</p>
</div>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agent ID
</p>
<p className="mt-1 text-sm text-muted">{agent.id}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session key
</p>
<p className="mt-1 text-sm text-muted">
{agent.openclaw_session_id ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">
Gateway main (no board)
</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
>
{linkedBoard.name}
</Link>
) : (
<p className="mt-1 text-sm text-strong"></p>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen
</p>
<p className="mt-1 text-sm text-strong">
{formatRelative(agent.last_seen_at)}
</p>
<p className="text-xs text-quiet">
{formatTimestamp(agent.last_seen_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Updated
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.updated_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Created
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.created_at)}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Heartbeat window</span>
<span>{formatRelative(agent.last_seen_at)}</span>
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>
{agent.openclaw_session_id ? "Bound" : "Unbound"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agentStatus}</span>
</div>
</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="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Agents
</p>
<h1 className="text-2xl font-semibold text-strong">
{agent?.name ?? "Agent"}
</h1>
<p className="text-sm text-muted">
Review agent health, session binding, and recent activity.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
{agent ? (
<Link
href={`/agents/${agent.id}/edit`}
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
) : null}
{agent ? (
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
) : null}
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Activity
</p>
<p className="text-xs text-quiet">
{agentEvents.length} events
</p>
</div>
<div className="space-y-3">
{agentEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
No activity yet for this agent.
</div>
) : (
agentEvents.map((event) => (
<div
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Loading agent details
</div>
) : agent ? (
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Overview
</p>
<p className="mt-1 text-xs text-quiet">
{formatTimestamp(event.created_at)}
<p className="mt-1 text-lg font-semibold text-strong">
{agent.name}
</p>
</div>
))
)}
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agent ID
</p>
<p className="mt-1 text-sm text-muted">{agent.id}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session key
</p>
<p className="mt-1 text-sm text-muted">
{agent.openclaw_session_id ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">
Gateway main (no board)
</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
>
{linkedBoard.name}
</Link>
) : (
<p className="mt-1 text-sm text-strong"></p>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen
</p>
<p className="mt-1 text-sm text-strong">
{formatRelative(agent.last_seen_at)}
</p>
<p className="text-xs text-quiet">
{formatTimestamp(agent.last_seen_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Updated
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.updated_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Created
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.created_at)}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Heartbeat window</span>
<span>{formatRelative(agent.last_seen_at)}</span>
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>
{agent.openclaw_session_id ? "Bound" : "Unbound"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agentStatus}</span>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Activity
</p>
<p className="text-xs text-quiet">
{agentEvents.length} events
</p>
</div>
<div className="space-y-3">
{agentEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
No activity yet for this agent.
</div>
) : (
agentEvents.map((event) => (
<div
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
</p>
<p className="mt-1 text-xs text-quiet">
{formatTimestamp(event.created_at)}
</p>
</div>
))
)}
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Agent not found.
</div>
)}
</div>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Agent not found.
</div>
)}
</div>
)}
</SignedIn>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>

View File

@@ -13,6 +13,10 @@ import {
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -80,6 +84,20 @@ export default function NewAgentPage() {
const router = useRouter();
const { isSignedIn } = useAuth();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState("");
const [boardId, setBoardId] = useState<string>("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
@@ -95,7 +113,7 @@ export default function NewAgentPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
@@ -182,193 +200,204 @@ export default function NewAgentPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm 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="space-y-2">
<label className="text-sm font-medium text-slate-900">
Agent name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot"
disabled={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 create agents.
</div>
) : (
<form
onSubmit={handleSubmit}
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm 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="space-y-2">
<label className="text-sm font-medium text-slate-900">
Agent name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Role
</label>
<Input
value={identityProfile.role}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
role: event.target.value,
}))
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select board"
value={displayBoardId}
onValueChange={setBoardId}
options={getBoardOptions(boards)}
placeholder="Select board"
searchPlaceholder="Search boards..."
emptyMessage="No matching boards."
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={boards.length === 0}
/>
{boards.length === 0 ? (
<p className="text-xs text-slate-500">
Create a board before adding agents.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Emoji
</label>
<Select
value={identityProfile.emoji}
onValueChange={(value) =>
setIdentityProfile((current) => ({
...current,
emoji: value,
}))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select emoji" />
</SelectTrigger>
<SelectContent>
{EMOJI_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.glyph} {option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Personality & behavior
</p>
<div className="mt-4 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Role
Communication style
</label>
<Input
value={identityProfile.role}
value={identityProfile.communication_style}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
role: event.target.value,
communication_style: event.target.value,
}))
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Soul template
</label>
<Textarea
value={soulTemplate}
onChange={(event) =>
setSoulTemplate(event.target.value)
}
rows={10}
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications
</p>
<div className="mt-4 grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board <span className="text-red-500">*</span>
Interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target
</label>
<SearchableSelect
ariaLabel="Select board"
value={displayBoardId}
onValueChange={setBoardId}
options={getBoardOptions(boards)}
placeholder="Select board"
searchPlaceholder="Search boards..."
emptyMessage="No matching boards."
ariaLabel="Select heartbeat target"
value={heartbeatTarget}
onValueChange={setHeartbeatTarget}
options={HEARTBEAT_TARGET_OPTIONS}
placeholder="Select target"
searchPlaceholder="Search targets..."
emptyMessage="No matching targets."
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={boards.length === 0}
/>
{boards.length === 0 ? (
<p className="text-xs text-slate-500">
Create a board before adding agents.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Emoji
</label>
<Select
value={identityProfile.emoji}
onValueChange={(value) =>
setIdentityProfile((current) => ({
...current,
emoji: value,
}))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select emoji" />
</SelectTrigger>
<SelectContent>
{EMOJI_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.glyph} {option.label}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Personality & behavior
</p>
<div className="mt-4 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Communication style
</label>
<Input
value={identityProfile.communication_style}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
communication_style: event.target.value,
}))
}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Soul template
</label>
<Textarea
value={soulTemplate}
onChange={(event) => setSoulTemplate(event.target.value)}
rows={10}
disabled={isLoading}
/>
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{errorMessage}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating…" : "Create agent"}
</Button>
<Button
variant="outline"
type="button"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications
</p>
<div className="mt-4 grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target
</label>
<SearchableSelect
ariaLabel="Select heartbeat target"
value={heartbeatTarget}
onValueChange={setHeartbeatTarget}
options={HEARTBEAT_TARGET_OPTIONS}
placeholder="Select target"
searchPlaceholder="Search targets..."
emptyMessage="No matching targets."
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}
/>
</div>
</div>
</div>
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{errorMessage}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating…" : "Create agent"}
</Button>
<Button
variant="outline"
type="button"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
</div>
</form>
</form>
)}
</div>
</main>
</SignedIn>

View File

@@ -42,6 +42,10 @@ import {
getListBoardsApiV1BoardsGetQueryKey,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { AgentRead } from "@/api/generated/model";
const parseTimestamp = (value?: string | null) => {
@@ -88,6 +92,20 @@ export default function AgentsPage() {
const queryClient = useQueryClient();
const router = useRouter();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
@@ -102,7 +120,7 @@ export default function AgentsPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000,
refetchOnMount: "always",
},
@@ -113,7 +131,7 @@ export default function AgentsPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 15_000,
refetchOnMount: "always",
},
@@ -323,97 +341,105 @@ export default function AgentsPage() {
</div>
<div className="p-8">
<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">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{agentsQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No agents yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first agent to start executing tasks
on this board.
</p>
<Link
href="/agents/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first agent
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
{!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 agents.
</div>
</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">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{agentsQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No agents yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first agent to start executing
tasks on this board.
</p>
<Link
href="/agents/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first agent
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{agentsQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{agentsQuery.error.message}
</p>
) : null}
{agentsQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{agentsQuery.error.message}
</p>
) : null}
</>
)}
</div>
</main>
</SignedIn>

View File

@@ -27,9 +27,14 @@ import {
streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet,
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet,
} from "@/api/generated/board-group-memory/board-group-memory";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type {
BoardGroupHeartbeatApplyResult,
BoardGroupMemoryRead,
OrganizationMemberRead,
} from "@/api/generated/model";
import type { BoardGroupBoardSnapshot } from "@/api/generated/model";
import { Markdown } from "@/components/atoms/Markdown";
@@ -96,6 +101,18 @@ const priorityTone = (value?: string | null) => {
const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) =>
snapshot.task_counts?.[key] ?? 0;
const canWriteGroupBoards = (
member: OrganizationMemberRead | null,
boardIds: Set<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 }) {
return (
<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;
const group = snapshot?.group ?? null;
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 =
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet<
@@ -554,6 +599,10 @@ export default function BoardGroupDetailPage() {
setChatError("Sign in to send messages.");
return false;
}
if (!canWriteGroup) {
setChatError("Read-only access. You cannot post group messages.");
return false;
}
const trimmed = content.trim();
if (!trimmed) return false;
@@ -583,7 +632,7 @@ export default function BoardGroupDetailPage() {
setIsChatSending(false);
}
},
[chatBroadcast, groupId, isSignedIn, mergeChatMessages],
[canWriteGroup, chatBroadcast, groupId, isSignedIn, mergeChatMessages],
);
const sendGroupNote = useCallback(
@@ -592,6 +641,10 @@ export default function BoardGroupDetailPage() {
setNoteSendError("Sign in to post.");
return false;
}
if (!canWriteGroup) {
setNoteSendError("Read-only access. You cannot post notes.");
return false;
}
const trimmed = content.trim();
if (!trimmed) return false;
@@ -621,7 +674,7 @@ export default function BoardGroupDetailPage() {
setIsNoteSending(false);
}
},
[groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
[canWriteGroup, groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
);
const applyHeartbeat = useCallback(async () => {
@@ -629,6 +682,10 @@ export default function BoardGroupDetailPage() {
setHeartbeatApplyError("Sign in to apply.");
return;
}
if (!canManageHeartbeat) {
setHeartbeatApplyError("Read-only access. You cannot change agent pace.");
return;
}
const trimmed = heartbeatEvery.trim();
if (!trimmed) {
setHeartbeatApplyError("Heartbeat cadence is required.");
@@ -653,7 +710,13 @@ export default function BoardGroupDetailPage() {
} finally {
setIsHeartbeatApplying(false);
}
}, [groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
}, [
canManageHeartbeat,
groupId,
heartbeatEvery,
includeBoardLeads,
isSignedIn,
]);
return (
<DashboardShell>
@@ -793,7 +856,10 @@ export default function BoardGroupDetailPage() {
heartbeatEvery === value
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
!canManageHeartbeat &&
"opacity-50 cursor-not-allowed",
)}
disabled={!canManageHeartbeat}
onClick={() => {
setHeartbeatAmount(String(preset.amount));
setHeartbeatUnit(preset.unit);
@@ -812,19 +878,25 @@ export default function BoardGroupDetailPage() {
heartbeatEvery
? "border-slate-200"
: "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100",
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
)}
placeholder="10"
inputMode="numeric"
type="number"
min={1}
step={1}
disabled={!canManageHeartbeat}
/>
<select
value={heartbeatUnit}
onChange={(event) =>
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="m">min</option>
@@ -839,6 +911,7 @@ export default function BoardGroupDetailPage() {
onChange={(event) =>
setIncludeBoardLeads(event.target.checked)
}
disabled={!canManageHeartbeat}
/>
Include leads
</label>
@@ -846,11 +919,26 @@ export default function BoardGroupDetailPage() {
size="sm"
variant="outline"
onClick={() => void applyHeartbeat()}
disabled={isHeartbeatApplying || !heartbeatEvery}
disabled={
isHeartbeatApplying ||
!heartbeatEvery ||
!canManageHeartbeat
}
title={
canManageHeartbeat
? "Apply heartbeat"
: "Read-only access"
}
>
{isHeartbeatApplying ? "Applying…" : "Apply"}
</Button>
</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>
@@ -1035,6 +1123,7 @@ export default function BoardGroupDetailPage() {
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={chatBroadcast}
onChange={(event) => setChatBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/>
Broadcast
</label>
@@ -1072,9 +1161,14 @@ export default function BoardGroupDetailPage() {
</div>
<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}
onSend={sendGroupChat}
disabled={!canWriteGroup}
/>
</div>
</div>
@@ -1115,6 +1209,7 @@ export default function BoardGroupDetailPage() {
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={notesBroadcast}
onChange={(event) => setNotesBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/>
Broadcast
</label>
@@ -1152,9 +1247,14 @@ export default function BoardGroupDetailPage() {
</div>
<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}
onSend={sendGroupNote}
disabled={!canWriteGroup}
/>
</div>
</div>

View File

@@ -22,6 +22,10 @@ import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type {
BoardGroupRead,
BoardRead,
@@ -59,6 +63,20 @@ export default function EditBoardPage() {
const boardIdParam = params?.boardId;
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 [board, setBoard] = useState<BoardRead | null>(null);
@@ -130,7 +148,7 @@ export default function EditBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -141,7 +159,7 @@ export default function EditBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -152,7 +170,7 @@ export default function EditBoardPage() {
ApiError
>(boardId ?? "", {
query: {
enabled: Boolean(isSignedIn && boardId),
enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always",
retry: false,
},
@@ -318,183 +336,191 @@ export default function EditBoardPage() {
</div>
<div className="p-8">
<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" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success
metrics.
{!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 edit board settings.
</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" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success
metrics.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Start onboarding
</Button>
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</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 className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select
value={resolvedBoardType}
onValueChange={setBoardType}
>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={resolvedBoardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
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">
Boards in the same group can share cross-board context
for agents.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Start onboarding
</Button>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) =>
setTargetDate(event.target.value)
}
disabled={isLoading}
/>
</div>
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</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 className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
Objective
</label>
<Select
value={resolvedBoardType}
onValueChange={setBoardType}
>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
Success metrics (JSON)
</label>
<SearchableSelect
ariaLabel="Select board group"
value={resolvedBoardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
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"
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) =>
setSuccessMetrics(event.target.value)
}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Boards in the same group can share cross-board context
for agents.
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) =>
setSuccessMetrics(event.target.value)
}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
{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 Gateways to
continue.
</p>
</div>
) : null}
</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 Gateways to
continue.
</p>
{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/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || !baseBoard || !isFormReady}
>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</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/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || !baseBoard || !isFormReady}
>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</form>
</div>
)}
</div>
</main>
</SignedIn>

View File

@@ -45,6 +45,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError } from "@/api/mutator";
import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
import {
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
@@ -62,6 +63,10 @@ import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
} from "@/api/generated/board-memory/board-memory";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import {
createTaskApiV1BoardsBoardIdTasksPost,
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
@@ -76,6 +81,7 @@ import type {
BoardGroupSnapshot,
BoardMemoryRead,
BoardRead,
OrganizationMemberRead,
TaskCardRead,
TaskCommentRead,
TaskRead,
@@ -168,6 +174,49 @@ 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({
comment,
authorLabel,
@@ -322,6 +371,31 @@ export default function BoardDetailPage() {
const isPageActive = usePageActive();
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 [tasks, setTasks] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
@@ -387,7 +461,10 @@ export default function BoardDetailPage() {
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board");
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const isLiveFeedOpenRef = useRef(false);
const toastIdRef = useRef(0);
const toastTimersRef = useRef<Record<number, number>>({});
const pushLiveFeed = useCallback((comment: TaskComment) => {
const alreadySeen = liveFeedRef.current.some(
(item) => item.id === comment.id,
@@ -423,6 +500,31 @@ export default function BoardDetailPage() {
}, 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(() => {
liveFeedHistoryLoadedRef.current = false;
setIsLiveFeedHistoryLoading(false);
@@ -448,6 +550,17 @@ export default function BoardDetailPage() {
};
}, []);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
Object.values(toastTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
toastTimersRef.current = {};
};
}, []);
useEffect(() => {
if (!isLiveFeedOpen) return;
if (!isSignedIn || !boardId) return;
@@ -1269,7 +1382,7 @@ export default function BoardDetailPage() {
useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId) return;
if (!isSignedIn || !boardId || !isOrgAdmin) return;
let isCancelled = false;
const abortController = new AbortController();
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
@@ -1372,7 +1485,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout);
}
};
}, [board, boardId, isPageActive, isSignedIn]);
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn]);
const resetForm = () => {
setTitle("");
@@ -1411,9 +1524,9 @@ export default function BoardDetailPage() {
setIsDialogOpen(false);
resetForm();
} catch (err) {
setCreateError(
err instanceof Error ? err.message : "Something went wrong.",
);
const message = formatActionError(err, "Something went wrong.");
setCreateError(message);
pushToast(message);
} finally {
setIsCreating(false);
}
@@ -1454,8 +1567,7 @@ export default function BoardDetailPage() {
}
return { ok: true, error: null };
} catch (err) {
const message =
err instanceof Error ? err.message : "Unable to send message.";
const message = formatActionError(err, "Unable to send message.");
return { ok: false, error: message };
}
},
@@ -1473,6 +1585,7 @@ export default function BoardDetailPage() {
if (!result.ok) {
if (result.error) {
setChatError(result.error);
pushToast(result.error);
}
return false;
}
@@ -1481,7 +1594,7 @@ export default function BoardDetailPage() {
setIsChatSending(false);
}
},
[postBoardChatMessage],
[postBoardChatMessage, pushToast],
);
const openAgentsControlDialog = (action: "pause" | "resume") => {
@@ -1497,16 +1610,16 @@ export default function BoardDetailPage() {
try {
const result = await postBoardChatMessage(command);
if (!result.ok) {
setAgentsControlError(
result.error ?? `Unable to send ${command} command.`,
);
const message = result.error ?? `Unable to send ${command} command.`;
setAgentsControlError(message);
pushToast(message);
return;
}
setIsAgentsControlDialogOpen(false);
} finally {
setIsAgentsControlSending(false);
}
}, [agentsControlAction, postBoardChatMessage]);
}, [agentsControlAction, postBoardChatMessage, pushToast]);
const assigneeById = useMemo(() => {
const map = new Map<string, string>();
@@ -1746,9 +1859,9 @@ export default function BoardDetailPage() {
setComments((prev) => [created, ...prev]);
setNewComment("");
} catch (err) {
setPostCommentError(
err instanceof Error ? err.message : "Unable to send message.",
);
const message = formatActionError(err, "Unable to send message.");
setPostCommentError(message);
pushToast(message);
} finally {
setIsPostingComment(false);
taskCommentInputRef.current?.focus();
@@ -1830,9 +1943,9 @@ export default function BoardDetailPage() {
setIsEditDialogOpen(false);
}
} catch (err) {
setSaveTaskError(
err instanceof Error ? err.message : "Something went wrong.",
);
const message = formatActionError(err, "Something went wrong.");
setSaveTaskError(message);
pushToast(message);
} finally {
setIsSavingTask(false);
}
@@ -1863,9 +1976,9 @@ export default function BoardDetailPage() {
setIsDeleteDialogOpen(false);
closeComments();
} catch (err) {
setDeleteTaskError(
err instanceof Error ? err.message : "Something went wrong.",
);
const message = formatActionError(err, "Something went wrong.");
setDeleteTaskError(message);
pushToast(message);
} finally {
setIsDeletingTask(false);
}
@@ -1936,10 +2049,12 @@ export default function BoardDetailPage() {
);
} catch (err) {
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) =>
@@ -2085,6 +2200,12 @@ export default function BoardDetailPage() {
const handleApprovalDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => {
if (!isSignedIn || !boardId) return;
if (!canWrite) {
pushToast(
"Read-only access. You do not have permission to update approvals.",
);
return;
}
setApprovalsUpdatingId(approvalId);
setApprovalsError(null);
try {
@@ -2102,14 +2223,14 @@ export default function BoardDetailPage() {
prev.map((item) => (item.id === approvalId ? updated : item)),
);
} catch (err) {
setApprovalsError(
err instanceof Error ? err.message : "Unable to update approval.",
);
const message = formatActionError(err, "Unable to update approval.");
setApprovalsError(message);
pushToast(message);
} finally {
setApprovalsUpdatingId(null);
}
},
[boardId, isSignedIn],
[boardId, canWrite, isSignedIn, pushToast],
);
return (
@@ -2174,7 +2295,8 @@ export default function BoardDetailPage() {
onClick={() => setIsDialogOpen(true)}
className="h-9 w-9 p-0"
aria-label="New task"
title="New task"
title={canWrite ? "New task" : "Read-only access"}
disabled={!canWrite}
>
<Plus className="h-4 w-4" />
</Button>
@@ -2192,31 +2314,44 @@ export default function BoardDetailPage() {
</span>
) : null}
</Button>
<Button
variant="outline"
onClick={() =>
openAgentsControlDialog(
isAgentsPaused ? "resume" : "pause",
)
}
disabled={!isSignedIn || !boardId || isAgentsControlSending}
className={cn(
"h-9 w-9 p-0",
isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "",
)}
aria-label={
isAgentsPaused ? "Resume agents" : "Pause agents"
}
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
>
{isAgentsPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
{isOrgAdmin ? (
<Button
variant="outline"
onClick={() =>
openAgentsControlDialog(
isAgentsPaused ? "resume" : "pause",
)
}
disabled={
!isSignedIn ||
!boardId ||
isAgentsControlSending ||
!canWrite
}
className={cn(
"h-9 w-9 p-0",
isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "",
)}
aria-label={
isAgentsPaused ? "Resume agents" : "Pause agents"
}
title={
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
variant="outline"
onClick={openBoardChat}
@@ -2235,83 +2370,87 @@ export default function BoardDetailPage() {
>
<Activity className="h-4 w-4" />
</Button>
<button
type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)}
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"
aria-label="Board settings"
title="Board settings"
>
<Settings className="h-4 w-4" />
</button>
{isOrgAdmin ? (
<button
type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)}
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"
aria-label="Board settings"
title="Board settings"
>
<Settings className="h-4 w-4" />
</button>
) : null}
</div>
</div>
</div>
</div>
<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">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Agents
</p>
<p className="text-xs text-slate-400">
{sortedAgents.length} total
</p>
</div>
<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.
{isOrgAdmin ? (
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Agents
</p>
<p className="text-xs text-slate-400">
{sortedAgents.length} total
</p>
</div>
) : (
sortedAgents.map((agent) => {
const isWorking = workingAgentIds.has(agent.id);
return (
<button
key={agent.id}
type="button"
className={cn(
"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 className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{agentAvatarLabel(agent)}
<span
className={cn(
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
isWorking
? "bg-emerald-500"
: agent.status === "online"
? "bg-green-500"
: "bg-slate-300",
)}
/>
</div>
<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>
<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>
) : (
sortedAgents.map((agent) => {
const isWorking = workingAgentIds.has(agent.id);
return (
<button
key={agent.id}
type="button"
className={cn(
"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 className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{agentAvatarLabel(agent)}
<span
className={cn(
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
isWorking
? "bg-emerald-500"
: agent.status === "online"
? "bg-green-500"
: "bg-slate-300",
)}
/>
</div>
<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">
{error && (
@@ -2364,16 +2503,18 @@ export default function BoardDetailPage() {
>
View group
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Settings
</Button>
{isOrgAdmin ? (
<Button
variant="ghost"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Settings
</Button>
) : null}
</div>
</div>
</div>
@@ -2528,7 +2669,8 @@ export default function BoardDetailPage() {
<TaskBoard
tasks={tasks}
onTaskSelect={openComments}
onTaskMove={handleTaskMove}
onTaskMove={canWrite ? handleTaskMove : undefined}
readOnly={!canWrite}
/>
) : (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -2546,7 +2688,8 @@ export default function BoardDetailPage() {
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isCreating}
disabled={isCreating || !canWrite}
title={canWrite ? "New task" : "Read-only access"}
>
New task
</Button>
@@ -2660,7 +2803,8 @@ export default function BoardDetailPage() {
type="button"
onClick={() => setIsEditDialogOpen(true)}
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" />
</button>
@@ -2826,7 +2970,10 @@ export default function BoardDetailPage() {
onClick={() =>
handleApprovalDecision(approval.id, "approved")
}
disabled={approvalsUpdatingId === approval.id}
disabled={
approvalsUpdatingId === approval.id || !canWrite
}
title={canWrite ? "Approve" : "Read-only access"}
>
Approve
</Button>
@@ -2836,7 +2983,10 @@ export default function BoardDetailPage() {
onClick={() =>
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"
>
Reject
@@ -2861,22 +3011,36 @@ export default function BoardDetailPage() {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
if (!canWrite) return;
event.preventDefault();
if (isPostingComment) return;
if (!newComment.trim()) return;
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"
disabled={!canWrite || isPostingComment}
/>
{postCommentError ? (
<p className="text-xs text-rose-600">{postCommentError}</p>
) : 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">
<Button
size="sm"
onClick={handlePostComment}
disabled={isPostingComment || !newComment.trim()}
disabled={
!canWrite || isPostingComment || !newComment.trim()
}
title={canWrite ? "Send message" : "Read-only access"}
>
{isPostingComment ? "Sending…" : "Send message"}
</Button>
@@ -2956,6 +3120,12 @@ export default function BoardDetailPage() {
<BoardChatComposer
isSending={isChatSending}
onSend={handleSendChat}
disabled={!canWrite}
placeholder={
canWrite
? "Message the board lead. Tag agents with @name."
: "Read-only access. Chat is disabled."
}
/>
</div>
</div>
@@ -3052,7 +3222,7 @@ export default function BoardDetailPage() {
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder="Task title"
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
<div className="space-y-2">
@@ -3064,7 +3234,7 @@ export default function BoardDetailPage() {
onChange={(event) => setEditDescription(event.target.value)}
placeholder="Task details"
className="min-h-[140px]"
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -3075,7 +3245,7 @@ export default function BoardDetailPage() {
<Select
value={editStatus}
onValueChange={(value) => setEditStatus(value as TaskStatus)}
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
@@ -3096,7 +3266,7 @@ export default function BoardDetailPage() {
<Select
value={editPriority}
onValueChange={setEditPriority}
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
@@ -3120,7 +3290,7 @@ export default function BoardDetailPage() {
onValueChange={(value) =>
setEditAssigneeId(value === "unassigned" ? "" : value)
}
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
@@ -3155,7 +3325,8 @@ export default function BoardDetailPage() {
disabled={
!selectedTask ||
isSavingTask ||
selectedTask.status === "done"
selectedTask.status === "done" ||
!canWrite
}
emptyMessage="No other tasks found."
/>
@@ -3195,8 +3366,14 @@ export default function BoardDetailPage() {
<button
type="button"
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"
disabled={!canWrite}
>
<X className="h-3 w-3" />
</button>
@@ -3217,21 +3394,26 @@ export default function BoardDetailPage() {
<Button
variant="outline"
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"
title={canWrite ? "Delete task" : "Read-only access"}
>
Delete task
</Button>
<Button
variant="outline"
onClick={handleTaskReset}
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
>
Reset
</Button>
<Button
onClick={() => handleTaskSave(true)}
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
>
{isSavingTask ? "Saving…" : "Save changes"}
</Button>
@@ -3262,7 +3444,7 @@ export default function BoardDetailPage() {
</Button>
<Button
onClick={handleDeleteTask}
disabled={isDeletingTask}
disabled={isDeletingTask || !canWrite}
className="bg-rose-600 text-white hover:bg-rose-700"
>
{isDeletingTask ? "Deleting…" : "Delete task"}
@@ -3294,6 +3476,7 @@ export default function BoardDetailPage() {
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="e.g. Prepare launch notes"
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
@@ -3305,13 +3488,18 @@ export default function BoardDetailPage() {
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional details"
className="min-h-[120px]"
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Priority
</label>
<Select value={priority} onValueChange={setPriority}>
<Select
value={priority}
onValueChange={setPriority}
disabled={!canWrite || isCreating}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
@@ -3334,77 +3522,117 @@ export default function BoardDetailPage() {
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateTask} disabled={isCreating}>
<Button
onClick={handleCreateTask}
disabled={!canWrite || isCreating}
>
{isCreating ? "Creating…" : "Create task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isAgentsControlDialogOpen}
onOpenChange={(nextOpen) => {
setIsAgentsControlDialogOpen(nextOpen);
if (!nextOpen) {
setAgentsControlError(null);
}
}}
>
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause"
? "Pause agents"
: "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"
{isOrgAdmin ? (
<Dialog
open={isAgentsControlDialogOpen}
onOpenChange={(nextOpen) => {
setIsAgentsControlDialogOpen(nextOpen);
if (!nextOpen) {
setAgentsControlError(null);
}
}}
>
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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"
: "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 */}
</DashboardShell>

View File

@@ -18,6 +18,10 @@ import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardGroupRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -36,6 +40,20 @@ export default function NewBoardPage() {
const router = useRouter();
const { isSignedIn } = useAuth();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState("");
const [gatewayId, setGatewayId] = useState<string>("");
const [boardGroupId, setBoardGroupId] = useState<string>("none");
@@ -47,7 +65,7 @@ export default function NewBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -58,7 +76,7 @@ export default function NewBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -166,100 +184,106 @@ export default function NewBoardPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-4">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Release operations"
disabled={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 create boards.
</div>
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-4">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</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 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 className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={boardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
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>
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={boardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
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.
{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>
</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>
</main>
</SignedIn>

View File

@@ -25,6 +25,10 @@ import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -56,6 +60,20 @@ const compactId = (value: string) =>
export default function BoardsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
@@ -264,7 +282,7 @@ export default function BoardsPage() {
{boards.length === 1 ? "" : "s"} total.
</p>
</div>
{boards.length > 0 ? (
{boards.length > 0 && isAdmin ? (
<Link
href="/boards/new"
className={buttonVariants({

View File

@@ -15,6 +15,10 @@ import {
useGetGatewayApiV1GatewaysGatewayIdGet,
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { GatewayUpdate } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -50,6 +54,20 @@ export default function EditGatewayPage() {
? gatewayIdParam[0]
: gatewayIdParam;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState<string | undefined>(undefined);
const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined);
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
@@ -77,7 +95,7 @@ export default function EditGatewayPage() {
ApiError
>(gatewayId ?? "", {
query: {
enabled: Boolean(isSignedIn && gatewayId),
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchOnMount: "always",
retry: false,
},
@@ -230,137 +248,145 @@ export default function EditGatewayPage() {
</div>
<div className="p-8">
<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}
/>
{!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 edit gateways.
</div>
<div className="grid gap-6 md:grid-cols-2">
) : (
<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 URL <span className="text-red-500">*</span>
Gateway name <span className="text-red-500">*</span>
</label>
<div className="relative">
<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="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
value={resolvedGatewayUrl}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={
gatewayUrlError ? "border-red-500" : undefined
}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={resolvedGatewayUrl}
value={resolvedGatewayToken}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
placeholder="Bearer token"
disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={resolvedGatewayToken}
onChange={(event) => {
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={resolvedMainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={resolvedMainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={resolvedWorkspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={resolvedWorkspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
{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("/gateways")}
disabled={isLoading}
/>
>
Back
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
{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("/gateways")}
disabled={isLoading}
>
Back
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</form>
)}
</div>
</main>
</SignedIn>

View File

@@ -18,6 +18,10 @@ import {
type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -49,12 +53,26 @@ export default function GatewayDetailPage() {
? gatewayIdParam[0]
: gatewayIdParam;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
getGatewayApiV1GatewaysGatewayIdGetResponse,
ApiError
>(gatewayId ?? "", {
query: {
enabled: Boolean(isSignedIn && gatewayId),
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchInterval: 30_000,
},
});
@@ -67,7 +85,7 @@ export default function GatewayDetailPage() {
ApiError
>(gatewayId ? { gateway_id: gatewayId } : undefined, {
query: {
enabled: Boolean(isSignedIn && gatewayId),
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchInterval: 15_000,
},
});
@@ -85,7 +103,7 @@ export default function GatewayDetailPage() {
ApiError
>(statusParams, {
query: {
enabled: Boolean(isSignedIn && statusParams),
enabled: Boolean(isSignedIn && isAdmin && statusParams),
refetchInterval: 15_000,
},
});
@@ -142,7 +160,7 @@ export default function GatewayDetailPage() {
>
Back to gateways
</Button>
{gatewayId ? (
{isAdmin && gatewayId ? (
<Button
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
>
@@ -154,7 +172,11 @@ export default function GatewayDetailPage() {
</div>
<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">
Loading gateway
</div>

View File

@@ -13,6 +13,10 @@ import {
gatewaysStatusApiV1GatewaysStatusGet,
useCreateGatewayApiV1GatewaysPost,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -42,6 +46,20 @@ export default function NewGatewayPage() {
const { isSignedIn } = useAuth();
const router = useRouter();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState("");
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
@@ -191,135 +209,143 @@ export default function NewGatewayPage() {
</div>
<div className="p-8">
<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}
/>
{!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 create gateways.
</div>
<div className="grid gap-6 md:grid-cols-2">
) : (
<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 URL <span className="text-red-500">*</span>
Gateway name <span className="text-red-500">*</span>
</label>
<div className="relative">
<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="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
value={gatewayUrl}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={
gatewayUrlError ? "border-red-500" : undefined
}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={gatewayUrl}
value={gatewayToken}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
placeholder="Bearer token"
disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={gatewayToken}
onChange={(event) => {
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={mainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={mainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
/>
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Creating…" : "Create gateway"}
</Button>
</div>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Creating…" : "Create gateway"}
</Button>
</div>
</form>
</form>
)}
</div>
</main>
</SignedIn>

View File

@@ -35,6 +35,10 @@ import {
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { GatewayRead } from "@/api/generated/model";
const truncate = (value?: string | null, max = 24) => {
@@ -58,6 +62,20 @@ const formatTimestamp = (value?: string | null) => {
export default function GatewaysPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
@@ -69,7 +87,7 @@ export default function GatewaysPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000,
refetchOnMount: "always",
},
@@ -240,7 +258,7 @@ export default function GatewaysPage() {
Manage OpenClaw gateway connections used by boards
</p>
</div>
{gateways.length > 0 ? (
{isAdmin && gateways.length > 0 ? (
<Link
href="/gateways/new"
className={buttonVariants({
@@ -256,102 +274,110 @@ export default function GatewaysPage() {
</div>
<div className="p-8">
<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">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{gatewaysQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="2"
y="7"
width="20"
height="14"
rx="2"
ry="2"
/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No gateways yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first gateway to connect boards and
start managing your OpenClaw connections.
</p>
<Link
href="/gateways/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first gateway
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
{!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>
</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">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{gatewaysQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="2"
y="7"
width="20"
height="14"
rx="2"
ry="2"
/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No gateways yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first gateway to connect boards
and start managing your OpenClaw connections.
</p>
<Link
href="/gateways/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first gateway
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{gatewaysQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{gatewaysQuery.error.message}
</p>
) : null}
{gatewaysQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{gatewaysQuery.error.message}
</p>
) : null}
</>
)}
</div>
</main>
</SignedIn>

View File

@@ -0,0 +1,171 @@
"use client";
export const dynamic = "force-dynamic";
import { Suspense, 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";
function InviteContent() {
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>
);
}
export default function InvitePage() {
return (
<Suspense
fallback={
<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="text-sm text-muted">Loading invite</div>
</div>
</main>
</div>
}
>
<InviteContent />
</Suspense>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -8,11 +8,17 @@ import {
Bot,
CheckCircle2,
Folder,
Building2,
LayoutGrid,
Network,
} from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import {
type healthzHealthzGetResponse,
useHealthzHealthzGet,
@@ -21,6 +27,20 @@ import { cn } from "@/lib/utils";
export function DashboardSidebar() {
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>(
{
query: {
@@ -67,18 +87,20 @@ export function DashboardSidebar() {
<BarChart3 className="h-4 w-4" />
Dashboard
</Link>
<Link
href="/gateways"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/gateways")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Network className="h-4 w-4" />
Gateways
</Link>
{isAdmin ? (
<Link
href="/gateways"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/gateways")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Network className="h-4 w-4" />
Gateways
</Link>
) : null}
<Link
href="/board-groups"
className={cn(
@@ -103,6 +125,18 @@ export function DashboardSidebar() {
<LayoutGrid className="h-4 w-4" />
Boards
</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
href="/approvals"
className={cn(
@@ -127,18 +161,20 @@ export function DashboardSidebar() {
<Activity className="h-4 w-4" />
Live feed
</Link>
<Link
href="/agents"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/agents")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Bot className="h-4 w-4" />
Agents
</Link>
{isAdmin ? (
<Link
href="/agents"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/agents")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Bot className="h-4 w-4" />
Agents
</Link>
) : null}
</nav>
</div>
<div className="border-t border-slate-200 p-4">

View File

@@ -0,0 +1,239 @@
"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[];
onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
readOnly?: boolean;
};
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
@@ -99,6 +100,7 @@ export const TaskBoard = memo(function TaskBoard({
tasks,
onTaskSelect,
onTaskMove,
readOnly = false,
}: TaskBoardProps) {
const boardRef = useRef<HTMLDivElement | null>(null);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -268,6 +270,10 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) {
event.preventDefault();
return;
}
if (task.is_blocked) {
event.preventDefault();
return;
@@ -287,6 +293,7 @@ export const TaskBoard = memo(function TaskBoard({
const handleDrop =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault();
setActiveColumn(null);
const raw = event.dataTransfer.getData("text/plain");
@@ -303,6 +310,7 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragOver =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault();
if (activeColumn !== status) {
setActiveColumn(status);
@@ -310,6 +318,7 @@ export const TaskBoard = memo(function TaskBoard({
};
const handleDragLeave = (status: TaskStatus) => () => {
if (readOnly) return;
if (activeColumn === status) {
setActiveColumn(null);
}
@@ -368,11 +377,13 @@ export const TaskBoard = memo(function TaskBoard({
key={column.title}
className={cn(
"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)}
onDragOver={handleDragOver(column.status)}
onDragLeave={handleDragLeave(column.status)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(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="flex items-center justify-between">
@@ -445,10 +456,10 @@ export const TaskBoard = memo(function TaskBoard({
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!task.is_blocked}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
onDragStart={readOnly ? undefined : handleDragStart(task)}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
</div>
))}

View File

@@ -1,10 +1,12 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { SignedIn, useUser } from "@/auth/clerk";
import { BrandMark } from "@/components/atoms/BrandMark";
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
import { UserMenu } from "@/components/organisms/UserMenu";
export function DashboardShell({ children }: { children: ReactNode }) {
@@ -12,13 +14,46 @@ export function DashboardShell({ children }: { children: ReactNode }) {
const displayName =
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 (
<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">
<div className="flex items-center justify-between px-6 py-3">
<BrandMark />
<div className="grid grid-cols-[260px_1fr_auto] items-center gap-0 py-3">
<div className="flex items-center px-6">
<BrandMark />
</div>
<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">
<p className="text-sm font-semibold text-slate-900">
{displayName}