Sfoglia il codice sorgente

feat: a projects screen, and a way to move work between projects

Projects existed only over the API. This adds the screen: team projects
and personal ones listed apart, what each holds, and the role you hold in
it. A personal project says plainly that it is yours and not shared,
because the absence of a share button is not an explanation.

Members are managed from the project: add somebody, change their role,
remove them, or leave yourself. The role dropdown carries what the role
actually means - "create, change, run and delete the work in it" rather
than the word "editor" on its own, which tells nobody anything.

The workflows page gains a project selector, and a workflow can be moved
between projects from its own menu. Moving is worded as what it is: a
project decides who can see and change a workflow, so moving it may take
it away from people who can reach it now, or hand it to people who
cannot. The project it is already in is marked rather than offered.

Folders are untouched and still sit inside a project - a folder tidies a
project up, a project decides who can see it at all.

Verified in the browser: the page lists five projects, a team project can
be created, bob added to it as a member and dropped from the "add
somebody" list once he is one, the workflow filter narrows to one
project's work, and a workflow moved to another project appears under it.
Everything was moved back afterwards.
fszontagh 1 mese fa
parent
commit
ea4e7836b3

+ 2 - 0
webui/src/App.tsx

@@ -7,6 +7,7 @@ import LoginPage from './pages/LoginPage'
 import WorkflowsPage from './pages/WorkflowsPage'
 import WorkflowEditorPage from './pages/WorkflowEditorPage'
 import ExecutionsPage from './pages/ExecutionsPage'
+import ProjectsPage from './pages/ProjectsPage'
 import SettingsPage from './pages/SettingsPage'
 import NodesPage from './pages/NodesPage'
 import DatabasePage from './pages/DatabasePage'
