Merge pull request #61 from abhi1693/feat/organizations
feat(orgs): introduce organizations, invites, and board access controls
This commit is contained in:
@@ -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
|
||||
259
backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py
Normal file
259
backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py
Normal 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")
|
||||
24
backend/alembic/versions/2c7b1c4d9e10_merge_heads.py
Normal file
24
backend/alembic/versions/2c7b1c4d9e10_merge_heads.py
Normal 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
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -334,7 +334,6 @@ async def list_task_comments(
|
||||
return await tasks_api.list_task_comments(
|
||||
task=task,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
401
backend/app/api/organizations.py
Normal file
401
backend/app/api/organizations.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
backend/app/models/organization_board_access.py
Normal file
28
backend/app/models/organization_board_access.py
Normal 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)
|
||||
28
backend/app/models/organization_invite_board_access.py
Normal file
28
backend/app/models/organization_invite_board_access.py
Normal 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)
|
||||
27
backend/app/models/organization_invites.py
Normal file
27
backend/app/models/organization_invites.py
Normal 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)
|
||||
29
backend/app/models/organization_members.py
Normal file
29
backend/app/models/organization_members.py
Normal 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)
|
||||
19
backend/app/models/organizations.py
Normal file
19
backend/app/models/organizations.py
Normal 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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,5 +24,6 @@ class BoardGroupUpdate(SQLModel):
|
||||
|
||||
class BoardGroupRead(BoardGroupBase):
|
||||
id: UUID
|
||||
organization_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -54,5 +54,6 @@ class BoardUpdate(SQLModel):
|
||||
|
||||
class BoardRead(BoardBase):
|
||||
id: UUID
|
||||
organization_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -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
|
||||
|
||||
100
backend/app/schemas/organizations.py
Normal file
100
backend/app/schemas/organizations.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
461
backend/app/services/organizations.py
Normal file
461
backend/app/services/organizations.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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 won’t connect.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface BoardGroupRead {
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
organization_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
10
frontend/src/api/generated/model/organizationActiveUpdate.ts
Normal file
10
frontend/src/api/generated/model/organizationActiveUpdate.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
10
frontend/src/api/generated/model/organizationCreate.ts
Normal file
10
frontend/src/api/generated/model/organizationCreate.ts
Normal 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;
|
||||
}
|
||||
10
frontend/src/api/generated/model/organizationInviteAccept.ts
Normal file
10
frontend/src/api/generated/model/organizationInviteAccept.ts
Normal 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;
|
||||
}
|
||||
15
frontend/src/api/generated/model/organizationInviteCreate.ts
Normal file
15
frontend/src/api/generated/model/organizationInviteCreate.ts
Normal 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[];
|
||||
}
|
||||
21
frontend/src/api/generated/model/organizationInviteRead.ts
Normal file
21
frontend/src/api/generated/model/organizationInviteRead.ts
Normal 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;
|
||||
}
|
||||
13
frontend/src/api/generated/model/organizationListItem.ts
Normal file
13
frontend/src/api/generated/model/organizationListItem.ts
Normal 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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
21
frontend/src/api/generated/model/organizationMemberRead.ts
Normal file
21
frontend/src/api/generated/model/organizationMemberRead.ts
Normal 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[];
|
||||
}
|
||||
10
frontend/src/api/generated/model/organizationMemberUpdate.ts
Normal file
10
frontend/src/api/generated/model/organizationMemberUpdate.ts
Normal 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;
|
||||
}
|
||||
13
frontend/src/api/generated/model/organizationRead.ts
Normal file
13
frontend/src/api/generated/model/organizationRead.ts
Normal 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;
|
||||
}
|
||||
13
frontend/src/api/generated/model/organizationUserRead.ts
Normal file
13
frontend/src/api/generated/model/organizationUserRead.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
12
frontend/src/api/generated/model/soulUpdateRequest.ts
Normal file
12
frontend/src/api/generated/model/soulUpdateRequest.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
13
frontend/src/api/generated/model/soulsDirectorySoulRef.ts
Normal file
13
frontend/src/api/generated/model/soulsDirectorySoulRef.ts
Normal 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;
|
||||
}
|
||||
2329
frontend/src/api/generated/organizations/organizations.ts
Normal file
2329
frontend/src/api/generated/organizations/organizations.ts
Normal file
File diff suppressed because it is too large
Load Diff
727
frontend/src/api/generated/souls-directory/souls-directory.ts
Normal file
727
frontend/src/api/generated/souls-directory/souls-directory.ts
Normal 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 };
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
171
frontend/src/app/invite/page.tsx
Normal file
171
frontend/src/app/invite/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1174
frontend/src/app/organization/page.tsx
Normal file
1174
frontend/src/app/organization/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
239
frontend/src/components/organisms/OrgSwitcher.tsx
Normal file
239
frontend/src/components/organisms/OrgSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user