diff --git a/server/src/__tests__/projects.test.ts b/server/src/__tests__/projects.test.ts index 9cb8e1b..f6127f7 100644 --- a/server/src/__tests__/projects.test.ts +++ b/server/src/__tests__/projects.test.ts @@ -45,14 +45,89 @@ describe('Projects API', () => { }); it('rejects an invalid repository URL without persisting a project', async () => { - await request(app) - .post(`/api/companies/${companyId}/projects`) - .send({ name: 'Invalid repository', repoUrl: 'not-a-url' }) - .expect(400); + for (const repoUrl of ['not-a-url', 'javascript:alert(document.domain)']) { + await request(app) + .post(`/api/companies/${companyId}/projects`) + .send({ name: 'Invalid repository', repoUrl }) + .expect(400); + } const list = await request(app) .get(`/api/companies/${companyId}/projects`) .expect(200); expect(list.body.data).toEqual([]); }); + + it('updates and soft-archives a project with durable detail reads', async () => { + const created = await request(app) + .post(`/api/companies/${companyId}/projects`) + .send({ name: 'Lifecycle draft', status: 'planning', repoUrl: null }) + .expect(201); + const projectId = created.body.data.id; + + await request(app) + .patch(`/api/companies/${companyId}/projects/${projectId}`) + .send({ + name: 'Lifecycle verified', + description: 'Prove edits survive a canonical detail reload.', + status: 'active', + repoUrl: 'https://github.com/vertical-labs/eidolon', + }) + .expect(200) + .expect(({ body }) => { + expect(body.data).toEqual(expect.objectContaining({ + id: projectId, + name: 'Lifecycle verified', + status: 'active', + repoUrl: 'https://github.com/vertical-labs/eidolon', + })); + }); + + await request(app) + .get(`/api/companies/${companyId}/projects/${projectId}`) + .expect(200) + .expect(({ body }) => { + expect(body.data).toEqual(expect.objectContaining({ + id: projectId, + name: 'Lifecycle verified', + description: 'Prove edits survive a canonical detail reload.', + })); + }); + + await request(app) + .delete(`/api/companies/${companyId}/projects/${projectId}`) + .expect(200) + .expect(({ body }) => { + expect(body.data).toEqual(expect.objectContaining({ id: projectId, status: 'archived' })); + }); + + await request(app) + .get(`/api/companies/${companyId}/projects/${projectId}`) + .expect(200) + .expect(({ body }) => { + expect(body.data).toEqual(expect.objectContaining({ id: projectId, status: 'archived' })); + }); + }); + + it('rejects an invalid repository URL during an update without changing the project', async () => { + const created = await request(app) + .post(`/api/companies/${companyId}/projects`) + .send({ name: 'Keep canonical state', status: 'planning', repoUrl: null }) + .expect(201); + + await request(app) + .patch(`/api/companies/${companyId}/projects/${created.body.data.id}`) + .send({ name: 'Do not persist', repoUrl: 'javascript:alert(document.domain)' }) + .expect(400); + + await request(app) + .get(`/api/companies/${companyId}/projects/${created.body.data.id}`) + .expect(200) + .expect(({ body }) => { + expect(body.data).toEqual(expect.objectContaining({ + name: 'Keep canonical state', + repoUrl: null, + })); + }); + }); }); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 3ed67b7..74d1066 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -7,18 +7,27 @@ import eventBus from '../realtime/events.js'; import type { DbInstance } from '../types.js'; import { routeParams } from '../utils/route-params.js'; +const HttpUrl = z.string().url().refine((value) => { + try { + const protocol = new URL(value).protocol; + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}, 'Repository URL must start with http:// or https://'); + const CreateProjectBody = z.object({ name: z.string().min(1).max(255), description: z.string().max(5000).optional(), status: z.enum(['planning', 'active', 'completed', 'archived']).default('planning'), - repoUrl: z.string().url().nullable().default(null), + repoUrl: HttpUrl.nullable().default(null), }); const UpdateProjectBody = z.object({ name: z.string().min(1).max(255).optional(), description: z.string().max(5000).nullable().optional(), status: z.enum(['planning', 'active', 'completed', 'archived']).optional(), - repoUrl: z.string().url().nullable().optional(), + repoUrl: HttpUrl.nullable().optional(), }); const ProjectListQuery = z.object({ diff --git a/ui/src/components/projects/CreateProjectModal.tsx b/ui/src/components/projects/CreateProjectModal.tsx index 1f4839c..c1316fc 100644 --- a/ui/src/components/projects/CreateProjectModal.tsx +++ b/ui/src/components/projects/CreateProjectModal.tsx @@ -1,10 +1,5 @@ -import { useState } from "react"; -import { z } from "zod"; -import { Modal } from "@/components/ui/Modal"; -import { Input, Select, Textarea } from "@/components/ui/Input"; -import { Button } from "@/components/ui/Button"; -import { useCreateProject } from "@/lib/hooks"; -import type { Project, ProjectStatus } from "@/lib/api"; +import { ProjectFormModal } from "./ProjectFormModal"; +import type { Project } from "@/lib/api"; interface CreateProjectModalProps { open: boolean; @@ -13,192 +8,18 @@ interface CreateProjectModalProps { companyId: string; } -const projectStatuses: { value: ProjectStatus; label: string }[] = [ - { value: "planning", label: "Planning" }, - { value: "active", label: "Active" }, - { value: "completed", label: "Completed" }, - { value: "archived", label: "Archived" }, -]; - -const projectSchema = z.object({ - name: z.string().trim().min(1, "Enter a project name.").max(255, "Use 255 characters or fewer."), - description: z.string().max(5000, "Use 5,000 characters or fewer."), - status: z.enum(["planning", "active", "completed", "archived"]), - repoUrl: z.union([ - z.literal(""), - z.url("Enter a complete repository URL, such as https://github.com/org/repo."), - ]), -}); - -type ProjectFormErrors = Partial, string>>; - -function FieldError({ id, message }: { id: string; message?: string }) { - if (!message) return null; - - return ( - - ); -} - export function CreateProjectModal({ open, onClose, onCreated, companyId, }: CreateProjectModalProps) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [status, setStatus] = useState("planning"); - const [repoUrl, setRepoUrl] = useState(""); - const [errors, setErrors] = useState({}); - const [submitError, setSubmitError] = useState(null); - const mutation = useCreateProject(companyId); - - function resetForm() { - setName(""); - setDescription(""); - setStatus("planning"); - setRepoUrl(""); - setErrors({}); - setSubmitError(null); - mutation.reset(); - } - - function handleClose() { - resetForm(); - onClose(); - } - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmitError(null); - - const parsed = projectSchema.safeParse({ name, description, status, repoUrl }); - if (!parsed.success) { - const fieldErrors = parsed.error.flatten().fieldErrors; - setErrors({ - name: fieldErrors.name?.[0], - description: fieldErrors.description?.[0], - status: fieldErrors.status?.[0], - repoUrl: fieldErrors.repoUrl?.[0], - }); - return; - } - - setErrors({}); - mutation.mutate( - { - name: parsed.data.name, - description: parsed.data.description.trim() || undefined, - status: parsed.data.status, - repoUrl: parsed.data.repoUrl || null, - }, - { - onSuccess: (project) => { - resetForm(); - onClose(); - onCreated(project); - }, - onError: (error) => { - setSubmitError( - error instanceof Error - ? error.message - : "Project creation failed. Check your connection and try again.", - ); - }, - }, - ); - } - return ( - -
-
- setName(event.target.value)} - aria-invalid={!!errors.name} - aria-describedby={errors.name ? "project-name-error" : undefined} - className="text-base placeholder:text-text-primary/60 sm:text-sm" - autoFocus - maxLength={255} - disabled={mutation.isPending} - required - /> - -
-
-