@@ -48,6 +49,7 @@ function App() {
         <Route path="nodes" element={<NodesPage />} />
         <Route path="credentials" element={<CredentialsPage />} />
         <Route path="database" element={<DatabasePage />} />
+        <Route path="projects" element={<ProjectsPage />} />
         <Route path="settings" element={<SettingsPage />} />
       </Route>
     </Routes>

+ 12 - 0
webui/src/api/workflows.ts

@@ -35,6 +35,8 @@ export interface Workflow {
   // one it loaded back on save so the server can refuse to overwrite somebody
   // else's work rather than silently discarding it.
   version?: number
+  /** The project this belongs to - who may see and change it. */
+  projectId?: string
   /** True when what runs differs from what is stored - see getWorkflow. */
   hasUnpublishedChanges?: boolean
   publishedVersion?: number
@@ -78,6 +80,7 @@ function transformWorkflow(data: any): Workflow {
     description: data.description,
     active: data.active,
     groupId: data.groupId,
+    projectId: data.projectId,
     nodes: data.nodes || [],
     connections: data.connections || [],
     settings: data.settings || {},
@@ -233,6 +236,15 @@ export const workflowsApi = {
    * @param id - Workflow ID
    * @param groupId - Target group ID, or null/undefined to remove from group
    */
+  /**
+   * Move a workflow into another project. This changes who can reach it, so it
+   * is a different act from putting it in a folder.
+   */
+  moveToProject: async (id: string, projectId: string) => {
+    const response = await api.put(`/workflows/${id}`, { projectId })
+    return transformWorkflow(response.data)
+  },
+
   moveToGroup: async (id: string, groupId?: string | null) => {
     const response = await api.put(`/workflows/${id}`, { groupId: groupId || '' })
     return transformWorkflow(response.data)

+ 2 - 1
webui/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { Outlet, NavLink, useNavigate } from 'react-router-dom'
 import { useAuthStore } from '../stores/authStore'
-import { Workflow, Play, Settings, LogOut, Menu, Database, Code, Key } from 'lucide-react'
+import { Workflow, Play, Settings, LogOut, Menu, Database, Code, Key, FolderKanban} from 'lucide-react'
 import { useState } from 'react'
 import clsx from 'clsx'
 import { ThemeToggle } from './ThemeToggle'
@@ -20,6 +20,7 @@ export default function Layout() {
     { to: '/executions', icon: Play, label: 'Executions' },
     { to: '/nodes', icon: Code, label: 'Nodes' },
     { to: '/credentials', icon: Key, label: 'Credentials' },
+    { to: '/projects', icon: FolderKanban, label: 'Projects' },
     { to: '/database', icon: Database, label: 'Database' },
     { to: '/settings', icon: Settings, label: 'Settings' },
   ]

+ 324 - 0
webui/src/pages/ProjectsPage.tsx

@@ -0,0 +1,324 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { FolderKanban, Plus, Trash2, UserPlus, X, Lock, Users } from 'lucide-react'
+import { projectsApi, usersApi, Project } from '../api/users'
+import { useAuthStore } from '../stores/authStore'
+
+/**
+ * Projects: who work belongs to, and who may touch it.
+ *
+ * Everybody has a personal project - it is where their own work lives, and it
+ * cannot be shared or deleted. Team projects are the ones with members, and a
+ * member's role there decides what they can do to everything inside it.
+ */
+
+const ROLE_HELP: Record<string, string> = {
+  admin: 'The project itself, its members, and everything in it',
+  editor: 'Create, change, run and delete the work in it',
+  viewer: 'Look, and watch it run',
+}
+
+export default function ProjectsPage() {
+  const queryClient = useQueryClient()
+  const me = useAuthStore((s) => s.user)
+
+  const [showNew, setShowNew] = useState(false)
+  const [newProject, setNewProject] = useState({ name: '', description: '' })
+  const [managing, setManaging] = useState<Project | null>(null)
+  const [addUserId, setAddUserId] = useState('')
+  const [addRole, setAddRole] = useState('editor')
+  const [error, setError] = useState<string | null>(null)
+
+  const { data: projects = [], isLoading } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => projectsApi.list(),
+  })
+
+  // Only somebody who can manage users can read the whole list, so this is
+  // allowed to fail quietly - it only fills the "add a member" dropdown.
+  const { data: users = [] } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => usersApi.list(),
+    retry: false,
+  })
+
+  const refresh = () => queryClient.invalidateQueries({ queryKey: ['projects'] })
+  const fail = (e: any) => setError(e?.response?.data?.error || e.message || 'That did not work')
+
+  const createMutation = useMutation({
+    mutationFn: () => projectsApi.create(newProject),
+    onSuccess: () => { refresh(); setShowNew(false); setNewProject({ name: '', description: '' }); setError(null) },
+    onError: fail,
+  })
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: string) => projectsApi.remove(id),
+    onSuccess: () => { refresh(); setError(null) },
+    onError: (e: any) => {
+      // The refusal carries what the project still holds, which is the thing
+      // somebody needs in order to act on it.
+      const holding = e?.response?.data?.holding
+      const detail = holding
+        ? ' It still holds ' + Object.entries(holding).map(([k, v]) => `${v} ${k}`).join(', ') + '.'
+        : ''
+      setError((e?.response?.data?.error || 'Could not delete it') + detail)
+    },
+  })
+
+  const memberMutation = useMutation({
+    mutationFn: ({ action, project, userId, role }: any) => {
+      if (action === 'add') return projectsApi.addMember(project, userId, role)
+      if (action === 'role') return projectsApi.setMemberRole(project, userId, role)
+      return projectsApi.removeMember(project, userId)
+    },
+    onSuccess: (updated) => {
+      refresh()
+      setManaging(updated)
+      setAddUserId('')
+      setError(null)
+    },
+    onError: fail,
+  })
+
+  const personal = projects.filter((p) => p.type === 'personal')
+  const teams = projects.filter((p) => p.type !== 'personal')
+
+  const card = (project: Project) => {
+    const isPersonal = project.type === 'personal'
+    const canManage = project.myRole === 'admin'
+    return (
+      <div
+        key={project._id}
+        className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-4"
+      >
+        <div className="flex items-start justify-between mb-2">
+          <div className="flex items-center gap-2 min-w-0">
+            {isPersonal ? <Lock className="w-4 h-4 text-gray-400 shrink-0" /> : <Users className="w-4 h-4 text-primary-500 shrink-0" />}
+            <h3 className="font-medium text-gray-900 dark:text-gray-100 truncate">{project.name}</h3>
+          </div>
+          <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300 shrink-0">
+            {project.myRole}
+          </span>
+        </div>
+
+        {project.description && (
+          <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{project.description}</p>
+        )}
+
+        <div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
+          {project.workflowCount} workflow{project.workflowCount === 1 ? '' : 's'}
+          {!isPersonal && ` · ${project.members.length} member${project.members.length === 1 ? '' : 's'}`}
+        </div>
+
+        <div className="flex items-center gap-2">
+          {isPersonal ? (
+            <span className="text-xs text-gray-400">Your own - not shared</span>
+          ) : (
+            <>
+              <button
+                onClick={() => { setManaging(project); setError(null) }}
+                className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
+              >
+                Members
+              </button>
+              {canManage && (
+                <button
+                  onClick={() => {
+                    if (confirm(`Delete "${project.name}"? It has to be empty first.`)) {
+                      deleteMutation.mutate(project._id)
+                    }
+                  }}
+                  className="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400 rounded"
+                  title="Delete this project"
+                >
+                  <Trash2 className="w-4 h-4" />
+                </button>
+              )}
+            </>
+          )}
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="h-full flex flex-col">
+      <div className="h-14 px-6 flex items-center justify-between bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
+        <div className="flex items-center gap-2">
+          <FolderKanban className="w-5 h-5 text-gray-400" />
+          <h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Projects</h1>
+        </div>
+        <button
+          onClick={() => { setShowNew(true); setError(null) }}
+          className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+        >
+          <Plus className="w-4 h-4" />
+          New project
+        </button>
+      </div>
+
+      <div className="flex-1 overflow-auto p-6">
+        {error && (
+          <div className="mb-4 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm flex items-center justify-between">
+            <span>{error}</span>
+            <button onClick={() => setError(null)}><X className="w-4 h-4" /></button>
+          </div>
+        )}
+
+        {isLoading && <p className="text-gray-500 dark:text-gray-400">Reading the projects...</p>}
+
+        {teams.length > 0 && (
+          <>
+            <h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
+              Team projects
+            </h2>
+            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
+              {teams.map(card)}
+            </div>
+          </>
+        )}
+
+        <h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
+          Personal
+        </h2>
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          {personal.map(card)}
+        </div>
+
+        {!isLoading && projects.length === 0 && (
+          <p className="text-gray-500 dark:text-gray-400">
+            No projects yet. Yours should be here - if it is not, something went wrong creating your account.
+          </p>
+        )}
+      </div>
+
+      {showNew && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setShowNew(false)}>
+          <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
+            <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">New project</h3>
+            <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
+              You will be its admin. Add people to it afterwards.
+            </p>
+            {error && (
+              <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
+                {error}
+              </div>
+            )}
+            <input
+              autoFocus
+              placeholder="Name"
+              value={newProject.name}
+              onChange={(e) => setNewProject({ ...newProject, name: e.target.value })}
+              className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 mb-2"
+            />
+            <input
+              placeholder="What is it for? (optional)"
+              value={newProject.description}
+              onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
+              className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+            />
+            <div className="flex justify-end gap-2 mt-5">
+              <button onClick={() => setShowNew(false)} className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400">
+                Cancel
+              </button>
+              <button
+                onClick={() => createMutation.mutate()}
+                disabled={!newProject.name.trim() || createMutation.isPending}
+                className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
+              >
+                {createMutation.isPending ? 'Creating...' : 'Create'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {managing && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setManaging(null)}>
+          <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-5" onClick={(e) => e.stopPropagation()}>
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="font-semibold text-gray-900 dark:text-gray-100">{managing.name} · members</h3>
+              <button onClick={() => setManaging(null)}><X className="w-5 h-5 text-gray-400" /></button>
+            </div>
+
+            {error && (
+              <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
+                {error}
+              </div>
+            )}
+
+            <div className="divide-y divide-gray-100 dark:divide-slate-700 mb-4">
+              {managing.members.map((member) => (
+                <div key={member.userId} className="py-2 flex items-center gap-3 text-sm">
+                  <span className="flex-1 text-gray-900 dark:text-gray-100">
+                    {member.username}
+                    {member.userId === me?.id && <span className="text-xs text-gray-400 ml-1">you</span>}
+                  </span>
+                  <select
+                    value={member.role}
+                    disabled={managing.myRole !== 'admin' || memberMutation.isPending}
+                    onChange={(e) => memberMutation.mutate({
+                      action: 'role', project: managing._id, userId: member.userId, role: e.target.value,
+                    })}
+                    className="px-2 py-1 text-xs border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 disabled:opacity-50"
+                    title={ROLE_HELP[member.role]}
+                  >
+                    <option value="admin">Admin</option>
+                    <option value="editor">Editor</option>
+                    <option value="viewer">Viewer</option>
+                  </select>
+                  <button
+                    onClick={() => memberMutation.mutate({ action: 'remove', project: managing._id, userId: member.userId })}
+                    disabled={managing.myRole !== 'admin' && member.userId !== me?.id}
+                    className="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400 rounded disabled:opacity-40"
+                    title={member.userId === me?.id ? 'Leave this project' : 'Remove from the project'}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                  </button>
+                </div>
+              ))}
+            </div>
+
+            {managing.myRole === 'admin' && (
+              <div className="flex items-center gap-2">
+                <select
+                  value={addUserId}
+                  onChange={(e) => setAddUserId(e.target.value)}
+                  className="flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+                >
+                  <option value="">Add somebody...</option>
+                  {users
+                    .filter((u) => !managing.members.some((m) => m.userId === u.id))
+                    .map((u) => (
+                      <option key={u.id} value={u.id}>{u.username}</option>
+                    ))}
+                </select>
+                <select
+                  value={addRole}
+                  onChange={(e) => setAddRole(e.target.value)}
+                  className="px-2 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+                >
+                  <option value="admin">Admin</option>
+                  <option value="editor">Editor</option>
+                  <option value="viewer">Viewer</option>
+                </select>
+                <button
+                  onClick={() => memberMutation.mutate({ action: 'add', project: managing._id, userId: addUserId, role: addRole })}
+                  disabled={!addUserId || memberMutation.isPending}
+                  className="flex items-center gap-1 px-2 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
+                >
+                  <UserPlus className="w-4 h-4" />
+                  Add
+                </button>
+              </div>
+            )}
+
+            <p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
+              {ROLE_HELP[addRole]}
+            </p>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 89 - 5
webui/src/pages/WorkflowsPage.tsx

@@ -1,10 +1,10 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { Link, useNavigate, useSearchParams } from 'react-router-dom'
 import { workflowsApi, nodesApi, Workflow } from '../api/workflows'
-import { usersApi } from '../api/users'
+import { usersApi, projectsApi } from '../api/users'
 import { workflowGroupsApi, WorkflowGroup } from '../api/workflowGroups'
-import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput, Clock, User, Zap, Copy } from 'lucide-react'
-import { useState } from 'react'
+import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput, Clock, User, Zap, Copy, FolderKanban} from 'lucide-react'
+import { useState, useMemo} from 'react'
 import { formatDistanceToNow, format } from 'date-fns'
 import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
 import { GroupCard } from '../components/GroupCard'
@@ -253,6 +253,30 @@ export default function WorkflowsPage() {
     }
   }
 
