refactor: clean up code formatting and improve readability across multiple files

This commit is contained in:
Abhimanyu Saharan
2026-02-08 21:17:26 +05:30
parent e03125a382
commit 60744ddfac
24 changed files with 811 additions and 778 deletions

View File

@@ -229,9 +229,7 @@ async def _fetch_agent_events(
return list(await session.exec(statement)) return list(await session.exec(statement))
async def _require_user_context( async def _require_user_context(session: AsyncSession, user: User | None) -> OrganizationContext:
session: AsyncSession, user: User | None
) -> OrganizationContext:
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_active_membership(session, user) member = await get_active_membership(session, user)

View File

@@ -33,6 +33,7 @@ from app.models.gateways import Gateway
from app.models.users import User 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.mentions import extract_mentions, matches_agent_mention
from app.services.organizations import ( from app.services.organizations import (
OrganizationContext, OrganizationContext,
is_org_admin, is_org_admin,
@@ -40,7 +41,6 @@ from app.services.organizations import (
member_all_boards_read, member_all_boards_read,
member_all_boards_write, member_all_boards_write,
) )
from app.services.mentions import extract_mentions, matches_agent_mention
router = APIRouter(tags=["board-group-memory"]) router = APIRouter(tags=["board-group-memory"])

View File

@@ -83,9 +83,7 @@ async def list_board_groups(
ctx=Depends(require_org_member), ctx=Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardGroupRead]: ) -> DefaultLimitOffsetPage[BoardGroupRead]:
if member_all_boards_read(ctx.member): if member_all_boards_read(ctx.member):
statement = select(BoardGroup).where( statement = select(BoardGroup).where(col(BoardGroup.organization_id) == ctx.organization.id)
col(BoardGroup.organization_id) == ctx.organization.id
)
else: else:
accessible_boards = select(Board.board_group_id).where( accessible_boards = select(Board.board_group_id).where(
board_access_filter(ctx.member, write=False) board_access_filter(ctx.member, write=False)

View File

@@ -129,7 +129,9 @@ 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"], organization_id=board.organization_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( await _require_board_group(
session, session,

View File

@@ -11,9 +11,10 @@ from app.core.auth import AuthContext, get_auth_context, get_auth_context_option
from app.db.session import get_session from app.db.session import get_session
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.organizations import Organization
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.admin_access import require_admin
from app.services.organizations import ( from app.services.organizations import (
OrganizationContext, OrganizationContext,
ensure_member_for_user, ensure_member_for_user,
@@ -21,7 +22,6 @@ from app.services.organizations import (
is_org_admin, is_org_admin,
require_board_access, require_board_access,
) )
from app.services.admin_access import require_admin
def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthContext: def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthContext:

View File

@@ -21,7 +21,6 @@ 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,
@@ -32,6 +31,7 @@ from app.schemas.gateway_api import (
GatewaySessionsResponse, GatewaySessionsResponse,
GatewaysStatusResponse, GatewaysStatusResponse,
) )
from app.services.organizations import OrganizationContext, require_board_access
router = APIRouter(prefix="/gateways", tags=["gateways"]) router = APIRouter(prefix="/gateways", tags=["gateways"])

View File

@@ -23,6 +23,7 @@ from app.models.organizations import Organization
from app.models.users import User from app.models.users import User
from app.schemas.organizations import ( from app.schemas.organizations import (
OrganizationActiveUpdate, OrganizationActiveUpdate,
OrganizationBoardAccessRead,
OrganizationCreate, OrganizationCreate,
OrganizationInviteAccept, OrganizationInviteAccept,
OrganizationInviteCreate, OrganizationInviteCreate,
@@ -31,7 +32,6 @@ from app.schemas.organizations import (
OrganizationMemberAccessUpdate, OrganizationMemberAccessUpdate,
OrganizationMemberRead, OrganizationMemberRead,
OrganizationMemberUpdate, OrganizationMemberUpdate,
OrganizationBoardAccessRead,
OrganizationRead, OrganizationRead,
OrganizationUserRead, OrganizationUserRead,
) )
@@ -39,8 +39,8 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import ( from app.services.organizations import (
OrganizationContext, OrganizationContext,
accept_invite, accept_invite,
apply_invite_to_member,
apply_invite_board_access, apply_invite_board_access,
apply_invite_to_member,
apply_member_access_update, apply_member_access_update,
get_active_membership, get_active_membership,
get_member, get_member,
@@ -298,9 +298,7 @@ async def create_org_invite(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
existing_user = ( existing_user = (
await session.exec( await session.exec(select(User).where(func.lower(col(User.email)) == email))
select(User).where(func.lower(col(User.email)) == email)
)
).first() ).first()
if existing_user is not None: if existing_user is not None:
existing_member = await get_member( existing_member = await get_member(
@@ -380,7 +378,9 @@ async def accept_org_invite(
if invite is None: if invite is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if invite.invited_email and auth.user.email: if invite.invited_email and auth.user.email:
if normalize_invited_email(invite.invited_email) != normalize_invited_email(auth.user.email): if normalize_invited_email(invite.invited_email) != normalize_invited_email(
auth.user.email
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
existing = await get_member( existing = await get_member(

View File

@@ -20,9 +20,7 @@ class OrganizationBoardAccess(SQLModel, table=True):
) )
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_member_id: UUID = Field( organization_member_id: UUID = Field(foreign_key="organization_members.id", index=True)
foreign_key="organization_members.id", index=True
)
board_id: UUID = Field(foreign_key="boards.id", index=True) board_id: UUID = Field(foreign_key="boards.id", index=True)
can_read: bool = Field(default=True) can_read: bool = Field(default=True)
can_write: bool = Field(default=False) can_write: bool = Field(default=False)

View File

@@ -20,9 +20,7 @@ class OrganizationInviteBoardAccess(SQLModel, table=True):
) )
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_invite_id: UUID = Field( organization_invite_id: UUID = Field(foreign_key="organization_invites.id", index=True)
foreign_key="organization_invites.id", index=True
)
board_id: UUID = Field(foreign_key="boards.id", index=True) board_id: UUID = Field(foreign_key="boards.id", index=True)
can_read: bool = Field(default=True) can_read: bool = Field(default=True)
can_write: bool = Field(default=False) can_write: bool = Field(default=False)

View File

@@ -79,9 +79,7 @@ async def set_active_organization(
user: User, user: User,
organization_id: UUID, organization_id: UUID,
) -> OrganizationMember: ) -> OrganizationMember:
member = await get_member( member = await get_member(session, user_id=user.id, organization_id=organization_id)
session, user_id=user.id, organization_id=organization_id
)
if member is None: if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access")
if user.active_organization_id != organization_id: if user.active_organization_id != organization_id:
@@ -199,8 +197,7 @@ async def ensure_member_for_user(session: AsyncSession, user: User) -> Organizat
now = utcnow() now = utcnow()
member_count = ( member_count = (
await session.exec( await session.exec(
select(func.count()) select(func.count()).where(col(OrganizationMember.organization_id) == org.id)
.where(col(OrganizationMember.organization_id) == org.id)
) )
).one() ).one()
is_first = int(member_count or 0) == 0 is_first = int(member_count or 0) == 0

View File

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

View File

@@ -212,190 +212,193 @@ export default function AgentDetailPage() {
</div> </div>
) : ( ) : (
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8"> <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">
Agents Agents
</p> </p>
<h1 className="text-2xl font-semibold text-strong"> <h1 className="text-2xl font-semibold text-strong">
{agent?.name ?? "Agent"} {agent?.name ?? "Agent"}
</h1> </h1>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Review agent health, session binding, and recent activity. Review agent health, session binding, and recent activity.
</p> </p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push("/agents")}>
Back to agents
</Button>
{agent ? (
<Link
href={`/agents/${agent.id}/edit`}
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
) : null}
{agent ? (
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
) : null}
</div>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Loading agent details
</div>
) : agent ? (
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Overview
</p>
<p className="mt-1 text-lg font-semibold text-strong">
{agent.name}
</p>
</div>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agent ID
</p>
<p className="mt-1 text-sm text-muted">{agent.id}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session key
</p>
<p className="mt-1 text-sm text-muted">
{agent.openclaw_session_id ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">
Gateway main (no board)
</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
>
{linkedBoard.name}
</Link>
) : (
<p className="mt-1 text-sm text-strong"></p>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen
</p>
<p className="mt-1 text-sm text-strong">
{formatRelative(agent.last_seen_at)}
</p>
<p className="text-xs text-quiet">
{formatTimestamp(agent.last_seen_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Updated
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.updated_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Created
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.created_at)}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Heartbeat window</span>
<span>{formatRelative(agent.last_seen_at)}</span>
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>
{agent.openclaw_session_id ? "Bound" : "Unbound"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agentStatus}</span>
</div>
</div>
</div>
</div> </div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
{agent ? (
<Link
href={`/agents/${agent.id}/edit`}
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
) : null}
{agent ? (
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
) : null}
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5"> {error ? (
<div className="mb-4 flex items-center justify-between"> <div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet"> {error}
Activity </div>
</p> ) : null}
<p className="text-xs text-quiet">
{agentEvents.length} events {isLoading ? (
</p> <div className="flex flex-1 items-center justify-center text-sm text-muted">
</div> Loading agent details
<div className="space-y-3"> </div>
{agentEvents.length === 0 ? ( ) : agent ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"> <div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
No activity yet for this agent. <div className="space-y-6">
</div> <div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
) : ( <div className="flex items-center justify-between">
agentEvents.map((event) => ( <div>
<div <p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
key={event.id} Overview
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
</p> </p>
<p className="mt-1 text-xs text-quiet"> <p className="mt-1 text-lg font-semibold text-strong">
{formatTimestamp(event.created_at)} {agent.name}
</p> </p>
</div> </div>
)) <StatusPill status={agentStatus} />
)} </div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agent ID
</p>
<p className="mt-1 text-sm text-muted">{agent.id}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session key
</p>
<p className="mt-1 text-sm text-muted">
{agent.openclaw_session_id ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">
Gateway main (no board)
</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
>
{linkedBoard.name}
</Link>
) : (
<p className="mt-1 text-sm text-strong"></p>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen
</p>
<p className="mt-1 text-sm text-strong">
{formatRelative(agent.last_seen_at)}
</p>
<p className="text-xs text-quiet">
{formatTimestamp(agent.last_seen_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Updated
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.updated_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Created
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.created_at)}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Heartbeat window</span>
<span>{formatRelative(agent.last_seen_at)}</span>
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>
{agent.openclaw_session_id ? "Bound" : "Unbound"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agentStatus}</span>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Activity
</p>
<p className="text-xs text-quiet">
{agentEvents.length} events
</p>
</div>
<div className="space-y-3">
{agentEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
No activity yet for this agent.
</div>
) : (
agentEvents.map((event) => (
<div
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
</p>
<p className="mt-1 text-xs text-quiet">
{formatTimestamp(event.created_at)}
</p>
</div>
))
)}
</div>
</div> </div>
</div> </div>
</div> ) : (
) : ( <div className="flex flex-1 items-center justify-center text-sm text-muted">
<div className="flex flex-1 items-center justify-center text-sm text-muted"> Agent not found.
Agent not found. </div>
</div> )}
)}
</div> </div>
)} )}
</SignedIn> </SignedIn>

