From 63da197fe23c6dbc94498a8d10562125d85f4b94 Mon Sep 17 00:00:00 2001 From: Prajwal Aradhya Date: Fri, 14 Aug 2026 17:42:41 +0100 Subject: [PATCH] feat(projects): multi-step project creation wizard Server: - POST /v1/projects now accepts description, members[] and settings{}, all written in the existing db.transaction so a project can never exist without the access grants it was created with. - CREATE_TIME_SETTING_KEYS is the single source shared by the API schema and the wizard; connection-id keys stay out (they need integration wiring). - Fix dead duplicate-name check: create/service used checkProjectExist(id) from routes/create, so it matched name against id and never fired. - Fix name length: projects.name is varchar(50) but create/update DTOs allowed 100/255, so long names passed zod then died on the insert. - Replace the copy-pasted get-all stub spec with real create tests. Portal: - New /projects/new wizard route (basics -> members -> config), behind the existing isSystemAdmin gate. Members step is a two-column transfer panel with a per-member role dropdown reusing ROLES from RoleSelector. - Drop the single-field new-project modal from ProjectsTab. - All styling uses design-system tokens; no hardcoded colors. AGENT.md: document the never-hardcode-colors rule. Co-Authored-By: Claude Opus 5 --- AGENT.md | 16 + .../src/components/common/RoleSelector.tsx | 2 +- .../src/components/home/ProjectsTab.tsx | 90 +-- apps/portal/src/query/projectsQuery.ts | 19 +- .../src/routes/_authed/projects.new.tsx | 557 ++++++++++++++++++ apps/server/src/api/v1/projects/create/dto.ts | 48 +- .../src/api/v1/projects/create/service.ts | 49 +- .../v1/projects/create/tests/create.spec.ts | 90 ++- apps/server/src/api/v1/projects/update/dto.ts | 4 +- 9 files changed, 770 insertions(+), 105 deletions(-) create mode 100644 apps/portal/src/routes/_authed/projects.new.tsx diff --git a/AGENT.md b/AGENT.md index dd75caf1..deef7513 100644 --- a/AGENT.md +++ b/AGENT.md @@ -58,6 +58,22 @@ When creating branches or Pull Requests via the `gh` CLI: ## Known Issues & Fixes +### ⚠️ CRITICAL — Never Hardcode Colors in Portal UI +**Issue:** New portal pages render unthemed — wrong background, invisible text, borders that vanish — because the markup carries literal hex values (`bg-[#12151D]`, `border-[#1E232F]`, `text-[#D0F237]`, `bg-[#ccff00]`) or Tailwind's default palette (`text-zinc-400`, `text-white`, `text-black`, `bg-white/[0.04]`). These are frozen dark-theme values: they ignore `--accent`, do not flip under `.light` / `[data-theme="light"]`, and drift from the design system the moment a token changes. +**Cause:** Copying an existing page as a starting point. Several older files still contain hardcoded hex, so the wrong pattern looks like the house style. **A hex value in a neighbouring file is NOT precedent — it is unconverted debt.** +**Fix & Best Practices:** +1. **Always use the semantic utility classes.** The theme is defined in `packages/components/src/styles.css` as CSS variables; the Tailwind utilities built on them are the only supported way to colour portal UI: + - Surfaces: `bg-background`, `bg-background-secondary`, `bg-surface`, `bg-surface-secondary`, `bg-overlay` + - Text: `text-foreground`, `text-muted`, `text-muted-foreground`, `text-accent`, `text-accent-foreground` + - Lines & rings: `border-border`, `border-accent`, `ring-accent`, `ring-focus` + - Status: `text-danger`, `text-success`, `bg-warning` (and their `border-*` / `bg-*` forms) +2. **Never write `text-white` / `text-black` / `text-zinc-*` / `bg-white/[0.04]`.** Map them: primary text → `text-foreground`, secondary text → `text-muted`, hover wash → `hover:bg-surface-secondary`, selected wash → `bg-accent/10`, hairline ring → `ring-border`. +3. **The lime accent is `--accent`, never a literal.** `#ccff00` / `#D0F237` → `bg-accent` with `text-accent-foreground` (the accent needs dark text for contrast; `text-accent-foreground` already encodes that). +4. **Prefer the component over restyling a native element.** A lime CTA is ` - - - - - - Create a project -

- Give it a name to get started — you can rename it later. -

