feat: add organization-related models and update schemas for organization management
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 typing import Any, cast
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from sqlalchemy import asc, desc, func
|
from sqlalchemy import asc, desc, func
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
from app.api.deps import ActorContext, require_admin_or_agent, require_org_member
|
||||||
from app.core.auth import AuthContext
|
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
from app.db.session import async_session_maker, get_session
|
from app.db.session import async_session_maker, get_session
|
||||||
@@ -25,6 +24,7 @@ from app.models.boards import Board
|
|||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
|
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
|
from app.services.organizations import get_active_membership, list_accessible_board_ids
|
||||||
|
|
||||||
router = APIRouter(prefix="/activity", tags=["activity"])
|
router = APIRouter(prefix="/activity", tags=["activity"])
|
||||||
|
|
||||||
@@ -112,6 +112,17 @@ async def list_activity(
|
|||||||
statement = select(ActivityEvent)
|
statement = select(ActivityEvent)
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
if actor.actor_type == "agent" and actor.agent:
|
||||||
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
|
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
|
||||||
|
elif actor.actor_type == "user" and actor.user:
|
||||||
|
member = await get_active_membership(session, actor.user)
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
board_ids = await list_accessible_board_ids(session, member=member, write=False)
|
||||||
|
if not board_ids:
|
||||||
|
statement = statement.where(col(ActivityEvent.id).is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.join(Task, col(ActivityEvent.task_id) == col(Task.id)).where(
|
||||||
|
col(Task.board_id).in_(board_ids)
|
||||||
|
)
|
||||||
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
|
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
|
||||||
return await paginate(session, statement)
|
return await paginate(session, statement)
|
||||||
|
|
||||||
@@ -123,7 +134,7 @@ async def list_activity(
|
|||||||
async def list_task_comment_feed(
|
async def list_task_comment_feed(
|
||||||
board_id: UUID | None = Query(default=None),
|
board_id: UUID | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_member),
|
||||||
) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]:
|
) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]:
|
||||||
statement = (
|
statement = (
|
||||||
select(ActivityEvent, Task, Board, Agent)
|
select(ActivityEvent, Task, Board, Agent)
|
||||||
@@ -134,8 +145,15 @@ async def list_task_comment_feed(
|
|||||||
.where(func.length(func.trim(col(ActivityEvent.message))) > 0)
|
.where(func.length(func.trim(col(ActivityEvent.message))) > 0)
|
||||||
.order_by(desc(col(ActivityEvent.created_at)))
|
.order_by(desc(col(ActivityEvent.created_at)))
|
||||||
)
|
)
|
||||||
|
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||||
if board_id is not None:
|
if board_id is not None:
|
||||||
|
if board_id not in set(board_ids):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
statement = statement.where(col(Task.board_id) == board_id)
|
statement = statement.where(col(Task.board_id) == board_id)
|
||||||
|
elif board_ids:
|
||||||
|
statement = statement.where(col(Task.board_id).in_(board_ids))
|
||||||
|
else:
|
||||||
|
statement = statement.where(col(Task.id).is_(None))
|
||||||
|
|
||||||
def _transform(items: Sequence[Any]) -> Sequence[Any]:
|
def _transform(items: Sequence[Any]) -> Sequence[Any]:
|
||||||
rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items)
|
rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items)
|
||||||
@@ -149,9 +167,14 @@ async def stream_task_comment_feed(
|
|||||||
request: Request,
|
request: Request,
|
||||||
board_id: UUID | None = Query(default=None),
|
board_id: UUID | None = Query(default=None),
|
||||||
since: str | None = Query(default=None),
|
since: str | None = Query(default=None),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx=Depends(require_org_member),
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
since_dt = _parse_since(since) or utcnow()
|
since_dt = _parse_since(since) or utcnow()
|
||||||
|
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||||
|
allowed_ids = set(board_ids)
|
||||||
|
if board_id is not None and board_id not in allowed_ids:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
seen_ids: set[UUID] = set()
|
seen_ids: set[UUID] = set()
|
||||||
seen_queue: deque[UUID] = deque()
|
seen_queue: deque[UUID] = deque()
|
||||||
|
|
||||||
@@ -161,7 +184,13 @@ async def stream_task_comment_feed(
|
|||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
break
|
break
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id)
|
if board_id is not None:
|
||||||
|
rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id)
|
||||||
|
elif allowed_ids:
|
||||||
|
rows = await _fetch_task_comment_events(session, last_seen)
|
||||||
|
rows = [row for row in rows if row[1].board_id in allowed_ids]
|
||||||
|
else:
|
||||||
|
rows = []
|
||||||
for event, task, board, agent in rows:
|
for event, task, board, agent in rows:
|
||||||
event_id = event.id
|
event_id = event.id
|
||||||
if event_id in seen_ids:
|
if event_id in seen_ids:
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ async def list_task_comments(
|
|||||||
return await tasks_api.list_task_comments(
|
return await tasks_api.list_task_comments(
|
||||||
task=task,
|
task=task,
|
||||||
session=session,
|
session=session,
|
||||||
actor=_actor(agent_ctx),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ from sqlmodel import col, select
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin
|
||||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
||||||
from app.core.auth import AuthContext
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
from app.db.session import async_session_maker, get_session
|
from app.db.session import async_session_maker, get_session
|
||||||
@@ -26,7 +26,9 @@ from app.models.activity_events import ActivityEvent
|
|||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
|
from app.models.organizations import Organization
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
|
from app.models.users import User
|
||||||
from app.schemas.agents import (
|
from app.schemas.agents import (
|
||||||
AgentCreate,
|
AgentCreate,
|
||||||
AgentHeartbeat,
|
AgentHeartbeat,
|
||||||
@@ -43,6 +45,14 @@ from app.services.agent_provisioning import (
|
|||||||
provision_agent,
|
provision_agent,
|
||||||
provision_main_agent,
|
provision_main_agent,
|
||||||
)
|
)
|
||||||
|
from app.services.organizations import (
|
||||||
|
OrganizationContext,
|
||||||
|
get_active_membership,
|
||||||
|
has_board_access,
|
||||||
|
is_org_admin,
|
||||||
|
list_accessible_board_ids,
|
||||||
|
require_board_access,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||||
|
|
||||||
@@ -85,7 +95,13 @@ def _workspace_path(agent_name: str, workspace_root: str | None) -> str:
|
|||||||
return f"{root}/workspace-{_slugify(agent_name)}"
|
return f"{root}/workspace-{_slugify(agent_name)}"
|
||||||
|
|
||||||
|
|
||||||
async def _require_board(session: AsyncSession, board_id: UUID | str | None) -> Board:
|
async def _require_board(
|
||||||
|
session: AsyncSession,
|
||||||
|
board_id: UUID | str | None,
|
||||||
|
*,
|
||||||
|
user: object | None = None,
|
||||||
|
write: bool = False,
|
||||||
|
) -> Board:
|
||||||
if not board_id:
|
if not board_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
@@ -94,6 +110,8 @@ async def _require_board(session: AsyncSession, board_id: UUID | str | None) ->
|
|||||||
board = await session.get(Board, board_id)
|
board = await session.get(Board, board_id)
|
||||||
if board is None:
|
if board is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||||
|
if user is not None:
|
||||||
|
await require_board_access(session, user=user, board=board, write=write) # type: ignore[arg-type]
|
||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +129,11 @@ async def _require_gateway(
|
|||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="Board gateway_id is invalid",
|
detail="Board gateway_id is invalid",
|
||||||
)
|
)
|
||||||
|
if gateway.organization_id != board.organization_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Board gateway_id is invalid",
|
||||||
|
)
|
||||||
if not gateway.main_session_key:
|
if not gateway.main_session_key:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
@@ -206,6 +229,42 @@ async def _fetch_agent_events(
|
|||||||
return list(await session.exec(statement))
|
return list(await session.exec(statement))
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_user_context(
|
||||||
|
session: AsyncSession, user: User | None
|
||||||
|
) -> OrganizationContext:
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
member = await get_active_membership(session, user)
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
organization = await session.get(Organization, member.organization_id)
|
||||||
|
if organization is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return OrganizationContext(organization=organization, member=member)
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_agent_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
agent: Agent,
|
||||||
|
ctx,
|
||||||
|
write: bool,
|
||||||
|
) -> None:
|
||||||
|
if agent.board_id is None:
|
||||||
|
if not is_org_admin(ctx.member):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
gateway = await _find_gateway_for_main_session(session, agent.openclaw_session_id)
|
||||||
|
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return
|
||||||
|
|
||||||
|
board = await session.get(Board, agent.board_id)
|
||||||
|
if board is None or board.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if not await has_board_access(session, member=ctx.member, board=board, write=write):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
def _record_heartbeat(session: AsyncSession, agent: Agent) -> None:
|
def _record_heartbeat(session: AsyncSession, agent: Agent) -> None:
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
@@ -245,13 +304,28 @@ async def list_agents(
|
|||||||
board_id: UUID | None = Query(default=None),
|
board_id: UUID | None = Query(default=None),
|
||||||
gateway_id: UUID | None = Query(default=None),
|
gateway_id: UUID | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_admin),
|
||||||
) -> DefaultLimitOffsetPage[AgentRead]:
|
) -> DefaultLimitOffsetPage[AgentRead]:
|
||||||
main_session_keys = await _get_gateway_main_session_keys(session)
|
main_session_keys = await _get_gateway_main_session_keys(session)
|
||||||
statement = select(Agent)
|
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||||
|
if board_id is not None and board_id not in set(board_ids):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
if not board_ids:
|
||||||
|
statement = select(Agent).where(col(Agent.id).is_(None))
|
||||||
|
else:
|
||||||
|
base_filter = col(Agent.board_id).in_(board_ids)
|
||||||
|
if is_org_admin(ctx.member):
|
||||||
|
gateway_keys = select(Gateway.main_session_key).where(
|
||||||
|
col(Gateway.organization_id) == ctx.organization.id
|
||||||
|
)
|
||||||
|
base_filter = or_(base_filter, col(Agent.openclaw_session_id).in_(gateway_keys))
|
||||||
|
statement = select(Agent).where(base_filter)
|
||||||
if board_id is not None:
|
if board_id is not None:
|
||||||
statement = statement.where(col(Agent.board_id) == board_id)
|
statement = statement.where(col(Agent.board_id) == board_id)
|
||||||
if gateway_id is not None:
|
if gateway_id is not None:
|
||||||
|
gateway = await session.get(Gateway, gateway_id)
|
||||||
|
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
statement = statement.join(Board, col(Agent.board_id) == col(Board.id)).where(
|
statement = statement.join(Board, col(Agent.board_id) == col(Board.id)).where(
|
||||||
col(Board.gateway_id) == gateway_id
|
col(Board.gateway_id) == gateway_id
|
||||||
)
|
)
|
||||||
@@ -269,10 +343,15 @@ async def stream_agents(
|
|||||||
request: Request,
|
request: Request,
|
||||||
board_id: UUID | None = Query(default=None),
|
board_id: UUID | None = Query(default=None),
|
||||||
since: str | None = Query(default=None),
|
since: str | None = Query(default=None),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx=Depends(require_org_admin),
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
since_dt = _parse_since(since) or utcnow()
|
since_dt = _parse_since(since) or utcnow()
|
||||||
last_seen = since_dt
|
last_seen = since_dt
|
||||||
|
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||||
|
allowed_ids = set(board_ids)
|
||||||
|
if board_id is not None and board_id not in allowed_ids:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
||||||
nonlocal last_seen
|
nonlocal last_seen
|
||||||
@@ -280,7 +359,13 @@ async def stream_agents(
|
|||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
break
|
break
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
agents = await _fetch_agent_events(session, board_id, last_seen)
|
if board_id is not None:
|
||||||
|
agents = await _fetch_agent_events(session, board_id, last_seen)
|
||||||
|
elif allowed_ids:
|
||||||
|
agents = await _fetch_agent_events(session, None, last_seen)
|
||||||
|
agents = [agent for agent in agents if agent.board_id in allowed_ids]
|
||||||
|
else:
|
||||||
|
agents = []
|
||||||
main_session_keys = (
|
main_session_keys = (
|
||||||
await _get_gateway_main_session_keys(session) if agents else set()
|
await _get_gateway_main_session_keys(session) if agents else set()
|
||||||
)
|
)
|
||||||
@@ -301,6 +386,10 @@ async def create_agent(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> AgentRead:
|
) -> AgentRead:
|
||||||
|
if actor.actor_type == "user":
|
||||||
|
ctx = await _require_user_context(session, actor.user)
|
||||||
|
if not is_org_admin(ctx.member):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "agent":
|
||||||
if not actor.agent or not actor.agent.is_board_lead:
|
if not actor.agent or not actor.agent.is_board_lead:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -319,7 +408,12 @@ async def create_agent(
|
|||||||
)
|
)
|
||||||
payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id})
|
payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id})
|
||||||
|
|
||||||
board = await _require_board(session, payload.board_id)
|
board = await _require_board(
|
||||||
|
session,
|
||||||
|
payload.board_id,
|
||||||
|
user=actor.user if actor.actor_type == "user" else None,
|
||||||
|
write=actor.actor_type == "user",
|
||||||
|
)
|
||||||
gateway, client_config = await _require_gateway(session, board)
|
gateway, client_config = await _require_gateway(session, board)
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
requested_name = (data.get("name") or "").strip()
|
requested_name = (data.get("name") or "").strip()
|
||||||
@@ -436,11 +530,12 @@ async def create_agent(
|
|||||||
async def get_agent(
|
async def get_agent(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_admin),
|
||||||
) -> AgentRead:
|
) -> AgentRead:
|
||||||
agent = await session.get(Agent, agent_id)
|
agent = await session.get(Agent, agent_id)
|
||||||
if agent is None:
|
if agent is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await _require_agent_access(session, agent=agent, ctx=ctx, write=False)
|
||||||
main_session_keys = await _get_gateway_main_session_keys(session)
|
main_session_keys = await _get_gateway_main_session_keys(session)
|
||||||
return _to_agent_read(_with_computed_status(agent), main_session_keys)
|
return _to_agent_read(_with_computed_status(agent), main_session_keys)
|
||||||
|
|
||||||
@@ -451,18 +546,28 @@ async def update_agent(
|
|||||||
payload: AgentUpdate,
|
payload: AgentUpdate,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx=Depends(require_org_admin),
|
||||||
) -> AgentRead:
|
) -> AgentRead:
|
||||||
agent = await session.get(Agent, agent_id)
|
agent = await session.get(Agent, agent_id)
|
||||||
if agent is None:
|
if agent is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
make_main = updates.pop("is_gateway_main", None)
|
make_main = updates.pop("is_gateway_main", None)
|
||||||
|
if make_main is True and not is_org_admin(ctx.member):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if "status" in updates:
|
if "status" in updates:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="status is controlled by agent heartbeat",
|
detail="status is controlled by agent heartbeat",
|
||||||
)
|
)
|
||||||
|
if "board_id" in updates and updates["board_id"] is not None:
|
||||||
|
new_board = await _require_board(session, updates["board_id"])
|
||||||
|
if new_board.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if not await has_board_access(session, member=ctx.member, board=new_board, write=True):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if not updates and not force and make_main is None:
|
if not updates and not force and make_main is None:
|
||||||
main_session_keys = await _get_gateway_main_session_keys(session)
|
main_session_keys = await _get_gateway_main_session_keys(session)
|
||||||
return _to_agent_read(_with_computed_status(agent), main_session_keys)
|
return _to_agent_read(_with_computed_status(agent), main_session_keys)
|
||||||
@@ -628,6 +733,11 @@ async def heartbeat_agent(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
|
if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
if actor.actor_type == "user":
|
||||||
|
ctx = await _require_user_context(session, actor.user)
|
||||||
|
if not is_org_admin(ctx.member):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
|
||||||
if payload.status:
|
if payload.status:
|
||||||
agent.status = payload.status
|
agent.status = payload.status
|
||||||
elif agent.status == "provisioning":
|
elif agent.status == "provisioning":
|
||||||
@@ -664,7 +774,16 @@ async def heartbeat_or_create_agent(
|
|||||||
if agent is None:
|
if agent is None:
|
||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "agent":
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
board = await _require_board(session, payload.board_id)
|
if actor.actor_type == "user":
|
||||||
|
ctx = await _require_user_context(session, actor.user)
|
||||||
|
if not is_org_admin(ctx.member):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
board = await _require_board(
|
||||||
|
session,
|
||||||
|
payload.board_id,
|
||||||
|
user=actor.user,
|
||||||
|
write=True,
|
||||||
|
)
|
||||||
gateway, client_config = await _require_gateway(session, board)
|
gateway, client_config = await _require_gateway(session, board)
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
@@ -724,6 +843,9 @@ async def heartbeat_or_create_agent(
|
|||||||
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
||||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
elif actor.actor_type == "user":
|
||||||
|
ctx = await _require_user_context(session, actor.user)
|
||||||
|
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
|
||||||
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
|
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
elif agent.agent_token_hash is None and actor.actor_type == "user":
|
elif agent.agent_token_hash is None and actor.actor_type == "user":
|
||||||
@@ -737,7 +859,12 @@ async def heartbeat_or_create_agent(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(agent)
|
await session.refresh(agent)
|
||||||
try:
|
try:
|
||||||
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
|
board = await _require_board(
|
||||||
|
session,
|
||||||
|
str(agent.board_id) if agent.board_id else None,
|
||||||
|
user=actor.user if actor.actor_type == "user" else None,
|
||||||
|
write=actor.actor_type == "user",
|
||||||
|
)
|
||||||
gateway, client_config = await _require_gateway(session, board)
|
gateway, client_config = await _require_gateway(session, board)
|
||||||
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
|
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
|
||||||
await _send_wakeup_message(agent, client_config, verb="provisioned")
|
await _send_wakeup_message(agent, client_config, verb="provisioned")
|
||||||
@@ -767,7 +894,12 @@ async def heartbeat_or_create_agent(
|
|||||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
elif not agent.openclaw_session_id:
|
elif not agent.openclaw_session_id:
|
||||||
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
|
board = await _require_board(
|
||||||
|
session,
|
||||||
|
str(agent.board_id) if agent.board_id else None,
|
||||||
|
user=actor.user if actor.actor_type == "user" else None,
|
||||||
|
write=actor.actor_type == "user",
|
||||||
|
)
|
||||||
gateway, client_config = await _require_gateway(session, board)
|
gateway, client_config = await _require_gateway(session, board)
|
||||||
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
||||||
agent.openclaw_session_id = session_key
|
agent.openclaw_session_id = session_key
|
||||||
@@ -804,11 +936,12 @@ async def heartbeat_or_create_agent(
|
|||||||
async def delete_agent(
|
async def delete_agent(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_admin),
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
agent = await session.get(Agent, agent_id)
|
agent = await session.get(Agent, agent_id)
|
||||||
if agent is None:
|
if agent is None:
|
||||||
return OkResponse()
|
return OkResponse()
|
||||||
|
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
|
||||||
|
|
||||||
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
|
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||||
gateway, client_config = await _require_gateway(session, board)
|
gateway, client_config = await _require_gateway(session, board)
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ from sqlmodel import col, select
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
|
from app.api.deps import (
|
||||||
from app.core.auth import AuthContext
|
ActorContext,
|
||||||
|
get_board_for_actor_read,
|
||||||
|
get_board_for_actor_write,
|
||||||
|
get_board_for_user_write,
|
||||||
|
require_admin_or_agent,
|
||||||
|
)
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
from app.db.session import async_session_maker, get_session
|
from app.db.session import async_session_maker, get_session
|
||||||
@@ -88,13 +93,10 @@ async def _fetch_approval_events(
|
|||||||
@router.get("", response_model=DefaultLimitOffsetPage[ApprovalRead])
|
@router.get("", response_model=DefaultLimitOffsetPage[ApprovalRead])
|
||||||
async def list_approvals(
|
async def list_approvals(
|
||||||
status_filter: ApprovalStatus | None = Query(default=None, alias="status"),
|
status_filter: ApprovalStatus | None = Query(default=None, alias="status"),
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> DefaultLimitOffsetPage[ApprovalRead]:
|
) -> DefaultLimitOffsetPage[ApprovalRead]:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
statement = select(Approval).where(col(Approval.board_id) == board.id)
|
statement = select(Approval).where(col(Approval.board_id) == board.id)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
statement = statement.where(col(Approval.status) == status_filter)
|
statement = statement.where(col(Approval.status) == status_filter)
|
||||||
@@ -105,13 +107,10 @@ async def list_approvals(
|
|||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
async def stream_approvals(
|
async def stream_approvals(
|
||||||
request: Request,
|
request: Request,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
since: str | None = Query(default=None),
|
since: str | None = Query(default=None),
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
since_dt = _parse_since(since) or utcnow()
|
||||||
last_seen = since_dt
|
last_seen = since_dt
|
||||||
|
|
||||||
@@ -180,13 +179,10 @@ async def stream_approvals(
|
|||||||
@router.post("", response_model=ApprovalRead)
|
@router.post("", response_model=ApprovalRead)
|
||||||
async def create_approval(
|
async def create_approval(
|
||||||
payload: ApprovalCreate,
|
payload: ApprovalCreate,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> Approval:
|
) -> Approval:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
task_id = payload.task_id or _extract_task_id(payload.payload)
|
task_id = payload.task_id or _extract_task_id(payload.payload)
|
||||||
approval = Approval(
|
approval = Approval(
|
||||||
board_id=board.id,
|
board_id=board.id,
|
||||||
@@ -208,9 +204,8 @@ async def create_approval(
|
|||||||
async def update_approval(
|
async def update_approval(
|
||||||
approval_id: str,
|
approval_id: str,
|
||||||
payload: ApprovalUpdate,
|
payload: ApprovalUpdate,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
|
||||||
) -> Approval:
|
) -> Approval:
|
||||||
approval = await session.get(Approval, approval_id)
|
approval = await session.get(Approval, approval_id)
|
||||||
if approval is None or approval.board_id != board.id:
|
if approval is None or approval.board_id != board.id:
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ from sqlmodel import col, select
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
|
from app.api.deps import (
|
||||||
from app.core.auth import AuthContext
|
ActorContext,
|
||||||
|
get_board_for_actor_read,
|
||||||
|
get_board_for_actor_write,
|
||||||
|
require_admin_or_agent,
|
||||||
|
require_org_member,
|
||||||
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
@@ -25,8 +30,16 @@ from app.models.board_group_memory import BoardGroupMemory
|
|||||||
from app.models.board_groups import BoardGroup
|
from app.models.board_groups import BoardGroup
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
|
from app.models.users import User
|
||||||
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
|
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
|
from app.services.organizations import (
|
||||||
|
OrganizationContext,
|
||||||
|
is_org_admin,
|
||||||
|
list_accessible_board_ids,
|
||||||
|
member_all_boards_read,
|
||||||
|
member_all_boards_write,
|
||||||
|
)
|
||||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||||
|
|
||||||
router = APIRouter(tags=["board-group-memory"])
|
router = APIRouter(tags=["board-group-memory"])
|
||||||
@@ -96,6 +109,38 @@ async def _fetch_memory_events(
|
|||||||
return list(await session.exec(statement))
|
return list(await session.exec(statement))
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_group_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
group_id: UUID,
|
||||||
|
ctx: OrganizationContext,
|
||||||
|
write: bool,
|
||||||
|
) -> BoardGroup:
|
||||||
|
group = await session.get(BoardGroup, group_id)
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if group.organization_id != ctx.member.organization_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
if write and member_all_boards_write(ctx.member):
|
||||||
|
return group
|
||||||
|
if not write and member_all_boards_read(ctx.member):
|
||||||
|
return group
|
||||||
|
|
||||||
|
board_ids = list(
|
||||||
|
await session.exec(select(Board.id).where(col(Board.board_group_id) == group_id))
|
||||||
|
)
|
||||||
|
if not board_ids:
|
||||||
|
if is_org_admin(ctx.member):
|
||||||
|
return group
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
allowed_ids = await list_accessible_board_ids(session, member=ctx.member, write=write)
|
||||||
|
if not set(board_ids).intersection(set(allowed_ids)):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
async def _notify_group_memory_targets(
|
async def _notify_group_memory_targets(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -193,11 +238,9 @@ async def list_board_group_memory(
|
|||||||
group_id: UUID,
|
group_id: UUID,
|
||||||
is_chat: bool | None = Query(default=None),
|
is_chat: bool | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx: OrganizationContext = Depends(require_org_member),
|
||||||
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
|
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
|
||||||
group = await session.get(BoardGroup, group_id)
|
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
statement = (
|
statement = (
|
||||||
select(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id)
|
select(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id)
|
||||||
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
||||||
@@ -217,11 +260,9 @@ async def stream_board_group_memory(
|
|||||||
since: str | None = Query(default=None),
|
since: str | None = Query(default=None),
|
||||||
is_chat: bool | None = Query(default=None),
|
is_chat: bool | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx: OrganizationContext = Depends(require_org_member),
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
group = await session.get(BoardGroup, group_id)
|
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
since_dt = _parse_since(since) or utcnow()
|
||||||
last_seen = since_dt
|
last_seen = since_dt
|
||||||
|
|
||||||
@@ -252,13 +293,12 @@ async def create_board_group_memory(
|
|||||||
group_id: UUID,
|
group_id: UUID,
|
||||||
payload: BoardGroupMemoryCreate,
|
payload: BoardGroupMemoryCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx: OrganizationContext = Depends(require_org_member),
|
||||||
) -> BoardGroupMemory:
|
) -> BoardGroupMemory:
|
||||||
group = await session.get(BoardGroup, group_id)
|
group = await _require_group_access(session, group_id=group_id, ctx=ctx, write=True)
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
actor = ActorContext(actor_type="user", user=auth.user)
|
user = await session.get(User, ctx.member.user_id)
|
||||||
|
actor = ActorContext(actor_type="user", user=user)
|
||||||
tags = set(payload.tags or [])
|
tags = set(payload.tags or [])
|
||||||
is_chat = "chat" in tags
|
is_chat = "chat" in tags
|
||||||
mentions = extract_mentions(payload.content)
|
mentions = extract_mentions(payload.content)
|
||||||
@@ -287,13 +327,9 @@ async def create_board_group_memory(
|
|||||||
@board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
|
@board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
|
||||||
async def list_board_group_memory_for_board(
|
async def list_board_group_memory_for_board(
|
||||||
is_chat: bool | None = Query(default=None),
|
is_chat: bool | None = Query(default=None),
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
|
||||||
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
|
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
group_id = board.board_group_id
|
group_id = board.board_group_id
|
||||||
if group_id is None:
|
if group_id is None:
|
||||||
statement = select(BoardGroupMemory).where(col(BoardGroupMemory.id).is_(None))
|
statement = select(BoardGroupMemory).where(col(BoardGroupMemory.id).is_(None))
|
||||||
@@ -314,14 +350,10 @@ async def list_board_group_memory_for_board(
|
|||||||
@board_router.get("/stream")
|
@board_router.get("/stream")
|
||||||
async def stream_board_group_memory_for_board(
|
async def stream_board_group_memory_for_board(
|
||||||
request: Request,
|
request: Request,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
|
||||||
since: str | None = Query(default=None),
|
since: str | None = Query(default=None),
|
||||||
is_chat: bool | None = Query(default=None),
|
is_chat: bool | None = Query(default=None),
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
group_id = board.board_group_id
|
group_id = board.board_group_id
|
||||||
since_dt = _parse_since(since) or utcnow()
|
since_dt = _parse_since(since) or utcnow()
|
||||||
last_seen = since_dt
|
last_seen = since_dt
|
||||||
@@ -354,13 +386,10 @@ async def stream_board_group_memory_for_board(
|
|||||||
@board_router.post("", response_model=BoardGroupMemoryRead)
|
@board_router.post("", response_model=BoardGroupMemoryRead)
|
||||||
async def create_board_group_memory_for_board(
|
async def create_board_group_memory_for_board(
|
||||||
payload: BoardGroupMemoryCreate,
|
payload: BoardGroupMemoryCreate,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> BoardGroupMemory:
|
) -> BoardGroupMemory:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
group_id = board.board_group_id
|
group_id = board.board_group_id
|
||||||
if group_id is None:
|
if group_id is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ from sqlalchemy import delete, func, update
|
|||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin, require_org_member
|
||||||
from app.core.auth import AuthContext
|
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
@@ -29,6 +28,14 @@ from app.schemas.pagination import DefaultLimitOffsetPage
|
|||||||
from app.schemas.view_models import BoardGroupSnapshot
|
from app.schemas.view_models import BoardGroupSnapshot
|
||||||
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats
|
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats
|
||||||
from app.services.board_group_snapshot import build_group_snapshot
|
from app.services.board_group_snapshot import build_group_snapshot
|
||||||
|
from app.services.organizations import (
|
||||||
|
board_access_filter,
|
||||||
|
get_member,
|
||||||
|
is_org_admin,
|
||||||
|
list_accessible_board_ids,
|
||||||
|
member_all_boards_read,
|
||||||
|
member_all_boards_write,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/board-groups", tags=["board-groups"])
|
router = APIRouter(prefix="/board-groups", tags=["board-groups"])
|
||||||
|
|
||||||
@@ -38,12 +45,56 @@ def _slugify(value: str) -> str:
|
|||||||
return slug or uuid4().hex
|
return slug or uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_group_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
group_id: UUID,
|
||||||
|
member,
|
||||||
|
write: bool,
|
||||||
|
) -> BoardGroup:
|
||||||
|
group = await session.get(BoardGroup, group_id)
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if group.organization_id != member.organization_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
if write and member_all_boards_write(member):
|
||||||
|
return group
|
||||||
|
if not write and member_all_boards_read(member):
|
||||||
|
return group
|
||||||
|
|
||||||
|
board_ids = list(
|
||||||
|
await session.exec(select(Board.id).where(col(Board.board_group_id) == group_id))
|
||||||
|
)
|
||||||
|
if not board_ids:
|
||||||
|
if is_org_admin(member):
|
||||||
|
return group
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
allowed_ids = await list_accessible_board_ids(session, member=member, write=write)
|
||||||
|
if not set(board_ids).intersection(set(allowed_ids)):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead])
|
@router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead])
|
||||||
async def list_board_groups(
|
async def list_board_groups(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_member),
|
||||||
) -> DefaultLimitOffsetPage[BoardGroupRead]:
|
) -> DefaultLimitOffsetPage[BoardGroupRead]:
|
||||||
statement = select(BoardGroup).order_by(func.lower(col(BoardGroup.name)).asc())
|
if member_all_boards_read(ctx.member):
|
||||||
|
statement = select(BoardGroup).where(
|
||||||
|
col(BoardGroup.organization_id) == ctx.organization.id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
accessible_boards = select(Board.board_group_id).where(
|
||||||
|
board_access_filter(ctx.member, write=False)
|
||||||
|
)
|
||||||
|
statement = select(BoardGroup).where(
|
||||||
|
col(BoardGroup.organization_id) == ctx.organization.id,
|
||||||
|
col(BoardGroup.id).in_(accessible_boards),
|
||||||
|
)
|
||||||
|
statement = statement.order_by(func.lower(col(BoardGroup.name)).asc())
|
||||||
return await paginate(session, statement)
|
return await paginate(session, statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,11 +102,12 @@ async def list_board_groups(
|
|||||||
async def create_board_group(
|
async def create_board_group(
|
||||||
payload: BoardGroupCreate,
|
payload: BoardGroupCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_admin),
|
||||||
) -> BoardGroup:
|
) -> BoardGroup:
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
if not (data.get("slug") or "").strip():
|
if not (data.get("slug") or "").strip():
|
||||||
data["slug"] = _slugify(data.get("name") or "")
|
data["slug"] = _slugify(data.get("name") or "")
|
||||||
|
data["organization_id"] = ctx.organization.id
|
||||||
return await crud.create(session, BoardGroup, **data)
|
return await crud.create(session, BoardGroup, **data)
|
||||||
|
|
||||||
|
|
||||||
@@ -63,12 +115,9 @@ async def create_board_group(
|
|||||||
async def get_board_group(
|
async def get_board_group(
|
||||||
group_id: UUID,
|
group_id: UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_member),
|
||||||
) -> BoardGroup:
|
) -> BoardGroup:
|
||||||
group = await session.get(BoardGroup, group_id)
|
return await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return group
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot)
|
@router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot)
|
||||||
@@ -77,20 +126,22 @@ async def get_board_group_snapshot(
|
|||||||
include_done: bool = False,
|
include_done: bool = False,
|
||||||
per_board_task_limit: int = 5,
|
per_board_task_limit: int = 5,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_member),
|
||||||
) -> BoardGroupSnapshot:
|
) -> BoardGroupSnapshot:
|
||||||
group = await session.get(BoardGroup, group_id)
|
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if per_board_task_limit < 0:
|
if per_board_task_limit < 0:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
return await build_group_snapshot(
|
snapshot = await build_group_snapshot(
|
||||||
session,
|
session,
|
||||||
group=group,
|
group=group,
|
||||||
exclude_board_id=None,
|
exclude_board_id=None,
|
||||||
include_done=include_done,
|
include_done=include_done,
|
||||||
per_board_task_limit=per_board_task_limit,
|
per_board_task_limit=per_board_task_limit,
|
||||||
)
|
)
|
||||||
|
if not member_all_boards_read(ctx.member) and snapshot.boards:
|
||||||
|
allowed_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=False))
|
||||||
|
snapshot.boards = [item for item in snapshot.boards if item.board.id in allowed_ids]
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
|
@router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
|
||||||
@@ -104,7 +155,23 @@ async def apply_board_group_heartbeat(
|
|||||||
if group is None:
|
if group is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "user":
|
||||||
|
if actor.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
member = await get_member(
|
||||||
|
session,
|
||||||
|
user_id=actor.user.id,
|
||||||
|
organization_id=group.organization_id,
|
||||||
|
)
|
||||||
|
if member is None or not is_org_admin(member):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
await _require_group_access(
|
||||||
|
session,
|
||||||
|
group_id=group_id,
|
||||||
|
member=member,
|
||||||
|
write=True,
|
||||||
|
)
|
||||||
|
elif actor.actor_type == "agent":
|
||||||
agent = actor.agent
|
agent = actor.agent
|
||||||
if agent is None:
|
if agent is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
@@ -188,11 +255,9 @@ async def update_board_group(
|
|||||||
payload: BoardGroupUpdate,
|
payload: BoardGroupUpdate,
|
||||||
group_id: UUID,
|
group_id: UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_admin),
|
||||||
) -> BoardGroup:
|
) -> BoardGroup:
|
||||||
group = await session.get(BoardGroup, group_id)
|
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
|
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
|
||||||
updates["slug"] = _slugify(updates.get("name") or group.name)
|
updates["slug"] = _slugify(updates.get("name") or group.name)
|
||||||
@@ -206,11 +271,9 @@ async def update_board_group(
|
|||||||
async def delete_board_group(
|
async def delete_board_group(
|
||||||
group_id: UUID,
|
group_id: UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_admin),
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
group = await session.get(BoardGroup, group_id)
|
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
# Boards reference groups, so clear the FK first to keep deletes simple.
|
# Boards reference groups, so clear the FK first to keep deletes simple.
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ from sqlmodel import col, select
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.api.deps import ActorContext, get_board_or_404, require_admin_or_agent
|
from app.api.deps import (
|
||||||
|
ActorContext,
|
||||||
|
get_board_for_actor_read,
|
||||||
|
get_board_for_actor_write,
|
||||||
|
require_admin_or_agent,
|
||||||
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
@@ -178,13 +183,10 @@ async def _notify_chat_targets(
|
|||||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
|
@router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
|
||||||
async def list_board_memory(
|
async def list_board_memory(
|
||||||
is_chat: bool | None = Query(default=None),
|
is_chat: bool | None = Query(default=None),
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
|
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
statement = (
|
statement = (
|
||||||
select(BoardMemory).where(col(BoardMemory.board_id) == board.id)
|
select(BoardMemory).where(col(BoardMemory.board_id) == board.id)
|
||||||
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
||||||
@@ -200,14 +202,11 @@ async def list_board_memory(
|
|||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
async def stream_board_memory(
|
async def stream_board_memory(
|
||||||
request: Request,
|
request: Request,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
since: str | None = Query(default=None),
|
since: str | None = Query(default=None),
|
||||||
is_chat: bool | None = Query(default=None),
|
is_chat: bool | None = Query(default=None),
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
since_dt = _parse_since(since) or utcnow()
|
||||||
last_seen = since_dt
|
last_seen = since_dt
|
||||||
|
|
||||||
@@ -236,13 +235,10 @@ async def stream_board_memory(
|
|||||||
@router.post("", response_model=BoardMemoryRead)
|
@router.post("", response_model=BoardMemoryRead)
|
||||||
async def create_board_memory(
|
async def create_board_memory(
|
||||||
payload: BoardMemoryCreate,
|
payload: BoardMemoryCreate,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> BoardMemory:
|
) -> BoardMemory:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
is_chat = payload.tags is not None and "chat" in payload.tags
|
is_chat = payload.tags is not None and "chat" in payload.tags
|
||||||
source = payload.source
|
source = payload.source
|
||||||
if is_chat and not source:
|
if is_chat and not source:
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ from pydantic import ValidationError
|
|||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
|
from app.api.deps import (
|
||||||
|
ActorContext,
|
||||||
|
get_board_for_user_read,
|
||||||
|
get_board_for_user_write,
|
||||||
|
get_board_or_404,
|
||||||
|
require_admin_auth,
|
||||||
|
require_admin_or_agent,
|
||||||
|
)
|
||||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
||||||
from app.core.auth import AuthContext
|
from app.core.auth import AuthContext
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -136,9 +143,8 @@ async def _ensure_lead_agent(
|
|||||||
|
|
||||||
@router.get("", response_model=BoardOnboardingRead)
|
@router.get("", response_model=BoardOnboardingRead)
|
||||||
async def get_onboarding(
|
async def get_onboarding(
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
|
||||||
) -> BoardOnboardingSession:
|
) -> BoardOnboardingSession:
|
||||||
onboarding = (
|
onboarding = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
@@ -155,9 +161,8 @@ async def get_onboarding(
|
|||||||
@router.post("/start", response_model=BoardOnboardingRead)
|
@router.post("/start", response_model=BoardOnboardingRead)
|
||||||
async def start_onboarding(
|
async def start_onboarding(
|
||||||
payload: BoardOnboardingStart,
|
payload: BoardOnboardingStart,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
|
||||||
) -> BoardOnboardingSession:
|
) -> BoardOnboardingSession:
|
||||||
onboarding = (
|
onboarding = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
@@ -239,9 +244,8 @@ async def start_onboarding(
|
|||||||
@router.post("/answer", response_model=BoardOnboardingRead)
|
@router.post("/answer", response_model=BoardOnboardingRead)
|
||||||
async def answer_onboarding(
|
async def answer_onboarding(
|
||||||
payload: BoardOnboardingAnswer,
|
payload: BoardOnboardingAnswer,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
|
||||||
) -> BoardOnboardingSession:
|
) -> BoardOnboardingSession:
|
||||||
onboarding = (
|
onboarding = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
@@ -342,7 +346,7 @@ async def agent_onboarding_update(
|
|||||||
@router.post("/confirm", response_model=BoardRead)
|
@router.post("/confirm", response_model=BoardRead)
|
||||||
async def confirm_onboarding(
|
async def confirm_onboarding(
|
||||||
payload: BoardOnboardingConfirm,
|
payload: BoardOnboardingConfirm,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> Board:
|
) -> Board:
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ from sqlalchemy import delete, func
|
|||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
|
from app.api.deps import (
|
||||||
from app.core.auth import AuthContext
|
get_board_for_actor_read,
|
||||||
|
get_board_for_user_read,
|
||||||
|
get_board_for_user_write,
|
||||||
|
require_org_admin,
|
||||||
|
require_org_member,
|
||||||
|
)
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
@@ -38,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
|
|||||||
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
|
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
|
||||||
from app.services.board_group_snapshot import build_board_group_snapshot
|
from app.services.board_group_snapshot import build_board_group_snapshot
|
||||||
from app.services.board_snapshot import build_board_snapshot
|
from app.services.board_snapshot import build_board_snapshot
|
||||||
|
from app.services.organizations import board_access_filter
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards", tags=["boards"])
|
router = APIRouter(prefix="/boards", tags=["boards"])
|
||||||
|
|
||||||
@@ -53,40 +59,66 @@ def _build_session_key(agent_name: str) -> str:
|
|||||||
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
|
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
|
||||||
|
|
||||||
|
|
||||||
async def _require_gateway(session: AsyncSession, gateway_id: object) -> Gateway:
|
async def _require_gateway(
|
||||||
|
session: AsyncSession,
|
||||||
|
gateway_id: object,
|
||||||
|
*,
|
||||||
|
organization_id: UUID | None = None,
|
||||||
|
) -> Gateway:
|
||||||
gateway = await crud.get_by_id(session, Gateway, gateway_id)
|
gateway = await crud.get_by_id(session, Gateway, gateway_id)
|
||||||
if gateway is None:
|
if gateway is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="gateway_id is invalid",
|
detail="gateway_id is invalid",
|
||||||
)
|
)
|
||||||
|
if organization_id is not None and gateway.organization_id != organization_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="gateway_id is invalid",
|
||||||
|
)
|
||||||
return gateway
|
return gateway
|
||||||
|
|
||||||
|
|
||||||
async def _require_gateway_for_create(
|
async def _require_gateway_for_create(
|
||||||
payload: BoardCreate,
|
payload: BoardCreate,
|
||||||
|
ctx=Depends(require_org_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Gateway:
|
) -> Gateway:
|
||||||
return await _require_gateway(session, payload.gateway_id)
|
return await _require_gateway(session, payload.gateway_id, organization_id=ctx.organization.id)
|
||||||
|
|
||||||
|
|
||||||
async def _require_board_group(session: AsyncSession, board_group_id: object) -> BoardGroup:
|
async def _require_board_group(
|
||||||
|
session: AsyncSession,
|
||||||
|
board_group_id: object,
|
||||||
|
*,
|
||||||
|
organization_id: UUID | None = None,
|
||||||
|
) -> BoardGroup:
|
||||||
group = await crud.get_by_id(session, BoardGroup, board_group_id)
|
group = await crud.get_by_id(session, BoardGroup, board_group_id)
|
||||||
if group is None:
|
if group is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="board_group_id is invalid",
|
detail="board_group_id is invalid",
|
||||||
)
|
)
|
||||||
|
if organization_id is not None and group.organization_id != organization_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="board_group_id is invalid",
|
||||||
|
)
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
async def _require_board_group_for_create(
|
async def _require_board_group_for_create(
|
||||||
payload: BoardCreate,
|
payload: BoardCreate,
|
||||||
|
ctx=Depends(require_org_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> BoardGroup | None:
|
) -> BoardGroup | None:
|
||||||
if payload.board_group_id is None:
|
if payload.board_group_id is None:
|
||||||
return None
|
return None
|
||||||
return await _require_board_group(session, payload.board_group_id)
|
return await _require_board_group(
|
||||||
|
session,
|
||||||
|
payload.board_group_id,
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _apply_board_update(
|
async def _apply_board_update(
|
||||||
@@ -97,9 +129,13 @@ async def _apply_board_update(
|
|||||||
) -> Board:
|
) -> Board:
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
if "gateway_id" in updates:
|
if "gateway_id" in updates:
|
||||||
await _require_gateway(session, updates["gateway_id"])
|
await _require_gateway(session, updates["gateway_id"], organization_id=board.organization_id)
|
||||||
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
||||||
await _require_board_group(session, updates["board_group_id"])
|
await _require_board_group(
|
||||||
|
session,
|
||||||
|
updates["board_group_id"],
|
||||||
|
organization_id=board.organization_id,
|
||||||
|
)
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
setattr(board, key, value)
|
setattr(board, key, value)
|
||||||
if updates.get("board_type") == "goal":
|
if updates.get("board_type") == "goal":
|
||||||
@@ -182,9 +218,9 @@ async def list_boards(
|
|||||||
gateway_id: UUID | None = Query(default=None),
|
gateway_id: UUID | None = Query(default=None),
|
||||||
board_group_id: UUID | None = Query(default=None),
|
board_group_id: UUID | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
ctx=Depends(require_org_member),
|
||||||
) -> DefaultLimitOffsetPage[BoardRead]:
|
) -> DefaultLimitOffsetPage[BoardRead]:
|
||||||
statement = select(Board)
|
statement = select(Board).where(board_access_filter(ctx.member, write=False))
|
||||||
if gateway_id is not None:
|
if gateway_id is not None:
|
||||||
statement = statement.where(col(Board.gateway_id) == gateway_id)
|
statement = statement.where(col(Board.gateway_id) == gateway_id)
|
||||||
if board_group_id is not None:
|
if board_group_id is not None:
|
||||||
@@ -199,28 +235,25 @@ async def create_board(
|
|||||||
_gateway: Gateway = Depends(_require_gateway_for_create),
|
_gateway: Gateway = Depends(_require_gateway_for_create),
|
||||||
_board_group: BoardGroup | None = Depends(_require_board_group_for_create),
|
_board_group: BoardGroup | None = Depends(_require_board_group_for_create),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_admin),
|
||||||
) -> Board:
|
) -> Board:
|
||||||
return await crud.create(session, Board, **payload.model_dump())
|
data = payload.model_dump()
|
||||||
|
data["organization_id"] = ctx.organization.id
|
||||||
|
return await crud.create(session, Board, **data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{board_id}", response_model=BoardRead)
|
@router.get("/{board_id}", response_model=BoardRead)
|
||||||
def get_board(
|
def get_board(
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_read),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
|
||||||
) -> Board:
|
) -> Board:
|
||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
||||||
async def get_board_snapshot(
|
async def get_board_snapshot(
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
|
||||||
) -> BoardSnapshot:
|
) -> BoardSnapshot:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return await build_board_snapshot(session, board)
|
return await build_board_snapshot(session, board)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,13 +262,9 @@ async def get_board_group_snapshot(
|
|||||||
include_self: bool = Query(default=False),
|
include_self: bool = Query(default=False),
|
||||||
include_done: bool = Query(default=False),
|
include_done: bool = Query(default=False),
|
||||||
per_board_task_limit: int = Query(default=5, ge=0, le=100),
|
per_board_task_limit: int = Query(default=5, ge=0, le=100),
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
|
||||||
) -> BoardGroupSnapshot:
|
) -> BoardGroupSnapshot:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return await build_board_group_snapshot(
|
return await build_board_group_snapshot(
|
||||||
session,
|
session,
|
||||||
board=board,
|
board=board,
|
||||||
@@ -249,8 +278,7 @@ async def get_board_group_snapshot(
|
|||||||
async def update_board(
|
async def update_board(
|
||||||
payload: BoardUpdate,
|
payload: BoardUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_write),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
|
||||||
) -> Board:
|
) -> Board:
|
||||||
return await _apply_board_update(payload=payload, session=session, board=board)
|
return await _apply_board_update(payload=payload, session=session, board=board)
|
||||||
|
|
||||||
@@ -258,8 +286,7 @@ async def update_board(
|
|||||||
@router.delete("/{board_id}", response_model=OkResponse)
|
@router.delete("/{board_id}", response_model=OkResponse)
|
||||||
async def delete_board(
|
async def delete_board(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_write),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
agents = list(await session.exec(select(Agent).where(Agent.board_id == board.id)))
|
agents = list(await session.exec(select(Agent).where(Agent.board_id == board.id)))
|
||||||
task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id)))
|
task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id)))
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ from app.models.agents import Agent
|
|||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
from app.models.organizations import Organization
|
||||||
|
from app.services.organizations import (
|
||||||
|
OrganizationContext,
|
||||||
|
ensure_member_for_user,
|
||||||
|
get_active_membership,
|
||||||
|
is_org_admin,
|
||||||
|
require_board_access,
|
||||||
|
)
|
||||||
from app.services.admin_access import require_admin
|
from app.services.admin_access import require_admin
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +48,31 @@ def require_admin_or_agent(
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_org_member(
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> OrganizationContext:
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
member = await get_active_membership(session, auth.user)
|
||||||
|
if member is None:
|
||||||
|
member = await ensure_member_for_user(session, auth.user)
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
organization = await session.get(Organization, member.organization_id)
|
||||||
|
if organization is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return OrganizationContext(organization=organization, member=member)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_org_admin(
|
||||||
|
ctx: OrganizationContext = Depends(require_org_member),
|
||||||
|
) -> OrganizationContext:
|
||||||
|
if not is_org_admin(ctx.member):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
async def get_board_or_404(
|
async def get_board_or_404(
|
||||||
board_id: str,
|
board_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -50,9 +83,73 @@ async def get_board_or_404(
|
|||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
async def get_board_for_actor_read(
|
||||||
|
board_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
|
) -> Board:
|
||||||
|
board = await session.get(Board, board_id)
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if actor.actor_type == "agent":
|
||||||
|
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return board
|
||||||
|
if actor.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
await require_board_access(session, user=actor.user, board=board, write=False)
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
async def get_board_for_actor_write(
|
||||||
|
board_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
|
) -> Board:
|
||||||
|
board = await session.get(Board, board_id)
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if actor.actor_type == "agent":
|
||||||
|
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return board
|
||||||
|
if actor.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
await require_board_access(session, user=actor.user, board=board, write=True)
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
async def get_board_for_user_read(
|
||||||
|
board_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
) -> Board:
|
||||||
|
board = await session.get(Board, board_id)
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
await require_board_access(session, user=auth.user, board=board, write=False)
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
async def get_board_for_user_write(
|
||||||
|
board_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
) -> Board:
|
||||||
|
board = await session.get(Board, board_id)
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
await require_board_access(session, user=auth.user, board=board, write=True)
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
async def get_task_or_404(
|
async def get_task_or_404(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Task:
|
) -> Task:
|
||||||
task = await session.get(Task, task_id)
|
task = await session.get(Task, task_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import require_org_admin
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||||
@@ -20,6 +21,7 @@ from app.integrations.openclaw_gateway_protocol import (
|
|||||||
)
|
)
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
|
from app.services.organizations import OrganizationContext, require_board_access
|
||||||
from app.schemas.common import OkResponse
|
from app.schemas.common import OkResponse
|
||||||
from app.schemas.gateway_api import (
|
from app.schemas.gateway_api import (
|
||||||
GatewayCommandsResponse,
|
GatewayCommandsResponse,
|
||||||
@@ -40,6 +42,8 @@ async def _resolve_gateway(
|
|||||||
gateway_url: str | None,
|
gateway_url: str | None,
|
||||||
gateway_token: str | None,
|
gateway_token: str | None,
|
||||||
gateway_main_session_key: str | None,
|
gateway_main_session_key: str | None,
|
||||||
|
*,
|
||||||
|
user: object | None = None,
|
||||||
) -> tuple[Board | None, GatewayClientConfig, str | None]:
|
) -> tuple[Board | None, GatewayClientConfig, str | None]:
|
||||||
if gateway_url:
|
if gateway_url:
|
||||||
return (
|
return (
|
||||||
@@ -55,6 +59,8 @@ async def _resolve_gateway(
|
|||||||
board = await session.get(Board, board_id)
|
board = await session.get(Board, board_id)
|
||||||
if board is None:
|
if board is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||||
|
if isinstance(user, object) and user is not None:
|
||||||
|
await require_board_access(session, user=user, board=board, write=False) # type: ignore[arg-type]
|
||||||
if not board.gateway_id:
|
if not board.gateway_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
@@ -79,9 +85,16 @@ async def _resolve_gateway(
|
|||||||
|
|
||||||
|
|
||||||
async def _require_gateway(
|
async def _require_gateway(
|
||||||
session: AsyncSession, board_id: str | None
|
session: AsyncSession, board_id: str | None, *, user: object | None = None
|
||||||
) -> tuple[Board, GatewayClientConfig, str | None]:
|
) -> tuple[Board, GatewayClientConfig, str | None]:
|
||||||
board, config, main_session = await _resolve_gateway(session, board_id, None, None, None)
|
board, config, main_session = await _resolve_gateway(
|
||||||
|
session,
|
||||||
|
board_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
if board is None:
|
if board is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
@@ -95,6 +108,7 @@ async def gateways_status(
|
|||||||
params: GatewayResolveQuery = Depends(),
|
params: GatewayResolveQuery = Depends(),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
) -> GatewaysStatusResponse:
|
) -> GatewaysStatusResponse:
|
||||||
board, config, main_session = await _resolve_gateway(
|
board, config, main_session = await _resolve_gateway(
|
||||||
session,
|
session,
|
||||||
@@ -102,7 +116,10 @@ async def gateways_status(
|
|||||||
params.gateway_url,
|
params.gateway_url,
|
||||||
params.gateway_token,
|
params.gateway_token,
|
||||||
params.gateway_main_session_key,
|
params.gateway_main_session_key,
|
||||||
|
user=auth.user,
|
||||||
)
|
)
|
||||||
|
if board is not None and board.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
try:
|
try:
|
||||||
sessions = await openclaw_call("sessions.list", config=config)
|
sessions = await openclaw_call("sessions.list", config=config)
|
||||||
if isinstance(sessions, dict):
|
if isinstance(sessions, dict):
|
||||||
@@ -136,6 +153,7 @@ async def list_gateway_sessions(
|
|||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
) -> GatewaySessionsResponse:
|
) -> GatewaySessionsResponse:
|
||||||
board, config, main_session = await _resolve_gateway(
|
board, config, main_session = await _resolve_gateway(
|
||||||
session,
|
session,
|
||||||
@@ -143,7 +161,10 @@ async def list_gateway_sessions(
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
user=auth.user,
|
||||||
)
|
)
|
||||||
|
if board is not None and board.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
try:
|
try:
|
||||||
sessions = await openclaw_call("sessions.list", config=config)
|
sessions = await openclaw_call("sessions.list", config=config)
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
@@ -175,6 +196,7 @@ async def get_gateway_session(
|
|||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
) -> GatewaySessionResponse:
|
) -> GatewaySessionResponse:
|
||||||
board, config, main_session = await _resolve_gateway(
|
board, config, main_session = await _resolve_gateway(
|
||||||
session,
|
session,
|
||||||
@@ -182,7 +204,10 @@ async def get_gateway_session(
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
user=auth.user,
|
||||||
)
|
)
|
||||||
|
if board is not None and board.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
try:
|
try:
|
||||||
sessions = await openclaw_call("sessions.list", config=config)
|
sessions = await openclaw_call("sessions.list", config=config)
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
@@ -220,8 +245,11 @@ async def get_session_history(
|
|||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
) -> GatewaySessionHistoryResponse:
|
) -> GatewaySessionHistoryResponse:
|
||||||
_, config, _ = await _require_gateway(session, board_id)
|
board, config, _ = await _require_gateway(session, board_id, user=auth.user)
|
||||||
|
if board.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
try:
|
try:
|
||||||
history = await get_chat_history(session_id, config=config)
|
history = await get_chat_history(session_id, config=config)
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
@@ -238,8 +266,14 @@ async def send_gateway_session_message(
|
|||||||
board_id: str | None = Query(default=None),
|
board_id: str | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
board, config, main_session = await _require_gateway(session, board_id)
|
board, config, main_session = await _require_gateway(session, board_id, user=auth.user)
|
||||||
|
if board.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
await require_board_access(session, user=auth.user, board=board, write=True)
|
||||||
try:
|
try:
|
||||||
if main_session and session_id == main_session:
|
if main_session and session_id == main_session:
|
||||||
await ensure_session(main_session, config=config, label="Main Agent")
|
await ensure_session(main_session, config=config, label="Main Agent")
|
||||||
@@ -252,6 +286,7 @@ async def send_gateway_session_message(
|
|||||||
@router.get("/commands", response_model=GatewayCommandsResponse)
|
@router.get("/commands", response_model=GatewayCommandsResponse)
|
||||||
async def gateway_commands(
|
async def gateway_commands(
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
_ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
) -> GatewayCommandsResponse:
|
) -> GatewayCommandsResponse:
|
||||||
return GatewayCommandsResponse(
|
return GatewayCommandsResponse(
|
||||||
protocol_version=PROTOCOL_VERSION,
|
protocol_version=PROTOCOL_VERSION,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import require_admin_auth
|
from app.api.deps import require_org_admin
|
||||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
@@ -131,9 +131,13 @@ async def _ensure_main_agent(
|
|||||||
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
|
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
|
||||||
async def list_gateways(
|
async def list_gateways(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
ctx=Depends(require_org_admin),
|
||||||
) -> DefaultLimitOffsetPage[GatewayRead]:
|
) -> DefaultLimitOffsetPage[GatewayRead]:
|
||||||
statement = select(Gateway).order_by(col(Gateway.created_at).desc())
|
statement = (
|
||||||
|
select(Gateway)
|
||||||
|
.where(col(Gateway.organization_id) == ctx.organization.id)
|
||||||
|
.order_by(col(Gateway.created_at).desc())
|
||||||
|
)
|
||||||
return await paginate(session, statement)
|
return await paginate(session, statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,8 +146,10 @@ async def create_gateway(
|
|||||||
payload: GatewayCreate,
|
payload: GatewayCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx=Depends(require_org_admin),
|
||||||
) -> Gateway:
|
) -> Gateway:
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
|
data["organization_id"] = ctx.organization.id
|
||||||
gateway = Gateway.model_validate(data)
|
gateway = Gateway.model_validate(data)
|
||||||
session.add(gateway)
|
session.add(gateway)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -156,10 +162,10 @@ async def create_gateway(
|
|||||||
async def get_gateway(
|
async def get_gateway(
|
||||||
gateway_id: UUID,
|
gateway_id: UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
ctx=Depends(require_org_admin),
|
||||||
) -> Gateway:
|
) -> Gateway:
|
||||||
gateway = await session.get(Gateway, gateway_id)
|
gateway = await session.get(Gateway, gateway_id)
|
||||||
if gateway is None:
|
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
||||||
return gateway
|
return gateway
|
||||||
|
|
||||||
@@ -170,9 +176,10 @@ async def update_gateway(
|
|||||||
payload: GatewayUpdate,
|
payload: GatewayUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx=Depends(require_org_admin),
|
||||||
) -> Gateway:
|
) -> Gateway:
|
||||||
gateway = await session.get(Gateway, gateway_id)
|
gateway = await session.get(Gateway, gateway_id)
|
||||||
if gateway is None:
|
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
||||||
previous_name = gateway.name
|
previous_name = gateway.name
|
||||||
previous_session_key = gateway.main_session_key
|
previous_session_key = gateway.main_session_key
|
||||||
@@ -202,10 +209,11 @@ async def sync_gateway_templates(
|
|||||||
force_bootstrap: bool = Query(default=False),
|
force_bootstrap: bool = Query(default=False),
|
||||||
board_id: UUID | None = Query(default=None),
|
board_id: UUID | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
ctx=Depends(require_org_admin),
|
||||||
) -> GatewayTemplatesSyncResult:
|
) -> GatewayTemplatesSyncResult:
|
||||||
gateway = await session.get(Gateway, gateway_id)
|
gateway = await session.get(Gateway, gateway_id)
|
||||||
if gateway is None:
|
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
||||||
return await sync_gateway_templates_service(
|
return await sync_gateway_templates_service(
|
||||||
session,
|
session,
|
||||||
@@ -223,10 +231,10 @@ async def sync_gateway_templates(
|
|||||||
async def delete_gateway(
|
async def delete_gateway(
|
||||||
gateway_id: UUID,
|
gateway_id: UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(get_auth_context),
|
ctx=Depends(require_org_admin),
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
gateway = await session.get(Gateway, gateway_id)
|
gateway = await session.get(Gateway, gateway_id)
|
||||||
if gateway is None:
|
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
|
||||||
await session.delete(gateway)
|
await session.delete(gateway)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import DateTime, case, cast, func
|
from sqlalchemy import DateTime, case, cast, func
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import require_admin_auth
|
from app.api.deps import require_org_member
|
||||||
from app.core.auth import AuthContext
|
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.activity_events import ActivityEvent
|
from app.models.activity_events import ActivityEvent
|
||||||
@@ -26,6 +26,7 @@ from app.schemas.metrics import (
|
|||||||
DashboardWipRangeSeries,
|
DashboardWipRangeSeries,
|
||||||
DashboardWipSeriesSet,
|
DashboardWipSeriesSet,
|
||||||
)
|
)
|
||||||
|
from app.services.organizations import list_accessible_board_ids
|
||||||
|
|
||||||
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
||||||
|
|
||||||
@@ -113,22 +114,29 @@ def _wip_series_from_mapping(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _query_throughput(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries:
|
async def _query_throughput(
|
||||||
|
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
|
||||||
|
) -> DashboardRangeSeries:
|
||||||
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
||||||
statement = (
|
statement = (
|
||||||
select(bucket_col, func.count())
|
select(bucket_col, func.count())
|
||||||
.where(col(Task.status) == "review")
|
.where(col(Task.status) == "review")
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
.where(col(Task.updated_at) >= range_spec.start)
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
.where(col(Task.updated_at) <= range_spec.end)
|
||||||
.group_by(bucket_col)
|
)
|
||||||
.order_by(bucket_col)
|
if not board_ids:
|
||||||
|
return _series_from_mapping(range_spec, {})
|
||||||
|
statement = (
|
||||||
|
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
|
||||||
)
|
)
|
||||||
results = (await session.exec(statement)).all()
|
results = (await session.exec(statement)).all()
|
||||||
mapping = {row[0]: float(row[1]) for row in results}
|
mapping = {row[0]: float(row[1]) for row in results}
|
||||||
return _series_from_mapping(range_spec, mapping)
|
return _series_from_mapping(range_spec, mapping)
|
||||||
|
|
||||||
|
|
||||||
async def _query_cycle_time(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries:
|
async def _query_cycle_time(
|
||||||
|
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
|
||||||
|
) -> DashboardRangeSeries:
|
||||||
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
||||||
in_progress = cast(Task.in_progress_at, DateTime)
|
in_progress = cast(Task.in_progress_at, DateTime)
|
||||||
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
|
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
|
||||||
@@ -138,15 +146,20 @@ async def _query_cycle_time(session: AsyncSession, range_spec: RangeSpec) -> Das
|
|||||||
.where(col(Task.in_progress_at).is_not(None))
|
.where(col(Task.in_progress_at).is_not(None))
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
.where(col(Task.updated_at) >= range_spec.start)
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
.where(col(Task.updated_at) <= range_spec.end)
|
||||||
.group_by(bucket_col)
|
)
|
||||||
.order_by(bucket_col)
|
if not board_ids:
|
||||||
|
return _series_from_mapping(range_spec, {})
|
||||||
|
statement = (
|
||||||
|
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
|
||||||
)
|
)
|
||||||
results = (await session.exec(statement)).all()
|
results = (await session.exec(statement)).all()
|
||||||
mapping = {row[0]: float(row[1] or 0) for row in results}
|
mapping = {row[0]: float(row[1] or 0) for row in results}
|
||||||
return _series_from_mapping(range_spec, mapping)
|
return _series_from_mapping(range_spec, mapping)
|
||||||
|
|
||||||
|
|
||||||
async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries:
|
async def _query_error_rate(
|
||||||
|
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
|
||||||
|
) -> DashboardRangeSeries:
|
||||||
bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label("bucket")
|
bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label("bucket")
|
||||||
error_case = case(
|
error_case = case(
|
||||||
(
|
(
|
||||||
@@ -157,10 +170,14 @@ async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> Das
|
|||||||
)
|
)
|
||||||
statement = (
|
statement = (
|
||||||
select(bucket_col, func.sum(error_case), func.count())
|
select(bucket_col, func.sum(error_case), func.count())
|
||||||
|
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
|
||||||
.where(col(ActivityEvent.created_at) >= range_spec.start)
|
.where(col(ActivityEvent.created_at) >= range_spec.start)
|
||||||
.where(col(ActivityEvent.created_at) <= range_spec.end)
|
.where(col(ActivityEvent.created_at) <= range_spec.end)
|
||||||
.group_by(bucket_col)
|
)
|
||||||
.order_by(bucket_col)
|
if not board_ids:
|
||||||
|
return _series_from_mapping(range_spec, {})
|
||||||
|
statement = (
|
||||||
|
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
|
||||||
)
|
)
|
||||||
results = (await session.exec(statement)).all()
|
results = (await session.exec(statement)).all()
|
||||||
mapping: dict[datetime, float] = {}
|
mapping: dict[datetime, float] = {}
|
||||||
@@ -172,7 +189,9 @@ async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> Das
|
|||||||
return _series_from_mapping(range_spec, mapping)
|
return _series_from_mapping(range_spec, mapping)
|
||||||
|
|
||||||
|
|
||||||
async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardWipRangeSeries:
|
async def _query_wip(
|
||||||
|
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
|
||||||
|
) -> DashboardWipRangeSeries:
|
||||||
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
||||||
inbox_case = case((col(Task.status) == "inbox", 1), else_=0)
|
inbox_case = case((col(Task.status) == "inbox", 1), else_=0)
|
||||||
progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
|
progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
|
||||||
@@ -186,8 +205,11 @@ async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardW
|
|||||||
)
|
)
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
.where(col(Task.updated_at) >= range_spec.start)
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
.where(col(Task.updated_at) <= range_spec.end)
|
||||||
.group_by(bucket_col)
|
)
|
||||||
.order_by(bucket_col)
|
if not board_ids:
|
||||||
|
return _wip_series_from_mapping(range_spec, {})
|
||||||
|
statement = (
|
||||||
|
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
|
||||||
)
|
)
|
||||||
results = (await session.exec(statement)).all()
|
results = (await session.exec(statement)).all()
|
||||||
mapping: dict[datetime, dict[str, int]] = {}
|
mapping: dict[datetime, dict[str, int]] = {}
|
||||||
@@ -200,7 +222,7 @@ async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardW
|
|||||||
return _wip_series_from_mapping(range_spec, mapping)
|
return _wip_series_from_mapping(range_spec, mapping)
|
||||||
|
|
||||||
|
|
||||||
async def _median_cycle_time_7d(session: AsyncSession) -> float | None:
|
async def _median_cycle_time_7d(session: AsyncSession, board_ids: list[UUID]) -> float | None:
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
start = now - timedelta(days=7)
|
start = now - timedelta(days=7)
|
||||||
in_progress = cast(Task.in_progress_at, DateTime)
|
in_progress = cast(Task.in_progress_at, DateTime)
|
||||||
@@ -212,6 +234,9 @@ async def _median_cycle_time_7d(session: AsyncSession) -> float | None:
|
|||||||
.where(col(Task.updated_at) >= start)
|
.where(col(Task.updated_at) >= start)
|
||||||
.where(col(Task.updated_at) <= now)
|
.where(col(Task.updated_at) <= now)
|
||||||
)
|
)
|
||||||
|
if not board_ids:
|
||||||
|
return None
|
||||||
|
statement = statement.where(col(Task.board_id).in_(board_ids))
|
||||||
value = (await session.exec(statement)).one_or_none()
|
value = (await session.exec(statement)).one_or_none()
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -222,7 +247,9 @@ async def _median_cycle_time_7d(session: AsyncSession) -> float | None:
|
|||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float:
|
async def _error_rate_kpi(
|
||||||
|
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
|
||||||
|
) -> float:
|
||||||
error_case = case(
|
error_case = case(
|
||||||
(
|
(
|
||||||
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
|
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
|
||||||
@@ -232,9 +259,13 @@ async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float
|
|||||||
)
|
)
|
||||||
statement = (
|
statement = (
|
||||||
select(func.sum(error_case), func.count())
|
select(func.sum(error_case), func.count())
|
||||||
|
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
|
||||||
.where(col(ActivityEvent.created_at) >= range_spec.start)
|
.where(col(ActivityEvent.created_at) >= range_spec.start)
|
||||||
.where(col(ActivityEvent.created_at) <= range_spec.end)
|
.where(col(ActivityEvent.created_at) <= range_spec.end)
|
||||||
)
|
)
|
||||||
|
if not board_ids:
|
||||||
|
return 0.0
|
||||||
|
statement = statement.where(col(Task.board_id).in_(board_ids))
|
||||||
result = (await session.exec(statement)).one_or_none()
|
result = (await session.exec(statement)).one_or_none()
|
||||||
if result is None:
|
if result is None:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -244,18 +275,27 @@ async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float
|
|||||||
return (error_count / total_count) * 100 if total_count > 0 else 0.0
|
return (error_count / total_count) * 100 if total_count > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
async def _active_agents(session: AsyncSession) -> int:
|
async def _active_agents(session: AsyncSession, board_ids: list[UUID]) -> int:
|
||||||
threshold = utcnow() - OFFLINE_AFTER
|
threshold = utcnow() - OFFLINE_AFTER
|
||||||
statement = select(func.count()).where(
|
statement = select(func.count()).where(
|
||||||
col(Agent.last_seen_at).is_not(None),
|
col(Agent.last_seen_at).is_not(None),
|
||||||
col(Agent.last_seen_at) >= threshold,
|
col(Agent.last_seen_at) >= threshold,
|
||||||
)
|
)
|
||||||
|
if not board_ids:
|
||||||
|
return 0
|
||||||
|
statement = statement.where(col(Agent.board_id).in_(board_ids))
|
||||||
result = (await session.exec(statement)).one()
|
result = (await session.exec(statement)).one()
|
||||||
return int(result)
|
return int(result)
|
||||||
|
|
||||||
|
|
||||||
async def _tasks_in_progress(session: AsyncSession) -> int:
|
async def _tasks_in_progress(session: AsyncSession, board_ids: list[UUID]) -> int:
|
||||||
statement = select(func.count()).where(col(Task.status) == "in_progress")
|
if not board_ids:
|
||||||
|
return 0
|
||||||
|
statement = (
|
||||||
|
select(func.count())
|
||||||
|
.where(col(Task.status) == "in_progress")
|
||||||
|
.where(col(Task.board_id).in_(board_ids))
|
||||||
|
)
|
||||||
result = (await session.exec(statement)).one()
|
result = (await session.exec(statement)).one()
|
||||||
return int(result)
|
return int(result)
|
||||||
|
|
||||||
@@ -264,41 +304,42 @@ async def _tasks_in_progress(session: AsyncSession) -> int:
|
|||||||
async def dashboard_metrics(
|
async def dashboard_metrics(
|
||||||
range: Literal["24h", "7d"] = Query(default="24h"),
|
range: Literal["24h", "7d"] = Query(default="24h"),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
ctx=Depends(require_org_member),
|
||||||
) -> DashboardMetrics:
|
) -> DashboardMetrics:
|
||||||
primary = _resolve_range(range)
|
primary = _resolve_range(range)
|
||||||
comparison = _comparison_range(range)
|
comparison = _comparison_range(range)
|
||||||
|
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||||
|
|
||||||
throughput_primary = await _query_throughput(session, primary)
|
throughput_primary = await _query_throughput(session, primary, board_ids)
|
||||||
throughput_comparison = await _query_throughput(session, comparison)
|
throughput_comparison = await _query_throughput(session, comparison, board_ids)
|
||||||
throughput = DashboardSeriesSet(
|
throughput = DashboardSeriesSet(
|
||||||
primary=throughput_primary,
|
primary=throughput_primary,
|
||||||
comparison=throughput_comparison,
|
comparison=throughput_comparison,
|
||||||
)
|
)
|
||||||
cycle_time_primary = await _query_cycle_time(session, primary)
|
cycle_time_primary = await _query_cycle_time(session, primary, board_ids)
|
||||||
cycle_time_comparison = await _query_cycle_time(session, comparison)
|
cycle_time_comparison = await _query_cycle_time(session, comparison, board_ids)
|
||||||
cycle_time = DashboardSeriesSet(
|
cycle_time = DashboardSeriesSet(
|
||||||
primary=cycle_time_primary,
|
primary=cycle_time_primary,
|
||||||
comparison=cycle_time_comparison,
|
comparison=cycle_time_comparison,
|
||||||
)
|
)
|
||||||
error_rate_primary = await _query_error_rate(session, primary)
|
error_rate_primary = await _query_error_rate(session, primary, board_ids)
|
||||||
error_rate_comparison = await _query_error_rate(session, comparison)
|
error_rate_comparison = await _query_error_rate(session, comparison, board_ids)
|
||||||
error_rate = DashboardSeriesSet(
|
error_rate = DashboardSeriesSet(
|
||||||
primary=error_rate_primary,
|
primary=error_rate_primary,
|
||||||
comparison=error_rate_comparison,
|
comparison=error_rate_comparison,
|
||||||
)
|
)
|
||||||
wip_primary = await _query_wip(session, primary)
|
wip_primary = await _query_wip(session, primary, board_ids)
|
||||||
wip_comparison = await _query_wip(session, comparison)
|
wip_comparison = await _query_wip(session, comparison, board_ids)
|
||||||
wip = DashboardWipSeriesSet(
|
wip = DashboardWipSeriesSet(
|
||||||
primary=wip_primary,
|
primary=wip_primary,
|
||||||
comparison=wip_comparison,
|
comparison=wip_comparison,
|
||||||
)
|
)
|
||||||
|
|
||||||
kpis = DashboardKpis(
|
kpis = DashboardKpis(
|
||||||
active_agents=await _active_agents(session),
|
active_agents=await _active_agents(session, board_ids),
|
||||||
tasks_in_progress=await _tasks_in_progress(session),
|
tasks_in_progress=await _tasks_in_progress(session, board_ids),
|
||||||
error_rate_pct=await _error_rate_kpi(session, primary),
|
error_rate_pct=await _error_rate_kpi(session, primary, board_ids),
|
||||||
median_cycle_time_hours_7d=await _median_cycle_time_7d(session),
|
median_cycle_time_hours_7d=await _median_cycle_time_7d(session, board_ids),
|
||||||
)
|
)
|
||||||
|
|
||||||
return DashboardMetrics(
|
return DashboardMetrics(
|
||||||
|
|||||||
403
backend/app/api/organizations.py
Normal file
403
backend/app/api/organizations.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlmodel import col, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import require_org_admin, require_org_member
|
||||||
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
|
from app.core.time import utcnow
|
||||||
|
from app.db.pagination import paginate
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.boards import Board
|
||||||
|
from app.models.organization_board_access import OrganizationBoardAccess
|
||||||
|
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
||||||
|
from app.models.organization_invites import OrganizationInvite
|
||||||
|
from app.models.organization_members import OrganizationMember
|
||||||
|
from app.models.organizations import Organization
|
||||||
|
from app.models.users import User
|
||||||
|
from app.schemas.organizations import (
|
||||||
|
OrganizationActiveUpdate,
|
||||||
|
OrganizationCreate,
|
||||||
|
OrganizationInviteAccept,
|
||||||
|
OrganizationInviteCreate,
|
||||||
|
OrganizationInviteRead,
|
||||||
|
OrganizationListItem,
|
||||||
|
OrganizationMemberAccessUpdate,
|
||||||
|
OrganizationMemberRead,
|
||||||
|
OrganizationMemberUpdate,
|
||||||
|
OrganizationBoardAccessRead,
|
||||||
|
OrganizationRead,
|
||||||
|
OrganizationUserRead,
|
||||||
|
)
|
||||||
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
|
from app.services.organizations import (
|
||||||
|
OrganizationContext,
|
||||||
|
accept_invite,
|
||||||
|
apply_invite_to_member,
|
||||||
|
apply_invite_board_access,
|
||||||
|
apply_member_access_update,
|
||||||
|
get_active_membership,
|
||||||
|
get_member,
|
||||||
|
is_org_admin,
|
||||||
|
normalize_invited_email,
|
||||||
|
normalize_role,
|
||||||
|
set_active_organization,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||||
|
|
||||||
|
|
||||||
|
def _member_to_read(member: OrganizationMember, user: User | None) -> OrganizationMemberRead:
|
||||||
|
model = OrganizationMemberRead.model_validate(member, from_attributes=True)
|
||||||
|
if user is not None:
|
||||||
|
model.user = OrganizationUserRead.model_validate(user, from_attributes=True)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=OrganizationRead)
|
||||||
|
async def create_organization(
|
||||||
|
payload: OrganizationCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
) -> OrganizationRead:
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
name = payload.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
existing = (
|
||||||
|
await session.exec(
|
||||||
|
select(Organization).where(func.lower(col(Organization.name)) == name.lower())
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
org = Organization(name=name, created_at=now, updated_at=now)
|
||||||
|
session.add(org)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
member = OrganizationMember(
|
||||||
|
organization_id=org.id,
|
||||||
|
user_id=auth.user.id,
|
||||||
|
role="owner",
|
||||||
|
all_boards_read=True,
|
||||||
|
all_boards_write=True,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(member)
|
||||||
|
await session.flush()
|
||||||
|
await set_active_organization(session, user=auth.user, organization_id=org.id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(org)
|
||||||
|
return OrganizationRead.model_validate(org, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/list", response_model=list[OrganizationListItem])
|
||||||
|
async def list_my_organizations(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
) -> list[OrganizationListItem]:
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
await get_active_membership(session, auth.user)
|
||||||
|
db_user = await session.get(User, auth.user.id)
|
||||||
|
active_id = db_user.active_organization_id if db_user else auth.user.active_organization_id
|
||||||
|
|
||||||
|
statement = (
|
||||||
|
select(Organization, OrganizationMember)
|
||||||
|
.join(OrganizationMember, col(OrganizationMember.organization_id) == col(Organization.id))
|
||||||
|
.where(col(OrganizationMember.user_id) == auth.user.id)
|
||||||
|
.order_by(func.lower(col(Organization.name)).asc())
|
||||||
|
)
|
||||||
|
rows = list(await session.exec(statement))
|
||||||
|
return [
|
||||||
|
OrganizationListItem(
|
||||||
|
id=org.id,
|
||||||
|
name=org.name,
|
||||||
|
role=member.role,
|
||||||
|
is_active=org.id == active_id,
|
||||||
|
)
|
||||||
|
for org, member in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/active", response_model=OrganizationRead)
|
||||||
|
async def set_active_org(
|
||||||
|
payload: OrganizationActiveUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
) -> OrganizationRead:
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
member = await set_active_organization(
|
||||||
|
session, user=auth.user, organization_id=payload.organization_id
|
||||||
|
)
|
||||||
|
organization = await session.get(Organization, member.organization_id)
|
||||||
|
if organization is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return OrganizationRead.model_validate(organization, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=OrganizationRead)
|
||||||
|
async def get_my_org(ctx: OrganizationContext = Depends(require_org_member)) -> OrganizationRead:
|
||||||
|
return OrganizationRead.model_validate(ctx.organization, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/member", response_model=OrganizationMemberRead)
|
||||||
|
async def get_my_membership(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_member),
|
||||||
|
) -> OrganizationMemberRead:
|
||||||
|
user = await session.get(User, ctx.member.user_id)
|
||||||
|
access_rows = list(
|
||||||
|
await session.exec(
|
||||||
|
select(OrganizationBoardAccess).where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id) == ctx.member.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model = _member_to_read(ctx.member, user)
|
||||||
|
model.board_access = [
|
||||||
|
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) # type: ignore[name-defined]
|
||||||
|
for row in access_rows
|
||||||
|
]
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/members", response_model=DefaultLimitOffsetPage[OrganizationMemberRead])
|
||||||
|
async def list_org_members(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_member),
|
||||||
|
) -> DefaultLimitOffsetPage[OrganizationMemberRead]:
|
||||||
|
statement = (
|
||||||
|
select(OrganizationMember, User)
|
||||||
|
.join(User, col(User.id) == col(OrganizationMember.user_id))
|
||||||
|
.where(col(OrganizationMember.organization_id) == ctx.organization.id)
|
||||||
|
.order_by(func.lower(col(User.email)).asc(), col(User.name).asc())
|
||||||
|
)
|
||||||
|
|
||||||
|
def _transform(items: Sequence[Any]) -> Sequence[Any]:
|
||||||
|
output: list[OrganizationMemberRead] = []
|
||||||
|
for member, user in items:
|
||||||
|
output.append(_member_to_read(member, user))
|
||||||
|
return output
|
||||||
|
|
||||||
|
return await paginate(session, statement, transformer=_transform)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/members/{member_id}", response_model=OrganizationMemberRead)
|
||||||
|
async def get_org_member(
|
||||||
|
member_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_member),
|
||||||
|
) -> OrganizationMemberRead:
|
||||||
|
member = await session.get(OrganizationMember, member_id)
|
||||||
|
if member is None or member.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if not is_org_admin(ctx.member) and member.user_id != ctx.member.user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
user = await session.get(User, member.user_id)
|
||||||
|
access_rows = list(
|
||||||
|
await session.exec(
|
||||||
|
select(OrganizationBoardAccess).where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id) == member.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model = _member_to_read(member, user)
|
||||||
|
model.board_access = [
|
||||||
|
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) # type: ignore[name-defined]
|
||||||
|
for row in access_rows
|
||||||
|
]
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/members/{member_id}", response_model=OrganizationMemberRead)
|
||||||
|
async def update_org_member(
|
||||||
|
member_id: UUID,
|
||||||
|
payload: OrganizationMemberUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
|
) -> OrganizationMemberRead:
|
||||||
|
member = await session.get(OrganizationMember, member_id)
|
||||||
|
if member is None or member.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
if "role" in updates and updates["role"] is not None:
|
||||||
|
member.role = normalize_role(updates["role"])
|
||||||
|
member.updated_at = utcnow()
|
||||||
|
session.add(member)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(member)
|
||||||
|
user = await session.get(User, member.user_id)
|
||||||
|
return _member_to_read(member, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/members/{member_id}/access", response_model=OrganizationMemberRead)
|
||||||
|
async def update_member_access(
|
||||||
|
member_id: UUID,
|
||||||
|
payload: OrganizationMemberAccessUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
|
) -> OrganizationMemberRead:
|
||||||
|
member = await session.get(OrganizationMember, member_id)
|
||||||
|
if member is None or member.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
board_ids = {entry.board_id for entry in payload.board_access}
|
||||||
|
if board_ids:
|
||||||
|
valid_board_ids = set(
|
||||||
|
await session.exec(
|
||||||
|
select(Board.id)
|
||||||
|
.where(col(Board.id).in_(board_ids))
|
||||||
|
.where(col(Board.organization_id) == ctx.organization.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if valid_board_ids != board_ids:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
|
||||||
|
await apply_member_access_update(session, member=member, update=payload)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(member)
|
||||||
|
user = await session.get(User, member.user_id)
|
||||||
|
return _member_to_read(member, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/invites", response_model=DefaultLimitOffsetPage[OrganizationInviteRead])
|
||||||
|
async def list_org_invites(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
|
) -> DefaultLimitOffsetPage[OrganizationInviteRead]:
|
||||||
|
statement = (
|
||||||
|
select(OrganizationInvite)
|
||||||
|
.where(col(OrganizationInvite.organization_id) == ctx.organization.id)
|
||||||
|
.where(col(OrganizationInvite.accepted_at).is_(None))
|
||||||
|
.order_by(col(OrganizationInvite.created_at).desc())
|
||||||
|
)
|
||||||
|
return await paginate(session, statement)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/invites", response_model=OrganizationInviteRead)
|
||||||
|
async def create_org_invite(
|
||||||
|
payload: OrganizationInviteCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
|
) -> OrganizationInviteRead:
|
||||||
|
email = normalize_invited_email(payload.invited_email)
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
|
||||||
|
existing_user = (
|
||||||
|
await session.exec(
|
||||||
|
select(User).where(func.lower(col(User.email)) == email)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing_user is not None:
|
||||||
|
existing_member = await get_member(
|
||||||
|
session,
|
||||||
|
user_id=existing_user.id,
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
)
|
||||||
|
if existing_member is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
invite = OrganizationInvite(
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
invited_email=email,
|
||||||
|
token=token,
|
||||||
|
role=normalize_role(payload.role),
|
||||||
|
all_boards_read=payload.all_boards_read,
|
||||||
|
all_boards_write=payload.all_boards_write,
|
||||||
|
created_by_user_id=ctx.member.user_id,
|
||||||
|
created_at=utcnow(),
|
||||||
|
updated_at=utcnow(),
|
||||||
|
)
|
||||||
|
session.add(invite)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
board_ids = {entry.board_id for entry in payload.board_access}
|
||||||
|
if board_ids:
|
||||||
|
valid_board_ids = set(
|
||||||
|
await session.exec(
|
||||||
|
select(Board.id)
|
||||||
|
.where(col(Board.id).in_(board_ids))
|
||||||
|
.where(col(Board.organization_id) == ctx.organization.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if valid_board_ids != board_ids:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
await apply_invite_board_access(session, invite=invite, entries=payload.board_access)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(invite)
|
||||||
|
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me/invites/{invite_id}", response_model=OrganizationInviteRead)
|
||||||
|
async def revoke_org_invite(
|
||||||
|
invite_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
|
) -> OrganizationInviteRead:
|
||||||
|
invite = await session.get(OrganizationInvite, invite_id)
|
||||||
|
if invite is None or invite.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await session.execute(
|
||||||
|
OrganizationInviteBoardAccess.__table__.delete().where(
|
||||||
|
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.delete(invite)
|
||||||
|
await session.commit()
|
||||||
|
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invites/accept", response_model=OrganizationMemberRead)
|
||||||
|
async def accept_org_invite(
|
||||||
|
payload: OrganizationInviteAccept,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
) -> OrganizationMemberRead:
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
invite = (
|
||||||
|
await session.exec(
|
||||||
|
select(OrganizationInvite)
|
||||||
|
.where(col(OrganizationInvite.token) == payload.token)
|
||||||
|
.where(col(OrganizationInvite.accepted_at).is_(None))
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if invite is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if invite.invited_email and auth.user.email:
|
||||||
|
if normalize_invited_email(invite.invited_email) != normalize_invited_email(auth.user.email):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
existing = await get_member(
|
||||||
|
session,
|
||||||
|
user_id=auth.user.id,
|
||||||
|
organization_id=invite.organization_id,
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
member = await accept_invite(session, invite, auth.user)
|
||||||
|
else:
|
||||||
|
await apply_invite_to_member(session, member=existing, invite=invite)
|
||||||
|
invite.accepted_by_user_id = auth.user.id
|
||||||
|
invite.accepted_at = utcnow()
|
||||||
|
invite.updated_at = utcnow()
|
||||||
|
session.add(invite)
|
||||||
|
await session.commit()
|
||||||
|
member = existing
|
||||||
|
|
||||||
|
user = await session.get(User, member.user_id)
|
||||||
|
return _member_to_read(member, user)
|
||||||
@@ -17,7 +17,8 @@ from sse_starlette.sse import EventSourceResponse
|
|||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
ActorContext,
|
ActorContext,
|
||||||
get_board_or_404,
|
get_board_for_actor_read,
|
||||||
|
get_board_for_user_write,
|
||||||
get_task_or_404,
|
get_task_or_404,
|
||||||
require_admin_auth,
|
require_admin_auth,
|
||||||
require_admin_or_agent,
|
require_admin_or_agent,
|
||||||
@@ -42,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
|
|||||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||||
from app.services.activity_log import record_activity
|
from app.services.activity_log import record_activity
|
||||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||||
|
from app.services.organizations import require_board_access
|
||||||
from app.services.task_dependencies import (
|
from app.services.task_dependencies import (
|
||||||
blocked_by_dependency_ids,
|
blocked_by_dependency_ids,
|
||||||
dependency_ids_by_task_id,
|
dependency_ids_by_task_id,
|
||||||
@@ -442,7 +444,7 @@ async def _notify_lead_on_task_unassigned(
|
|||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
async def stream_tasks(
|
async def stream_tasks(
|
||||||
request: Request,
|
request: Request,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
since: str | None = Query(default=None),
|
since: str | None = Query(default=None),
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
@@ -525,13 +527,10 @@ async def list_tasks(
|
|||||||
status_filter: str | None = Query(default=None, alias="status"),
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
assigned_agent_id: UUID | None = None,
|
assigned_agent_id: UUID | None = None,
|
||||||
unassigned: bool | None = None,
|
unassigned: bool | None = None,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_actor_read),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> DefaultLimitOffsetPage[TaskRead]:
|
) -> DefaultLimitOffsetPage[TaskRead]:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
statement = select(Task).where(Task.board_id == board.id)
|
statement = select(Task).where(Task.board_id == board.id)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
|
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
|
||||||
@@ -586,7 +585,7 @@ async def list_tasks(
|
|||||||
@router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}})
|
@router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}})
|
||||||
async def create_task(
|
async def create_task(
|
||||||
payload: TaskCreate,
|
payload: TaskCreate,
|
||||||
board: Board = Depends(get_board_or_404),
|
board: Board = Depends(get_board_for_user_write),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> TaskRead:
|
) -> TaskRead:
|
||||||
@@ -669,6 +668,11 @@ async def update_task(
|
|||||||
detail="Task board_id is required.",
|
detail="Task board_id is required.",
|
||||||
)
|
)
|
||||||
board_id = task.board_id
|
board_id = task.board_id
|
||||||
|
if actor.actor_type == "user" and actor.user is not None:
|
||||||
|
board = await session.get(Board, board_id)
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await require_board_access(session, user=actor.user, board=board, write=True)
|
||||||
|
|
||||||
previous_status = task.status
|
previous_status = task.status
|
||||||
previous_assigned = task.assigned_agent_id
|
previous_assigned = task.assigned_agent_id
|
||||||
@@ -978,6 +982,14 @@ async def delete_task(
|
|||||||
task: Task = Depends(get_task_or_404),
|
task: Task = Depends(get_task_or_404),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
|
if task.board_id is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
board = await session.get(Board, task.board_id)
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
await require_board_access(session, user=auth.user, board=board, write=True)
|
||||||
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
|
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
|
||||||
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
|
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
|
||||||
await session.execute(delete(Approval).where(col(Approval.task_id) == task.id))
|
await session.execute(delete(Approval).where(col(Approval.task_id) == task.id))
|
||||||
@@ -998,11 +1010,7 @@ async def delete_task(
|
|||||||
async def list_task_comments(
|
async def list_task_comments(
|
||||||
task: Task = Depends(get_task_or_404),
|
task: Task = Depends(get_task_or_404),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
|
||||||
) -> DefaultLimitOffsetPage[TaskCommentRead]:
|
) -> DefaultLimitOffsetPage[TaskCommentRead]:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
statement = (
|
statement = (
|
||||||
select(ActivityEvent)
|
select(ActivityEvent)
|
||||||
.where(col(ActivityEvent.task_id) == task.id)
|
.where(col(ActivityEvent.task_id) == task.id)
|
||||||
@@ -1019,6 +1027,13 @@ async def create_task_comment(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> ActivityEvent:
|
) -> ActivityEvent:
|
||||||
|
if task.board_id is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
if actor.actor_type == "user" and actor.user is not None:
|
||||||
|
board = await session.get(Board, task.board_id)
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await require_board_access(session, user=actor.user, board=board, write=True)
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
if actor.actor_type == "agent" and actor.agent:
|
||||||
if actor.agent.is_board_lead and task.status != "review":
|
if actor.agent.is_board_lead and task.status != "review":
|
||||||
if not await _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
|
if not await _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
|
||||||
@@ -1030,8 +1045,6 @@ async def create_task_comment(
|
|||||||
"Board leads can only comment during review, when mentioned, or on tasks they created."
|
"Board leads can only comment during review, when mentioned, or on tasks they created."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
event = ActivityEvent(
|
event = ActivityEvent(
|
||||||
event_type="task.comment",
|
event_type="task.comment",
|
||||||
message=payload.message,
|
message=payload.message,
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ async def get_auth_context(
|
|||||||
clerk_user_id=clerk_user_id,
|
clerk_user_id=clerk_user_id,
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
|
from app.services.organizations import ensure_member_for_user
|
||||||
|
|
||||||
|
await ensure_member_for_user(session, user)
|
||||||
|
|
||||||
return AuthContext(
|
return AuthContext(
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
@@ -146,6 +149,9 @@ async def get_auth_context_optional(
|
|||||||
clerk_user_id=clerk_user_id,
|
clerk_user_id=clerk_user_id,
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
|
from app.services.organizations import ensure_member_for_user
|
||||||
|
|
||||||
|
await ensure_member_for_user(session, user)
|
||||||
|
|
||||||
return AuthContext(
|
return AuthContext(
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.api.boards import router as boards_router
|
|||||||
from app.api.gateway import router as gateway_router
|
from app.api.gateway import router as gateway_router
|
||||||
from app.api.gateways import router as gateways_router
|
from app.api.gateways import router as gateways_router
|
||||||
from app.api.metrics import router as metrics_router
|
from app.api.metrics import router as metrics_router
|
||||||
|
from app.api.organizations import router as organizations_router
|
||||||
from app.api.souls_directory import router as souls_directory_router
|
from app.api.souls_directory import router as souls_directory_router
|
||||||
from app.api.tasks import router as tasks_router
|
from app.api.tasks import router as tasks_router
|
||||||
from app.api.users import router as users_router
|
from app.api.users import router as users_router
|
||||||
@@ -75,6 +76,7 @@ api_v1.include_router(activity_router)
|
|||||||
api_v1.include_router(gateway_router)
|
api_v1.include_router(gateway_router)
|
||||||
api_v1.include_router(gateways_router)
|
api_v1.include_router(gateways_router)
|
||||||
api_v1.include_router(metrics_router)
|
api_v1.include_router(metrics_router)
|
||||||
|
api_v1.include_router(organizations_router)
|
||||||
api_v1.include_router(souls_directory_router)
|
api_v1.include_router(souls_directory_router)
|
||||||
api_v1.include_router(board_groups_router)
|
api_v1.include_router(board_groups_router)
|
||||||
api_v1.include_router(board_group_memory_router)
|
api_v1.include_router(board_group_memory_router)
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from app.models.board_memory import BoardMemory
|
|||||||
from app.models.board_onboarding import BoardOnboardingSession
|
from app.models.board_onboarding import BoardOnboardingSession
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
|
from app.models.organization_board_access import OrganizationBoardAccess
|
||||||
|
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
||||||
|
from app.models.organization_invites import OrganizationInvite
|
||||||
|
from app.models.organization_members import OrganizationMember
|
||||||
|
from app.models.organizations import Organization
|
||||||
from app.models.task_dependencies import TaskDependency
|
from app.models.task_dependencies import TaskDependency
|
||||||
from app.models.task_fingerprints import TaskFingerprint
|
from app.models.task_fingerprints import TaskFingerprint
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
@@ -22,6 +27,11 @@ __all__ = [
|
|||||||
"BoardGroup",
|
"BoardGroup",
|
||||||
"Board",
|
"Board",
|
||||||
"Gateway",
|
"Gateway",
|
||||||
|
"Organization",
|
||||||
|
"OrganizationMember",
|
||||||
|
"OrganizationBoardAccess",
|
||||||
|
"OrganizationInvite",
|
||||||
|
"OrganizationInviteBoardAccess",
|
||||||
"TaskDependency",
|
"TaskDependency",
|
||||||
"Task",
|
"Task",
|
||||||
"TaskFingerprint",
|
"TaskFingerprint",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class BoardGroup(TenantScoped, table=True):
|
|||||||
__tablename__ = "board_groups"
|
__tablename__ = "board_groups"
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
||||||
name: str
|
name: str
|
||||||
slug: str = Field(index=True)
|
slug: str = Field(index=True)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class Board(TenantScoped, table=True):
|
|||||||
__tablename__ = "boards"
|
__tablename__ = "boards"
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
||||||
name: str
|
name: str
|
||||||
slug: str = Field(index=True)
|
slug: str = Field(index=True)
|
||||||
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)
|
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class Gateway(SQLModel, table=True):
|
|||||||
__tablename__ = "gateways"
|
__tablename__ = "gateways"
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
token: str | None = Field(default=None)
|
token: str | None = Field(default=None)
|
||||||
|
|||||||
30
backend/app/models/organization_board_access.py
Normal file
30
backend/app/models/organization_board_access.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from app.core.time import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationBoardAccess(SQLModel, table=True):
|
||||||
|
__tablename__ = "organization_board_access"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"organization_member_id",
|
||||||
|
"board_id",
|
||||||
|
name="uq_org_board_access_member_board",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
organization_member_id: UUID = Field(
|
||||||
|
foreign_key="organization_members.id", index=True
|
||||||
|
)
|
||||||
|
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
||||||
|
can_read: bool = Field(default=True)
|
||||||
|
can_write: bool = Field(default=False)
|
||||||
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=utcnow)
|
||||||
30
backend/app/models/organization_invite_board_access.py
Normal file
30
backend/app/models/organization_invite_board_access.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from app.core.time import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationInviteBoardAccess(SQLModel, table=True):
|
||||||
|
__tablename__ = "organization_invite_board_access"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"organization_invite_id",
|
||||||
|
"board_id",
|
||||||
|
name="uq_org_invite_board_access_invite_board",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
organization_invite_id: UUID = Field(
|
||||||
|
foreign_key="organization_invites.id", index=True
|
||||||
|
)
|
||||||
|
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
||||||
|
can_read: bool = Field(default=True)
|
||||||
|
can_write: bool = Field(default=False)
|
||||||
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=utcnow)
|
||||||
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
|
notes: str | None = None
|
||||||
context: str | None = None
|
context: str | None = None
|
||||||
is_super_admin: bool = Field(default=False)
|
is_super_admin: bool = Field(default=False)
|
||||||
|
active_organization_id: UUID | None = Field(
|
||||||
|
default=None, foreign_key="organizations.id", index=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ from app.schemas.board_onboarding import (
|
|||||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
||||||
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
|
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
|
||||||
from app.schemas.metrics import DashboardMetrics
|
from app.schemas.metrics import DashboardMetrics
|
||||||
|
from app.schemas.organizations import (
|
||||||
|
OrganizationActiveUpdate,
|
||||||
|
OrganizationCreate,
|
||||||
|
OrganizationInviteAccept,
|
||||||
|
OrganizationInviteCreate,
|
||||||
|
OrganizationInviteRead,
|
||||||
|
OrganizationListItem,
|
||||||
|
OrganizationMemberAccessUpdate,
|
||||||
|
OrganizationMemberRead,
|
||||||
|
OrganizationMemberUpdate,
|
||||||
|
OrganizationRead,
|
||||||
|
)
|
||||||
from app.schemas.souls_directory import (
|
from app.schemas.souls_directory import (
|
||||||
SoulsDirectoryMarkdownResponse,
|
SoulsDirectoryMarkdownResponse,
|
||||||
SoulsDirectorySearchResponse,
|
SoulsDirectorySearchResponse,
|
||||||
@@ -43,6 +55,16 @@ __all__ = [
|
|||||||
"GatewayRead",
|
"GatewayRead",
|
||||||
"GatewayUpdate",
|
"GatewayUpdate",
|
||||||
"DashboardMetrics",
|
"DashboardMetrics",
|
||||||
|
"OrganizationActiveUpdate",
|
||||||
|
"OrganizationCreate",
|
||||||
|
"OrganizationInviteAccept",
|
||||||
|
"OrganizationInviteCreate",
|
||||||
|
"OrganizationInviteRead",
|
||||||
|
"OrganizationListItem",
|
||||||
|
"OrganizationMemberAccessUpdate",
|
||||||
|
"OrganizationMemberRead",
|
||||||
|
"OrganizationMemberUpdate",
|
||||||
|
"OrganizationRead",
|
||||||
"SoulsDirectoryMarkdownResponse",
|
"SoulsDirectoryMarkdownResponse",
|
||||||
"SoulsDirectorySearchResponse",
|
"SoulsDirectorySearchResponse",
|
||||||
"SoulsDirectorySoulRef",
|
"SoulsDirectorySoulRef",
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ class BoardGroupUpdate(SQLModel):
|
|||||||
|
|
||||||
class BoardGroupRead(BoardGroupBase):
|
class BoardGroupRead(BoardGroupBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
|
organization_id: UUID
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -54,5 +54,6 @@ class BoardUpdate(SQLModel):
|
|||||||
|
|
||||||
class BoardRead(BoardBase):
|
class BoardRead(BoardBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
|
organization_id: UUID
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class GatewayUpdate(SQLModel):
|
|||||||
|
|
||||||
class GatewayRead(GatewayBase):
|
class GatewayRead(GatewayBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
|
organization_id: UUID
|
||||||
token: str | None = None
|
token: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
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
|
||||||
464
backend/app/services/organizations.py
Normal file
464
backend/app/services/organizations.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Iterable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlmodel import col, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.time import utcnow
|
||||||
|
from app.models.boards import Board
|
||||||
|
from app.models.organization_board_access import OrganizationBoardAccess
|
||||||
|
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
||||||
|
from app.models.organization_invites import OrganizationInvite
|
||||||
|
from app.models.organization_members import OrganizationMember
|
||||||
|
from app.models.organizations import Organization
|
||||||
|
from app.models.users import User
|
||||||
|
from app.schemas.organizations import OrganizationBoardAccessSpec, OrganizationMemberAccessUpdate
|
||||||
|
|
||||||
|
DEFAULT_ORG_NAME = "Personal"
|
||||||
|
ADMIN_ROLES = {"owner", "admin"}
|
||||||
|
ROLE_RANK = {"member": 0, "admin": 1, "owner": 2}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrganizationContext:
|
||||||
|
organization: Organization
|
||||||
|
member: OrganizationMember
|
||||||
|
|
||||||
|
|
||||||
|
def is_org_admin(member: OrganizationMember) -> bool:
|
||||||
|
return member.role in ADMIN_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
async def get_default_org(session: AsyncSession) -> Organization | None:
|
||||||
|
statement = select(Organization).where(col(Organization.name) == DEFAULT_ORG_NAME)
|
||||||
|
return (await session.exec(statement)).first()
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_default_org(session: AsyncSession) -> Organization:
|
||||||
|
org = await get_default_org(session)
|
||||||
|
if org is not None:
|
||||||
|
return org
|
||||||
|
org = Organization(name=DEFAULT_ORG_NAME, created_at=utcnow(), updated_at=utcnow())
|
||||||
|
session.add(org)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(org)
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def get_member(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: UUID,
|
||||||
|
organization_id: UUID,
|
||||||
|
) -> OrganizationMember | None:
|
||||||
|
statement = select(OrganizationMember).where(
|
||||||
|
col(OrganizationMember.organization_id) == organization_id,
|
||||||
|
col(OrganizationMember.user_id) == user_id,
|
||||||
|
)
|
||||||
|
return (await session.exec(statement)).first()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_first_membership(session: AsyncSession, user_id: UUID) -> OrganizationMember | None:
|
||||||
|
statement = (
|
||||||
|
select(OrganizationMember)
|
||||||
|
.where(col(OrganizationMember.user_id) == user_id)
|
||||||
|
.order_by(col(OrganizationMember.created_at).asc())
|
||||||
|
)
|
||||||
|
return (await session.exec(statement)).first()
|
||||||
|
|
||||||
|
|
||||||
|
async def set_active_organization(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
organization_id: UUID,
|
||||||
|
) -> OrganizationMember:
|
||||||
|
member = await get_member(
|
||||||
|
session, user_id=user.id, organization_id=organization_id
|
||||||
|
)
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access")
|
||||||
|
if user.active_organization_id != organization_id:
|
||||||
|
user.active_organization_id = organization_id
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_membership(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
) -> OrganizationMember | None:
|
||||||
|
db_user = await session.get(User, user.id)
|
||||||
|
if db_user is None:
|
||||||
|
db_user = user
|
||||||
|
if db_user.active_organization_id:
|
||||||
|
member = await get_member(
|
||||||
|
session,
|
||||||
|
user_id=db_user.id,
|
||||||
|
organization_id=db_user.active_organization_id,
|
||||||
|
)
|
||||||
|
if member is not None:
|
||||||
|
user.active_organization_id = db_user.active_organization_id
|
||||||
|
return member
|
||||||
|
db_user.active_organization_id = None
|
||||||
|
session.add(db_user)
|
||||||
|
await session.commit()
|
||||||
|
member = await get_first_membership(session, db_user.id)
|
||||||
|
if member is None:
|
||||||
|
return None
|
||||||
|
await set_active_organization(
|
||||||
|
session,
|
||||||
|
user=db_user,
|
||||||
|
organization_id=member.organization_id,
|
||||||
|
)
|
||||||
|
user.active_organization_id = db_user.active_organization_id
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_pending_invite(
|
||||||
|
session: AsyncSession,
|
||||||
|
email: str,
|
||||||
|
) -> OrganizationInvite | None:
|
||||||
|
statement = (
|
||||||
|
select(OrganizationInvite)
|
||||||
|
.where(col(OrganizationInvite.accepted_at).is_(None))
|
||||||
|
.where(col(OrganizationInvite.invited_email) == email)
|
||||||
|
.order_by(col(OrganizationInvite.created_at).asc())
|
||||||
|
)
|
||||||
|
return (await session.exec(statement)).first()
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_invite(
|
||||||
|
session: AsyncSession,
|
||||||
|
invite: OrganizationInvite,
|
||||||
|
user: User,
|
||||||
|
) -> OrganizationMember:
|
||||||
|
now = utcnow()
|
||||||
|
member = OrganizationMember(
|
||||||
|
organization_id=invite.organization_id,
|
||||||
|
user_id=user.id,
|
||||||
|
role=invite.role,
|
||||||
|
all_boards_read=invite.all_boards_read,
|
||||||
|
all_boards_write=invite.all_boards_write,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(member)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if not (invite.all_boards_read or invite.all_boards_write):
|
||||||
|
access_rows = list(
|
||||||
|
await session.exec(
|
||||||
|
select(OrganizationInviteBoardAccess).where(
|
||||||
|
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in access_rows:
|
||||||
|
session.add(
|
||||||
|
OrganizationBoardAccess(
|
||||||
|
organization_member_id=member.id,
|
||||||
|
board_id=row.board_id,
|
||||||
|
can_read=row.can_read,
|
||||||
|
can_write=row.can_write,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
invite.accepted_by_user_id = user.id
|
||||||
|
invite.accepted_at = now
|
||||||
|
invite.updated_at = now
|
||||||
|
session.add(invite)
|
||||||
|
if user.active_organization_id is None:
|
||||||
|
user.active_organization_id = invite.organization_id
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(member)
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_member_for_user(session: AsyncSession, user: User) -> OrganizationMember:
|
||||||
|
existing = await get_active_membership(session, user)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
if user.email:
|
||||||
|
invite = await _find_pending_invite(session, user.email)
|
||||||
|
if invite is not None:
|
||||||
|
return await accept_invite(session, invite, user)
|
||||||
|
|
||||||
|
org = await ensure_default_org(session)
|
||||||
|
now = utcnow()
|
||||||
|
member_count = (
|
||||||
|
await session.exec(
|
||||||
|
select(func.count())
|
||||||
|
.where(col(OrganizationMember.organization_id) == org.id)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
is_first = int(member_count or 0) == 0
|
||||||
|
member = OrganizationMember(
|
||||||
|
organization_id=org.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="owner" if is_first else "member",
|
||||||
|
all_boards_read=is_first,
|
||||||
|
all_boards_write=is_first,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
user.active_organization_id = org.id
|
||||||
|
session.add(user)
|
||||||
|
session.add(member)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(member)
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
def member_all_boards_read(member: OrganizationMember) -> bool:
|
||||||
|
return member.all_boards_read or member.all_boards_write
|
||||||
|
|
||||||
|
|
||||||
|
def member_all_boards_write(member: OrganizationMember) -> bool:
|
||||||
|
return member.all_boards_write
|
||||||
|
|
||||||
|
|
||||||
|
async def has_board_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
member: OrganizationMember,
|
||||||
|
board: Board,
|
||||||
|
write: bool,
|
||||||
|
) -> bool:
|
||||||
|
if member.organization_id != board.organization_id:
|
||||||
|
return False
|
||||||
|
if write:
|
||||||
|
if member_all_boards_write(member):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if member_all_boards_read(member):
|
||||||
|
return True
|
||||||
|
statement = select(OrganizationBoardAccess).where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id) == member.id,
|
||||||
|
col(OrganizationBoardAccess.board_id) == board.id,
|
||||||
|
)
|
||||||
|
access = (await session.exec(statement)).first()
|
||||||
|
if access is None:
|
||||||
|
return False
|
||||||
|
if write:
|
||||||
|
return bool(access.can_write)
|
||||||
|
return bool(access.can_read or access.can_write)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_board_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
board: Board,
|
||||||
|
write: bool,
|
||||||
|
) -> OrganizationMember:
|
||||||
|
member = await get_member(session, user_id=user.id, organization_id=board.organization_id)
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access")
|
||||||
|
if not await has_board_access(session, member=member, board=board, write=write):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Board access denied")
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
def board_access_filter(member: OrganizationMember, *, write: bool) -> object:
|
||||||
|
if write and member_all_boards_write(member):
|
||||||
|
return col(Board.organization_id) == member.organization_id
|
||||||
|
if not write and member_all_boards_read(member):
|
||||||
|
return col(Board.organization_id) == member.organization_id
|
||||||
|
access_stmt = select(OrganizationBoardAccess.board_id).where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id) == member.id
|
||||||
|
)
|
||||||
|
if write:
|
||||||
|
access_stmt = access_stmt.where(col(OrganizationBoardAccess.can_write).is_(True))
|
||||||
|
else:
|
||||||
|
access_stmt = access_stmt.where(
|
||||||
|
or_(
|
||||||
|
col(OrganizationBoardAccess.can_read).is_(True),
|
||||||
|
col(OrganizationBoardAccess.can_write).is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return col(Board.id).in_(access_stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_accessible_board_ids(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
member: OrganizationMember,
|
||||||
|
write: bool,
|
||||||
|
) -> list[UUID]:
|
||||||
|
if (write and member_all_boards_write(member)) or (
|
||||||
|
not write and member_all_boards_read(member)
|
||||||
|
):
|
||||||
|
ids = await session.exec(
|
||||||
|
select(Board.id).where(col(Board.organization_id) == member.organization_id)
|
||||||
|
)
|
||||||
|
return list(ids)
|
||||||
|
|
||||||
|
access_stmt = select(OrganizationBoardAccess.board_id).where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id) == member.id
|
||||||
|
)
|
||||||
|
if write:
|
||||||
|
access_stmt = access_stmt.where(col(OrganizationBoardAccess.can_write).is_(True))
|
||||||
|
else:
|
||||||
|
access_stmt = access_stmt.where(
|
||||||
|
or_(
|
||||||
|
col(OrganizationBoardAccess.can_read).is_(True),
|
||||||
|
col(OrganizationBoardAccess.can_write).is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
board_ids = await session.exec(access_stmt)
|
||||||
|
return list(board_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_member_access_update(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
member: OrganizationMember,
|
||||||
|
update: OrganizationMemberAccessUpdate,
|
||||||
|
) -> None:
|
||||||
|
now = utcnow()
|
||||||
|
member.all_boards_read = update.all_boards_read
|
||||||
|
member.all_boards_write = update.all_boards_write
|
||||||
|
member.updated_at = now
|
||||||
|
session.add(member)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
OrganizationBoardAccess.__table__.delete().where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id) == member.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if update.all_boards_read or update.all_boards_write:
|
||||||
|
return
|
||||||
|
|
||||||
|
rows: list[OrganizationBoardAccess] = []
|
||||||
|
for entry in update.board_access:
|
||||||
|
rows.append(
|
||||||
|
OrganizationBoardAccess(
|
||||||
|
organization_member_id=member.id,
|
||||||
|
board_id=entry.board_id,
|
||||||
|
can_read=entry.can_read,
|
||||||
|
can_write=entry.can_write,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add_all(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_invite_board_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
invite: OrganizationInvite,
|
||||||
|
entries: Iterable[OrganizationBoardAccessSpec],
|
||||||
|
) -> None:
|
||||||
|
await session.execute(
|
||||||
|
OrganizationInviteBoardAccess.__table__.delete().where(
|
||||||
|
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if invite.all_boards_read or invite.all_boards_write:
|
||||||
|
return
|
||||||
|
now = utcnow()
|
||||||
|
rows: list[OrganizationInviteBoardAccess] = []
|
||||||
|
for entry in entries:
|
||||||
|
rows.append(
|
||||||
|
OrganizationInviteBoardAccess(
|
||||||
|
organization_invite_id=invite.id,
|
||||||
|
board_id=entry.board_id,
|
||||||
|
can_read=entry.can_read,
|
||||||
|
can_write=entry.can_write,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add_all(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_invited_email(email: str) -> str:
|
||||||
|
return email.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_role(role: str) -> str:
|
||||||
|
return role.strip().lower() or "member"
|
||||||
|
|
||||||
|
|
||||||
|
def _role_rank(role: str | None) -> int:
|
||||||
|
if not role:
|
||||||
|
return 0
|
||||||
|
return ROLE_RANK.get(role, 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_invite_to_member(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
member: OrganizationMember,
|
||||||
|
invite: OrganizationInvite,
|
||||||
|
) -> None:
|
||||||
|
now = utcnow()
|
||||||
|
member_changed = False
|
||||||
|
invite_role = normalize_role(invite.role or "member")
|
||||||
|
if _role_rank(invite_role) > _role_rank(member.role):
|
||||||
|
member.role = invite_role
|
||||||
|
member_changed = True
|
||||||
|
|
||||||
|
if invite.all_boards_read or invite.all_boards_write:
|
||||||
|
member.all_boards_read = (
|
||||||
|
member.all_boards_read or invite.all_boards_read or invite.all_boards_write
|
||||||
|
)
|
||||||
|
member.all_boards_write = member.all_boards_write or invite.all_boards_write
|
||||||
|
member_changed = True
|
||||||
|
if member_changed:
|
||||||
|
member.updated_at = now
|
||||||
|
session.add(member)
|
||||||
|
return
|
||||||
|
|
||||||
|
access_rows = list(
|
||||||
|
await session.exec(
|
||||||
|
select(OrganizationInviteBoardAccess).where(
|
||||||
|
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in access_rows:
|
||||||
|
existing = (
|
||||||
|
await session.exec(
|
||||||
|
select(OrganizationBoardAccess).where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id) == member.id,
|
||||||
|
col(OrganizationBoardAccess.board_id) == row.board_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
can_write = bool(row.can_write)
|
||||||
|
can_read = bool(row.can_read or row.can_write)
|
||||||
|
if existing is None:
|
||||||
|
session.add(
|
||||||
|
OrganizationBoardAccess(
|
||||||
|
organization_member_id=member.id,
|
||||||
|
board_id=row.board_id,
|
||||||
|
can_read=can_read,
|
||||||
|
can_write=can_write,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
existing.can_read = bool(existing.can_read or can_read)
|
||||||
|
existing.can_write = bool(existing.can_write or can_write)
|
||||||
|
existing.updated_at = now
|
||||||
|
session.add(existing)
|
||||||
|
|
||||||
|
if member_changed:
|
||||||
|
member.updated_at = now
|
||||||
|
session.add(member)
|
||||||
@@ -53,6 +53,7 @@ import type {
|
|||||||
ListTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams,
|
ListTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams,
|
||||||
ListTasksApiV1AgentBoardsBoardIdTasksGetParams,
|
ListTasksApiV1AgentBoardsBoardIdTasksGetParams,
|
||||||
OkResponse,
|
OkResponse,
|
||||||
|
SoulUpdateRequest,
|
||||||
TaskCommentCreate,
|
TaskCommentCreate,
|
||||||
TaskCommentRead,
|
TaskCommentRead,
|
||||||
TaskCreate,
|
TaskCreate,
|
||||||
@@ -3035,6 +3036,449 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
|
|||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* @summary Get Agent Soul
|
||||||
|
*/
|
||||||
|
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 =
|
||||||
|
{
|
||||||
|
data: string;
|
||||||
|
status: 200;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 =
|
||||||
|
{
|
||||||
|
data: HTTPValidationError;
|
||||||
|
status: 422;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess =
|
||||||
|
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError =
|
||||||
|
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse =
|
||||||
|
| getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess
|
||||||
|
| getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError;
|
||||||
|
|
||||||
|
export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetUrl = (
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
) => {
|
||||||
|
return `/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet = async (
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse> => {
|
||||||
|
return customFetch<getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse>(
|
||||||
|
getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetUrl(
|
||||||
|
boardId,
|
||||||
|
agentId,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryKey =
|
||||||
|
(boardId: string, agentId: string) => {
|
||||||
|
return [`/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryOptions =
|
||||||
|
<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey =
|
||||||
|
queryOptions?.queryKey ??
|
||||||
|
getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryKey(
|
||||||
|
boardId,
|
||||||
|
agentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>
|
||||||
|
> = ({ signal }) =>
|
||||||
|
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet(
|
||||||
|
boardId,
|
||||||
|
agentId,
|
||||||
|
{ signal, ...requestOptions },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled: !!(boardId && agentId),
|
||||||
|
...queryOptions,
|
||||||
|
} as UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryResult =
|
||||||
|
NonNullable<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryError =
|
||||||
|
HTTPValidationError;
|
||||||
|
|
||||||
|
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
options: {
|
||||||
|
query: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
> &
|
||||||
|
Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
"initialData"
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): DefinedUseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
> &
|
||||||
|
Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
"initialData"
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @summary Get Agent Soul
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
|
||||||
|
TData = Awaited<
|
||||||
|
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
|
||||||
|
>,
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
>(
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
} {
|
||||||
|
const queryOptions =
|
||||||
|
getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryOptions(
|
||||||
|
boardId,
|
||||||
|
agentId,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||||
|
TData,
|
||||||
|
TError
|
||||||
|
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update Agent Soul
|
||||||
|
*/
|
||||||
|
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse200 =
|
||||||
|
{
|
||||||
|
data: OkResponse;
|
||||||
|
status: 200;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse422 =
|
||||||
|
{
|
||||||
|
data: HTTPValidationError;
|
||||||
|
status: 422;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseSuccess =
|
||||||
|
updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse200 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseError =
|
||||||
|
updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse422 & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse =
|
||||||
|
| updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseSuccess
|
||||||
|
| updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseError;
|
||||||
|
|
||||||
|
export const getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutUrl =
|
||||||
|
(boardId: string, agentId: string) => {
|
||||||
|
return `/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut =
|
||||||
|
async (
|
||||||
|
boardId: string,
|
||||||
|
agentId: string,
|
||||||
|
soulUpdateRequest: SoulUpdateRequest,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse> => {
|
||||||
|
return customFetch<updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse>(
|
||||||
|
getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutUrl(
|
||||||
|
boardId,
|
||||||
|
agentId,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||||
|
body: JSON.stringify(soulUpdateRequest),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationOptions =
|
||||||
|
<TError = HTTPValidationError, TContext = unknown>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
{ boardId: string; agentId: string; data: SoulUpdateRequest },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
{ boardId: string; agentId: string; data: SoulUpdateRequest },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = [
|
||||||
|
"updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut",
|
||||||
|
];
|
||||||
|
const { mutation: mutationOptions, request: requestOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
"mutationKey" in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey }, request: undefined };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
{ boardId: string; agentId: string; data: SoulUpdateRequest }
|
||||||
|
> = (props) => {
|
||||||
|
const { boardId, agentId, data } = props ?? {};
|
||||||
|
|
||||||
|
return updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut(
|
||||||
|
boardId,
|
||||||
|
agentId,
|
||||||
|
data,
|
||||||
|
requestOptions,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationResult =
|
||||||
|
NonNullable<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationBody =
|
||||||
|
SoulUpdateRequest;
|
||||||
|
export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationError =
|
||||||
|
HTTPValidationError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update Agent Soul
|
||||||
|
*/
|
||||||
|
export const useUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
{ boardId: string; agentId: string; data: SoulUpdateRequest },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
TError,
|
||||||
|
{ boardId: string; agentId: string; data: SoulUpdateRequest },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(
|
||||||
|
getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationOptions(
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* @summary Ask User Via Gateway Main
|
* @summary Ask User Via Gateway Main
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface BoardGroupRead {
|
|||||||
slug: string;
|
slug: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface BoardRead {
|
|||||||
goal_confirmed?: boolean;
|
goal_confirmed?: boolean;
|
||||||
goal_source?: string | null;
|
goal_source?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface GatewayRead {
|
|||||||
main_session_key: string;
|
main_session_key: string;
|
||||||
workspace_root: string;
|
workspace_root: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead";
|
|||||||
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
|
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
|
export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
|
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
|
||||||
|
export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead";
|
||||||
|
export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedTaskCommentRead";
|
export * from "./limitOffsetPageTypeVarCustomizedTaskCommentRead";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedTaskRead";
|
export * from "./limitOffsetPageTypeVarCustomizedTaskRead";
|
||||||
export * from "./listActivityApiV1ActivityGetParams";
|
export * from "./listActivityApiV1ActivityGetParams";
|
||||||
@@ -147,6 +149,8 @@ export * from "./listBoardsApiV1AgentBoardsGetParams";
|
|||||||
export * from "./listBoardsApiV1BoardsGetParams";
|
export * from "./listBoardsApiV1BoardsGetParams";
|
||||||
export * from "./listGatewaysApiV1GatewaysGetParams";
|
export * from "./listGatewaysApiV1GatewaysGetParams";
|
||||||
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
|
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
|
||||||
|
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";
|
||||||
|
export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams";
|
||||||
export * from "./listSessionsApiV1GatewaySessionsGet200";
|
export * from "./listSessionsApiV1GatewaySessionsGet200";
|
||||||
export * from "./listSessionsApiV1GatewaySessionsGetParams";
|
export * from "./listSessionsApiV1GatewaySessionsGetParams";
|
||||||
export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams";
|
export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams";
|
||||||
@@ -155,11 +159,29 @@ export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams"
|
|||||||
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
|
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
|
||||||
export * from "./listTasksApiV1BoardsBoardIdTasksGetParams";
|
export * from "./listTasksApiV1BoardsBoardIdTasksGetParams";
|
||||||
export * from "./okResponse";
|
export * from "./okResponse";
|
||||||
|
export * from "./organizationActiveUpdate";
|
||||||
|
export * from "./organizationBoardAccessRead";
|
||||||
|
export * from "./organizationBoardAccessSpec";
|
||||||
|
export * from "./organizationCreate";
|
||||||
|
export * from "./organizationInviteAccept";
|
||||||
|
export * from "./organizationInviteCreate";
|
||||||
|
export * from "./organizationInviteRead";
|
||||||
|
export * from "./organizationListItem";
|
||||||
|
export * from "./organizationMemberAccessUpdate";
|
||||||
|
export * from "./organizationMemberRead";
|
||||||
|
export * from "./organizationMemberUpdate";
|
||||||
|
export * from "./organizationRead";
|
||||||
|
export * from "./organizationUserRead";
|
||||||
export * from "./readyzReadyzGet200";
|
export * from "./readyzReadyzGet200";
|
||||||
|
export * from "./searchApiV1SoulsDirectorySearchGetParams";
|
||||||
export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams";
|
export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams";
|
||||||
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost200";
|
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost200";
|
||||||
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostBody";
|
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostBody";
|
||||||
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParams";
|
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParams";
|
||||||
|
export * from "./soulsDirectoryMarkdownResponse";
|
||||||
|
export * from "./soulsDirectorySearchResponse";
|
||||||
|
export * from "./soulsDirectorySoulRef";
|
||||||
|
export * from "./soulUpdateRequest";
|
||||||
export * from "./streamAgentsApiV1AgentsStreamGetParams";
|
export * from "./streamAgentsApiV1AgentsStreamGetParams";
|
||||||
export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams";
|
export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams";
|
||||||
export * from "./streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGetParams";
|
export * from "./streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGetParams";
|
||||||
|
|||||||
@@ -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,
|
type listBoardsApiV1BoardsGetResponse,
|
||||||
useListBoardsApiV1BoardsGet,
|
useListBoardsApiV1BoardsGet,
|
||||||
} from "@/api/generated/boards/boards";
|
} from "@/api/generated/boards/boards";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type {
|
import type {
|
||||||
ActivityEventRead,
|
ActivityEventRead,
|
||||||
AgentRead,
|
AgentRead,
|
||||||
@@ -80,6 +84,20 @@ export default function AgentDetailPage() {
|
|||||||
const agentIdParam = params?.agentId;
|
const agentIdParam = params?.agentId;
|
||||||
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
|
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -88,7 +106,7 @@ export default function AgentDetailPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(agentId ?? "", {
|
>(agentId ?? "", {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn && agentId),
|
enabled: Boolean(isSignedIn && isAdmin && agentId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -102,7 +120,7 @@ export default function AgentDetailPage() {
|
|||||||
{ limit: 200 },
|
{ limit: 200 },
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
@@ -114,7 +132,7 @@ export default function AgentDetailPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -186,7 +204,14 @@ export default function AgentDetailPage() {
|
|||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<DashboardSidebar />
|
<DashboardSidebar />
|
||||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
{!isAdmin ? (
|
||||||
|
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||||
|
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-6 py-5 text-sm text-muted">
|
||||||
|
Only organization owners and admins can access agents.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
||||||
@@ -371,7 +396,8 @@ export default function AgentDetailPage() {
|
|||||||
Agent not found.
|
Agent not found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|
||||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
useListBoardsApiV1BoardsGet,
|
useListBoardsApiV1BoardsGet,
|
||||||
} from "@/api/generated/boards/boards";
|
} from "@/api/generated/boards/boards";
|
||||||
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
|
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type { BoardRead } from "@/api/generated/model";
|
import type { BoardRead } from "@/api/generated/model";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
@@ -80,6 +84,20 @@ export default function NewAgentPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [boardId, setBoardId] = useState<string>("");
|
const [boardId, setBoardId] = useState<string>("");
|
||||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||||
@@ -95,7 +113,7 @@ export default function NewAgentPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -182,15 +200,20 @@ export default function NewAgentPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form
|
{!isAdmin ? (
|
||||||
onSubmit={handleSubmit}
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
Only organization owners and admins can create agents.
|
||||||
>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<form
|
||||||
Basic configuration
|
onSubmit={handleSubmit}
|
||||||
</p>
|
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||||
<div className="mt-4 space-y-6">
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Basic configuration
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-6">
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
@@ -369,6 +392,7 @@ export default function NewAgentPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ import {
|
|||||||
getListBoardsApiV1BoardsGetQueryKey,
|
getListBoardsApiV1BoardsGetQueryKey,
|
||||||
useListBoardsApiV1BoardsGet,
|
useListBoardsApiV1BoardsGet,
|
||||||
} from "@/api/generated/boards/boards";
|
} from "@/api/generated/boards/boards";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type { AgentRead } from "@/api/generated/model";
|
import type { AgentRead } from "@/api/generated/model";
|
||||||
|
|
||||||
const parseTimestamp = (value?: string | null) => {
|
const parseTimestamp = (value?: string | null) => {
|
||||||
@@ -88,6 +92,20 @@ export default function AgentsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "name", desc: false },
|
{ id: "name", desc: false },
|
||||||
]);
|
]);
|
||||||
@@ -102,7 +120,7 @@ export default function AgentsPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
},
|
},
|
||||||
@@ -113,7 +131,7 @@ export default function AgentsPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
},
|
},
|
||||||
@@ -323,9 +341,15 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
{!isAdmin ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
<table className="w-full text-left text-sm">
|
Only organization owners and admins can access agents.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
@@ -409,11 +433,13 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{agentsQuery.error ? (
|
{agentsQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">
|
<p className="mt-4 text-sm text-red-500">
|
||||||
{agentsQuery.error.message}
|
{agentsQuery.error.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
@@ -27,9 +27,14 @@ import {
|
|||||||
streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet,
|
streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet,
|
||||||
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet,
|
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet,
|
||||||
} from "@/api/generated/board-group-memory/board-group-memory";
|
} from "@/api/generated/board-group-memory/board-group-memory";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type {
|
import type {
|
||||||
BoardGroupHeartbeatApplyResult,
|
BoardGroupHeartbeatApplyResult,
|
||||||
BoardGroupMemoryRead,
|
BoardGroupMemoryRead,
|
||||||
|
OrganizationMemberRead,
|
||||||
} from "@/api/generated/model";
|
} from "@/api/generated/model";
|
||||||
import type { BoardGroupBoardSnapshot } from "@/api/generated/model";
|
import type { BoardGroupBoardSnapshot } from "@/api/generated/model";
|
||||||
import { Markdown } from "@/components/atoms/Markdown";
|
import { Markdown } from "@/components/atoms/Markdown";
|
||||||
@@ -96,6 +101,18 @@ const priorityTone = (value?: string | null) => {
|
|||||||
const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) =>
|
const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) =>
|
||||||
snapshot.task_counts?.[key] ?? 0;
|
snapshot.task_counts?.[key] ?? 0;
|
||||||
|
|
||||||
|
const canWriteGroupBoards = (
|
||||||
|
member: OrganizationMemberRead | null,
|
||||||
|
boardIds: Set<string>,
|
||||||
|
) => {
|
||||||
|
if (!member) return false;
|
||||||
|
if (member.all_boards_write) return true;
|
||||||
|
if (!member.board_access || boardIds.size === 0) return false;
|
||||||
|
return member.board_access.some(
|
||||||
|
(access) => access.can_write && boardIds.has(access.board_id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function GroupChatMessageCard({ message }: { message: BoardGroupMemoryRead }) {
|
function GroupChatMessageCard({ message }: { message: BoardGroupMemoryRead }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
|
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
|
||||||
@@ -215,6 +232,34 @@ export default function BoardGroupDetailPage() {
|
|||||||
snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null;
|
snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null;
|
||||||
const group = snapshot?.group ?? null;
|
const group = snapshot?.group ?? null;
|
||||||
const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]);
|
const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]);
|
||||||
|
const boardIdSet = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
boards.forEach((item) => {
|
||||||
|
if (item.board?.id) {
|
||||||
|
ids.add(item.board.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}, [boards]);
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member?.role === "admin" || member?.role === "owner";
|
||||||
|
const canWriteGroup = useMemo(
|
||||||
|
() => canWriteGroupBoards(member, boardIdSet),
|
||||||
|
[boardIdSet, member],
|
||||||
|
);
|
||||||
|
const canManageHeartbeat = Boolean(isAdmin && canWriteGroup);
|
||||||
|
|
||||||
const chatHistoryQuery =
|
const chatHistoryQuery =
|
||||||
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet<
|
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet<
|
||||||
@@ -554,6 +599,10 @@ export default function BoardGroupDetailPage() {
|
|||||||
setChatError("Sign in to send messages.");
|
setChatError("Sign in to send messages.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!canWriteGroup) {
|
||||||
|
setChatError("Read-only access. You cannot post group messages.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
|
|
||||||
@@ -583,7 +632,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
setIsChatSending(false);
|
setIsChatSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chatBroadcast, groupId, isSignedIn, mergeChatMessages],
|
[canWriteGroup, chatBroadcast, groupId, isSignedIn, mergeChatMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendGroupNote = useCallback(
|
const sendGroupNote = useCallback(
|
||||||
@@ -592,6 +641,10 @@ export default function BoardGroupDetailPage() {
|
|||||||
setNoteSendError("Sign in to post.");
|
setNoteSendError("Sign in to post.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!canWriteGroup) {
|
||||||
|
setNoteSendError("Read-only access. You cannot post notes.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
|
|
||||||
@@ -621,7 +674,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
setIsNoteSending(false);
|
setIsNoteSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
|
[canWriteGroup, groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyHeartbeat = useCallback(async () => {
|
const applyHeartbeat = useCallback(async () => {
|
||||||
@@ -629,6 +682,10 @@ export default function BoardGroupDetailPage() {
|
|||||||
setHeartbeatApplyError("Sign in to apply.");
|
setHeartbeatApplyError("Sign in to apply.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canManageHeartbeat) {
|
||||||
|
setHeartbeatApplyError("Read-only access. You cannot change agent pace.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const trimmed = heartbeatEvery.trim();
|
const trimmed = heartbeatEvery.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setHeartbeatApplyError("Heartbeat cadence is required.");
|
setHeartbeatApplyError("Heartbeat cadence is required.");
|
||||||
@@ -653,7 +710,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsHeartbeatApplying(false);
|
setIsHeartbeatApplying(false);
|
||||||
}
|
}
|
||||||
}, [groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
|
}, [canManageHeartbeat, groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell>
|
<DashboardShell>
|
||||||
@@ -793,7 +850,9 @@ export default function BoardGroupDetailPage() {
|
|||||||
heartbeatEvery === value
|
heartbeatEvery === value
|
||||||
? "bg-slate-900 text-white"
|
? "bg-slate-900 text-white"
|
||||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||||
|
!canManageHeartbeat && "opacity-50 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
|
disabled={!canManageHeartbeat}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHeartbeatAmount(String(preset.amount));
|
setHeartbeatAmount(String(preset.amount));
|
||||||
setHeartbeatUnit(preset.unit);
|
setHeartbeatUnit(preset.unit);
|
||||||
@@ -812,19 +871,25 @@ export default function BoardGroupDetailPage() {
|
|||||||
heartbeatEvery
|
heartbeatEvery
|
||||||
? "border-slate-200"
|
? "border-slate-200"
|
||||||
: "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100",
|
: "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100",
|
||||||
|
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
placeholder="10"
|
placeholder="10"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
|
disabled={!canManageHeartbeat}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={heartbeatUnit}
|
value={heartbeatUnit}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setHeartbeatUnit(event.target.value as HeartbeatUnit)
|
setHeartbeatUnit(event.target.value as HeartbeatUnit)
|
||||||
}
|
}
|
||||||
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm"
|
className={cn(
|
||||||
|
"h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm",
|
||||||
|
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
disabled={!canManageHeartbeat}
|
||||||
>
|
>
|
||||||
<option value="s">sec</option>
|
<option value="s">sec</option>
|
||||||
<option value="m">min</option>
|
<option value="m">min</option>
|
||||||
@@ -839,6 +904,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setIncludeBoardLeads(event.target.checked)
|
setIncludeBoardLeads(event.target.checked)
|
||||||
}
|
}
|
||||||
|
disabled={!canManageHeartbeat}
|
||||||
/>
|
/>
|
||||||
Include leads
|
Include leads
|
||||||
</label>
|
</label>
|
||||||
@@ -846,11 +912,24 @@ export default function BoardGroupDetailPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void applyHeartbeat()}
|
onClick={() => void applyHeartbeat()}
|
||||||
disabled={isHeartbeatApplying || !heartbeatEvery}
|
disabled={
|
||||||
|
isHeartbeatApplying || !heartbeatEvery || !canManageHeartbeat
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
canManageHeartbeat
|
||||||
|
? "Apply heartbeat"
|
||||||
|
: "Read-only access"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isHeartbeatApplying ? "Applying…" : "Apply"}
|
{isHeartbeatApplying ? "Applying…" : "Apply"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{!canManageHeartbeat ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Read-only access. You cannot change agent pace for this
|
||||||
|
group.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1035,6 +1114,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||||
checked={chatBroadcast}
|
checked={chatBroadcast}
|
||||||
onChange={(event) => setChatBroadcast(event.target.checked)}
|
onChange={(event) => setChatBroadcast(event.target.checked)}
|
||||||
|
disabled={!canWriteGroup}
|
||||||
/>
|
/>
|
||||||
Broadcast
|
Broadcast
|
||||||
</label>
|
</label>
|
||||||
@@ -1072,9 +1152,14 @@ export default function BoardGroupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BoardChatComposer
|
<BoardChatComposer
|
||||||
placeholder="Message the whole group. Tag @lead, @name, or @all."
|
placeholder={
|
||||||
|
canWriteGroup
|
||||||
|
? "Message the whole group. Tag @lead, @name, or @all."
|
||||||
|
: "Read-only access. Group chat is disabled."
|
||||||
|
}
|
||||||
isSending={isChatSending}
|
isSending={isChatSending}
|
||||||
onSend={sendGroupChat}
|
onSend={sendGroupChat}
|
||||||
|
disabled={!canWriteGroup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1115,6 +1200,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||||
checked={notesBroadcast}
|
checked={notesBroadcast}
|
||||||
onChange={(event) => setNotesBroadcast(event.target.checked)}
|
onChange={(event) => setNotesBroadcast(event.target.checked)}
|
||||||
|
disabled={!canWriteGroup}
|
||||||
/>
|
/>
|
||||||
Broadcast
|
Broadcast
|
||||||
</label>
|
</label>
|
||||||
@@ -1152,9 +1238,14 @@ export default function BoardGroupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BoardChatComposer
|
<BoardChatComposer
|
||||||
placeholder="Post a shared note for all linked boards. Tag @lead, @name, or @all."
|
placeholder={
|
||||||
|
canWriteGroup
|
||||||
|
? "Post a shared note for all linked boards. Tag @lead, @name, or @all."
|
||||||
|
: "Read-only access. Notes are disabled."
|
||||||
|
}
|
||||||
isSending={isNoteSending}
|
isSending={isNoteSending}
|
||||||
onSend={sendGroupNote}
|
onSend={sendGroupNote}
|
||||||
|
disabled={!canWriteGroup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
type listGatewaysApiV1GatewaysGetResponse,
|
type listGatewaysApiV1GatewaysGetResponse,
|
||||||
useListGatewaysApiV1GatewaysGet,
|
useListGatewaysApiV1GatewaysGet,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type {
|
import type {
|
||||||
BoardGroupRead,
|
BoardGroupRead,
|
||||||
BoardRead,
|
BoardRead,
|
||||||
@@ -59,6 +63,20 @@ export default function EditBoardPage() {
|
|||||||
const boardIdParam = params?.boardId;
|
const boardIdParam = params?.boardId;
|
||||||
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
|
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const mainRef = useRef<HTMLElement | null>(null);
|
const mainRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const [board, setBoard] = useState<BoardRead | null>(null);
|
const [board, setBoard] = useState<BoardRead | null>(null);
|
||||||
@@ -130,7 +148,7 @@ export default function EditBoardPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
@@ -141,7 +159,7 @@ export default function EditBoardPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
@@ -152,7 +170,7 @@ export default function EditBoardPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(boardId ?? "", {
|
>(boardId ?? "", {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn && boardId),
|
enabled: Boolean(isSignedIn && isAdmin && boardId),
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
@@ -315,14 +333,20 @@ export default function EditBoardPage() {
|
|||||||
Update board settings and gateway.
|
Update board settings and gateway.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="space-y-6">
|
{!isAdmin ? (
|
||||||
<form
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
onSubmit={handleSubmit}
|
Only organization owners and admins can edit board settings.
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
</div>
|
||||||
>
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
{resolvedBoardType !== "general" &&
|
{resolvedBoardType !== "general" &&
|
||||||
baseBoard &&
|
baseBoard &&
|
||||||
!(baseBoard.goal_confirmed ?? false) ? (
|
!(baseBoard.goal_confirmed ?? false) ? (
|
||||||
@@ -495,6 +519,7 @@ export default function EditBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
|
import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
|
||||||
import {
|
import {
|
||||||
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
|
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
|
||||||
@@ -62,6 +63,10 @@ import {
|
|||||||
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
|
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
|
||||||
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
|
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
|
||||||
} from "@/api/generated/board-memory/board-memory";
|
} from "@/api/generated/board-memory/board-memory";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import {
|
import {
|
||||||
createTaskApiV1BoardsBoardIdTasksPost,
|
createTaskApiV1BoardsBoardIdTasksPost,
|
||||||
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
|
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
|
||||||
@@ -76,6 +81,7 @@ import type {
|
|||||||
BoardGroupSnapshot,
|
BoardGroupSnapshot,
|
||||||
BoardMemoryRead,
|
BoardMemoryRead,
|
||||||
BoardRead,
|
BoardRead,
|
||||||
|
OrganizationMemberRead,
|
||||||
TaskCardRead,
|
TaskCardRead,
|
||||||
TaskCommentRead,
|
TaskCommentRead,
|
||||||
TaskRead,
|
TaskRead,
|
||||||
@@ -168,6 +174,47 @@ const formatShortTimestamp = (value: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToastMessage = {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
tone: "error" | "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatActionError = (err: unknown, fallback: string) => {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.status === 403) {
|
||||||
|
return "Read-only access. You do not have permission to make changes.";
|
||||||
|
}
|
||||||
|
return err.message || fallback;
|
||||||
|
}
|
||||||
|
if (err instanceof Error && err.message) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveBoardAccess = (
|
||||||
|
member: OrganizationMemberRead | null,
|
||||||
|
boardId?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!member || !boardId) {
|
||||||
|
return { canRead: false, canWrite: false };
|
||||||
|
}
|
||||||
|
if (member.all_boards_write) {
|
||||||
|
return { canRead: true, canWrite: true };
|
||||||
|
}
|
||||||
|
if (member.all_boards_read) {
|
||||||
|
return { canRead: true, canWrite: false };
|
||||||
|
}
|
||||||
|
const entry = member.board_access?.find((access) => access.board_id === boardId);
|
||||||
|
if (!entry) {
|
||||||
|
return { canRead: false, canWrite: false };
|
||||||
|
}
|
||||||
|
const canWrite = Boolean(entry.can_write);
|
||||||
|
const canRead = Boolean(entry.can_read || entry.can_write);
|
||||||
|
return { canRead, canWrite };
|
||||||
|
};
|
||||||
|
|
||||||
const TaskCommentCard = memo(function TaskCommentCard({
|
const TaskCommentCard = memo(function TaskCommentCard({
|
||||||
comment,
|
comment,
|
||||||
authorLabel,
|
authorLabel,
|
||||||
@@ -322,6 +369,31 @@ export default function BoardDetailPage() {
|
|||||||
const isPageActive = usePageActive();
|
const isPageActive = usePageActive();
|
||||||
const taskIdFromUrl = searchParams.get("taskId");
|
const taskIdFromUrl = searchParams.get("taskId");
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const boardAccess = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveBoardAccess(
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null,
|
||||||
|
boardId,
|
||||||
|
),
|
||||||
|
[membershipQuery.data, boardId],
|
||||||
|
);
|
||||||
|
const isOrgAdmin = useMemo(() => {
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
return member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
}, [membershipQuery.data]);
|
||||||
|
const canWrite = boardAccess.canWrite;
|
||||||
|
|
||||||
const [board, setBoard] = useState<Board | null>(null);
|
const [board, setBoard] = useState<Board | null>(null);
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
@@ -387,7 +459,10 @@ export default function BoardDetailPage() {
|
|||||||
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
||||||
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
|
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
const isLiveFeedOpenRef = useRef(false);
|
const isLiveFeedOpenRef = useRef(false);
|
||||||
|
const toastIdRef = useRef(0);
|
||||||
|
const toastTimersRef = useRef<Record<number, number>>({});
|
||||||
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
||||||
const alreadySeen = liveFeedRef.current.some(
|
const alreadySeen = liveFeedRef.current.some(
|
||||||
(item) => item.id === comment.id,
|
(item) => item.id === comment.id,
|
||||||
@@ -423,6 +498,31 @@ export default function BoardDetailPage() {
|
|||||||
}, 2200);
|
}, 2200);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
const timer = toastTimersRef.current[id];
|
||||||
|
if (timer !== undefined) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
delete toastTimersRef.current[id];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pushToast = useCallback(
|
||||||
|
(message: string, tone: ToastMessage["tone"] = "error") => {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const id = toastIdRef.current + 1;
|
||||||
|
toastIdRef.current = id;
|
||||||
|
setToasts((prev) => [...prev, { id, message: trimmed, tone }]);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
toastTimersRef.current[id] = window.setTimeout(() => {
|
||||||
|
dismissToast(id);
|
||||||
|
}, 3500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dismissToast],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
liveFeedHistoryLoadedRef.current = false;
|
liveFeedHistoryLoadedRef.current = false;
|
||||||
setIsLiveFeedHistoryLoading(false);
|
setIsLiveFeedHistoryLoading(false);
|
||||||
@@ -448,6 +548,17 @@ export default function BoardDetailPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
Object.values(toastTimersRef.current).forEach((timerId) => {
|
||||||
|
window.clearTimeout(timerId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toastTimersRef.current = {};
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLiveFeedOpen) return;
|
if (!isLiveFeedOpen) return;
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
@@ -1269,7 +1380,7 @@ export default function BoardDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPageActive) return;
|
if (!isPageActive) return;
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId || !isOrgAdmin) return;
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
||||||
@@ -1372,7 +1483,7 @@ export default function BoardDetailPage() {
|
|||||||
window.clearTimeout(reconnectTimeout);
|
window.clearTimeout(reconnectTimeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [board, boardId, isPageActive, isSignedIn]);
|
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
@@ -1411,9 +1522,9 @@ export default function BoardDetailPage() {
|
|||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError(
|
const message = formatActionError(err, "Something went wrong.");
|
||||||
err instanceof Error ? err.message : "Something went wrong.",
|
setCreateError(message);
|
||||||
);
|
pushToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
@@ -1454,8 +1565,7 @@ export default function BoardDetailPage() {
|
|||||||
}
|
}
|
||||||
return { ok: true, error: null };
|
return { ok: true, error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message = formatActionError(err, "Unable to send message.");
|
||||||
err instanceof Error ? err.message : "Unable to send message.";
|
|
||||||
return { ok: false, error: message };
|
return { ok: false, error: message };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1473,6 +1583,7 @@ export default function BoardDetailPage() {
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setChatError(result.error);
|
setChatError(result.error);
|
||||||
|
pushToast(result.error);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1481,7 +1592,7 @@ export default function BoardDetailPage() {
|
|||||||
setIsChatSending(false);
|
setIsChatSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[postBoardChatMessage],
|
[postBoardChatMessage, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openAgentsControlDialog = (action: "pause" | "resume") => {
|
const openAgentsControlDialog = (action: "pause" | "resume") => {
|
||||||
@@ -1497,16 +1608,16 @@ export default function BoardDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await postBoardChatMessage(command);
|
const result = await postBoardChatMessage(command);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setAgentsControlError(
|
const message = result.error ?? `Unable to send ${command} command.`;
|
||||||
result.error ?? `Unable to send ${command} command.`,
|
setAgentsControlError(message);
|
||||||
);
|
pushToast(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsAgentsControlDialogOpen(false);
|
setIsAgentsControlDialogOpen(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsAgentsControlSending(false);
|
setIsAgentsControlSending(false);
|
||||||
}
|
}
|
||||||
}, [agentsControlAction, postBoardChatMessage]);
|
}, [agentsControlAction, postBoardChatMessage, pushToast]);
|
||||||
|
|
||||||
const assigneeById = useMemo(() => {
|
const assigneeById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -1746,9 +1857,9 @@ export default function BoardDetailPage() {
|
|||||||
setComments((prev) => [created, ...prev]);
|
setComments((prev) => [created, ...prev]);
|
||||||
setNewComment("");
|
setNewComment("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPostCommentError(
|
const message = formatActionError(err, "Unable to send message.");
|
||||||
err instanceof Error ? err.message : "Unable to send message.",
|
setPostCommentError(message);
|
||||||
);
|
pushToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsPostingComment(false);
|
setIsPostingComment(false);
|
||||||
taskCommentInputRef.current?.focus();
|
taskCommentInputRef.current?.focus();
|
||||||
@@ -1830,9 +1941,9 @@ export default function BoardDetailPage() {
|
|||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveTaskError(
|
const message = formatActionError(err, "Something went wrong.");
|
||||||
err instanceof Error ? err.message : "Something went wrong.",
|
setSaveTaskError(message);
|
||||||
);
|
pushToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingTask(false);
|
setIsSavingTask(false);
|
||||||
}
|
}
|
||||||
@@ -1863,9 +1974,9 @@ export default function BoardDetailPage() {
|
|||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
closeComments();
|
closeComments();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteTaskError(
|
const message = formatActionError(err, "Something went wrong.");
|
||||||
err instanceof Error ? err.message : "Something went wrong.",
|
setDeleteTaskError(message);
|
||||||
);
|
pushToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeletingTask(false);
|
setIsDeletingTask(false);
|
||||||
}
|
}
|
||||||
@@ -1936,10 +2047,12 @@ export default function BoardDetailPage() {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTasks(previousTasks);
|
setTasks(previousTasks);
|
||||||
setError(err instanceof Error ? err.message : "Unable to move task.");
|
const message = formatActionError(err, "Unable to move task.");
|
||||||
|
setError(message);
|
||||||
|
pushToast(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[boardId, isSignedIn, taskTitleById],
|
[boardId, isSignedIn, pushToast, taskTitleById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentInitials = (agent: Agent) =>
|
const agentInitials = (agent: Agent) =>
|
||||||
@@ -2085,6 +2198,10 @@ export default function BoardDetailPage() {
|
|||||||
const handleApprovalDecision = useCallback(
|
const handleApprovalDecision = useCallback(
|
||||||
async (approvalId: string, status: "approved" | "rejected") => {
|
async (approvalId: string, status: "approved" | "rejected") => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
|
if (!canWrite) {
|
||||||
|
pushToast("Read-only access. You do not have permission to update approvals.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setApprovalsUpdatingId(approvalId);
|
setApprovalsUpdatingId(approvalId);
|
||||||
setApprovalsError(null);
|
setApprovalsError(null);
|
||||||
try {
|
try {
|
||||||
@@ -2102,14 +2219,14 @@ export default function BoardDetailPage() {
|
|||||||
prev.map((item) => (item.id === approvalId ? updated : item)),
|
prev.map((item) => (item.id === approvalId ? updated : item)),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setApprovalsError(
|
const message = formatActionError(err, "Unable to update approval.");
|
||||||
err instanceof Error ? err.message : "Unable to update approval.",
|
setApprovalsError(message);
|
||||||
);
|
pushToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setApprovalsUpdatingId(null);
|
setApprovalsUpdatingId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[boardId, isSignedIn],
|
[boardId, canWrite, isSignedIn, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2174,7 +2291,8 @@ export default function BoardDetailPage() {
|
|||||||
onClick={() => setIsDialogOpen(true)}
|
onClick={() => setIsDialogOpen(true)}
|
||||||
className="h-9 w-9 p-0"
|
className="h-9 w-9 p-0"
|
||||||
aria-label="New task"
|
aria-label="New task"
|
||||||
title="New task"
|
title={canWrite ? "New task" : "Read-only access"}
|
||||||
|
disabled={!canWrite}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2192,31 +2310,44 @@ export default function BoardDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{isOrgAdmin ? (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() =>
|
variant="outline"
|
||||||
openAgentsControlDialog(
|
onClick={() =>
|
||||||
isAgentsPaused ? "resume" : "pause",
|
openAgentsControlDialog(
|
||||||
)
|
isAgentsPaused ? "resume" : "pause",
|
||||||
}
|
)
|
||||||
disabled={!isSignedIn || !boardId || isAgentsControlSending}
|
}
|
||||||
className={cn(
|
disabled={
|
||||||
"h-9 w-9 p-0",
|
!isSignedIn ||
|
||||||
isAgentsPaused
|
!boardId ||
|
||||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
|
isAgentsControlSending ||
|
||||||
: "",
|
!canWrite
|
||||||
)}
|
}
|
||||||
aria-label={
|
className={cn(
|
||||||
isAgentsPaused ? "Resume agents" : "Pause agents"
|
"h-9 w-9 p-0",
|
||||||
}
|
isAgentsPaused
|
||||||
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
|
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
|
||||||
>
|
: "",
|
||||||
{isAgentsPaused ? (
|
)}
|
||||||
<Play className="h-4 w-4" />
|
aria-label={
|
||||||
) : (
|
isAgentsPaused ? "Resume agents" : "Pause agents"
|
||||||
<Pause className="h-4 w-4" />
|
}
|
||||||
)}
|
title={
|
||||||
</Button>
|
canWrite
|
||||||
|
? isAgentsPaused
|
||||||
|
? "Resume agents"
|
||||||
|
: "Pause agents"
|
||||||
|
: "Read-only access"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isAgentsPaused ? (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={openBoardChat}
|
onClick={openBoardChat}
|
||||||
@@ -2235,83 +2366,87 @@ export default function BoardDetailPage() {
|
|||||||
>
|
>
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
{isOrgAdmin ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
type="button"
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||||
aria-label="Board settings"
|
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||||
title="Board settings"
|
aria-label="Board settings"
|
||||||
>
|
title="Board settings"
|
||||||
<Settings className="h-4 w-4" />
|
>
|
||||||
</button>
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex gap-6 p-6">
|
<div className="relative flex gap-6 p-6">
|
||||||
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
{isOrgAdmin ? (
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div>
|
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<div>
|
||||||
Agents
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
</p>
|
Agents
|
||||||
<p className="text-xs text-slate-400">
|
</p>
|
||||||
{sortedAgents.length} total
|
<p className="text-xs text-slate-400">
|
||||||
</p>
|
{sortedAgents.length} total
|
||||||
</div>
|
</p>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push("/agents/new")}
|
|
||||||
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
|
||||||
{sortedAgents.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
|
|
||||||
No agents assigned yet.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button
|
||||||
sortedAgents.map((agent) => {
|
type="button"
|
||||||
const isWorking = workingAgentIds.has(agent.id);
|
onClick={() => router.push("/agents/new")}
|
||||||
return (
|
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||||
<button
|
>
|
||||||
key={agent.id}
|
Add
|
||||||
type="button"
|
</button>
|
||||||
className={cn(
|
</div>
|
||||||
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
|
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||||
)}
|
{sortedAgents.length === 0 ? (
|
||||||
onClick={() => router.push(`/agents/${agent.id}`)}
|
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
|
||||||
>
|
No agents assigned yet.
|
||||||
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
</div>
|
||||||
{agentAvatarLabel(agent)}
|
) : (
|
||||||
<span
|
sortedAgents.map((agent) => {
|
||||||
className={cn(
|
const isWorking = workingAgentIds.has(agent.id);
|
||||||
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
|
return (
|
||||||
isWorking
|
<button
|
||||||
? "bg-emerald-500"
|
key={agent.id}
|
||||||
: agent.status === "online"
|
type="button"
|
||||||
? "bg-green-500"
|
className={cn(
|
||||||
: "bg-slate-300",
|
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
|
||||||
)}
|
)}
|
||||||
/>
|
onClick={() => router.push(`/agents/${agent.id}`)}
|
||||||
</div>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
{agentAvatarLabel(agent)}
|
||||||
{agent.name}
|
<span
|
||||||
</p>
|
className={cn(
|
||||||
<p className="text-[11px] text-slate-500">
|
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
|
||||||
{agentRoleLabel(agent)}
|
isWorking
|
||||||
</p>
|
? "bg-emerald-500"
|
||||||
</div>
|
: agent.status === "online"
|
||||||
</button>
|
? "bg-green-500"
|
||||||
);
|
: "bg-slate-300",
|
||||||
})
|
)}
|
||||||
)}
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{agent.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
{agentRoleLabel(agent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-6">
|
<div className="min-w-0 flex-1 space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -2364,16 +2499,18 @@ export default function BoardDetailPage() {
|
|||||||
>
|
>
|
||||||
View group
|
View group
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{isOrgAdmin ? (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() =>
|
size="sm"
|
||||||
router.push(`/boards/${boardId}/edit`)
|
onClick={() =>
|
||||||
}
|
router.push(`/boards/${boardId}/edit`)
|
||||||
disabled={!boardId}
|
}
|
||||||
>
|
disabled={!boardId}
|
||||||
Settings
|
>
|
||||||
</Button>
|
Settings
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2528,7 +2665,8 @@ export default function BoardDetailPage() {
|
|||||||
<TaskBoard
|
<TaskBoard
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
onTaskSelect={openComments}
|
onTaskSelect={openComments}
|
||||||
onTaskMove={handleTaskMove}
|
onTaskMove={canWrite ? handleTaskMove : undefined}
|
||||||
|
readOnly={!canWrite}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
@@ -2546,7 +2684,8 @@ export default function BoardDetailPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsDialogOpen(true)}
|
onClick={() => setIsDialogOpen(true)}
|
||||||
disabled={isCreating}
|
disabled={isCreating || !canWrite}
|
||||||
|
title={canWrite ? "New task" : "Read-only access"}
|
||||||
>
|
>
|
||||||
New task
|
New task
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2660,7 +2799,8 @@ export default function BoardDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditDialogOpen(true)}
|
onClick={() => setIsEditDialogOpen(true)}
|
||||||
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
||||||
disabled={!selectedTask}
|
disabled={!selectedTask || !canWrite}
|
||||||
|
title={canWrite ? "Edit task" : "Read-only access"}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -2826,7 +2966,10 @@ export default function BoardDetailPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleApprovalDecision(approval.id, "approved")
|
handleApprovalDecision(approval.id, "approved")
|
||||||
}
|
}
|
||||||
disabled={approvalsUpdatingId === approval.id}
|
disabled={
|
||||||
|
approvalsUpdatingId === approval.id || !canWrite
|
||||||
|
}
|
||||||
|
title={canWrite ? "Approve" : "Read-only access"}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2836,7 +2979,10 @@ export default function BoardDetailPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleApprovalDecision(approval.id, "rejected")
|
handleApprovalDecision(approval.id, "rejected")
|
||||||
}
|
}
|
||||||
disabled={approvalsUpdatingId === approval.id}
|
disabled={
|
||||||
|
approvalsUpdatingId === approval.id || !canWrite
|
||||||
|
}
|
||||||
|
title={canWrite ? "Reject" : "Read-only access"}
|
||||||
className="border-slate-300 text-slate-700"
|
className="border-slate-300 text-slate-700"
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
@@ -2861,22 +3007,34 @@ export default function BoardDetailPage() {
|
|||||||
if (event.key !== "Enter") return;
|
if (event.key !== "Enter") return;
|
||||||
if (event.nativeEvent.isComposing) return;
|
if (event.nativeEvent.isComposing) return;
|
||||||
if (event.shiftKey) return;
|
if (event.shiftKey) return;
|
||||||
|
if (!canWrite) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isPostingComment) return;
|
if (isPostingComment) return;
|
||||||
if (!newComment.trim()) return;
|
if (!newComment.trim()) return;
|
||||||
void handlePostComment();
|
void handlePostComment();
|
||||||
}}
|
}}
|
||||||
placeholder="Write a message for the assigned agent…"
|
placeholder={
|
||||||
|
canWrite
|
||||||
|
? "Write a message for the assigned agent…"
|
||||||
|
: "Read-only access. Comments are disabled."
|
||||||
|
}
|
||||||
className="min-h-[80px] bg-white"
|
className="min-h-[80px] bg-white"
|
||||||
|
disabled={!canWrite || isPostingComment}
|
||||||
/>
|
/>
|
||||||
{postCommentError ? (
|
{postCommentError ? (
|
||||||
<p className="text-xs text-rose-600">{postCommentError}</p>
|
<p className="text-xs text-rose-600">{postCommentError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!canWrite ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Read-only access. You cannot post comments on this board.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handlePostComment}
|
onClick={handlePostComment}
|
||||||
disabled={isPostingComment || !newComment.trim()}
|
disabled={!canWrite || isPostingComment || !newComment.trim()}
|
||||||
|
title={canWrite ? "Send message" : "Read-only access"}
|
||||||
>
|
>
|
||||||
{isPostingComment ? "Sending…" : "Send message"}
|
{isPostingComment ? "Sending…" : "Send message"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2956,6 +3114,12 @@ export default function BoardDetailPage() {
|
|||||||
<BoardChatComposer
|
<BoardChatComposer
|
||||||
isSending={isChatSending}
|
isSending={isChatSending}
|
||||||
onSend={handleSendChat}
|
onSend={handleSendChat}
|
||||||
|
disabled={!canWrite}
|
||||||
|
placeholder={
|
||||||
|
canWrite
|
||||||
|
? "Message the board lead. Tag agents with @name."
|
||||||
|
: "Read-only access. Chat is disabled."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3052,7 +3216,7 @@ export default function BoardDetailPage() {
|
|||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(event) => setEditTitle(event.target.value)}
|
onChange={(event) => setEditTitle(event.target.value)}
|
||||||
placeholder="Task title"
|
placeholder="Task title"
|
||||||
disabled={!selectedTask || isSavingTask}
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -3064,7 +3228,7 @@ export default function BoardDetailPage() {
|
|||||||
onChange={(event) => setEditDescription(event.target.value)}
|
onChange={(event) => setEditDescription(event.target.value)}
|
||||||
placeholder="Task details"
|
placeholder="Task details"
|
||||||
className="min-h-[140px]"
|
className="min-h-[140px]"
|
||||||
disabled={!selectedTask || isSavingTask}
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -3075,7 +3239,7 @@ export default function BoardDetailPage() {
|
|||||||
<Select
|
<Select
|
||||||
value={editStatus}
|
value={editStatus}
|
||||||
onValueChange={(value) => setEditStatus(value as TaskStatus)}
|
onValueChange={(value) => setEditStatus(value as TaskStatus)}
|
||||||
disabled={!selectedTask || isSavingTask}
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder="Select status" />
|
||||||
@@ -3096,7 +3260,7 @@ export default function BoardDetailPage() {
|
|||||||
<Select
|
<Select
|
||||||
value={editPriority}
|
value={editPriority}
|
||||||
onValueChange={setEditPriority}
|
onValueChange={setEditPriority}
|
||||||
disabled={!selectedTask || isSavingTask}
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select priority" />
|
<SelectValue placeholder="Select priority" />
|
||||||
@@ -3120,7 +3284,7 @@ export default function BoardDetailPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setEditAssigneeId(value === "unassigned" ? "" : value)
|
setEditAssigneeId(value === "unassigned" ? "" : value)
|
||||||
}
|
}
|
||||||
disabled={!selectedTask || isSavingTask}
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Unassigned" />
|
<SelectValue placeholder="Unassigned" />
|
||||||
@@ -3155,7 +3319,8 @@ export default function BoardDetailPage() {
|
|||||||
disabled={
|
disabled={
|
||||||
!selectedTask ||
|
!selectedTask ||
|
||||||
isSavingTask ||
|
isSavingTask ||
|
||||||
selectedTask.status === "done"
|
selectedTask.status === "done" ||
|
||||||
|
!canWrite
|
||||||
}
|
}
|
||||||
emptyMessage="No other tasks found."
|
emptyMessage="No other tasks found."
|
||||||
/>
|
/>
|
||||||
@@ -3195,8 +3360,14 @@ export default function BoardDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeTaskDependency(depId)}
|
onClick={() => removeTaskDependency(depId)}
|
||||||
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700"
|
className={cn(
|
||||||
|
"rounded-full p-0.5 text-slate-500 transition",
|
||||||
|
canWrite
|
||||||
|
? "hover:bg-white hover:text-slate-700"
|
||||||
|
: "opacity-50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
aria-label="Remove dependency"
|
aria-label="Remove dependency"
|
||||||
|
disabled={!canWrite}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -3217,21 +3388,26 @@ export default function BoardDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsDeleteDialogOpen(true)}
|
onClick={() => setIsDeleteDialogOpen(true)}
|
||||||
disabled={!selectedTask || isSavingTask}
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
|
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
|
||||||
|
title={canWrite ? "Delete task" : "Read-only access"}
|
||||||
>
|
>
|
||||||
Delete task
|
Delete task
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleTaskReset}
|
onClick={handleTaskReset}
|
||||||
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
|
disabled={
|
||||||
|
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleTaskSave(true)}
|
onClick={() => handleTaskSave(true)}
|
||||||
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
|
disabled={
|
||||||
|
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSavingTask ? "Saving…" : "Save changes"}
|
{isSavingTask ? "Saving…" : "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -3262,7 +3438,7 @@ export default function BoardDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDeleteTask}
|
onClick={handleDeleteTask}
|
||||||
disabled={isDeletingTask}
|
disabled={isDeletingTask || !canWrite}
|
||||||
className="bg-rose-600 text-white hover:bg-rose-700"
|
className="bg-rose-600 text-white hover:bg-rose-700"
|
||||||
>
|
>
|
||||||
{isDeletingTask ? "Deleting…" : "Delete task"}
|
{isDeletingTask ? "Deleting…" : "Delete task"}
|
||||||
@@ -3294,6 +3470,7 @@ export default function BoardDetailPage() {
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
placeholder="e.g. Prepare launch notes"
|
placeholder="e.g. Prepare launch notes"
|
||||||
|
disabled={!canWrite || isCreating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -3305,13 +3482,18 @@ export default function BoardDetailPage() {
|
|||||||
onChange={(event) => setDescription(event.target.value)}
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
placeholder="Optional details"
|
placeholder="Optional details"
|
||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
|
disabled={!canWrite || isCreating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-strong">
|
<label className="text-sm font-medium text-strong">
|
||||||
Priority
|
Priority
|
||||||
</label>
|
</label>
|
||||||
<Select value={priority} onValueChange={setPriority}>
|
<Select
|
||||||
|
value={priority}
|
||||||
|
onValueChange={setPriority}
|
||||||
|
disabled={!canWrite || isCreating}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select priority" />
|
<SelectValue placeholder="Select priority" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -3334,77 +3516,116 @@ export default function BoardDetailPage() {
|
|||||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreateTask} disabled={isCreating}>
|
<Button onClick={handleCreateTask} disabled={!canWrite || isCreating}>
|
||||||
{isCreating ? "Creating…" : "Create task"}
|
{isCreating ? "Creating…" : "Create task"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
{isOrgAdmin ? (
|
||||||
open={isAgentsControlDialogOpen}
|
<Dialog
|
||||||
onOpenChange={(nextOpen) => {
|
open={isAgentsControlDialogOpen}
|
||||||
setIsAgentsControlDialogOpen(nextOpen);
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen) {
|
setIsAgentsControlDialogOpen(nextOpen);
|
||||||
setAgentsControlError(null);
|
if (!nextOpen) {
|
||||||
}
|
setAgentsControlError(null);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<DialogContent aria-label="Agent controls">
|
>
|
||||||
<DialogHeader>
|
<DialogContent aria-label="Agent controls">
|
||||||
<DialogTitle>
|
<DialogHeader>
|
||||||
{agentsControlAction === "pause"
|
<DialogTitle>
|
||||||
? "Pause agents"
|
{agentsControlAction === "pause"
|
||||||
: "Resume agents"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{agentsControlAction === "pause"
|
|
||||||
? "Send /pause to every agent on this board."
|
|
||||||
: "Send /resume to every agent on this board."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{agentsControlError ? (
|
|
||||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
|
||||||
{agentsControlError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
|
||||||
<p className="font-semibold text-slate-900">What happens</p>
|
|
||||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
|
||||||
<li>
|
|
||||||
This posts{" "}
|
|
||||||
<span className="font-mono">
|
|
||||||
{agentsControlAction === "pause" ? "/pause" : "/resume"}
|
|
||||||
</span>{" "}
|
|
||||||
to board chat.
|
|
||||||
</li>
|
|
||||||
<li>Mission Control forwards it to all agents on this board.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsAgentsControlDialogOpen(false)}
|
|
||||||
disabled={isAgentsControlSending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirmAgentsControl}
|
|
||||||
disabled={isAgentsControlSending}
|
|
||||||
>
|
|
||||||
{isAgentsControlSending
|
|
||||||
? "Sending…"
|
|
||||||
: agentsControlAction === "pause"
|
|
||||||
? "Pause agents"
|
? "Pause agents"
|
||||||
: "Resume agents"}
|
: "Resume agents"}
|
||||||
</Button>
|
</DialogTitle>
|
||||||
</DialogFooter>
|
<DialogDescription>
|
||||||
</DialogContent>
|
{agentsControlAction === "pause"
|
||||||
</Dialog>
|
? "Send /pause to every agent on this board."
|
||||||
|
: "Send /resume to every agent on this board."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{agentsControlError ? (
|
||||||
|
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
||||||
|
{agentsControlError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
||||||
|
<p className="font-semibold text-slate-900">What happens</p>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>
|
||||||
|
This posts{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{agentsControlAction === "pause" ? "/pause" : "/resume"}
|
||||||
|
</span>{" "}
|
||||||
|
to board chat.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Mission Control forwards it to all agents on this board.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsAgentsControlDialogOpen(false)}
|
||||||
|
disabled={isAgentsControlSending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmAgentsControl}
|
||||||
|
disabled={isAgentsControlSending}
|
||||||
|
>
|
||||||
|
{isAgentsControlSending
|
||||||
|
? "Sending…"
|
||||||
|
: agentsControlAction === "pause"
|
||||||
|
? "Pause agents"
|
||||||
|
: "Resume agents"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{toasts.length ? (
|
||||||
|
<div className="fixed bottom-6 right-6 z-[60] flex w-[320px] max-w-[90vw] flex-col gap-3">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-white px-4 py-3 text-sm shadow-lush",
|
||||||
|
toast.tone === "error"
|
||||||
|
? "border-rose-200 text-rose-700"
|
||||||
|
: "border-emerald-200 text-emerald-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-1 h-2 w-2 rounded-full",
|
||||||
|
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="flex-1 text-sm text-slate-700">
|
||||||
|
{toast.message}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-slate-400 hover:text-slate-600"
|
||||||
|
onClick={() => dismissToast(toast.id)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* onboarding moved to board settings */}
|
{/* onboarding moved to board settings */}
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
type listGatewaysApiV1GatewaysGetResponse,
|
type listGatewaysApiV1GatewaysGetResponse,
|
||||||
useListGatewaysApiV1GatewaysGet,
|
useListGatewaysApiV1GatewaysGet,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type { BoardGroupRead } from "@/api/generated/model";
|
import type { BoardGroupRead } from "@/api/generated/model";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
@@ -36,6 +40,20 @@ export default function NewBoardPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [gatewayId, setGatewayId] = useState<string>("");
|
const [gatewayId, setGatewayId] = useState<string>("");
|
||||||
const [boardGroupId, setBoardGroupId] = useState<string>("none");
|
const [boardGroupId, setBoardGroupId] = useState<string>("none");
|
||||||
@@ -47,7 +65,7 @@ export default function NewBoardPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
@@ -58,7 +76,7 @@ export default function NewBoardPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
@@ -166,100 +184,106 @@ export default function NewBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form
|
{!isAdmin ? (
|
||||||
onSubmit={handleSubmit}
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
Only organization owners and admins can create boards.
|
||||||
>
|
</div>
|
||||||
<div className="space-y-4">
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<form
|
||||||
<div className="space-y-2">
|
onSubmit={handleSubmit}
|
||||||
<label className="text-sm font-medium text-slate-900">
|
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
Board name <span className="text-red-500">*</span>
|
>
|
||||||
</label>
|
<div className="space-y-4">
|
||||||
<Input
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
value={name}
|
<div className="space-y-2">
|
||||||
onChange={(event) => setName(event.target.value)}
|
<label className="text-sm font-medium text-slate-900">
|
||||||
placeholder="e.g. Release operations"
|
Board name <span className="text-red-500">*</span>
|
||||||
disabled={isLoading}
|
</label>
|
||||||
/>
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="e.g. Release operations"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Gateway <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<SearchableSelect
|
||||||
|
ariaLabel="Select gateway"
|
||||||
|
value={displayGatewayId}
|
||||||
|
onValueChange={setGatewayId}
|
||||||
|
options={gatewayOptions}
|
||||||
|
placeholder="Select gateway"
|
||||||
|
searchPlaceholder="Search gateways..."
|
||||||
|
emptyMessage="No gateways found."
|
||||||
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
Gateway <span className="text-red-500">*</span>
|
<div className="space-y-2">
|
||||||
</label>
|
<label className="text-sm font-medium text-slate-900">
|
||||||
<SearchableSelect
|
Board group
|
||||||
ariaLabel="Select gateway"
|
</label>
|
||||||
value={displayGatewayId}
|
<SearchableSelect
|
||||||
onValueChange={setGatewayId}
|
ariaLabel="Select board group"
|
||||||
options={gatewayOptions}
|
value={boardGroupId}
|
||||||
placeholder="Select gateway"
|
onValueChange={setBoardGroupId}
|
||||||
searchPlaceholder="Search gateways..."
|
options={groupOptions}
|
||||||
emptyMessage="No gateways found."
|
placeholder="No group"
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
searchPlaceholder="Search groups..."
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
emptyMessage="No groups found."
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
/>
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Optional. Groups increase cross-board visibility.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
{gateways.length === 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<p>
|
||||||
Board group
|
No gateways available. Create one in{" "}
|
||||||
</label>
|
<Link
|
||||||
<SearchableSelect
|
href="/gateways"
|
||||||
ariaLabel="Select board group"
|
className="font-medium text-blue-600 hover:text-blue-700"
|
||||||
value={boardGroupId}
|
>
|
||||||
onValueChange={setBoardGroupId}
|
Gateways
|
||||||
options={groupOptions}
|
</Link>{" "}
|
||||||
placeholder="No group"
|
to continue.
|
||||||
searchPlaceholder="Search groups..."
|
|
||||||
emptyMessage="No groups found."
|
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Optional. Groups increase cross-board visibility.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push("/boards")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || !isFormReady}>
|
||||||
|
{isLoading ? "Creating…" : "Create board"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{gateways.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
|
||||||
<p>
|
|
||||||
No gateways available. Create one in{" "}
|
|
||||||
<Link
|
|
||||||
href="/gateways"
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
Gateways
|
|
||||||
</Link>{" "}
|
|
||||||
to continue.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.push("/boards")}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isLoading || !isFormReady}>
|
|
||||||
{isLoading ? "Creating…" : "Create board"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||||
useListBoardGroupsApiV1BoardGroupsGet,
|
useListBoardGroupsApiV1BoardGroupsGet,
|
||||||
} from "@/api/generated/board-groups/board-groups";
|
} from "@/api/generated/board-groups/board-groups";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
|
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
@@ -56,6 +60,20 @@ const compactId = (value: string) =>
|
|||||||
export default function BoardsPage() {
|
export default function BoardsPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
|
||||||
|
|
||||||
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
|
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
|
||||||
@@ -264,7 +282,7 @@ export default function BoardsPage() {
|
|||||||
{boards.length === 1 ? "" : "s"} total.
|
{boards.length === 1 ? "" : "s"} total.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{boards.length > 0 ? (
|
{boards.length > 0 && isAdmin ? (
|
||||||
<Link
|
<Link
|
||||||
href="/boards/new"
|
href="/boards/new"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
useGetGatewayApiV1GatewaysGatewayIdGet,
|
useGetGatewayApiV1GatewaysGatewayIdGet,
|
||||||
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
|
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type { GatewayUpdate } from "@/api/generated/model";
|
import type { GatewayUpdate } from "@/api/generated/model";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
@@ -50,6 +54,20 @@ export default function EditGatewayPage() {
|
|||||||
? gatewayIdParam[0]
|
? gatewayIdParam[0]
|
||||||
: gatewayIdParam;
|
: gatewayIdParam;
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const [name, setName] = useState<string | undefined>(undefined);
|
const [name, setName] = useState<string | undefined>(undefined);
|
||||||
const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined);
|
const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined);
|
||||||
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
|
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
|
||||||
@@ -77,7 +95,7 @@ export default function EditGatewayPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(gatewayId ?? "", {
|
>(gatewayId ?? "", {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn && gatewayId),
|
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
@@ -230,21 +248,26 @@ export default function EditGatewayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form
|
{!isAdmin ? (
|
||||||
onSubmit={handleSubmit}
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
Only organization owners and admins can edit gateways.
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Gateway name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={resolvedName}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
placeholder="Primary gateway"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Gateway name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={resolvedName}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Primary gateway"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -361,6 +384,7 @@ export default function EditGatewayPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
type listAgentsApiV1AgentsGetResponse,
|
type listAgentsApiV1AgentsGetResponse,
|
||||||
useListAgentsApiV1AgentsGet,
|
useListAgentsApiV1AgentsGet,
|
||||||
} from "@/api/generated/agents/agents";
|
} from "@/api/generated/agents/agents";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -49,12 +53,26 @@ export default function GatewayDetailPage() {
|
|||||||
? gatewayIdParam[0]
|
? gatewayIdParam[0]
|
||||||
: gatewayIdParam;
|
: gatewayIdParam;
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
|
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
|
||||||
getGatewayApiV1GatewaysGatewayIdGetResponse,
|
getGatewayApiV1GatewaysGatewayIdGetResponse,
|
||||||
ApiError
|
ApiError
|
||||||
>(gatewayId ?? "", {
|
>(gatewayId ?? "", {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn && gatewayId),
|
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -67,7 +85,7 @@ export default function GatewayDetailPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(gatewayId ? { gateway_id: gatewayId } : undefined, {
|
>(gatewayId ? { gateway_id: gatewayId } : undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn && gatewayId),
|
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -85,7 +103,7 @@ export default function GatewayDetailPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(statusParams, {
|
>(statusParams, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn && statusParams),
|
enabled: Boolean(isSignedIn && isAdmin && statusParams),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -142,7 +160,7 @@ export default function GatewayDetailPage() {
|
|||||||
>
|
>
|
||||||
Back to gateways
|
Back to gateways
|
||||||
</Button>
|
</Button>
|
||||||
{gatewayId ? (
|
{isAdmin && gatewayId ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
|
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
|
||||||
>
|
>
|
||||||
@@ -154,7 +172,11 @@ export default function GatewayDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
{gatewayQuery.isLoading ? (
|
{!isAdmin ? (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
|
Only organization owners and admins can access gateways.
|
||||||
|
</div>
|
||||||
|
) : gatewayQuery.isLoading ? (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
|
||||||
Loading gateway…
|
Loading gateway…
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
gatewaysStatusApiV1GatewaysStatusGet,
|
gatewaysStatusApiV1GatewaysStatusGet,
|
||||||
useCreateGatewayApiV1GatewaysPost,
|
useCreateGatewayApiV1GatewaysPost,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -42,6 +46,20 @@ export default function NewGatewayPage() {
|
|||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||||
const [gatewayToken, setGatewayToken] = useState("");
|
const [gatewayToken, setGatewayToken] = useState("");
|
||||||
@@ -191,21 +209,26 @@ export default function NewGatewayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<form
|
{!isAdmin ? (
|
||||||
onSubmit={handleSubmit}
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
Only organization owners and admins can create gateways.
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Gateway name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
placeholder="Primary gateway"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Gateway name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Primary gateway"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -320,6 +343,7 @@ export default function NewGatewayPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import {
|
|||||||
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
|
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
|
||||||
useListGatewaysApiV1GatewaysGet,
|
useListGatewaysApiV1GatewaysGet,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type { GatewayRead } from "@/api/generated/model";
|
import type { GatewayRead } from "@/api/generated/model";
|
||||||
|
|
||||||
const truncate = (value?: string | null, max = 24) => {
|
const truncate = (value?: string | null, max = 24) => {
|
||||||
@@ -58,6 +62,20 @@ const formatTimestamp = (value?: string | null) => {
|
|||||||
export default function GatewaysPage() {
|
export default function GatewaysPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "name", desc: false },
|
{ id: "name", desc: false },
|
||||||
]);
|
]);
|
||||||
@@ -69,7 +87,7 @@ export default function GatewaysPage() {
|
|||||||
ApiError
|
ApiError
|
||||||
>(undefined, {
|
>(undefined, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && isAdmin),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
},
|
},
|
||||||
@@ -240,7 +258,7 @@ export default function GatewaysPage() {
|
|||||||
Manage OpenClaw gateway connections used by boards
|
Manage OpenClaw gateway connections used by boards
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{gateways.length > 0 ? (
|
{isAdmin && gateways.length > 0 ? (
|
||||||
<Link
|
<Link
|
||||||
href="/gateways/new"
|
href="/gateways/new"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
@@ -256,9 +274,15 @@ export default function GatewaysPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
{!isAdmin ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||||
<table className="w-full text-left text-sm">
|
Only organization owners and admins can access gateways.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
@@ -347,11 +371,13 @@ export default function GatewaysPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{gatewaysQuery.error ? (
|
{gatewaysQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">
|
<p className="mt-4 text-sm text-red-500">
|
||||||
{gatewaysQuery.error.message}
|
{gatewaysQuery.error.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
141
frontend/src/app/invite/page.tsx
Normal file
141
frontend/src/app/invite/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
|
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import { useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost } from "@/api/generated/organizations/organizations";
|
||||||
|
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export default function InvitePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
|
||||||
|
const tokenFromQuery = (searchParams.get("token") ?? "").trim();
|
||||||
|
const [token, setToken] = useState(tokenFromQuery);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setToken(tokenFromQuery);
|
||||||
|
}, [tokenFromQuery]);
|
||||||
|
|
||||||
|
const acceptInviteMutation =
|
||||||
|
useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost<ApiError>({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.status === 200) {
|
||||||
|
setAccepted(true);
|
||||||
|
setError(null);
|
||||||
|
setTimeout(() => router.push("/organization"), 800);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.message || "Unable to accept invite.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAccept = (event?: React.FormEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (!isSignedIn) return;
|
||||||
|
const trimmed = token.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError("Invite token is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
acceptInviteMutation.mutate({ data: { token: trimmed } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubmitting = acceptInviteMutation.isPending;
|
||||||
|
const isReady = Boolean(token.trim());
|
||||||
|
const helperText = useMemo(() => {
|
||||||
|
if (accepted) {
|
||||||
|
return "Invite accepted. Redirecting to your organization…";
|
||||||
|
}
|
||||||
|
if (!token.trim()) {
|
||||||
|
return "Paste the invite token or open the invite link you were sent.";
|
||||||
|
}
|
||||||
|
return "Accept the invite to join the organization.";
|
||||||
|
}, [accepted, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-app text-strong">
|
||||||
|
<header className="border-b border-[color:var(--border)] bg-white">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||||
|
<BrandMark />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
|
||||||
|
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
||||||
|
Organization Invite
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold text-strong">
|
||||||
|
Join your team in OpenClaw
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted">{helperText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Invite Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={token}
|
||||||
|
onChange={(event) => setToken(event.target.value)}
|
||||||
|
placeholder="Paste invite token"
|
||||||
|
disabled={accepted || isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SignedOut>
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
|
||||||
|
<p>Sign in to accept your invite.</p>
|
||||||
|
<SignInButton mode="modal">
|
||||||
|
<Button size="md">Sign in</Button>
|
||||||
|
</SignInButton>
|
||||||
|
</div>
|
||||||
|
</SignedOut>
|
||||||
|
|
||||||
|
<SignedIn>
|
||||||
|
<form className="flex flex-wrap items-center gap-3" onSubmit={handleAccept}>
|
||||||
|
<Button type="submit" size="md" disabled={!isReady || isSubmitting || accepted}>
|
||||||
|
{accepted
|
||||||
|
? "Invite accepted"
|
||||||
|
: isSubmitting
|
||||||
|
? "Accepting…"
|
||||||
|
: "Accept invite"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="md"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SignedIn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1131
frontend/src/app/organization/page.tsx
Normal file
1131
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 = {
|
type BoardChatComposerProps = {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isSending?: boolean;
|
isSending?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onSend: (content: string) => Promise<boolean>;
|
onSend: (content: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BoardChatComposerImpl({
|
function BoardChatComposerImpl({
|
||||||
placeholder = "Message the board lead. Tag agents with @name.",
|
placeholder = "Message the board lead. Tag agents with @name.",
|
||||||
isSending = false,
|
isSending = false,
|
||||||
|
disabled = false,
|
||||||
onSend,
|
onSend,
|
||||||
}: BoardChatComposerProps) {
|
}: BoardChatComposerProps) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
@@ -28,7 +30,7 @@ function BoardChatComposerImpl({
|
|||||||
}, [isSending]);
|
}, [isSending]);
|
||||||
|
|
||||||
const send = useCallback(async () => {
|
const send = useCallback(async () => {
|
||||||
if (isSending) return;
|
if (isSending || disabled) return;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
const ok = await onSend(trimmed);
|
const ok = await onSend(trimmed);
|
||||||
@@ -53,12 +55,12 @@ function BoardChatComposerImpl({
|
|||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
disabled={isSending}
|
disabled={isSending || disabled}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void send()}
|
onClick={() => void send()}
|
||||||
disabled={isSending || !value.trim()}
|
disabled={isSending || disabled || !value.trim()}
|
||||||
>
|
>
|
||||||
{isSending ? "Sending…" : "Send"}
|
{isSending ? "Sending…" : "Send"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -93,8 +93,10 @@ export function TaskCard({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<p className="text-sm font-medium text-slate-900">{title}</p>
|
<p className="text-sm font-medium text-slate-900 line-clamp-2 break-words">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
{isBlocked ? (
|
{isBlocked ? (
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
|
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||||
@@ -114,7 +116,7 @@ export function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-shrink-0 flex-col items-end gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Folder,
|
Folder,
|
||||||
|
Building2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import { type getMyMembershipApiV1OrganizationsMeMemberGetResponse, useGetMyMembershipApiV1OrganizationsMeMemberGet } from "@/api/generated/organizations/organizations";
|
||||||
import {
|
import {
|
||||||
type healthzHealthzGetResponse,
|
type healthzHealthzGetResponse,
|
||||||
useHealthzHealthzGet,
|
useHealthzHealthzGet,
|
||||||
@@ -21,6 +24,20 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
export function DashboardSidebar() {
|
export function DashboardSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const member =
|
||||||
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||||
|
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||||
const healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>(
|
const healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>(
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
@@ -48,7 +65,7 @@ export function DashboardSidebar() {
|
|||||||
? "System status unavailable"
|
? "System status unavailable"
|
||||||
: "System degraded";
|
: "System degraded";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
||||||
<div className="flex-1 px-3 py-4">
|
<div className="flex-1 px-3 py-4">
|
||||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
@@ -67,18 +84,20 @@ export function DashboardSidebar() {
|
|||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{isAdmin ? (
|
||||||
href="/gateways"
|
<Link
|
||||||
className={cn(
|
href="/gateways"
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
className={cn(
|
||||||
pathname.startsWith("/gateways")
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||||
? "bg-blue-100 text-blue-800 font-medium"
|
pathname.startsWith("/gateways")
|
||||||
: "hover:bg-slate-100",
|
? "bg-blue-100 text-blue-800 font-medium"
|
||||||
)}
|
: "hover:bg-slate-100",
|
||||||
>
|
)}
|
||||||
<Network className="h-4 w-4" />
|
>
|
||||||
Gateways
|
<Network className="h-4 w-4" />
|
||||||
</Link>
|
Gateways
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href="/board-groups"
|
href="/board-groups"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -103,6 +122,18 @@ export function DashboardSidebar() {
|
|||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Boards
|
Boards
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/organization"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||||
|
pathname.startsWith("/organization")
|
||||||
|
? "bg-blue-100 text-blue-800 font-medium"
|
||||||
|
: "hover:bg-slate-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
Organization
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/approvals"
|
href="/approvals"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -127,18 +158,20 @@ export function DashboardSidebar() {
|
|||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
Live feed
|
Live feed
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{isAdmin ? (
|
||||||
href="/agents"
|
<Link
|
||||||
className={cn(
|
href="/agents"
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
className={cn(
|
||||||
pathname.startsWith("/agents")
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||||
? "bg-blue-100 text-blue-800 font-medium"
|
pathname.startsWith("/agents")
|
||||||
: "hover:bg-slate-100",
|
? "bg-blue-100 text-blue-800 font-medium"
|
||||||
)}
|
: "hover:bg-slate-100",
|
||||||
>
|
)}
|
||||||
<Bot className="h-4 w-4" />
|
>
|
||||||
Agents
|
<Bot className="h-4 w-4" />
|
||||||
</Link>
|
Agents
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-200 p-4">
|
<div className="border-t border-slate-200 p-4">
|
||||||
|
|||||||
240
frontend/src/components/organisms/OrgSwitcher.tsx
Normal file
240
frontend/src/components/organisms/OrgSwitcher.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Building2, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/clerk";
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import {
|
||||||
|
type listMyOrganizationsApiV1OrganizationsMeListGetResponse,
|
||||||
|
useCreateOrganizationApiV1OrganizationsPost,
|
||||||
|
useListMyOrganizationsApiV1OrganizationsMeListGet,
|
||||||
|
useSetActiveOrgApiV1OrganizationsMeActivePatch,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export function OrgSwitcher() {
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [orgName, setOrgName] = useState("");
|
||||||
|
const [orgError, setOrgError] = useState<string | null>(null);
|
||||||
|
const channelRef = useRef<BroadcastChannel | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!("BroadcastChannel" in window)) return;
|
||||||
|
const channel = new BroadcastChannel("org-switch");
|
||||||
|
channelRef.current = channel;
|
||||||
|
return () => {
|
||||||
|
channel.close();
|
||||||
|
channelRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const orgsQuery = useListMyOrganizationsApiV1OrganizationsMeListGet<
|
||||||
|
listMyOrganizationsApiV1OrganizationsMeListGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const orgs = orgsQuery.data?.status === 200 ? orgsQuery.data.data : [];
|
||||||
|
const activeOrg = orgs.find((item) => item.is_active) ?? null;
|
||||||
|
const orgValue = activeOrg?.id ?? "personal";
|
||||||
|
|
||||||
|
const announceOrgSwitch = (orgId: string) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const payload = JSON.stringify({ orgId, ts: Date.now() });
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem("openclaw_org_switch", payload);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures.
|
||||||
|
}
|
||||||
|
channelRef.current?.postMessage(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveOrgMutation =
|
||||||
|
useSetActiveOrgApiV1OrganizationsMeActivePatch<ApiError>({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: (_result, variables) => {
|
||||||
|
const orgId = variables?.data?.organization_id;
|
||||||
|
if (orgId) {
|
||||||
|
announceOrgSwitch(orgId);
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setOrgError(err.message || "Unable to switch organization.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createOrgMutation = useCreateOrganizationApiV1OrganizationsPost<ApiError>(
|
||||||
|
{
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
setOrgName("");
|
||||||
|
setOrgError(null);
|
||||||
|
setCreateOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["/api/v1/organizations/me/list"],
|
||||||
|
});
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
announceOrgSwitch("new");
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setOrgError(err.message || "Unable to create organization.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOrgChange = (value: string) => {
|
||||||
|
if (value === "__create__") {
|
||||||
|
setOrgError(null);
|
||||||
|
setCreateOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value || value === orgValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveOrgMutation.mutate({
|
||||||
|
data: { organization_id: value },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateOrg = () => {
|
||||||
|
const trimmed = orgName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setOrgError("Organization name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createOrgMutation.mutate({
|
||||||
|
data: { name: trimmed },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSignedIn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Select value={orgValue} onValueChange={handleOrgChange}>
|
||||||
|
<SelectTrigger className="h-9 w-[220px] rounded-md border-slate-200 bg-white px-3 text-sm font-medium text-slate-900 shadow-none focus:ring-2 focus:ring-blue-500/30 focus:ring-offset-0">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-slate-400" />
|
||||||
|
<SelectValue placeholder="Select organization" />
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="min-w-[220px] rounded-md border-slate-200 p-1 shadow-xl">
|
||||||
|
<div className="px-3 pb-2 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Org switcher
|
||||||
|
</div>
|
||||||
|
{orgs.length ? (
|
||||||
|
orgs.map((org) => (
|
||||||
|
<SelectItem
|
||||||
|
key={org.id}
|
||||||
|
value={org.id}
|
||||||
|
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700 data-[state=checked]:bg-slate-50 data-[state=checked]:text-slate-900 focus:bg-slate-100"
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem
|
||||||
|
value={orgValue}
|
||||||
|
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
Organization
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
<SelectSeparator className="my-2" />
|
||||||
|
<SelectItem
|
||||||
|
value="__create__"
|
||||||
|
className="rounded-md py-2 pl-3 pr-3 text-sm font-medium text-slate-600 hover:text-slate-900 focus:bg-slate-100 [&>span:first-child]:hidden"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-slate-400" />
|
||||||
|
Create new org
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{orgError && !createOpen ? (
|
||||||
|
<p className="absolute left-0 top-full mt-1 text-xs text-rose-500">
|
||||||
|
{orgError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent aria-label="Create organization">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create a new organization</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will switch you to the new organization as soon as it is
|
||||||
|
created.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="org-name"
|
||||||
|
className="text-xs font-semibold uppercase tracking-wide text-muted"
|
||||||
|
>
|
||||||
|
Organization name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="org-name"
|
||||||
|
placeholder="Acme Robotics"
|
||||||
|
value={orgName}
|
||||||
|
onChange={(event) => setOrgName(event.target.value)}
|
||||||
|
/>
|
||||||
|
{orgError ? (
|
||||||
|
<p className="text-sm text-rose-500">{orgError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCreateOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateOrg}
|
||||||
|
disabled={createOrgMutation.isPending}
|
||||||
|
>
|
||||||
|
{createOrgMutation.isPending ? "Creating..." : "Create org"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ type TaskBoardProps = {
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
onTaskSelect?: (task: Task) => void;
|
onTaskSelect?: (task: Task) => void;
|
||||||
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
|
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
|
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
|
||||||
@@ -99,6 +100,7 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
tasks,
|
tasks,
|
||||||
onTaskSelect,
|
onTaskSelect,
|
||||||
onTaskMove,
|
onTaskMove,
|
||||||
|
readOnly = false,
|
||||||
}: TaskBoardProps) {
|
}: TaskBoardProps) {
|
||||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
@@ -268,6 +270,10 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
|
|
||||||
const handleDragStart =
|
const handleDragStart =
|
||||||
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (readOnly) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (task.is_blocked) {
|
if (task.is_blocked) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -287,6 +293,7 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
|
|
||||||
const handleDrop =
|
const handleDrop =
|
||||||
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (readOnly) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setActiveColumn(null);
|
setActiveColumn(null);
|
||||||
const raw = event.dataTransfer.getData("text/plain");
|
const raw = event.dataTransfer.getData("text/plain");
|
||||||
@@ -303,6 +310,7 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
|
|
||||||
const handleDragOver =
|
const handleDragOver =
|
||||||
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (readOnly) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (activeColumn !== status) {
|
if (activeColumn !== status) {
|
||||||
setActiveColumn(status);
|
setActiveColumn(status);
|
||||||
@@ -310,6 +318,7 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (status: TaskStatus) => () => {
|
const handleDragLeave = (status: TaskStatus) => () => {
|
||||||
|
if (readOnly) return;
|
||||||
if (activeColumn === status) {
|
if (activeColumn === status) {
|
||||||
setActiveColumn(null);
|
setActiveColumn(null);
|
||||||
}
|
}
|
||||||
@@ -368,11 +377,13 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
key={column.title}
|
key={column.title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"kanban-column min-h-[calc(100vh-260px)]",
|
"kanban-column min-h-[calc(100vh-260px)]",
|
||||||
activeColumn === column.status && "ring-2 ring-slate-200",
|
activeColumn === column.status &&
|
||||||
|
!readOnly &&
|
||||||
|
"ring-2 ring-slate-200",
|
||||||
)}
|
)}
|
||||||
onDrop={handleDrop(column.status)}
|
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
||||||
onDragOver={handleDragOver(column.status)}
|
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
||||||
onDragLeave={handleDragLeave(column.status)}
|
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
|
||||||
>
|
>
|
||||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -445,10 +456,10 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
isBlocked={task.is_blocked}
|
isBlocked={task.is_blocked}
|
||||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||||
onClick={() => onTaskSelect?.(task)}
|
onClick={() => onTaskSelect?.(task)}
|
||||||
draggable={!task.is_blocked}
|
draggable={!readOnly && !task.is_blocked}
|
||||||
isDragging={draggingId === task.id}
|
isDragging={draggingId === task.id}
|
||||||
onDragStart={handleDragStart(task)}
|
onDragStart={readOnly ? undefined : handleDragStart(task)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { SignedIn, useUser } from "@/auth/clerk";
|
import { SignedIn, useUser } from "@/auth/clerk";
|
||||||
|
|
||||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||||
|
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
||||||
import { UserMenu } from "@/components/organisms/UserMenu";
|
import { UserMenu } from "@/components/organisms/UserMenu";
|
||||||
|
|
||||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||||
@@ -12,13 +14,46 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|||||||
const displayName =
|
const displayName =
|
||||||
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
|
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== "openclaw_org_switch" || !event.newValue) return;
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
|
||||||
|
let channel: BroadcastChannel | null = null;
|
||||||
|
if ("BroadcastChannel" in window) {
|
||||||
|
channel = new BroadcastChannel("org-switch");
|
||||||
|
channel.onmessage = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorage);
|
||||||
|
channel?.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-app text-strong">
|
<div className="min-h-screen bg-app text-strong">
|
||||||
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white shadow-sm">
|
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white shadow-sm">
|
||||||
<div className="flex items-center justify-between px-6 py-3">
|
<div className="grid grid-cols-[260px_1fr_auto] items-center gap-0 py-3">
|
||||||
<BrandMark />
|
<div className="flex items-center px-6">
|
||||||
|
<BrandMark />
|
||||||
|
</div>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center">
|
||||||
|
<div className="max-w-[220px]">
|
||||||
|
<OrgSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SignedIn>
|
||||||
|
<SignedIn>
|
||||||
|
<div className="flex items-center gap-3 px-6">
|
||||||
<div className="hidden text-right lg:block">
|
<div className="hidden text-right lg:block">
|
||||||
<p className="text-sm font-semibold text-slate-900">
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
{displayName}
|
{displayName}
|
||||||
|
|||||||
Reference in New Issue
Block a user