fix(tasks): hide human assignee when auth is disabled (#3417)
This commit is contained in:
@@ -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<string, { tasks: Array<{ id: string }> }> },
|
||||
@@ -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(<KanbanCardBody task={{ ...TASK, assigneeUserId: "user-1" }} repositoryChips={[]} />);
|
||||
|
||||
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(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
||||
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(
|
||||
<KanbanCardBody task={{ ...TASK, assigneeUserId: "default-user" }} repositoryChips={[]} />,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, { tasks: Array<{ id: string }> }> },
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
|
||||
{task.blocked && <BlockedBadge task={task} />}
|
||||
{task.assigneeUserId && <AssigneeBadge userId={task.assigneeUserId} />}
|
||||
{showHumanAssignee && task.assigneeUserId && <AssigneeBadge userId={task.assigneeUserId} />}
|
||||
{task.queuedForStepId && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -230,13 +232,13 @@ function BlockedBadge({ task }: { task: Task }) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasCardBadges(task: Task): boolean {
|
||||
function hasCardBadges(task: Task, showHumanAssignee: boolean): boolean {
|
||||
return Boolean(
|
||||
(task.sessionCount && task.sessionCount > 1) ||
|
||||
task.reviewStatus === "changes_requested" ||
|
||||
task.reviewStatus === "pending" ||
|
||||
task.queuedForStepId ||
|
||||
task.assigneeUserId ||
|
||||
(showHumanAssignee && task.assigneeUserId) ||
|
||||
task.blocked,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: () => <span data-testid="human-assignee-picker-trigger" />,
|
||||
}));
|
||||
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(<TaskProperties task={TASK} />);
|
||||
|
||||
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(<TaskProperties task={TASK} />);
|
||||
|
||||
expect(screen.queryByText("Assigned to", { exact: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the row when authentication is enabled for a signed-in user", () => {
|
||||
render(<TaskProperties task={TASK} />);
|
||||
|
||||
expect(screen.getByText("Assigned to", { exact: true })).toBeTruthy();
|
||||
expect(screen.getByTestId("human-assignee-picker-trigger")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<PropertyRow label={t("task:assignedTo")} valueClassName="ml-auto">
|
||||
<HumanAssigneePicker task={task} />
|
||||
|
||||
@@ -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(
|
||||
<StateProvider initialState={state}>
|
||||
<TaskAssigneeControl taskId="t-1" workspaceId="ws-1" />
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user