diff --git a/cypress/e2e/projects/create-project.cy.js b/cypress/e2e/projects/create-project.cy.js new file mode 100644 index 00000000..c868632e --- /dev/null +++ b/cypress/e2e/projects/create-project.cy.js @@ -0,0 +1,191 @@ +/** + * Ara Projects — projects list, empty state and create-project modal (#409) + * + * Two groups, because one of them writes: + * + * 1. The UI group stubs POST /api/projects with cy.intercept. It exercises + * the empty state, the modal, client-side validation and the navigation + * to workstream selection without touching the database, so it is safe to + * run anywhere. + * + * 2. The end-to-end group performs a real create and is the acceptance + * criterion's "create a project end-to-end". It INSERTs, so it is gated + * behind CYPRESS_ARA_PROJECTS_E2E_WRITES and must only ever be pointed at + * a non-production database. It also needs the #407 reference-data + * migration (0274) applied, or the project-type select will be empty and + * the page will not offer the CTA at all. + * + * Like the shell spec, this uses `cy.login` (cypress/support/commands.ts) to + * mint a real next-auth JWE cookie, so NEXTAUTH_JWT_SECRET must be set. + * + * NOT YET RUN. The environment this was written in has DATABASE_URL pointing at + * production, so Cypress was deliberately not executed. Treat the selectors and + * assertions below as unverified until someone runs them against a scratch DB. + */ + +const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET"); +const E2E_WRITES = Cypress.env("ARA_PROJECTS_E2E_WRITES"); + +const INTERNAL_USER = { + email: "dev@domna.homes", + name: "Internal User", + onboarded: true, + sub: "cypress-internal", +}; + +describe("Ara Projects — create project (stubbed)", function () { + beforeEach(function () { + if (!JWT_SECRET) { + cy.log("NEXTAUTH_JWT_SECRET not set — skipping"); + this.skip(); + } + cy.login(INTERNAL_USER); + }); + + it("offers the Create project CTA from the list", function () { + cy.visit("/projects"); + + // The CTA sits inside the welcome card when there are no projects, and in + // the page header once there are — either way there is exactly one. + cy.get("[data-testid=create-project-trigger]").should("be.visible").click(); + cy.get("[data-testid=create-project-form]").should("be.visible"); + cy.contains("Project details").should("be.visible"); + }); + + it("shows the welcome empty state when the user has no visible projects", function () { + // Only meaningful on an instance with no projects visible to this user; + // where there are some, the list renders instead and this is skipped. + cy.visit("/projects"); + cy.get("body").then(($body) => { + if ($body.find("[data-testid=projects-list-empty]").length === 0) { + cy.log("user has visible projects — empty state not applicable"); + return; + } + cy.contains("Welcome to Ara Projects").should("be.visible"); + cy.contains("How setup works").should("be.visible"); + cy.get("[data-testid=create-project-trigger]").should("be.visible"); + }); + }); + + it("refuses to submit without a name and a project type", function () { + cy.visit("/projects"); + cy.get("[data-testid=create-project-trigger]").click(); + + cy.intercept("POST", "/api/projects", cy.spy().as("createCall")); + cy.get("[data-testid=create-project-submit]").click(); + + cy.contains("Enter a project name").should("be.visible"); + cy.get("@createCall").should("not.have.been.called"); + }); + + it("rejects an end date before the start date", function () { + cy.visit("/projects"); + cy.get("[data-testid=create-project-trigger]").click(); + + cy.get("[data-testid=create-project-name]").type("Backwards dates"); + cy.get("[data-testid=create-project-start-date]").type("2026-09-30"); + cy.get("[data-testid=create-project-end-date]").type("2026-03-01"); + cy.get("[data-testid=create-project-submit]").click(); + + cy.contains("The end date cannot be before the start date").should( + "be.visible", + ); + }); + + it("navigates to workstream selection on success", function () { + cy.intercept("POST", "/api/projects", { + statusCode: 201, + body: { id: "4242" }, + }).as("createProject"); + + cy.visit("/projects"); + cy.get("[data-testid=create-project-trigger]").click(); + + cy.get("[data-testid=create-project-name]").type("Riverside retrofit 2026"); + selectFirstOption("create-project-organisation"); + selectFirstOption("create-project-type"); + cy.get("[data-testid=create-project-domna-admin-access]").click(); + cy.get("[data-testid=create-project-submit]").click(); + + cy.wait("@createProject").then(({ request }) => { + expect(request.body.name).to.eq("Riverside retrofit 2026"); + expect(request.body.domnaAdminAccess).to.eq(true); + }); + + cy.location("pathname").should("eq", "/projects/4242/setup/workstreams"); + }); + + it("surfaces the server's error message on a refusal", function () { + cy.intercept("POST", "/api/projects", { + statusCode: 403, + body: { error: "You cannot create a project for that organisation." }, + }).as("createProject"); + + cy.visit("/projects"); + cy.get("[data-testid=create-project-trigger]").click(); + cy.get("[data-testid=create-project-name]").type("Not mine"); + selectFirstOption("create-project-organisation"); + selectFirstOption("create-project-type"); + cy.get("[data-testid=create-project-submit]").click(); + + cy.wait("@createProject"); + cy.get("[data-testid=create-project-error]") + .should("be.visible") + .and("contain", "cannot create a project for that organisation"); + cy.location("pathname").should("eq", "/projects"); + }); +}); + +describe("Ara Projects — create project (end to end, writes)", function () { + beforeEach(function () { + if (!JWT_SECRET || !E2E_WRITES) { + cy.log( + "ARA_PROJECTS_E2E_WRITES not set — skipping the writing happy path", + ); + this.skip(); + } + cy.login(INTERNAL_USER); + }); + + it("creates a project and lands on workstream selection", function () { + // Unique per run so repeated runs do not collide on a name a human is + // eyeballing later. `project.name` has no unique constraint; this is for + // legibility, not correctness. + const name = `Cypress project ${Date.now()}`; + + cy.visit("/projects"); + cy.get("[data-testid=create-project-trigger]").click(); + + cy.get("[data-testid=create-project-name]").type(name); + selectFirstOption("create-project-organisation"); + selectFirstOption("create-project-type"); + cy.get("[data-testid=create-project-description]").type( + "Created by the Cypress happy path.", + ); + cy.get("[data-testid=create-project-start-date]").type("2026-03-01"); + cy.get("[data-testid=create-project-end-date]").type("2026-09-30"); + // Required for an internal user creating on behalf of an organisation they + // are not a member of — without it the guard refuses, by design. + cy.get("[data-testid=create-project-domna-admin-access]").click(); + + cy.get("[data-testid=create-project-submit]").click(); + + cy.location("pathname").should("match", /^\/projects\/\d+\/setup\/workstreams$/); + + // The new project is now visible in the list, which is the visibility rule + // agreeing with the write that just happened. + cy.visit("/projects"); + cy.get("[data-testid=projects-list]").should("exist"); + cy.contains("[data-testid=projects-list-item]", name).should("be.visible"); + }); +}); + +/** + * Pick the first item in a shadcn/Radix `Select`. The options live in a portal + * outside the trigger, so this opens the trigger and then reaches for the + * listbox at the document root. + */ +function selectFirstOption(testId) { + cy.get(`[data-testid=${testId}]`).click(); + cy.get("[role=option]").first().click(); +} diff --git a/cypress/e2e/projects/projects-shell.cy.js b/cypress/e2e/projects/projects-shell.cy.js index 2676d9da..6e484d68 100644 --- a/cypress/e2e/projects/projects-shell.cy.js +++ b/cypress/e2e/projects/projects-shell.cy.js @@ -76,7 +76,17 @@ describe("Ara Projects route shell", function () { cy.get("[data-testid=projects-shell]").should("exist"); }); - it("renders the per-project dashboard shell and its nav", function () { + // This assertion changed with #409. While the authz stub was in place, + // `canViewProject` could never return false, so /projects/ rendered + // the dashboard shell for any signed-in user — which is what this test used + // to check. The real lib (#408) is now wired in, so a project the user has no + // standing on is a 404, and a project id that does not exist is the same 404 + // (deliberately indistinguishable, so Project existence does not leak). + // + // Rendering the dashboard shell therefore now needs a real, visible project, + // which is covered by the create-project happy path in create-project.cy.js + // rather than by a hardcoded id here. + it("404s on a project the user has no standing on", function () { cy.login({ email: "dev@domna.homes", name: "Internal User", @@ -84,13 +94,21 @@ describe("Ara Projects route shell", function () { sub: "cypress-internal", }); - cy.visit("/projects/1"); + cy.request({ url: "/projects/999999999", failOnStatusCode: false }) + .its("status") + .should("eq", 404); + }); - cy.get("[data-testid=project-shell]").should("exist"); - cy.get("[data-testid=project-dashboard-heading]").should("be.visible"); - - ["dashboard", "workOrders", "import", "settings"].forEach((item) => { - cy.get(`[data-testid=projects-nav-${item}]`).should("be.visible"); + it("404s on a malformed project id", function () { + cy.login({ + email: "dev@domna.homes", + name: "Internal User", + onboarded: true, + sub: "cypress-internal", }); + + cy.request({ url: "/projects/not-an-id", failOnStatusCode: false }) + .its("status") + .should("eq", 404); }); }); diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 00000000..8e0c3bd1 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { canManageProject } from "@/lib/projects/authz"; +import { + createProjectSchema, + prospectiveProjectFacts, +} from "@/lib/projects/createProject"; +import { loadAuthzUserByEmail } from "@/app/repositories/projects/authzRepository"; +import { + insertProject, + organisationExists, + projectTypeExists, +} from "@/app/repositories/projects/projectsRepository"; + +/** + * POST /api/projects — create an Ara Project (issue #409). + * + * The authorization question "may this user create a project for this + * organisation?" is answered by the *existing* guard: build the facts the + * project would have once created and ask `canManageProject`. That keeps + * creation on the same rule as every other management action instead of + * inventing a parallel one. + * + * The rule has a consequence worth naming: `domna_admin_access` gates the + * `internal` role, so a Domna user creating a project for an organisation they + * are not a member of must grant that access. Without it they would be + * creating a project they could not then see, and the guard refuses rather + * than producing an orphan. + */ +export async function POST(req: NextRequest) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const user = await loadAuthzUserByEmail(session.user.email); + if (!user) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const parsed = createProjectSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid body" }, + { status: 400 }, + ); + } + const draft = parsed.data; + + if (!canManageProject(user, prospectiveProjectFacts(draft))) { + return NextResponse.json( + { + error: + "You cannot create a project for that organisation. Granting Domna admin access is required for an organisation you are not a member of.", + }, + { status: 403 }, + ); + } + + // Checked after authorization so an unauthorized caller cannot use the + // difference between 400 and 403 to probe which organisations exist. + const projectTypeId = BigInt(draft.projectTypeId); + const [orgOk, typeOk] = await Promise.all([ + organisationExists(draft.organisationId), + projectTypeExists(projectTypeId), + ]); + if (!orgOk) { + return NextResponse.json({ error: "Unknown organisation" }, { status: 400 }); + } + if (!typeOk) { + return NextResponse.json({ error: "Unknown project type" }, { status: 400 }); + } + + const id = await insertProject({ ...draft, projectTypeId }); + + // `id` is a bigint; JSON cannot carry one, so the client gets a string and + // uses it to build the next wizard step's path. + return NextResponse.json({ id: id.toString() }, { status: 201 }); +} diff --git a/src/app/projects/components/CreateProjectDialog.tsx b/src/app/projects/components/CreateProjectDialog.tsx new file mode 100644 index 00000000..505713a8 --- /dev/null +++ b/src/app/projects/components/CreateProjectDialog.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { PlusIcon } from "@heroicons/react/24/outline"; +import { + createProjectSchema, + type CreateProjectFormValues, + type SelectOption, +} from "@/lib/projects/createProject"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/app/shadcn_components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/app/shadcn_components/ui/form"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/app/shadcn_components/ui/select"; + +/** + * The HTTP call, kept outside the component so the component itself contains + * no `fetch` — TanStack Query owns the request lifecycle. + * + * Route handlers in this codebase answer errors as `{ error: string }`, so a + * failure is surfaced with the server's own message rather than a generic one. + */ +async function createProject( + values: CreateProjectFormValues, +): Promise<{ id: string }> { + const response = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + + const body = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(body?.error ?? "Could not create the project"); + } + return body; +} + +export interface CreateProjectDialogProps { + organisations: SelectOption[]; + projectTypes: SelectOption[]; + /** Rendered as the trigger's label — "Create project" everywhere so far. */ + label?: string; +} + +/** + * Create-project modal — step 1 of the setup wizard. + * + * On success it navigates to workstream selection (#410) rather than back to + * the list: creating a project is the start of setup, not the end of it. + */ +export function CreateProjectDialog({ + organisations, + projectTypes, + label = "Create project", +}: CreateProjectDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(createProjectSchema), + defaultValues: { + name: "", + // A client user typically belongs to exactly one organisation, so the + // select is pre-filled and effectively fixed. Internal users, offered + // every organisation, start with no choice made. + organisationId: organisations.length === 1 ? organisations[0].id : "", + projectTypeId: "", + description: "", + startDate: "", + endDate: "", + domnaAdminAccess: false, + }, + }); + + const mutation = useMutation(createProject, { + onSuccess: ({ id }) => router.push(`/projects/${id}/setup/workstreams`), + }); + + // Deliberately not reset on close: a user who dismissed the dialog by + // accident gets their typing back. A completed creation navigates away, so + // stale values are never shown against a project that already exists. + function onOpenChange(next: boolean) { + if (!next && mutation.isLoading) return; + setOpen(next); + if (next) mutation.reset(); + } + + return ( + <> + + + + + + Project details + + Step 1 of 5 — name the project and choose who it belongs to. You + will pick its workstreams next. + + + +
+ mutation.mutate(values))} + className="space-y-4" + data-testid="create-project-form" + > + ( + + Project name + + + + + + )} + /> + + ( + + Client + + {organisations.length === 1 && ( + + Projects you create belong to your organisation. + + )} + + + )} + /> + + ( + + Project type + + + + )} + /> + + ( + + Description + +