diff --git a/backend/druks/api/server.py b/backend/druks/api/server.py index 1a464f4b..3c4e610c 100644 --- a/backend/druks/api/server.py +++ b/backend/druks/api/server.py @@ -38,7 +38,7 @@ from druks.durable.engine import configure_engine, init_dbos, launch, shutdown from druks.durable.exceptions import AgentCallNotFound from druks.events.routes import router as events_router -from druks.exceptions import ObjectNotFound +from druks.exceptions import ObjectNotFound, ValidationError from druks.files.routes import router as files_router from druks.harnesses.exceptions import AgentConfigError, CatalogError from druks.harnesses.routes import router as providers_router @@ -63,6 +63,7 @@ setup_logging, ) from druks.skills.routes import router as skills_router +from druks.teams.routes import router as teams_router from druks.ui.exceptions import PageReadError from druks.user_settings.routes import agents_router from druks.user_settings.routes import router as settings_router @@ -181,6 +182,11 @@ async def _object_not_found_handler(request: Request, exc: ObjectNotFound) -> JS return await _http_exception_handler(request, HTTPException(404, str(exc))) +@app.exception_handler(ValidationError) +async def _validation_error_handler(request: Request, exc: ValidationError) -> JSONResponse: + return await _http_exception_handler(request, HTTPException(422, str(exc))) + + @app.exception_handler(PageReadError) async def _page_read_error_handler(request: Request, exc: PageReadError) -> JSONResponse: logging.getLogger(__name__).exception("page read failed: %s", exc) @@ -315,6 +321,7 @@ async def _unhandled_exception_handler( app.include_router(notifications_external_router) app.include_router(webhooks_router) app.include_router(auth_router) +app.include_router(teams_router) app.include_router(providers_router) app.include_router(secrets_router) app.include_router(browser_sessions_router) diff --git a/backend/druks/exceptions.py b/backend/druks/exceptions.py index 752cdf49..f3e79ff0 100644 --- a/backend/druks/exceptions.py +++ b/backend/druks/exceptions.py @@ -11,6 +11,10 @@ def __reduce__(self) -> tuple[Any, ...]: return copyreg.__newobj__, (type(self),), {"args": self.args, **vars(self)} +class ValidationError(DruksError): + """A person's input is refused. The message says why.""" + + class DetachedRowError(DruksError): """A row was used outside the session that loaded it.""" diff --git a/backend/druks/migrations/env.py b/backend/druks/migrations/env.py index a418d162..caaadd20 100644 --- a/backend/druks/migrations/env.py +++ b/backend/druks/migrations/env.py @@ -11,6 +11,7 @@ import druks.notifications.models # noqa: F401 import druks.services.models # noqa: F401 import druks.skills.models # noqa: F401 +import druks.teams.models # noqa: F401 import druks.user_settings.models # noqa: F401 from alembic import context from druks.alembic_support import run_alembic_env diff --git a/backend/druks/migrations/versions/1e4a9c2b7d3f_add_teams.py b/backend/druks/migrations/versions/1e4a9c2b7d3f_add_teams.py new file mode 100644 index 00000000..72134950 --- /dev/null +++ b/backend/druks/migrations/versions/1e4a9c2b7d3f_add_teams.py @@ -0,0 +1,40 @@ +"""Add teams. + +Revision ID: 1e4a9c2b7d3f +Revises: f2b6c8d1e4a9 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "1e4a9c2b7d3f" +down_revision: str | Sequence[str] | None = "f2b6c8d1e4a9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "teams", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("apps", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "team_members", + sa.Column("team_id", sa.String(), nullable=False), + sa.Column("account_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("team_id", "account_id"), + ) + + +def downgrade() -> None: + op.drop_table("team_members") + op.drop_table("teams") diff --git a/backend/druks/models.py b/backend/druks/models.py index 344e6c04..6e6a1d1b 100644 --- a/backend/druks/models.py +++ b/backend/druks/models.py @@ -119,20 +119,23 @@ async def delete(self) -> None: await self.session.flush() @classmethod - async def get(cls, **fields: object) -> Self: + async def get(cls, session: AsyncSession | None = None, **fields: object) -> Self: """The one row whose fields hold these values. A miss raises ``ObjectNotFound``, - which a route answers with 404 and a page with an empty state.""" - if row := await cls.get_or_none(**fields): + which a route answers with 404 and a page with an empty state. Platform code + passes its session, and an app leaves it out.""" + if row := await cls.get_or_none(session, **fields): return row raise ObjectNotFound(snake_name(cls.__name__).replace("_", " "), fields) @classmethod - async def get_or_none(cls, **fields: object) -> Self | None: + async def get_or_none( + cls, session: AsyncSession | None = None, **fields: object + ) -> Self | None: """The one row whose fields hold these values, or None. Two rows raise SQLAlchemy's ``MultipleResultsFound``.""" from druks.db import db_session - return (await db_session().scalars(cls._select_matching(fields))).one_or_none() + return (await (session or db_session()).scalars(cls._select_matching(fields))).one_or_none() @classmethod async def all(cls) -> list[Self]: diff --git a/backend/druks/teams/__init__.py b/backend/druks/teams/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/teams/models.py b/backend/druks/teams/models.py new file mode 100644 index 00000000..3247b823 --- /dev/null +++ b/backend/druks/teams/models.py @@ -0,0 +1,93 @@ +import sqlalchemy +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from druks.accounts.enums import AccountKind +from druks.accounts.models import Account +from druks.apps.loader import iter_apps +from druks.core.models import Uuid7Pk +from druks.exceptions import ValidationError +from druks.models import Base, Model + +team_members = sqlalchemy.Table( + "team_members", + Base.metadata, + sqlalchemy.Column( + "team_id", sqlalchemy.ForeignKey("teams.id", ondelete="CASCADE"), primary_key=True + ), + sqlalchemy.Column( + "account_id", + sqlalchemy.ForeignKey("accounts.id", ondelete="RESTRICT"), + primary_key=True, + ), +) + + +class Team(Model, Uuid7Pk, ordering=("name",)): + __tablename__ = "teams" + + name: Mapped[str] = mapped_column(unique=True) + members: Mapped[list[Account]] = relationship( + secondary=team_members, + lazy="selectin", + order_by=(Account.created_at, Account.id), + passive_deletes=True, + ) + apps: Mapped[list[str]] = mapped_column(JSONB, default=list) + + @classmethod + async def list_all(cls, session: AsyncSession) -> list["Team"]: + return list(await session.scalars(sqlalchemy.select(cls).order_by(cls.name))) + + @classmethod + async def create( + cls, session: AsyncSession, *, name: str, member_ids: list[str], apps: list[str] + ) -> "Team": + if await cls.get_or_none(session, name=name): + raise ValidationError(f"A team named {name!r} already exists.") + team = cls( + name=name, + members=await cls._get_members(session, member_ids), + apps=cls._get_apps(apps), + ) + session.add(team) + await session.flush() + return team + + async def replace( + self, session: AsyncSession, *, name: str, member_ids: list[str], apps: list[str] + ) -> None: + """Set the name, the members and the apps.""" + other = await Team.get_or_none(session, name=name) + if other and other.id != self.id: + raise ValidationError(f"A team named {name!r} already exists.") + self.name = name + self.members = await self._get_members(session, member_ids) + self.apps = self._get_apps(apps) + await session.flush() + + @staticmethod + async def _get_members(session: AsyncSession, member_ids: list[str]) -> list[Account]: + """The operator accounts with these ids. Bot and bot admin accounts are refused.""" + operators = await session.scalars( + sqlalchemy.select(Account).where( + Account.id.in_(member_ids), Account.kind == AccountKind.OPERATOR + ) + ) + members = {member.id: member for member in operators} + for member_id in member_ids: + if member_id not in members: + raise ValidationError(f"Account {member_id!r} is not an operator account.") + return list(members.values()) + + @staticmethod + def _get_apps(names: list[str]) -> list[str]: + """The names, each one time. An app must be installed and not built in.""" + installed = {app.name: app for app in iter_apps()} + for name in names: + if name not in installed: + raise ValidationError(f"App {name!r} is not installed.") + if installed[name].builtin: + raise ValidationError(f"App {name!r} is built in.") + return list(dict.fromkeys(names)) diff --git a/backend/druks/teams/routes.py b/backend/druks/teams/routes.py new file mode 100644 index 00000000..b7c7174b --- /dev/null +++ b/backend/druks/teams/routes.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends + +from druks.accounts.dependencies import current_admin_account +from druks.api.dependencies import SessionDep +from druks.teams.models import Team +from druks.teams.schemas import TeamRequest, TeamResponse + +router = APIRouter( + prefix="/api/teams", tags=["teams"], dependencies=[Depends(current_admin_account)] +) + + +@router.get("", response_model=list[TeamResponse], response_model_by_alias=True) +async def list_teams(session: SessionDep) -> list[Team]: + return await Team.list_all(session) + + +@router.post("", status_code=201, response_model=TeamResponse, response_model_by_alias=True) +async def create_team(session: SessionDep, body: TeamRequest) -> Team: + return await Team.create(session, name=body.name, member_ids=body.member_ids, apps=body.apps) + + +@router.put("/{team_id}", response_model=TeamResponse, response_model_by_alias=True) +async def update_team(team_id: str, session: SessionDep, body: TeamRequest) -> Team: + team = await Team.get(session, id=team_id) + await team.replace(session, name=body.name, member_ids=body.member_ids, apps=body.apps) + return team + + +@router.delete("/{team_id}", status_code=204) +async def delete_team(team_id: str, session: SessionDep) -> None: + team = await Team.get(session, id=team_id) + await team.delete() diff --git a/backend/druks/teams/schemas.py b/backend/druks/teams/schemas.py new file mode 100644 index 00000000..71fec2f8 --- /dev/null +++ b/backend/druks/teams/schemas.py @@ -0,0 +1,23 @@ +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + +from druks.accounts.schemas import AccountResponse +from druks.schemas import Schema + +NonBlank = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + + +class TeamRequest(BaseModel): + name: NonBlank + member_ids: list[str] = Field(alias="memberIds") + apps: list[str] + + +class TeamResponse(Schema): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + members: list[AccountResponse] + apps: list[str] diff --git a/backend/druks/testing.py b/backend/druks/testing.py index bd3d95cc..6d673fec 100644 --- a/backend/druks/testing.py +++ b/backend/druks/testing.py @@ -34,6 +34,7 @@ import druks.redis import druks.services.models # noqa: F401 import druks.skills.models # noqa: F401 +import druks.teams.models # noqa: F401 import druks.user_settings.models # noqa: F401 from druks.accounts.context import current_account_id from druks.accounts.models import Account diff --git a/backend/tests/test_auth_boundary.py b/backend/tests/test_auth_boundary.py index f929ce1d..b90673f3 100644 --- a/backend/tests/test_auth_boundary.py +++ b/backend/tests/test_auth_boundary.py @@ -58,6 +58,10 @@ # Shared settings and connections admit an admin from the browser only. ADMIN_API_ROUTES = { + ("GET", "/api/teams"), + ("POST", "/api/teams"), + ("PUT", "/api/teams/{team_id}"), + ("DELETE", "/api/teams/{team_id}"), ("PATCH", "/api/auth/accounts/{account_id}"), ("DELETE", "/api/browser-sessions/{name}"), ("POST", "/api/browser-sessions/{name}/login-window"), diff --git a/backend/tests/test_teams.py b/backend/tests/test_teams.py new file mode 100644 index 00000000..abac47c8 --- /dev/null +++ b/backend/tests/test_teams.py @@ -0,0 +1,148 @@ +from pathlib import Path + +import pytest +from conftest import IDENTITY_HEADER, header_client +from druks.accounts.enums import AccountKind +from druks.accounts.models import Account + + +async def test_admin_creates_lists_and_replaces_a_team(tmp_path: Path, druks_db): + admin = await Account.get_or_create(druks_db, "admin@example.com") + first = await Account.get_or_create(druks_db, "first@example.com") + second = await Account.get_or_create(druks_db, "second@example.com") + headers = {IDENTITY_HEADER: admin.username} + body = { + "name": "Engineering", + "memberIds": [first.id, second.id], + "apps": ["field_notes"], + } + + with header_client(tmp_path) as client: + created = client.post("/api/teams", json=body, headers=headers) + listed = client.get("/api/teams", headers=headers) + + assert created.status_code == 201 + assert created.json()["name"] == "Engineering" + assert {member["username"] for member in created.json()["members"]} == { + first.username, + second.username, + } + assert created.json()["apps"] == ["field_notes"] + assert listed.status_code == 200 + assert listed.json() == [created.json()] + + replaced = client.put( + f"/api/teams/{created.json()['id']}", + json={"name": "Platform", "memberIds": [first.id], "apps": ["field_notes"]}, + headers=headers, + ) + + assert replaced.status_code == 200 + assert replaced.json()["name"] == "Platform" + assert [member["id"] for member in replaced.json()["members"]] == [first.id] + + +async def test_member_is_refused_on_each_team_route(tmp_path: Path, druks_db): + await Account.get_or_create(druks_db, "admin@example.com") + member = await Account.get_or_create(druks_db, "member@example.com") + headers = {IDENTITY_HEADER: member.username} + body = {"name": "Team", "memberIds": [], "apps": []} + + with header_client(tmp_path) as client: + requests = [ + client.get("/api/teams", headers=headers), + client.post("/api/teams", json=body, headers=headers), + client.put("/api/teams/unknown", json=body, headers=headers), + client.delete("/api/teams/unknown", headers=headers), + ] + + assert [response.status_code for response in requests] == [403, 403, 403, 403] + + +async def test_team_names_are_trimmed_unique_and_not_empty(tmp_path: Path, druks_db): + admin = await Account.get_or_create(druks_db, "admin@example.com") + headers = {IDENTITY_HEADER: admin.username} + + with header_client(tmp_path) as client: + first = client.post( + "/api/teams", + json={"name": " Engineering ", "memberIds": [], "apps": []}, + headers=headers, + ) + duplicate = client.post( + "/api/teams", + json={"name": "Engineering", "memberIds": [], "apps": []}, + headers=headers, + ) + empty = client.post( + "/api/teams", + json={"name": " ", "memberIds": [], "apps": []}, + headers=headers, + ) + missing = {"name": "Missing", "memberIds": [], "apps": []} + updated_missing = client.put("/api/teams/unknown", json=missing, headers=headers) + deleted_missing = client.delete("/api/teams/unknown", headers=headers) + + assert first.status_code == 201 + assert first.json()["name"] == "Engineering" + assert duplicate.status_code == 422 + assert empty.status_code == 422 + assert updated_missing.status_code == 404 + assert deleted_missing.status_code == 404 + + +async def test_built_in_and_unknown_apps_are_refused(tmp_path: Path, druks_db): + admin = await Account.get_or_create(druks_db, "admin@example.com") + headers = {IDENTITY_HEADER: admin.username} + + with header_client(tmp_path) as client: + for app_name in ("core", "unknown"): + response = client.post( + "/api/teams", + json={"name": app_name, "memberIds": [], "apps": [app_name]}, + headers=headers, + ) + + assert response.status_code == 422 + + +@pytest.mark.parametrize("kind", [AccountKind.BOT, AccountKind.BOT_ADMIN]) +async def test_bot_accounts_are_refused_as_members(kind: AccountKind, tmp_path: Path, druks_db): + admin = await Account.get_or_create(druks_db, "admin@example.com") + bot = await Account.create_for_bot(druks_db, kind) + + with header_client(tmp_path) as client: + response = client.post( + "/api/teams", + json={"name": "Bots", "memberIds": [bot.id], "apps": []}, + headers={IDENTITY_HEADER: admin.username}, + ) + + assert response.status_code == 422 + + +async def test_deleting_a_team_keeps_its_accounts(tmp_path: Path, druks_db): + admin = await Account.get_or_create(druks_db, "admin@example.com") + member = await Account.get_or_create(druks_db, "member@example.com") + headers = {IDENTITY_HEADER: admin.username} + + with header_client(tmp_path) as client: + created = client.post( + "/api/teams", + json={ + "name": "Engineering", + "memberIds": [admin.id, member.id], + "apps": ["field_notes"], + }, + headers=headers, + ) + deleted = client.delete(f"/api/teams/{created.json()['id']}", headers=headers) + listed = client.get("/api/teams", headers=headers) + + assert created.status_code == 201 + assert deleted.status_code == 204 + assert listed.json() == [] + assert {account.username for account in await Account.list_operators(druks_db)} == { + admin.username, + member.username, + } diff --git a/docs/concepts.md b/docs/concepts.md index ccb3fe1e..9f71a4f3 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -312,6 +312,10 @@ admin. Each later account joins as a member. An admin changes a person's role in **Settings → People**. Only admins can change settings and connections that the installation shares. Druks refuses to remove the last admin. +A team is a named group of people with a list of apps. A person can be on more +than one team, and more than one team can list the same app. An admin manages +teams in **Settings → Teams**. + In `none` mode, Druks has no authentication and exactly one operator account. The first completed provider connection creates this account. Public `/_external` routes stay outside the identity gate. These routes include diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b1fb927e..2516256a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -23,6 +23,8 @@ import type { PersonalSettings, SubjectResponse, SubjectSummary, + Team, + TeamInput, UpdateAppsSettingsRequest, UpdatePersonalSettingsRequest, UpdateSettingsRequest, @@ -307,6 +309,11 @@ export const api = { accounts: () => getJSON('/api/auth/accounts'), setAccountRole: (accountId: string, role: AccountRole) => patchJSON(`/api/auth/accounts/${encodeURIComponent(accountId)}`, { role }), + teams: () => getJSON('/api/teams'), + createTeam: (body: TeamInput) => postJSON('/api/teams', body), + updateTeam: (teamId: string, body: TeamInput) => + postJSON(`/api/teams/${encodeURIComponent(teamId)}`, body, 'PUT'), + deleteTeam: (teamId: string) => deleteRequest(`/api/teams/${encodeURIComponent(teamId)}`), providers: () => getJSON('/api/providers'), providerSubscriptions: () => getJSON('/api/providers/subscriptions'), providerKeys: () => getJSON('/api/providers/keys'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a5161f5f..b7feba87 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -635,6 +635,19 @@ export interface Account { export type AccountRole = 'admin' | 'member' +export interface Team { + id: string + name: string + members: Account[] + apps: string[] +} + +export interface TeamInput { + name: string + memberIds: string[] + apps: string[] +} + /** What /api/auth/me answers: how this deployment authenticates, who the * request resolved to (null in the none/zero setup state), and whether that * identity still needs its first provider credential. */ diff --git a/frontend/src/components/SettingsPages.test.tsx b/frontend/src/components/SettingsPages.test.tsx index 4311ff88..611a431a 100644 --- a/frontend/src/components/SettingsPages.test.tsx +++ b/frontend/src/components/SettingsPages.test.tsx @@ -489,16 +489,26 @@ describe('SettingsPages app fields', () => { expect(await screen.findByRole('heading', { name: 'Services' })).toBeTruthy() expect(screen.queryByRole('link', { name: 'Agents' })).toBeNull() expect(screen.queryByRole('link', { name: 'Skills' })).toBeNull() + expect(screen.queryByRole('link', { name: 'Teams' })).toBeNull() expect(screen.queryByRole('link', { name: 'Browser' })).toBeNull() fireEvent.change(screen.getByLabelText('Search settings'), { target: { value: 'Field Notes' }, }) expect(screen.getByText('No matching settings.')).toBeTruthy() + fireEvent.change(screen.getByLabelText('Search settings'), { + target: { value: 'Teams' }, + }) + expect(screen.getByText('No matching settings.')).toBeTruthy() cleanup() renderSettings('/settings/providers', 'member') await screen.findByRole('heading', { name: 'Providers' }) expect(screen.queryByRole('button', { name: 'Add provider' })).toBeNull() + + cleanup() + renderSettings('/settings/providers') + await screen.findByRole('heading', { name: 'Providers' }) + expect(screen.getByRole('link', { name: 'Teams' })).toBeTruthy() }) it('hides an app settings link from a member', async () => { diff --git a/frontend/src/components/SettingsPages.tsx b/frontend/src/components/SettingsPages.tsx index 86b322c8..90c853ab 100644 --- a/frontend/src/components/SettingsPages.tsx +++ b/frontend/src/components/SettingsPages.tsx @@ -28,6 +28,7 @@ import { harnessColors } from '../lib/harnessColors' import { useThemePreference } from '../lib/preferences' import { Page } from './Page' import { PeoplePane } from './PeoplePane' +import { TeamsPane } from './TeamsPane' import { Sidebar } from './Sidebar' import { BrowserProfilesPane } from './BrowserProfilesPane' import { CallsPane } from './CallsPane' @@ -62,6 +63,7 @@ const SECTIONS = [ { id: 'mcp', label: 'MCP servers', group: 'Tools & access' }, { id: 'skills', label: 'Skills', group: 'Tools & access' }, { id: 'people', label: 'People', group: 'Organization' }, + { id: 'teams', label: 'Teams', group: 'Organization' }, { id: 'personal', label: 'Preferences', group: 'Personal' }, { id: 'api-tokens', label: 'API tokens', group: 'Personal' }, ] @@ -107,7 +109,9 @@ export function SettingsPages({ }) { const [location, navigate] = useLocation() const isAdmin = account.role === 'admin' - const sections = SECTIONS.filter((entry) => isAdmin || !['agents', 'skills'].includes(entry.id)) + const sections = SECTIONS.filter((entry) => + isAdmin || !['agents', 'skills', 'teams'].includes(entry.id), + ) const connectionTabs = CONNECTION_TABS.filter((tab) => isAdmin || tab.id !== 'browser') const searchParams = new URLSearchParams(useSearch()) const fieldTarget = searchParams.get('field') @@ -790,6 +794,7 @@ export function SettingsPages({ {page === 'mcp' && } {isAdmin && page === 'skills' && } {page === 'people' && } + {isAdmin && page === 'teams' && } {page === 'api-tokens' && } {app && hasChannels && validAppPage && paneSection === 'channels' && page === `apps/${app.name}` && diff --git a/frontend/src/components/TeamsPane.test.tsx b/frontend/src/components/TeamsPane.test.tsx new file mode 100644 index 00000000..c86c5e7a --- /dev/null +++ b/frontend/src/components/TeamsPane.test.tsx @@ -0,0 +1,100 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { api } from '../api/client' +import type { Account, App, Team } from '../api/types' +import { TeamsPane } from './TeamsPane' + +const admin: Account = { + id: 'admin', + username: 'admin@example.com', + isDefault: true, + role: 'admin', +} +const member: Account = { + id: 'member', + username: 'member@example.com', + isDefault: false, + role: 'member', +} +const fieldNotes: App = { + name: 'field_notes', + icon: 'box', + description: 'Field notes', + builtin: false, + subjectTypes: [], + hasFrontend: false, + navigation: [], + pages: [], + operations: [], +} + +function renderPane() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('TeamsPane', () => { + it('shows a team with its people and apps', async () => { + vi.spyOn(api, 'teams').mockResolvedValue([ + { id: 'team-1', name: 'Engineering', members: [admin, member], apps: ['field_notes'] }, + ]) + vi.spyOn(api, 'accounts').mockResolvedValue([admin, member]) + vi.spyOn(api, 'listApps').mockResolvedValue([fieldNotes]) + + renderPane() + + expect(await screen.findByText('Engineering')).toBeTruthy() + expect(screen.getByText('admin@example.com')).toBeTruthy() + expect(screen.getByText('member@example.com')).toBeTruthy() + expect(screen.getByText('Field Notes')).toBeTruthy() + }) + + it('creates a team with the chosen person and app', async () => { + let teams: Team[] = [] + const list = vi.spyOn(api, 'teams').mockImplementation(async () => teams) + vi.spyOn(api, 'accounts').mockResolvedValue([admin, member]) + vi.spyOn(api, 'listApps').mockResolvedValue([ + { ...fieldNotes, name: 'core', builtin: true }, + fieldNotes, + ]) + const create = vi.spyOn(api, 'createTeam').mockImplementation(async (body) => { + const team = { + id: 'team-1', + name: body.name, + members: [member], + apps: body.apps, + } + teams = [team] + return team + }) + + renderPane() + fireEvent.click(await screen.findByRole('button', { name: 'Add team' })) + fireEvent.change(screen.getByLabelText('Team name'), { target: { value: 'Reviewers' } }) + fireEvent.click(await screen.findByRole('button', { name: 'member@example.com' })) + expect(screen.queryByRole('button', { name: 'Core' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Field Notes' })) + fireEvent.click(screen.getByRole('button', { name: 'Create team' })) + + await waitFor(() => + expect(create).toHaveBeenCalledWith({ + name: 'Reviewers', + memberIds: ['member'], + apps: ['field_notes'], + }), + ) + expect(await screen.findByText('Reviewers')).toBeTruthy() + expect(list).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/components/TeamsPane.tsx b/frontend/src/components/TeamsPane.tsx new file mode 100644 index 00000000..94c36d1f --- /dev/null +++ b/frontend/src/components/TeamsPane.tsx @@ -0,0 +1,263 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' + +import { api } from '../api/client' +import { appLabel } from '../apps/registry' +import type { Team, TeamInput } from '../api/types' +import { Button, Field, TextInput } from './Control' +import { Switch } from './SettingsPanes' + +const EMPTY_TEAM: TeamInput = { name: '', memberIds: [], apps: [] } + +export function TeamsPane() { + const queryClient = useQueryClient() + const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: api.teams }) + const accountsQuery = useQuery({ queryKey: ['accounts'], queryFn: api.accounts }) + const appsQuery = useQuery({ queryKey: ['apps'], queryFn: api.listApps, staleTime: 60_000 }) + const [draft, setDraft] = useState(null) + const [editingTeamId, setEditingTeamId] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const teams = teamsQuery.data ?? [] + const apps = (appsQuery.data ?? []).filter((app) => !app.builtin) + + function addTeam() { + setDraft({ ...EMPTY_TEAM }) + setEditingTeamId(null) + setError(null) + } + + function editTeam(team: Team) { + setDraft({ + name: team.name, + memberIds: team.members.map((member) => member.id), + apps: [...team.apps], + }) + setEditingTeamId(team.id) + setError(null) + } + + function chooseMember(memberId: string, on: boolean) { + setDraft((current) => + current + ? { + ...current, + memberIds: on + ? [...current.memberIds, memberId] + : current.memberIds.filter((id) => id !== memberId), + } + : current, + ) + } + + function chooseApp(appName: string, on: boolean) { + setDraft((current) => + current + ? { + ...current, + apps: on + ? [...current.apps, appName] + : current.apps.filter((name) => name !== appName), + } + : current, + ) + } + + async function saveTeam(team: TeamInput) { + setBusy(true) + setError(null) + try { + if (editingTeamId) await api.updateTeam(editingTeamId, team) + else await api.createTeam(team) + await queryClient.invalidateQueries({ queryKey: ['teams'] }) + setDraft(null) + setEditingTeamId(null) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setBusy(false) + } + } + + async function deleteTeam(team: Team) { + if (window.confirm(`Delete team ${team.name}?`)) { + setBusy(true) + setError(null) + try { + await api.deleteTeam(team.id) + await queryClient.invalidateQueries({ queryKey: ['teams'] }) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setBusy(false) + } + } + } + + return ( +
+
+

A team is a group of people with a list of apps.

+
+ +
+
+ + {teamsQuery.isPending &&

Loading teams…

} + {teamsQuery.isError && ( +

+ Could not load teams.{' '} + +

+ )} + {error && ( +
+ {error} +
+ )} + + {draft && ( +
+ + setDraft({ ...draft, name: event.target.value })} + disabled={busy} + /> + + {(accountsQuery.isError || appsQuery.isError) && ( +

+ Could not load the people and the apps.{' '} + +

+ )} + {(accountsQuery.isPending || appsQuery.isPending) && ( +

Loading people and apps…

+ )} + {accountsQuery.isSuccess && ( + +
+ {accountsQuery.data.map((account) => { + const on = draft.memberIds.includes(account.id) + return ( +
+
+ {account.username} +
+ chooseMember(account.id, !on)} + disabled={busy} + label={account.username} + /> +
+ ) + })} +
+
+ )} + {appsQuery.isSuccess && ( + +
+ {apps.map((app) => { + const label = appLabel(app.name) + const on = draft.apps.includes(app.name) + return ( +
+
+ {label} +
+ chooseApp(app.name, !on)} + disabled={busy} + label={label} + /> +
+ ) + })} +
+
+ )} +
+ + +
+
+ )} + + {teamsQuery.isSuccess && teams.length === 0 &&

There are no teams yet.

} + {teams.length > 0 && ( + + + + + + + + + + + {teams.map((team) => ( + + + + + + + ))} + +
TeamPeopleAppsActions
+ {team.name} + + {team.members.map((member) => ( + {member.username} + ))} + {team.members.length === 0 && 'None'} + + {team.apps.map((name) => ( + {appLabel(name)} + ))} + {team.apps.length === 0 && 'None'} + +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/settings.css b/frontend/src/settings.css index 43e22df7..fd10f998 100644 --- a/frontend/src/settings.css +++ b/frontend/src/settings.css @@ -108,6 +108,11 @@ .connections-table tbody th { font-weight: 400; } .connections-table td { color: var(--druks-color-text-secondary); } .connections-table .set-select { width: auto; } +.teams-table thead th:first-child { width: 22%; } +.teams-table thead th:nth-child(2) { width: auto; } +.teams-table thead th:nth-child(3) { width: 20%; } +.teams-table thead th:last-child { width: 180px; } +.teams-table td span { display: block; } .connection-name { display: block; color: var(--druks-color-text-primary); font-weight: 500; } .connection-context { display: block; margin-top: 6px; color: var(--druks-color-text-tertiary); font-size: 13px; } .connection-actions .set-btn { white-space: nowrap; }