From c4ca73e2732ae8b2ddebe53f43390507e90ca128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 6 Sep 2026 11:36:08 +0100 Subject: [PATCH] fix(tasks): hide human assignee when auth is disabled (#3417) --- .../components/kanban-card-assignee.test.tsx | 27 +++- .../components/kanban-card-content.test.tsx | 6 + apps/web/components/kanban-card-content.tsx | 10 +- .../task/simple/task-properties.test.tsx | 84 +++++++++++ .../task/simple/task-properties.tsx | 5 +- .../task/task-assignee-control.test.tsx | 24 +++- .../components/task/task-assignee-control.tsx | 4 +- .../e2e/tests/office/property-pickers.spec.ts | 13 ++ .../task/human-assignee-auth-gate.spec.ts | 30 ++++ apps/web/lib/auth/human-assignee.ts | 5 + docs/plans/human-assignee-auth-gate/plan.md | 133 ++++++++++++++++++ .../task-01-gate-human-assignee-surfaces.md | 105 ++++++++++++++ docs/specs/tasks/README.md | 1 + .../tasks/requirements/human-assignee.md | 3 + .../tasks/system-design/human-assignee.md | 103 ++++++++++++++ 15 files changed, 541 insertions(+), 12 deletions(-) create mode 100644 apps/web/components/task/simple/task-properties.test.tsx create mode 100644 apps/web/e2e/tests/task/human-assignee-auth-gate.spec.ts create mode 100644 apps/web/lib/auth/human-assignee.ts create mode 100644 docs/plans/human-assignee-auth-gate/plan.md create mode 100644 docs/plans/human-assignee-auth-gate/task-01-gate-human-assignee-surfaces.md create mode 100644 docs/specs/tasks/system-design/human-assignee.md diff --git a/apps/web/components/kanban-card-assignee.test.tsx b/apps/web/components/kanban-card-assignee.test.tsx index 76dfb97f66..55aa76dbd7 100644 --- a/apps/web/components/kanban-card-assignee.test.tsx +++ b/apps/web/components/kanban-card-assignee.test.tsx @@ -2,6 +2,12 @@ import { render, screen, cleanup, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; const mockAppState = { + auth: { + mode: "enabled", + authenticated: true, + user: { id: "user-2" } as { id: string } | null, + ssoProviders: [], + }, workspaces: { activeId: "ws-1" }, kanban: { tasks: [] as Array<{ id: string; title: string }> }, kanbanMulti: { snapshots: {} as Record }> }, @@ -35,11 +41,14 @@ import { KanbanCardBody } from "./kanban-card-content"; import type { Task } from "./kanban-card"; const TASK: Task = { id: "task-1", title: "Fix the bug", workflowStepId: "step-1" }; +const ASSIGNEE_BADGE = "kanban-card-assignee"; afterEach(() => { cleanup(); vi.clearAllMocks(); resetDirectoryCacheForTests(); + mockAppState.auth.mode = "enabled"; + mockAppState.auth.user = { id: "user-2" }; }); describe("KanbanCardBody — human assignee", () => { @@ -47,13 +56,25 @@ describe("KanbanCardBody — human assignee", () => { render(); await waitFor(() => - expect(screen.getByTestId("kanban-card-assignee").textContent).toContain("Ada Lovelace"), + expect(screen.getByTestId(ASSIGNEE_BADGE).textContent).toContain("Ada Lovelace"), ); }); it("renders no assignee affordance on an unassigned task", () => { render(); - expect(screen.queryByTestId("kanban-card-assignee")).toBeNull(); + expect(screen.queryByTestId(ASSIGNEE_BADGE)).toBeNull(); + }); + + it("hides a persisted assignee for the synthetic user when authentication is disabled", () => { + mockAppState.auth.mode = "disabled"; + mockAppState.auth.user = { id: "default-user" }; + + render( + , + ); + + expect(screen.queryByTestId(ASSIGNEE_BADGE)).toBeNull(); + expect(listDirectoryUsers).not.toHaveBeenCalled(); }); it("fetches the directory once for a board full of cards", async () => { @@ -71,7 +92,7 @@ describe("KanbanCardBody — human assignee", () => { , ); - await waitFor(() => expect(screen.getAllByTestId("kanban-card-assignee")).toHaveLength(3)); + await waitFor(() => expect(screen.getAllByTestId(ASSIGNEE_BADGE)).toHaveLength(3)); expect(listDirectoryUsers).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/components/kanban-card-content.test.tsx b/apps/web/components/kanban-card-content.test.tsx index 1194482c92..11c8b2de0f 100644 --- a/apps/web/components/kanban-card-content.test.tsx +++ b/apps/web/components/kanban-card-content.test.tsx @@ -2,6 +2,12 @@ import { render, screen, cleanup } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; const mockAppState = { + auth: { + mode: "enabled", + authenticated: true, + user: { id: "user-1" }, + ssoProviders: [], + }, workspaces: { activeId: "ws-1" }, kanban: { tasks: [] as Array<{ id: string; title: string; parentTaskId?: string }> }, kanbanMulti: { snapshots: {} as Record }> }, diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index 11524b76b5..e549627953 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -40,6 +40,7 @@ import { } from "@/lib/ui/state-icons"; import { cn } from "@/lib/utils"; import { needsAction } from "@/lib/utils/needs-action"; +import { canShowHumanAssignee } from "@/lib/auth/human-assignee"; import type { RepositoryChip, Task } from "@/components/kanban-card"; const kanbanStatusDebug = createDebugLogger("kanban:task-status"); @@ -145,14 +146,15 @@ function KanbanCardRelationship({ task }: { task: Task }) { function KanbanCardBadges({ task }: { task: Task }) { const { t } = useTranslation(); - const showRow = hasCardBadges(task); + const showHumanAssignee = useAppStore((s) => canShowHumanAssignee(s.auth)); + const showRow = hasCardBadges(task, showHumanAssignee); if (!showRow) return null; return (
{task.blocked && } - {task.assigneeUserId && } + {showHumanAssignee && task.assigneeUserId && } {task.queuedForStepId && ( 1) || task.reviewStatus === "changes_requested" || task.reviewStatus === "pending" || task.queuedForStepId || - task.assigneeUserId || + (showHumanAssignee && task.assigneeUserId) || task.blocked, ); } diff --git a/apps/web/components/task/simple/task-properties.test.tsx b/apps/web/components/task/simple/task-properties.test.tsx new file mode 100644 index 0000000000..fe74bcef18 --- /dev/null +++ b/apps/web/components/task/simple/task-properties.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Task } from "@/app/office/tasks/[id]/types"; +import { TaskProperties } from "./task-properties"; + +const mockAppState = vi.hoisted(() => ({ + auth: { + mode: "enabled", + authenticated: true, + user: { id: "user-1" } as { id: string } | null, + ssoProviders: [], + }, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState), +})); + +vi.mock("./components/status-picker", () => ({ StatusPicker: () => null })); +vi.mock("./components/priority-picker", () => ({ PriorityPicker: () => null })); +vi.mock("./components/labels-picker", () => ({ LabelsPicker: () => null })); +vi.mock("./components/assignee-picker", () => ({ AssigneePicker: () => null })); +vi.mock("./components/human-assignee-picker", () => ({ + HumanAssigneePicker: () => , +})); +vi.mock("./components/project-picker", () => ({ ProjectPicker: () => null })); +vi.mock("./components/parent-picker", () => ({ ParentPicker: () => null })); +vi.mock("./components/blockers-picker", () => ({ BlockersPicker: () => null })); +vi.mock("./components/sub-issues-row", () => ({ SubIssuesRow: () => null })); +vi.mock("./components/reviewers-picker", () => ({ ReviewersPicker: () => null })); +vi.mock("./components/approvers-picker", () => ({ ApproversPicker: () => null })); +vi.mock("./components/pending-approval-badge", () => ({ PendingApprovalBadge: () => null })); +vi.mock("./components/quorum-status-badge", () => ({ QuorumStatusBadge: () => null })); + +const TASK = { + id: "task-1", + workspaceId: "workspace-1", + identifier: "TASK-1", + title: "Auth gate task", + status: "todo", + priority: "medium", + labels: [], + blockedBy: [], + blocking: [], + children: [], + reviewers: [], + approvers: [], + decisions: [], + createdBy: "user-1", + createdAt: "2026-09-05T12:00:00Z", + updatedAt: "2026-09-05T12:00:00Z", +} satisfies Task; + +afterEach(() => { + cleanup(); + mockAppState.auth.mode = "enabled"; + mockAppState.auth.user = { id: "user-1" }; +}); + +describe("TaskProperties human assignee", () => { + it.each(["disabled", "setup"] as const)("hides the row in %s auth mode", (mode) => { + mockAppState.auth.mode = mode; + mockAppState.auth.user = { id: "default-user" }; + + render(); + + expect(screen.queryByText("Assigned to", { exact: true })).toBeNull(); + }); + + it("hides the row when enabled auth has no signed-in user", () => { + mockAppState.auth.user = null; + + render(); + + expect(screen.queryByText("Assigned to", { exact: true })).toBeNull(); + }); + + it("shows the row when authentication is enabled for a signed-in user", () => { + render(); + + expect(screen.getByText("Assigned to", { exact: true })).toBeTruthy(); + expect(screen.getByTestId("human-assignee-picker-trigger")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/simple/task-properties.tsx b/apps/web/components/task/simple/task-properties.tsx index bd523f23f5..c37284a98b 100644 --- a/apps/web/components/task/simple/task-properties.tsx +++ b/apps/web/components/task/simple/task-properties.tsx @@ -21,6 +21,7 @@ import { PendingApprovalBadge } from "./components/pending-approval-badge"; import { QuorumStatusBadge } from "./components/quorum-status-badge"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; +import { canShowHumanAssignee } from "@/lib/auth/human-assignee"; type TaskPropertiesProps = { task: Task; @@ -60,8 +61,8 @@ function NoneLabel() { // user, so the control would offer a choice that cannot mean anything. function HumanAssigneeRow({ task }: { task: Task }) { const { t } = useTranslation(); - const hasIdentity = useAppStore((s) => Boolean(s.auth.user)); - if (!hasIdentity) return null; + const showHumanAssignee = useAppStore((s) => canShowHumanAssignee(s.auth)); + if (!showHumanAssignee) return null; return ( diff --git a/apps/web/components/task/task-assignee-control.test.tsx b/apps/web/components/task/task-assignee-control.test.tsx index 0c64b5cfa8..baca5005a8 100644 --- a/apps/web/components/task/task-assignee-control.test.tsx +++ b/apps/web/components/task/task-assignee-control.test.tsx @@ -28,6 +28,7 @@ vi.mock("@/lib/api/domains/team-access-api", () => ({ })); import { updateTask } from "@/lib/api/domains/kanban-api"; +import { listDirectoryUsers, listWorkspaceMembers } from "@/lib/api/domains/team-access-api"; afterEach(() => { cleanup(); @@ -76,6 +77,23 @@ function renderControl(assigneeUserId?: string, authenticated = true) { ); } +function renderControlWithDisabledSyntheticUser(assigneeUserId?: string) { + const state = stateWith(assigneeUserId); + state.auth.mode = "disabled"; + state.auth.user = { + id: "default-user", + email: "", + display_name: "", + role: "admin", + status: "active", + }; + return render( + + + , + ); +} + describe("TaskAssigneeControl", () => { it("shows the assignee's name from the store, not a raw user id", async () => { renderControl("user-1"); @@ -105,8 +123,10 @@ describe("TaskAssigneeControl", () => { await waitFor(() => expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "" })); }); - it("renders nothing when authentication is disabled", () => { - renderControl(undefined, false); + it("renders nothing for the synthetic user when authentication is disabled", () => { + renderControlWithDisabledSyntheticUser("default-user"); expect(screen.queryByTestId(CONTROL)).toBeNull(); + expect(listDirectoryUsers).not.toHaveBeenCalled(); + expect(listWorkspaceMembers).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/components/task/task-assignee-control.tsx b/apps/web/components/task/task-assignee-control.tsx index 16c90b4fc4..1a22da5b59 100644 --- a/apps/web/components/task/task-assignee-control.tsx +++ b/apps/web/components/task/task-assignee-control.tsx @@ -9,6 +9,7 @@ import { Combobox, type ComboboxOption } from "@/components/combobox"; import { useAppStore } from "@/components/state-provider"; import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people"; import { updateTask } from "@/lib/api/domains/kanban-api"; +import { canShowHumanAssignee } from "@/lib/auth/human-assignee"; import { initialsFor } from "@/lib/user-initials"; // i18n-exempt: sentinels compared with ===, never displayed. @@ -35,6 +36,7 @@ type Props = { */ export function TaskAssigneeControl({ taskId, workspaceId, isArchived }: Props) { const { t } = useTranslation(); + const showHumanAssignee = useAppStore((s) => canShowHumanAssignee(s.auth)); const currentUser = useAppStore((s) => s.auth.user); // Read from the store rather than through props: `task.updated` lands the // new assignee there, so another person taking the task over shows up here @@ -42,7 +44,7 @@ export function TaskAssigneeControl({ taskId, workspaceId, isArchived }: Props) const assigneeUserId = useAppStore( (s) => s.kanban.tasks.find((entry: { id: string }) => entry.id === taskId)?.assigneeUserId, ); - const enabled = Boolean(currentUser) && Boolean(taskId) && !isArchived; + const enabled = showHumanAssignee && Boolean(taskId) && !isArchived; const { people, nameFor } = useAssignablePeople(workspaceId, { enabled }); const [pending, setPending] = useState(false); diff --git a/apps/web/e2e/tests/office/property-pickers.spec.ts b/apps/web/e2e/tests/office/property-pickers.spec.ts index bbf9174416..5a1803c18f 100644 --- a/apps/web/e2e/tests/office/property-pickers.spec.ts +++ b/apps/web/e2e/tests/office/property-pickers.spec.ts @@ -30,6 +30,19 @@ function expectedInitials(name: string): string { } test.describe("property pickers", () => { + test("human assignee is hidden when authentication is disabled", async ({ + testPage, + apiClient, + officeSeed, + }) => { + const task = await apiClient.createTask(officeSeed.workspaceId, "Hidden Human Assignee", { + workflow_id: officeSeed.workflowId, + }); + await gotoTaskPage(testPage, task.id, "Hidden Human Assignee"); + + await expect(testPage.getByText("Assigned to", { exact: true })).toHaveCount(0); + }); + test("status picker updates task status and persists", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/task/human-assignee-auth-gate.spec.ts b/apps/web/e2e/tests/task/human-assignee-auth-gate.spec.ts new file mode 100644 index 0000000000..7baedae4c1 --- /dev/null +++ b/apps/web/e2e/tests/task/human-assignee-auth-gate.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { KanbanPage } from "../../pages/kanban-page"; + +useRegularMode(); + +test("auth-disabled installs hide human assignee controls and indicators", async ({ + testPage, + apiClient, + seedData, +}) => { + const task = await apiClient.createTask(seedData.workspaceId, "Hidden Human Assignee", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const response = await apiClient.rawRequest("PATCH", `/api/v1/tasks/${task.id}`, { + assignee_user_id: "default-user", + }); + expect(response.ok).toBe(true); + + await testPage.goto(`/t/${task.id}`); + await expect(testPage.getByTestId("task-topbar-title")).toHaveText("Hidden Human Assignee"); + await expect(testPage.getByTestId("task-assignee-control")).toHaveCount(0); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const card = kanban.taskCard(task.id); + await expect(card).toBeVisible(); + await expect(card.getByTestId("kanban-card-assignee")).toHaveCount(0); +}); diff --git a/apps/web/lib/auth/human-assignee.ts b/apps/web/lib/auth/human-assignee.ts new file mode 100644 index 0000000000..ac521a4a83 --- /dev/null +++ b/apps/web/lib/auth/human-assignee.ts @@ -0,0 +1,5 @@ +import type { AuthSliceState } from "@/lib/state/slices/auth/types"; + +export function canShowHumanAssignee(auth: AuthSliceState["auth"]): boolean { + return auth.mode === "enabled" && auth.user !== null; +} diff --git a/docs/plans/human-assignee-auth-gate/plan.md b/docs/plans/human-assignee-auth-gate/plan.md new file mode 100644 index 0000000000..a2e8189be4 --- /dev/null +++ b/docs/plans/human-assignee-auth-gate/plan.md @@ -0,0 +1,133 @@ +--- +created: 2026-09-05 +status: complete +requirements: + - REQ-TASKS-HUMAN-ASSIGNEE-001 +system_design: + - ../../specs/tasks/system-design/human-assignee.md +legacy_specs: [] +--- + +# Implementation Plan: Human Assignee Authentication Gate + +## Overview + +Hide the human-assignee feature unless authentication is enabled and a real +user is present. Apply one auth-mode rule to the task topbar, the Office +property row, and kanban card indicators. + +The backend intentionally returns a synthetic `default-user` when +authentication is disabled. The affected components test only for user +presence, so they expose a team feature on single-user installations. + +## Scope + +### In scope + +- Gate every human-assignee control and indicator on enabled authentication. +- Preserve the current enabled-mode assignment and takeover behavior. +- Avoid directory requests when the feature is hidden. +- Add component and browser regression coverage for the real disabled-mode + auth payload. + +### Out of scope + +- Changes to authentication mode resolution or the synthetic identity. +- Changes to assignment storage, APIs, events, or workspace-reach rules. +- Clearing assignments when authentication is disabled. +- Changes to task-write permissions or viewer behavior. +- Adding a human-assignee control to the mobile task topbar. + +## Technical approach + +### Shared exposure rule + +Use `auth.mode === "enabled"` and a non-null `auth.user` as the exposure +condition. Do not use `auth.authenticated` because it is true in disabled +mode. + +### Task topbar and Office properties + +Update `TaskAssigneeControl` in +`apps/web/components/task/task-assignee-control.tsx`. Return no content before +the directory hook becomes active when the exposure condition is false. + +Update `HumanAssigneeRow` in +`apps/web/components/task/simple/task-properties.tsx`. Hide the complete +property row in disabled, setup, and anonymous states. + +### Kanban card indicators + +Update `KanbanCardBadges` and `hasCardBadges` in +`apps/web/components/kanban-card-content.tsx`. Exclude the human-assignee +badge and its row contribution when the exposure condition is false. + +Keep `AssigneeBadge` presentational. It shall not own auth-mode decisions or +request directory data for a hidden badge. + +## Tests + +- Extend + `apps/web/components/task/task-assignee-control.test.tsx` with the synthetic + disabled-mode payload. The control stays absent and directory APIs are not + called. +- Add focused coverage for `HumanAssigneeRow` in + `apps/web/components/task/simple/task-properties.test.tsx`. Disabled and + setup modes hide the complete row. Enabled mode retains the picker. +- Extend `apps/web/components/kanban-card-assignee.test.tsx`. A persisted + assignee remains hidden in disabled mode and visible in enabled mode. +- Keep the existing enabled-mode assignment tests unchanged. + +## E2E tests + +- Add `apps/web/e2e/tests/task/human-assignee-auth-gate.spec.ts`. Use the + default disabled-auth fixture and a task assigned to `default-user`. +- Open the task page and prove that `task-assignee-control` is absent. +- Open the kanban board and prove that `kanban-card-assignee` is absent. +- Extend `apps/web/e2e/tests/office/property-pickers.spec.ts`. Prove that the + human-assignee property row is absent on the disabled-auth fixture. + +These tests cover `AC-TASKS-HUMAN-ASSIGNEE-001.9` through the user interface. + +## Mobile parity + +The task-topbar control is desktop-only. The mobile task topbar has no matching +control, so this change introduces no new mobile surface. + +The Office property row uses one component at every viewport. The auth gate +runs before its shared presentation. No layout, touch target, scroll owner, or +safe-area behavior changes. + +Focused component tests and browser regressions cover the shared gate. A mobile +Playwright test would repeat the same condition without a distinct mobile code +path, so this change does not add one. + +## Work orders + +- [x] [Task 01: Gate human-assignee surfaces](task-01-gate-human-assignee-surfaces.md) + +## Verification results + +- Added one shared auth-mode exposure rule for all human-assignee surfaces. +- Added component coverage for disabled, setup, anonymous, and enabled states. +- Added browser coverage for the task topbar, kanban card, and Office property + row on an authentication-disabled installation. +- Passed 27 focused component tests, both focused browser tests, TypeScript, + frontend lint, specification validation, and `git diff --check`. +- Addressed review feedback by making workorder commands root-safe, including + the kanban content test, asserting complete Office-row absence, and using + strict task-title locators. + +## Risks + +- A user-presence check can reintroduce the bug because disabled mode supplies + a synthetic user. +- A hidden card badge can still create an empty badge row unless + `hasCardBadges` uses the same exposure condition. +- The fix must not erase an existing assignment when auth mode changes. + +## Documentation + +No public documentation change is required. +`docs/public/team-access.md` already says that authentication-disabled +installations hide this feature. diff --git a/docs/plans/human-assignee-auth-gate/task-01-gate-human-assignee-surfaces.md b/docs/plans/human-assignee-auth-gate/task-01-gate-human-assignee-surfaces.md new file mode 100644 index 0000000000..513ec3415a --- /dev/null +++ b/docs/plans/human-assignee-auth-gate/task-01-gate-human-assignee-surfaces.md @@ -0,0 +1,105 @@ +--- +id: "01-gate-human-assignee-surfaces" +title: "Gate human-assignee surfaces" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-HUMAN-ASSIGNEE-001 +acceptance_criteria: + - AC-TASKS-HUMAN-ASSIGNEE-001.9 +system_design: + - ../../specs/tasks/system-design/human-assignee.md +--- + +# Task 01: Gate Human-Assignee Surfaces + +## Summary + +Use the auth mode to hide human-assignee controls and indicators outside +enabled authentication. Preserve the stored assignment and all enabled-mode +behavior. + +## In scope + +- Add failing component tests for the real synthetic disabled-mode state. +- Gate the task-topbar picker and Office property row. +- Gate the kanban card indicator without leaving an empty badge row. +- Add focused desktop Playwright coverage for task, board, and Office surfaces. + +## Out of scope + +- Backend, database, API, event, and migration changes. +- Permission-model changes. +- New mobile assignment interactions. +- Public documentation changes. + +## Acceptance + +- Disabled, setup, and anonymous auth states show no human-assignee controls or + indicators. +- Enabled auth with a real user preserves existing controls, assignment + actions, and card indicators. +- Hidden surfaces do not request user-directory or workspace-member data. +- Auth-mode changes never clear the stored human-assignee value. + +## Verification + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps && pnpm --filter @kandev/web test components/task/task-assignee-control.test.tsx components/task/simple/task-properties.test.tsx components/kanban-card-assignee.test.tsx components/kanban-card-content.test.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps && pnpm --filter @kandev/web lint) +(cd apps/web && pnpm e2e:run tests/task/human-assignee-auth-gate.spec.ts) +(cd apps/web && pnpm e2e:run tests/office/property-pickers.spec.ts -- --grep "human assignee") +git diff --check +``` + +## Files likely touched + +- `apps/web/components/task/task-assignee-control.tsx` +- `apps/web/components/task/task-assignee-control.test.tsx` +- `apps/web/components/task/simple/task-properties.tsx` +- `apps/web/components/task/simple/task-properties.test.tsx` +- `apps/web/components/kanban-card-content.tsx` +- `apps/web/components/kanban-card-assignee.test.tsx` +- `apps/web/components/kanban-card-content.test.tsx` +- `apps/web/lib/auth/human-assignee.ts` +- `apps/web/e2e/tests/task/human-assignee-auth-gate.spec.ts` +- `apps/web/e2e/tests/office/property-pickers.spec.ts` + +## Dependencies + +None. + +## Risks + +- The kanban badge-row predicate can diverge from the badge render condition. +- Test fixtures can hide the bug if disabled mode uses `user: null` instead + of the synthetic user. + +## Parallelism + +`sequential` + +## Inputs + +- `docs/specs/tasks/requirements/human-assignee.md` +- `docs/specs/tasks/system-design/human-assignee.md` +- `docs/decisions/2026-07-24-opt-in-authentication.md` +- `docs/public/team-access.md` +- Existing topbar, Office property, and kanban card assignee tests. + +## Results + +- Added `canShowHumanAssignee` as the shared enabled-auth and real-user gate. +- Gated the task topbar, Office property row, card indicator, and card badge-row + predicate without changing persisted assignment data. +- Added the real synthetic disabled-mode state to component tests. +- Added focused task, board, and Office browser regressions. +- Verification passed: 27 component tests, two Playwright tests, TypeScript, + frontend lint, specification validation, and whitespace checks. +- Addressed review feedback by making verification commands root-safe, covering + the kanban content test, asserting complete Office-row absence, and using a + strict task-topbar title locator. diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index 5f557c1a92..b83ef9f68b 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -159,6 +159,7 @@ signals, and task-scoped scheduling contracts. - [Workflow Step Fixed-Profile Routing](system-design/workflow-step-fixed-profile-routing.md) - [Workflow Profile Session Lifecycle](system-design/workflow-profile-session-lifecycle.md) - [Workflow task-step transition ledger](system-design/workflow-task-step-transition-ledger.md) +- [Human Assignee](system-design/human-assignee.md) - [Task Create Agent Compatibility Recovery](system-design/task-create-agent-executor-compatibility.md) ## Migration record diff --git a/docs/specs/tasks/requirements/human-assignee.md b/docs/specs/tasks/requirements/human-assignee.md index 6a4bccc751..c18a6ad364 100644 --- a/docs/specs/tasks/requirements/human-assignee.md +++ b/docs/specs/tasks/requirements/human-assignee.md @@ -61,6 +61,9 @@ that I can continue it without waiting for them to hand it off. including when the assignee is cleared. - **AC-TASKS-HUMAN-ASSIGNEE-001.8:** The system shall treat the human assignee as advisory, granting and withholding no permission on the basis of it. +- **AC-TASKS-HUMAN-ASSIGNEE-001.9:** When authentication is not enabled, the + web interface shall hide all human-assignee controls and indicators. It shall + not clear a stored assignment. ### REQ-TASKS-HUMAN-ASSIGNEE-002: Actor attribution diff --git a/docs/specs/tasks/system-design/human-assignee.md b/docs/specs/tasks/system-design/human-assignee.md new file mode 100644 index 0000000000..971fc2c615 --- /dev/null +++ b/docs/specs/tasks/system-design/human-assignee.md @@ -0,0 +1,103 @@ +--- +status: draft +system: tasks +requirements: + - REQ-TASKS-HUMAN-ASSIGNEE-001 +--- + +# Human Assignee System Design + +## Purpose and boundaries + +The task system owns the human-assignee value and its assignment flow. The +human assignee is independent of the agent profile that runs the task. + +Authentication supplies the current mode and user identity. Organization units +define whether a selected user can reach the task workspace. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-TASKS-HUMAN-ASSIGNEE-001` | [Data and contracts](#data-and-contracts), [Assignment flow](#assignment-flow), [Interface exposure](#interface-exposure) | + +## Data and contracts + +`models.Task.AssigneeUserID` stores the human assignee separately from +`AssigneeAgentProfileID`. An empty value means that the task has no human +assignee. + +The task HTTP and WebSocket update requests accept `assignee_user_id`. The +Office task update route delegates this field to the task service. + +Task DTOs, the boot payload, and `task.updated` events carry the field to +connected clients. Events include an explicit empty value when an assignment is +cleared. + +## Assignment flow + +The task service requires `task.write` for an assignment. The service accepts +self-assignment and reassignment without a confirmation step. + +The service refuses a target user who cannot reach the task workspace. An +assignment does not change the agent, session, worktree, executor, or current +agent turn. + +The frontend gets display names from the user directory and workspace member +list. The backend remains the authority for workspace reach. + +## Interface exposure + +The frontend auth state mirrors the backend auth payload. Authentication has +three modes: `disabled`, `setup`, and `enabled`. + +Disabled mode intentionally provides an authenticated synthetic +`default-user`. Therefore, user presence and `authenticated` are not valid +tests for multi-user feature exposure. + +Human-assignee controls and indicators are available only when +`auth.mode === "enabled"` and `auth.user` exists. All other states hide +these surfaces. + +This gate applies to the task-topbar picker, the Office task property row, and +kanban card indicators. Hidden surfaces do not request directory data. + +The gate changes presentation only. It does not clear `assignee_user_id` or +change the disabled-mode synthetic identity used by backend services. + +The task topbar is a desktop surface. The Office property row uses the same +auth gate at each viewport, so this correction adds no mobile interaction or +layout. + +## Failure and recovery + +An absent or unknown auth state hides the feature. A later valid enabled state +lets React render the controls from the same stored task data. + +Directory or member-list errors retain the existing fallback behavior. The +server returns assignment validation and authorization errors to the active +picker. + +## Security + +Interface hiding is not an authorization boundary. The task service always +enforces `task.write` and target-user reach. + +The synthetic disabled-mode identity remains available to backend operations. +This behavior preserves single-user compatibility. + +## Test strategy + +Component tests use the real disabled-mode auth shape: `mode: "disabled"`, +`authenticated: true`, and a synthetic `default-user`. + +The tests cover hidden topbar, Office, and card surfaces. Existing enabled-mode +tests continue to cover assignment and display behavior. + +A Playwright test uses the default authentication-disabled fixture. It proves +that the task page does not show the picker and the board does not show a +persisted synthetic assignment. + +## Related decisions + +- [Opt-in Authentication and Per-User Workspace Scoping](../../../decisions/2026-07-24-opt-in-authentication.md)