+  // Which project's work is on screen. Folders organise a project's workflows;
+  // the project decides who can see them at all, so it sits above the folders.
+  const { data: projects = [] } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => projectsApi.list(),
+  })
+  const [projectFilter, setProjectFilter] = useState<string>('all')
+  const [workflowToMoveProject, setWorkflowToMoveProject] = useState<Workflow | null>(null)
+
+  const moveToProjectMutation = useMutation({
+    mutationFn: ({ id, projectId }: { id: string; projectId: string }) =>
+      workflowsApi.moveToProject(id, projectId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['workflows'] })
+      queryClient.invalidateQueries({ queryKey: ['projects'] })
+      setWorkflowToMoveProject(null)
+    },
+  })
+
+  const visibleWorkflows = useMemo(() => {
+    if (projectFilter === 'all') return workflows
+    return workflows.filter((w: any) => w.projectId === projectFilter)
+  }, [workflows, projectFilter])
+
   // Get current group name for title
   const currentGroup = currentPath.length > 0 ? currentPath[currentPath.length - 1] : null
   const pageTitle = currentGroup?.name || 'Workflows'
@@ -268,6 +292,19 @@ export default function WorkflowsPage() {
       <div className="flex items-center justify-between mb-6">
         <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{pageTitle}</h1>
         <div className="flex items-center gap-2">
+          {projects.length > 1 && (
+            <select
+              value={projectFilter}
+              onChange={(e) => setProjectFilter(e.target.value)}
+              className="px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300"
+              title="Show one project's workflows"
+            >
+              <option value="all">All projects</option>
+              {projects.map((p) => (
+                <option key={p._id} value={p._id}>{p.name}</option>
+              ))}
+            </select>
+          )}
           <button
             onClick={() => setShowNewGroupModal(true)}
             className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700"
@@ -285,11 +322,45 @@ export default function WorkflowsPage() {
         </div>
       </div>
 
+      {workflowToMoveProject && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setWorkflowToMoveProject(null)}>
+          <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
+            <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
+              Move "{workflowToMoveProject.name}"
+            </h3>
+            <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
+              A project decides who can see and change this workflow. Moving it may
+              take it away from people who can reach it now, or hand it to people who cannot.
+            </p>
+            <div className="space-y-1 max-h-60 overflow-y-auto">
+              {projects.map((p) => (
+                <button
+                  key={p._id}
+                  onClick={() => moveToProjectMutation.mutate({ id: workflowToMoveProject.id, projectId: p._id })}
+                  disabled={p._id === (workflowToMoveProject as any).projectId || moveToProjectMutation.isPending}
+                  className="w-full px-3 py-2 text-left text-sm rounded hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-40 flex items-center justify-between"
+                >
+                  <span className="text-gray-900 dark:text-gray-100">{p.name}</span>
+                  {p._id === (workflowToMoveProject as any).projectId && (
+                    <span className="text-xs text-gray-400">where it is now</span>
+                  )}
+                </button>
+              ))}
+            </div>
+            <div className="flex justify-end mt-4">
+              <button onClick={() => setWorkflowToMoveProject(null)} className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400">
+                Cancel
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
       {isLoading ? (
         <div className="flex items-center justify-center py-12">
           <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
         </div>
-      ) : groups.length === 0 && workflows.length === 0 ? (
+      ) : groups.length === 0 && visibleWorkflows.length === 0 ? (
         <div className="text-center py-12 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700">
           <p className="text-gray-500 dark:text-gray-400 mb-4">
             {currentGroupId ? 'This folder is empty' : 'No workflows yet'}
@@ -342,7 +413,7 @@ export default function WorkflowsPage() {
                 </h2>
               )}
               <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
-                {workflows.map((workflow) => (
+                {visibleWorkflows.map((workflow) => (
                   <WorkflowCard
                     key={workflow.id || Math.random()}
                     workflow={workflow}
@@ -362,6 +433,7 @@ export default function WorkflowsPage() {
                       }
                     }}
                     onMove={() => setWorkflowToMove(workflow)}
+                    onMoveToProject={() => setWorkflowToMoveProject(workflow)}
                     onDelete={() => setWorkflowToDelete(workflow)}
                   />
                 ))}
@@ -489,6 +561,7 @@ function WorkflowCard({
   onToggleActive,
   onDuplicate,
   onMove,
+  onMoveToProject,
   onDelete,
 }: {
   workflow: Workflow
@@ -498,6 +571,7 @@ function WorkflowCard({
   onToggleActive: () => void
   onDuplicate: () => void
   onMove: () => void
+  onMoveToProject: () => void
   onDelete: () => void
 }) {
   const [showMenu, setShowMenu] = useState(false)
@@ -570,6 +644,16 @@ function WorkflowCard({
                 >
                   <FolderInput className="w-4 h-4" /> Move to Folder
                 </button>
+                <button
+                  onClick={() => {
+                    onMoveToProject()
+                    setShowMenu(false)
+                  }}
+                  className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2"
+                  title="A folder tidies a project up; a project decides who can see this at all"
+                >
+                  <FolderKanban className="w-4 h-4" /> Move to Project
+                </button>
                 <button
                   onClick={() => {
                     onDelete()