fix(tasks): hide human assignee when auth is disabled (#3417)

This commit is contained in:
Carlos Florêncio
2026-09-06 11:36:08 +01:00
committed by GitHub
parent c112ec9844
commit c4ca73e273
15 changed files with 541 additions and 12 deletions
@@ -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 }> }> },
+6 -4
View File
@@ -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);
});
+5
View File
@@ -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;
}
+133
View File
@@ -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.
+1
View File
@@ -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)