View File

@@ -214,184 +214,189 @@ export default function NewAgentPage() {
Basic configuration Basic configuration
</p> </p>
<div className="mt-4 space-y-6"> <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">
Agent name <span className="text-red-500">*</span> Agent name <span className="text-red-500">*</span>
</label> </label>
<Input <Input
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot" placeholder="e.g. Deploy bot"
disabled={isLoading} disabled={isLoading}
/> />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Role
</label>
<Input
value={identityProfile.role}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
role: event.target.value,
}))
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
/>
</div>
</div> </div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select board"
value={displayBoardId}
onValueChange={setBoardId}
options={getBoardOptions(boards)}
placeholder="Select board"
searchPlaceholder="Search boards..."
emptyMessage="No matching boards."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={boards.length === 0}
/>
{boards.length === 0 ? (
<p className="text-xs text-slate-500">
Create a board before adding agents.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Emoji
</label>
<Select
value={identityProfile.emoji}
onValueChange={(value) =>
setIdentityProfile((current) => ({
...current,
emoji: value,
}))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select emoji" />
</SelectTrigger>
<SelectContent>
{EMOJI_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.glyph} {option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Personality & behavior
</p>
<div className="mt-4 space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-900"> <label className="text-sm font-medium text-slate-900">
Role Communication style
</label> </label>
<Input <Input
value={identityProfile.role} value={identityProfile.communication_style}
onChange={(event) => onChange={(event) =>
setIdentityProfile((current) => ({ setIdentityProfile((current) => ({
...current, ...current,
role: event.target.value, communication_style: event.target.value,
})) }))
} }
placeholder="e.g. Founder, Social Media Manager" disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Soul template
</label>
<Textarea
value={soulTemplate}
onChange={(event) =>
setSoulTemplate(event.target.value)
}
rows={10}
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> </div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications
</p>
<div className="mt-4 grid gap-6 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-900"> <label className="text-sm font-medium text-slate-900">
Board <span className="text-red-500">*</span> Interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target
</label> </label>
<SearchableSelect <SearchableSelect
ariaLabel="Select board" ariaLabel="Select heartbeat target"
value={displayBoardId} value={heartbeatTarget}
onValueChange={setBoardId} onValueChange={setHeartbeatTarget}
options={getBoardOptions(boards)} options={HEARTBEAT_TARGET_OPTIONS}
placeholder="Select board" placeholder="Select target"
searchPlaceholder="Search boards..." searchPlaceholder="Search targets..."
emptyMessage="No matching boards." emptyMessage="No matching targets."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200" 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" 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" itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={boards.length === 0}
/>
{boards.length === 0 ? (
<p className="text-xs text-slate-500">
Create a board before adding agents.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Emoji
</label>
<Select
value={identityProfile.emoji}
onValueChange={(value) =>
setIdentityProfile((current) => ({
...current,
emoji: value,
}))
}
disabled={isLoading} disabled={isLoading}
> />
<SelectTrigger>
<SelectValue placeholder="Select emoji" />
</SelectTrigger>
<SelectContent>
{EMOJI_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.glyph} {option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
</div> </div>
</div>
<div> {errorMessage ? (
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
Personality & behavior {errorMessage}
</p>
<div className="mt-4 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Communication style
</label>
<Input
value={identityProfile.communication_style}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
communication_style: event.target.value,
}))
}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Soul template
</label>
<Textarea
value={soulTemplate}
onChange={(event) => setSoulTemplate(event.target.value)}
rows={10}
disabled={isLoading}
/>
</div> </div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating…" : "Create agent"}
</Button>
<Button
variant="outline"
type="button"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
</div> </div>
</div> </form>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications
</p>
<div className="mt-4 grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target
</label>
<SearchableSelect
ariaLabel="Select heartbeat target"
value={heartbeatTarget}
onValueChange={setHeartbeatTarget}
options={HEARTBEAT_TARGET_OPTIONS}
placeholder="Select target"
searchPlaceholder="Search targets..."
emptyMessage="No matching targets."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
</div>
</div>
</div>
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{errorMessage}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating…" : "Create agent"}
</Button>
<Button
variant="outline"
type="button"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
</div>
</form>
)} )}
</div> </div>
</main> </main>

View File

@@ -350,88 +350,88 @@ export default function AgentsPage() {
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <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}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3"> <th key={header.id} className="px-6 py-3">
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext(),
)} )}
</th> </th>
))}
</tr>
))} ))}
</tr> </thead>
))} <tbody className="divide-y divide-slate-100">
</thead> {agentsQuery.isLoading ? (
<tbody className="divide-y divide-slate-100"> <tr>
{agentsQuery.isLoading ? ( <td colSpan={columns.length} className="px-6 py-8">
<tr> <span className="text-sm text-slate-500">
<td colSpan={columns.length} className="px-6 py-8"> Loading
<span className="text-sm text-slate-500"> </span>
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td> </td>
))} </tr>
</tr> ) : table.getRowModel().rows.length ? (
)) table.getRowModel().rows.map((row) => (
) : ( <tr key={row.id} className="hover:bg-slate-50">
<tr> {row.getVisibleCells().map((cell) => (
<td colSpan={columns.length} className="px-6 py-16"> <td key={cell.id} className="px-6 py-4">
<div className="flex flex-col items-center justify-center text-center"> {flexRender(
<div className="mb-4 rounded-full bg-slate-50 p-4"> cell.column.columnDef.cell,
<svg cell.getContext(),
className="h-16 w-16 text-slate-300" )}
viewBox="0 0 24 24" </td>
fill="none" ))}
stroke="currentColor" </tr>
strokeWidth="1.5" ))
strokeLinecap="round" ) : (
strokeLinejoin="round" <tr>
> <td colSpan={columns.length} className="px-6 py-16">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> <div className="flex flex-col items-center justify-center text-center">
<circle cx="9" cy="7" r="4" /> <div className="mb-4 rounded-full bg-slate-50 p-4">
<path d="M22 21v-2a4 4 0 0 0-3-3.87" /> <svg
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> className="h-16 w-16 text-slate-300"
</svg> viewBox="0 0 24 24"
</div> fill="none"
<h3 className="mb-2 text-lg font-semibold text-slate-900"> stroke="currentColor"
No agents yet strokeWidth="1.5"
</h3> strokeLinecap="round"
<p className="mb-6 max-w-md text-sm text-slate-500"> strokeLinejoin="round"
Create your first agent to start executing tasks >
on this board. <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
</p> <circle cx="9" cy="7" r="4" />
<Link <path d="M22 21v-2a4 4 0 0 0-3-3.87" />
href="/agents/new" <path d="M16 3.13a4 4 0 0 1 0 7.75" />
className={buttonVariants({ </svg>
size: "md", </div>
variant: "primary", <h3 className="mb-2 text-lg font-semibold text-slate-900">
})} No agents yet
> </h3>
Create your first agent <p className="mb-6 max-w-md text-sm text-slate-500">
</Link> Create your first agent to start executing
</div> tasks on this board.
</td> </p>
</tr> <Link
)} href="/agents/new"
</tbody> className={buttonVariants({
</table> size: "md",
</div> variant: "primary",
</div> })}
>
Create your first agent
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{agentsQuery.error ? ( {agentsQuery.error ? (
<p className="mt-4 text-sm text-red-500"> <p className="mt-4 text-sm text-red-500">

View File

@@ -710,7 +710,13 @@ export default function BoardGroupDetailPage() {
} finally { } finally {
setIsHeartbeatApplying(false); setIsHeartbeatApplying(false);
} }
}, [canManageHeartbeat, groupId, heartbeatEvery, includeBoardLeads, isSignedIn]); }, [
canManageHeartbeat,
groupId,
heartbeatEvery,
includeBoardLeads,
isSignedIn,
]);
return ( return (
<DashboardShell> <DashboardShell>
@@ -850,7 +856,8 @@ 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", !canManageHeartbeat &&
"opacity-50 cursor-not-allowed",
)} )}
disabled={!canManageHeartbeat} disabled={!canManageHeartbeat}
onClick={() => { onClick={() => {
@@ -913,7 +920,9 @@ export default function BoardGroupDetailPage() {
variant="outline" variant="outline"
onClick={() => void applyHeartbeat()} onClick={() => void applyHeartbeat()}
disabled={ disabled={
isHeartbeatApplying || !heartbeatEvery || !canManageHeartbeat isHeartbeatApplying ||
!heartbeatEvery ||
!canManageHeartbeat
} }
title={ title={
canManageHeartbeat canManageHeartbeat

View File

@@ -206,7 +206,9 @@ const resolveBoardAccess = (
if (member.all_boards_read) { if (member.all_boards_read) {
return { canRead: true, canWrite: false }; return { canRead: true, canWrite: false };
} }
const entry = member.board_access?.find((access) => access.board_id === boardId); const entry = member.board_access?.find(
(access) => access.board_id === boardId,
);
if (!entry) { if (!entry) {
return { canRead: false, canWrite: false }; return { canRead: false, canWrite: false };
} }
@@ -2199,7 +2201,9 @@ export default function BoardDetailPage() {
async (approvalId: string, status: "approved" | "rejected") => { async (approvalId: string, status: "approved" | "rejected") => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
if (!canWrite) { if (!canWrite) {
pushToast("Read-only access. You do not have permission to update approvals."); pushToast(
"Read-only access. You do not have permission to update approvals.",
);
return; return;
} }
setApprovalsUpdatingId(approvalId); setApprovalsUpdatingId(approvalId);
@@ -3033,7 +3037,9 @@ export default function BoardDetailPage() {
<Button <Button
size="sm" size="sm"
onClick={handlePostComment} onClick={handlePostComment}
disabled={!canWrite || isPostingComment || !newComment.trim()} disabled={
!canWrite || isPostingComment || !newComment.trim()
}
title={canWrite ? "Send message" : "Read-only access"} title={canWrite ? "Send message" : "Read-only access"}
> >
{isPostingComment ? "Sending…" : "Send message"} {isPostingComment ? "Sending…" : "Send message"}
@@ -3516,7 +3522,10 @@ export default function BoardDetailPage() {
<Button variant="outline" onClick={() => setIsDialogOpen(false)}> <Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreateTask} disabled={!canWrite || isCreating}> <Button
onClick={handleCreateTask}
disabled={!canWrite || isCreating}
>
{isCreating ? "Creating…" : "Create task"} {isCreating ? "Creating…" : "Create task"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -3611,9 +3620,7 @@ export default function BoardDetailPage() {
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500", toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
)} )}
/> />
<p className="flex-1 text-sm text-slate-700"> <p className="flex-1 text-sm text-slate-700">{toast.message}</p>
{toast.message}
</p>
<button <button
type="button" type="button"
className="text-xs text-slate-400 hover:text-slate-600" className="text-xs text-slate-400 hover:text-slate-600"

View File

@@ -282,7 +282,7 @@ export default function NewBoardPage() {
{isLoading ? "Creating…" : "Create board"} {isLoading ? "Creating…" : "Create board"}
</Button> </Button>
</div> </div>
</form> </form>
)} )}
</div> </div>
</main> </main>

View File

@@ -269,121 +269,123 @@ export default function EditGatewayPage() {
/> />
</div> </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">
<label className="text-sm font-medium text-slate-900"> <label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span> Gateway URL <span className="text-red-500">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input
value={resolvedGatewayUrl}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={
gatewayUrlError ? "border-red-500" : undefined
}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input <Input
value={resolvedGatewayUrl} value={resolvedGatewayToken}
onChange={(event) => { onChange={(event) => {
setGatewayUrl(event.target.value); setGatewayToken(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle"); setGatewayCheckStatus("idle");
setGatewayCheckMessage(null); setGatewayCheckMessage(null);
}} }}
onBlur={runGatewayCheck} onBlur={runGatewayCheck}
placeholder="ws://gateway:18789" placeholder="Bearer token"
disabled={isLoading} disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/> />
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div> </div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={resolvedGatewayToken}
onChange={(event) => {
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="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">
Main session key <span className="text-red-500">*</span> Main session key <span className="text-red-500">*</span>
</label> </label>
<Input <Input
value={resolvedMainSessionKey} value={resolvedMainSessionKey}
onChange={(event) => { onChange={(event) => {
setMainSessionKey(event.target.value); setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle"); setGatewayCheckStatus("idle");
setGatewayCheckMessage(null); setGatewayCheckMessage(null);
}} }}
placeholder={DEFAULT_MAIN_SESSION_KEY} placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading} disabled={isLoading}
/> />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={resolvedWorkspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
disabled={isLoading}
/>
</div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900"> {errorMessage ? (
Workspace root <span className="text-red-500">*</span> <p className="text-sm text-red-500">{errorMessage}</p>
</label> ) : null}
<Input
value={resolvedWorkspaceRoot} <div className="flex justify-end gap-3">
onChange={(event) => setWorkspaceRoot(event.target.value)} <Button
placeholder={DEFAULT_WORKSPACE_ROOT} type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading} disabled={isLoading}
/> >
Back
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div> </div>
</div> </form>
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
>
Back
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
)} )}
</div> </div>
</main> </main>

View File

@@ -230,119 +230,121 @@ export default function NewGatewayPage() {
/> />
</div> </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">
<label className="text-sm font-medium text-slate-900"> <label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span> Gateway URL <span className="text-red-500">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input
value={gatewayUrl}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={
gatewayUrlError ? "border-red-500" : undefined
}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input <Input
value={gatewayUrl} value={gatewayToken}
onChange={(event) => { onChange={(event) => {
setGatewayUrl(event.target.value); setGatewayToken(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle"); setGatewayCheckStatus("idle");
setGatewayCheckMessage(null); setGatewayCheckMessage(null);
}} }}
onBlur={runGatewayCheck} onBlur={runGatewayCheck}
placeholder="ws://gateway:18789" placeholder="Bearer token"
disabled={isLoading} disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/> />
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div> </div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={gatewayToken}
onChange={(event) => {
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="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">
Main session key <span className="text-red-500">*</span> Main session key <span className="text-red-500">*</span>
</label> </label>
<Input <Input
value={mainSessionKey} value={mainSessionKey}
onChange={(event) => { onChange={(event) => {
setMainSessionKey(event.target.value); setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle"); setGatewayCheckStatus("idle");
setGatewayCheckMessage(null); setGatewayCheckMessage(null);
}} }}
placeholder={DEFAULT_MAIN_SESSION_KEY} placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading} disabled={isLoading}
/> />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
disabled={isLoading}
/>
</div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900"> {error ? <p className="text-sm text-red-500">{error}</p> : null}
Workspace root <span className="text-red-500">*</span>
</label> <div className="flex justify-end gap-3">
<Input <Button
value={workspaceRoot} type="button"
onChange={(event) => setWorkspaceRoot(event.target.value)} variant="ghost"
placeholder={DEFAULT_WORKSPACE_ROOT} onClick={() => router.push("/gateways")}
disabled={isLoading} disabled={isLoading}
/> >
Cancel
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Creating…" : "Create gateway"}
</Button>
</div> </div>
</div> </form>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Creating…" : "Create gateway"}
</Button>
</div>
</form>
)} )}
</div> </div>
</main> </main>

View File

@@ -283,93 +283,93 @@ export default function GatewaysPage() {
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <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}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3"> <th key={header.id} className="px-6 py-3">
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext(),
)} )}
</th> </th>
))}
</tr>
))} ))}
</tr> </thead>
))} <tbody className="divide-y divide-slate-100">
</thead> {gatewaysQuery.isLoading ? (
<tbody className="divide-y divide-slate-100"> <tr>
{gatewaysQuery.isLoading ? ( <td colSpan={columns.length} className="px-6 py-8">
<tr> <span className="text-sm text-slate-500">
<td colSpan={columns.length} className="px-6 py-8"> Loading
<span className="text-sm text-slate-500"> </span>
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td> </td>
))} </tr>
</tr> ) : table.getRowModel().rows.length ? (
)) table.getRowModel().rows.map((row) => (
) : ( <tr key={row.id} className="hover:bg-slate-50">
<tr> {row.getVisibleCells().map((cell) => (
<td colSpan={columns.length} className="px-6 py-16"> <td key={cell.id} className="px-6 py-4">
<div className="flex flex-col items-center justify-center text-center"> {flexRender(
<div className="mb-4 rounded-full bg-slate-50 p-4"> cell.column.columnDef.cell,
<svg cell.getContext(),
className="h-16 w-16 text-slate-300" )}
viewBox="0 0 24 24" </td>
fill="none" ))}
stroke="currentColor" </tr>
strokeWidth="1.5" ))
strokeLinecap="round" ) : (
strokeLinejoin="round" <tr>
> <td colSpan={columns.length} className="px-6 py-16">
<rect <div className="flex flex-col items-center justify-center text-center">
x="2" <div className="mb-4 rounded-full bg-slate-50 p-4">
y="7" <svg
width="20" className="h-16 w-16 text-slate-300"
height="14" viewBox="0 0 24 24"
rx="2" fill="none"
ry="2" stroke="currentColor"
/> strokeWidth="1.5"
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /> strokeLinecap="round"
</svg> strokeLinejoin="round"
</div> >
<h3 className="mb-2 text-lg font-semibold text-slate-900"> <rect
No gateways yet x="2"
</h3> y="7"
<p className="mb-6 max-w-md text-sm text-slate-500"> width="20"
Create your first gateway to connect boards and height="14"
start managing your OpenClaw connections. rx="2"
</p> ry="2"
<Link />
href="/gateways/new" <path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
className={buttonVariants({ </svg>
size: "md", </div>
variant: "primary", <h3 className="mb-2 text-lg font-semibold text-slate-900">
})} No gateways yet
> </h3>
Create your first gateway <p className="mb-6 max-w-md text-sm text-slate-500">
</Link> Create your first gateway to connect boards
</div> and start managing your OpenClaw connections.
</td> </p>
</tr> <Link
)} href="/gateways/new"
</tbody> className={buttonVariants({
</table> size: "md",
</div> variant: "primary",
</div> })}
>
Create your first gateway
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{gatewaysQuery.error ? ( {gatewaysQuery.error ? (
<p className="mt-4 text-sm text-red-500"> <p className="mt-4 text-sm text-red-500">

View File

@@ -114,8 +114,15 @@ export default function InvitePage() {
</SignedOut> </SignedOut>
<SignedIn> <SignedIn>
<form className="flex flex-wrap items-center gap-3" onSubmit={handleAccept}> <form
<Button type="submit" size="md" disabled={!isReady || isSubmitting || accepted}> className="flex flex-wrap items-center gap-3"
onSubmit={handleAccept}
>
<Button
type="submit"
size="md"
disabled={!isReady || isSubmitting || accepted}
>
{accepted {accepted
? "Invite accepted" ? "Invite accepted"
: isSubmitting : isSubmitting

View File

@@ -76,7 +76,9 @@ type AccessScope = "all" | "custom";
type BoardAccessState = Record<string, { read: boolean; write: boolean }>; type BoardAccessState = Record<string, { read: boolean; write: boolean }>;
const buildAccessList = (access: BoardAccessState): OrganizationBoardAccessSpec[] => const buildAccessList = (
access: BoardAccessState,
): OrganizationBoardAccessSpec[] =>
Object.entries(access) Object.entries(access)
.filter(([, entry]) => entry.read || entry.write) .filter(([, entry]) => entry.read || entry.write)
.map(([boardId, entry]) => ({ .map(([boardId, entry]) => ({
@@ -319,9 +321,8 @@ export default function OrganizationPage() {
const [inviteScope, setInviteScope] = useState<AccessScope>("all"); const [inviteScope, setInviteScope] = useState<AccessScope>("all");
const [inviteAllRead, setInviteAllRead] = useState(true); const [inviteAllRead, setInviteAllRead] = useState(true);
const [inviteAllWrite, setInviteAllWrite] = useState(false); const [inviteAllWrite, setInviteAllWrite] = useState(false);
const [inviteAccess, setInviteAccess] = useState<BoardAccessState>( const [inviteAccess, setInviteAccess] =
defaultBoardAccess, useState<BoardAccessState>(defaultBoardAccess);
);
const [inviteError, setInviteError] = useState<string | null>(null); const [inviteError, setInviteError] = useState<string | null>(null);
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null); const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
@@ -331,9 +332,8 @@ export default function OrganizationPage() {
const [accessAllRead, setAccessAllRead] = useState(false); const [accessAllRead, setAccessAllRead] = useState(false);
const [accessAllWrite, setAccessAllWrite] = useState(false); const [accessAllWrite, setAccessAllWrite] = useState(false);
const [accessRole, setAccessRole] = useState("member"); const [accessRole, setAccessRole] = useState("member");
const [accessMap, setAccessMap] = useState<BoardAccessState>( const [accessMap, setAccessMap] =
defaultBoardAccess, useState<BoardAccessState>(defaultBoardAccess);
);
const [accessError, setAccessError] = useState<string | null>(null); const [accessError, setAccessError] = useState<string | null>(null);
const orgQuery = useGetMyOrgApiV1OrganizationsMeGet< const orgQuery = useGetMyOrgApiV1OrganizationsMeGet<
@@ -426,32 +426,33 @@ export default function OrganizationPage() {
}, },
}); });
const createInviteMutation = useCreateOrgInviteApiV1OrganizationsMeInvitesPost< const createInviteMutation =
ApiError useCreateOrgInviteApiV1OrganizationsMeInvitesPost<ApiError>({
>({ mutation: {
mutation: { onSuccess: (result) => {
onSuccess: (result) => { if (result.status === 200) {
if (result.status === 200) { setInviteEmail("");
setInviteEmail(""); setInviteRole("member");
setInviteRole("member"); setInviteScope("all");
setInviteScope("all"); setInviteAllRead(true);
setInviteAllRead(true); setInviteAllWrite(false);
setInviteAllWrite(false); setInviteAccess(defaultBoardAccess);
setInviteAccess(defaultBoardAccess); setInviteError(null);
setInviteError(null); queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey(
queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey({ {
limit: 200, limit: 200,
}), },
}); ),
setInviteDialogOpen(false); });
} setInviteDialogOpen(false);
}
},
onError: (err) => {
setInviteError(err.message || "Unable to create invite.");
},
}, },
onError: (err) => { });
setInviteError(err.message || "Unable to create invite.");
},
},
});
const revokeInviteMutation = const revokeInviteMutation =
useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete<ApiError>({ useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete<ApiError>({
@@ -472,9 +473,11 @@ export default function OrganizationPage() {
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey({ queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey(
limit: 200, {
}), limit: 200,
},
),
}); });
if (activeMemberId) { if (activeMemberId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -535,9 +538,7 @@ export default function OrganizationPage() {
}, [inviteDialogOpen]); }, [inviteDialogOpen]);
const orgName = const orgName =
orgQuery.data?.status === 200 orgQuery.data?.status === 200 ? orgQuery.data.data.name : "Organization";
? orgQuery.data.data.name
: "Organization";
const handleInviteSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleInviteSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -710,7 +711,10 @@ export default function OrganizationPage() {
<h1 className="text-2xl font-semibold tracking-tight text-slate-900"> <h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Organization Organization
</h1> </h1>
<Badge variant="outline" className="flex items-center gap-2"> <Badge
variant="outline"
className="flex items-center gap-2"
>
<Building2 className="h-3.5 w-3.5" /> <Building2 className="h-3.5 w-3.5" />
{orgName} {orgName}
</Badge> </Badge>

View File

@@ -15,7 +15,10 @@ import {
import { useAuth } from "@/auth/clerk"; 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 {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { import {
type healthzHealthzGetResponse, type healthzHealthzGetResponse,
useHealthzHealthzGet, useHealthzHealthzGet,
@@ -65,7 +68,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">

View File

@@ -91,8 +91,8 @@ export function OrgSwitcher() {
}, },
}); });
const createOrgMutation = useCreateOrganizationApiV1OrganizationsPost<ApiError>( const createOrgMutation =
{ useCreateOrganizationApiV1OrganizationsPost<ApiError>({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
setOrgName(""); setOrgName("");
@@ -110,8 +110,7 @@ export function OrgSwitcher() {
setOrgError(err.message || "Unable to create organization."); setOrgError(err.message || "Unable to create organization.");
}, },
}, },
}, });
);
const handleOrgChange = (value: string) => { const handleOrgChange = (value: string) => {
if (value === "__create__") { if (value === "__create__") {