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";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mockAppState = {
|
const mockAppState = {
|
||||||
|
auth: {
|
||||||
|
mode: "enabled",
|
||||||
|
authenticated: true,
|
||||||
|
user: { id: "user-2" } as { id: string } | null,
|
||||||
|
ssoProviders: [],
|
||||||
|
},
|
||||||
workspaces: { activeId: "ws-1" },
|
workspaces: { activeId: "ws-1" },
|
||||||
kanban: { tasks: [] as Array<{ id: string; title: string }> },
|
kanban: { tasks: [] as Array<{ id: string; title: string }> },
|
||||||
kanbanMulti: { snapshots: {} as Record<string, { tasks: Array<{ id: 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";
|
import type { Task } from "./kanban-card";
|
||||||
|
|
||||||
const TASK: Task = { id: "task-1", title: "Fix the bug", workflowStepId: "step-1" };
|
const TASK: Task = { id: "task-1", title: "Fix the bug", workflowStepId: "step-1" };
|
||||||
|
const ASSIGNEE_BADGE = "kanban-card-assignee";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
resetDirectoryCacheForTests();
|
resetDirectoryCacheForTests();
|
||||||
|
mockAppState.auth.mode = "enabled";
|
||||||
|
mockAppState.auth.user = { id: "user-2" };
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("KanbanCardBody — human assignee", () => {
|
describe("KanbanCardBody — human assignee", () => {
|
||||||
@@ -47,13 +56,25 @@ describe("KanbanCardBody — human assignee", () => {
|
|||||||
render(<KanbanCardBody task={{ ...TASK, assigneeUserId: "user-1" }} repositoryChips={[]} />);
|
render(<KanbanCardBody task={{ ...TASK, assigneeUserId: "user-1" }} repositoryChips={[]} />);
|
||||||
|
|
||||||
await waitFor(() =>
|
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", () => {
|
it("renders no assignee affordance on an unassigned task", () => {
|
||||||
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
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 () => {
|
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);
|
expect(listDirectoryUsers).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { render, screen, cleanup } from "@testing-library/react";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mockAppState = {
|
const mockAppState = {
|
||||||
|
auth: {
|
||||||
|
mode: "enabled",
|
||||||
|
authenticated: true,
|
||||||
|
user: { id: "user-1" },
|
||||||
|
ssoProviders: [],
|
||||||
|
},
|
||||||
workspaces: { activeId: "ws-1" },
|
workspaces: { activeId: "ws-1" },
|
||||||
kanban: { tasks: [] as Array<{ id: string; title: string; parentTaskId?: string }> },
|
kanban: { tasks: [] as Array<{ id: string; title: string; parentTaskId?: string }> },
|
||||||
kanbanMulti: { snapshots: {} as Record<string, { tasks: Array<{ id: string }> }> },
|
kanbanMulti: { snapshots: {} as Record<string, { tasks: Array<{ id: string }> }> },
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "@/lib/ui/state-icons";
|
} from "@/lib/ui/state-icons";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { needsAction } from "@/lib/utils/needs-action";
|
import { needsAction } from "@/lib/utils/needs-action";
|
||||||
|
import { canShowHumanAssignee } from "@/lib/auth/human-assignee";
|
||||||
import type { RepositoryChip, Task } from "@/components/kanban-card";
|
import type { RepositoryChip, Task } from "@/components/kanban-card";
|
||||||
|
|
||||||
const kanbanStatusDebug = createDebugLogger("kanban:task-status");
|
const kanbanStatusDebug = createDebugLogger("kanban:task-status");
|
||||||
@@ -145,14 +146,15 @@ function KanbanCardRelationship({ task }: { task: Task }) {
|
|||||||
|
|
||||||
function KanbanCardBadges({ task }: { task: Task }) {
|
function KanbanCardBadges({ task }: { task: Task }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const showRow = hasCardBadges(task);
|
const showHumanAssignee = useAppStore((s) => canShowHumanAssignee(s.auth));
|
||||||
|
const showRow = hasCardBadges(task, showHumanAssignee);
|
||||||
|
|
||||||
if (!showRow) return null;
|
if (!showRow) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
|
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
|
||||||
{task.blocked && <BlockedBadge task={task} />}
|
{task.blocked && <BlockedBadge task={task} />}
|
||||||
{task.assigneeUserId && <AssigneeBadge userId={task.assigneeUserId} />}
|
{showHumanAssignee && task.assigneeUserId && <AssigneeBadge userId={task.assigneeUserId} />}
|
||||||
{task.queuedForStepId && (
|
{task.queuedForStepId && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
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(
|
return Boolean(
|
||||||
(task.sessionCount && task.sessionCount > 1) ||
|
(task.sessionCount && task.sessionCount > 1) ||
|
||||||
task.reviewStatus === "changes_requested" ||
|
task.reviewStatus === "changes_requested" ||
|
||||||
task.reviewStatus === "pending" ||
|
task.reviewStatus === "pending" ||
|
||||||
task.queuedForStepId ||
|
task.queuedForStepId ||
|
||||||
task.assigneeUserId ||
|
(showHumanAssignee && task.assigneeUserId) ||
|
||||||
task.blocked,
|
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 { QuorumStatusBadge } from "./components/quorum-status-badge";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
|
import { canShowHumanAssignee } from "@/lib/auth/human-assignee";
|
||||||
|
|
||||||
type TaskPropertiesProps = {
|
type TaskPropertiesProps = {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -60,8 +61,8 @@ function NoneLabel() {
|
|||||||
// user, so the control would offer a choice that cannot mean anything.
|
// user, so the control would offer a choice that cannot mean anything.
|
||||||
function HumanAssigneeRow({ task }: { task: Task }) {
|
function HumanAssigneeRow({ task }: { task: Task }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasIdentity = useAppStore((s) => Boolean(s.auth.user));
|
const showHumanAssignee = useAppStore((s) => canShowHumanAssignee(s.auth));
|
||||||
if (!hasIdentity) return null;
|
if (!showHumanAssignee) return null;
|
||||||
return (
|
return (
|
||||||
<PropertyRow label={t("task:assignedTo")} valueClassName="ml-auto">
|
<PropertyRow label={t("task:assignedTo")} valueClassName="ml-auto">
|
||||||
<HumanAssigneePicker task={task} />
|
<HumanAssigneePicker task={task} />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ vi.mock("@/lib/api/domains/team-access-api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { updateTask } from "@/lib/api/domains/kanban-api";
|
import { updateTask } from "@/lib/api/domains/kanban-api";
|
||||||
|
import { listDirectoryUsers, listWorkspaceMembers } from "@/lib/api/domains/team-access-api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
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", () => {
|
describe("TaskAssigneeControl", () => {
|
||||||
it("shows the assignee's name from the store, not a raw user id", async () => {
|
it("shows the assignee's name from the store, not a raw user id", async () => {
|
||||||
renderControl("user-1");
|
renderControl("user-1");
|
||||||
@@ -105,8 +123,10 @@ describe("TaskAssigneeControl", () => {
|
|||||||
await waitFor(() => expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "" }));
|
await waitFor(() => expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders nothing when authentication is disabled", () => {
|
it("renders nothing for the synthetic user when authentication is disabled", () => {
|
||||||
renderControl(undefined, false);
|
renderControlWithDisabledSyntheticUser("default-user");
|
||||||
expect(screen.queryByTestId(CONTROL)).toBeNull();
|
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 { useAppStore } from "@/components/state-provider";
|
||||||
import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people";
|
import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people";
|
||||||
import { updateTask } from "@/lib/api/domains/kanban-api";
|
import { updateTask } from "@/lib/api/domains/kanban-api";
|
||||||
|
import { canShowHumanAssignee } from "@/lib/auth/human-assignee";
|
||||||
import { initialsFor } from "@/lib/user-initials";
|
import { initialsFor } from "@/lib/user-initials";
|
||||||
|
|
||||||
// i18n-exempt: sentinels compared with ===, never displayed.
|
// i18n-exempt: sentinels compared with ===, never displayed.
|
||||||
@@ -35,6 +36,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
export function TaskAssigneeControl({ taskId, workspaceId, isArchived }: Props) {
|
export function TaskAssigneeControl({ taskId, workspaceId, isArchived }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showHumanAssignee = useAppStore((s) => canShowHumanAssignee(s.auth));
|
||||||
const currentUser = useAppStore((s) => s.auth.user);
|
const currentUser = useAppStore((s) => s.auth.user);
|
||||||
// Read from the store rather than through props: `task.updated` lands the
|
// 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
|
// 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(
|
const assigneeUserId = useAppStore(
|
||||||
(s) => s.kanban.tasks.find((entry: { id: string }) => entry.id === taskId)?.assigneeUserId,
|
(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 { people, nameFor } = useAssignablePeople(workspaceId, { enabled });
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ function expectedInitials(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("property pickers", () => {
|
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 ({
|
test("status picker updates task status and persists", async ({
|
||||||
testPage,
|
testPage,
|
||||||
apiClient,
|
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 Step Fixed-Profile Routing](system-design/workflow-step-fixed-profile-routing.md)
|
||||||
- [Workflow Profile Session Lifecycle](system-design/workflow-profile-session-lifecycle.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)
|
- [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)
|
- [Task Create Agent Compatibility Recovery](system-design/task-create-agent-executor-compatibility.md)
|
||||||
|
|
||||||
## Migration record
|
## 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.
|
including when the assignee is cleared.
|
||||||
- **AC-TASKS-HUMAN-ASSIGNEE-001.8:** The system shall treat the human assignee
|
- **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.
|
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
|
### 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