-
-
- - - - - {error} - - - - - - -
-
-
-
- + ); } diff --git a/apps/portal/src/query/projectsQuery.ts b/apps/portal/src/query/projectsQuery.ts index a468813e..12dfbb33 100644 --- a/apps/portal/src/query/projectsQuery.ts +++ b/apps/portal/src/query/projectsQuery.ts @@ -2,9 +2,26 @@ import { type GetAllProjectsQueryParams, projectsService, } from "@/services/projects"; -import { type QueryClient, useQuery } from "@tanstack/react-query"; +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import type { z } from "zod"; export const projectsQuery = { + create: { + mutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ( + body: z.infer, + ) => projectsService.create(body), + onSuccess: () => qc.invalidateQueries({ queryKey: ["projects", "list"] }), + }); + }, + }, getAll: { useQuery(query: GetAllProjectsQueryParams) { return useQuery({ diff --git a/apps/portal/src/routes/_authed/projects.new.tsx b/apps/portal/src/routes/_authed/projects.new.tsx new file mode 100644 index 00000000..92e994d0 --- /dev/null +++ b/apps/portal/src/routes/_authed/projects.new.tsx @@ -0,0 +1,557 @@ +import { useMemo, useState } from "react"; +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { + Button, + Checkbox, + Input, + Label, + LazyLoader, + ListBox, + Select, + TextArea, + TextField, + cn, + toast, +} from "@fluxify/components"; +import { + TbArrowLeft, + TbArrowRight, + TbCheck, + TbChevronLeft, + TbChevronRight, + TbSearch, +} from "react-icons/tb"; +import { authClient } from "@/lib/auth"; +import { authQuery } from "@/query/authQuery"; +import { projectsQuery } from "@/query/projectsQuery"; +import { showErrorNotification } from "@/lib/errorNotifier"; +import { createRouteHead } from "@/lib/seo"; +import { ROLES, type Role } from "@/components/common/RoleSelector"; + +// Same roles the project settings member list offers, shown as a dropdown here +// because each row already carries a name and a remove action. +const ROLE_OPTIONS = ROLES.map((role) => ({ id: role.id, label: role.title })); + +export const Route = createFileRoute("/_authed/projects/new")({ + head: createRouteHead( + "New Project", + "Create a project: name it, invite members and set its configuration.", + ), + // Same gate the New Project button honours on the home page — reaching this + // route by URL must not get further than clicking would. + beforeLoad: async () => { + const session = await authClient.getSession(); + if (!(session.data?.user as { isSystemAdmin?: boolean })?.isSystemAdmin) { + throw redirect({ to: "/", search: { tab: "projects" } }); + } + }, + component: CreateProjectPage, +}); + +type UserRow = { id: string; name: string | null; email: string }; +type Member = { userId: string; role: Role; label: string }; + +const STEPS = [ + { key: "basics", label: "Basics" }, + { key: "members", label: "Members" }, + { key: "config", label: "Configuration" }, +] as const; + +const STEP_COPY: Record< + (typeof STEPS)[number]["key"], + { title: string; description: string } +> = { + basics: { + title: "Name the project", + description: "How it appears in the project list. You can rename it later.", + }, + members: { + title: "Who can work on it", + description: + "Optional. Members and their roles are saved with the project — you can change them any time in project settings.", + }, + config: { + title: "Set the project's configuration", + description: + "Optional. Integrations, app configs and routes are set up inside the project once it exists.", + }, +}; + +function initialsOf(name: string | null, email: string) { + if (!name) return email.substring(0, 2).toUpperCase(); + const parts = name.trim().split(" "); + if (parts.length > 1) return (parts[0][0] + parts[1][0]).toUpperCase(); + return name.substring(0, 2).toUpperCase(); +} + +function CreateProjectPage() { + const navigate = useNavigate(); + const create = projectsQuery.create.mutation(); + + const [step, setStep] = useState(0); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [members, setMembers] = useState([]); + const [workerTimeouts, setWorkerTimeouts] = useState(false); + + // `projects.name` is varchar(50) and the API rejects anything longer, so the + // field has to stop the user rather than let the request fail. + const basicsValid = name.trim().length >= 2 && name.trim().length <= 50; + + const isLast = step === STEPS.length - 1; + const currentKey = STEPS[step].key; + + function submit() { + create.mutate( + { + name: name.trim(), + description: description.trim() || undefined, + members: members.length + ? members.map(({ userId, role }) => ({ userId, role })) + : undefined, + settings: { + "experimental.workerTimeouts.enabled": workerTimeouts + ? "true" + : "false", + }, + }, + { + onSuccess: (created) => { + toast.success("Project created"); + navigate({ to: "/$projectId", params: { projectId: created.id } }); + }, + onError: (error) => showErrorNotification(error as Error), + }, + ); + } + + return ( +
+
+ +
+

+ Create a project +

+

+ Name it, pick who works on it, and set how it runs. +

+
+
+ + + +
+
+

+ Step {step + 1} of {STEPS.length} +

+

+ {STEP_COPY[currentKey].title} +

+

+ {STEP_COPY[currentKey].description} +

+
+ +
+ {currentKey === "basics" && ( +
+ 50} + > + + +

+ 2 to 50 characters. Must be unique across the instance. +

+
+ + + +