Kaynağa Gözat

feat: the project is a global selection that workflows, folders, executions and credentials respect

It could only be changed on the workflows page, so nothing else knew about
it - the folder tree showed every folder whichever project was selected,
and the executions list showed every run on the instance. The selector is
in the sidebar now, above the navigation, so the answer to "which project
am I looking at" is always on screen. The choice is held in one store and
persisted, so moving between pages does not quietly move you between
projects.

Every filter is applied by the server on an indexed field, not by fetching
everything and discarding most of it in the browser. Executions have no
project of their own - they carry a workflowId - so a project is resolved
to its workflows and matched on those.

**That work exposed a permission gap.** The executions listing had no
reachability check at all: it showed runs of workflows in projects the
caller cannot open. Every workflow endpoint has always refused those;
their execution history did not. It is filtered now, and an empty set of
reachable workflows answers with nothing rather than with everything.

**A workflow now follows its folder into that folder's project**, which is
the answer to "what if I want to move a workflow into another project's
folder". A folder belongs to a project, so what is inside it belongs to
the same project; anything else leaves the tree and the workflow
disagreeing. Moving a folder between projects takes its workflows with it,
for the same reason. Both need the right to write in the project being
moved into.

That state already existed and was invisible: the folder named
"smartbotics" sat in admin's project while holding a workflow in the
smartbotics project, so that workflow appeared in neither listing once
folders started respecting projects. Fixed by moving the folder to match
its name, which leaves the workflow where it was deliberately put.

Credentials filter by project too, with one exception written into the
code: a credential shared with you personally stays visible whatever
project is selected. Sharing is about a person, not a project, and hiding
one somebody was deliberately given would make the share useless.

Verified in the browser and against the API: selecting smartbotics shows
its one folder, its one workflow, only its executions and no credentials;
selecting admin's project shows three folders, eight workflows and seven
credentials. 70 passed, 0 failed.

Noted, not fixed: the executions listing reports `total` as the number of
rows on the page rather than the collection count, so it says "20 of 20"
while hasMore is true. That predates this and is on the workflowId path
too.
fszontagh 1 ay önce
ebeveyn
işleme
1862413fe7

+ 16 - 0
src/webserver/api/credential_controller.cpp

@@ -236,10 +236,26 @@ void CredentialController::listCredentials(const httplib::Request& req, httplib:
     // Only the projects this caller can reach. A credential is the most
     // sensitive thing here - it is somebody's password to another system.
     const auto reachable = access_.projectsFor(ctx);
+
+    // And only the selected project, when one is selected. A credential shared
+    // with somebody outside its project stays visible either way: sharing is
+    // about a person, not a project, and hiding a credential somebody was
+    // deliberately given would make the share useless.
+    std::string wanted_project;
+    if (req.has_param("projectId")) wanted_project = req.get_param_value("projectId");
     const auto usage = credentialUsage();
     nlohmann::json credentials_json = nlohmann::json::array();
     for (const auto& cred : result.value()) {
         if (!reachable.contains(cred.project_id) && !mayUse(ctx, cred)) continue;
+        if (!wanted_project.empty() && cred.project_id != wanted_project) {
+            // Shared credentials are not somebody's project's, so a project
+            // filter does not take them away.
+            bool shared_with_me = false;
+            for (const auto& user_id : cred.shared_with) {
+                if (user_id == ctx.user_id) { shared_with_me = true; break; }
+            }
+            if (!shared_with_me) continue;
+        }
         auto entry = cred.toJson();
         const auto found = usage.find(cred.id);
         entry["usedByWorkflows"] = found == usage.end() ? 0 : found->second;

+ 45 - 0
src/webserver/api/execution_controller.cpp

@@ -184,6 +184,51 @@ void ExecutionController::listExecutions(const httplib::Request& req, httplib::R
         options.filters.push_back({"workflowId", req.get_param_value("workflowId")});
     }
 
+    // Executions belong to a project through their workflow - the record itself
+    // carries only a workflowId - so a project is narrowed to the workflows in
+    // it and matched on those.
+    //
+    // This is also where reachability is applied, which was missing entirely:
+    // the listing showed every execution on the instance, including runs of
+    // workflows in projects the caller cannot open. The workflow endpoints have
+    // always refused those; their execution history did not.
+    if (!req.has_param("workflowId")) {
+        const auto reachable = access_.projectsFor(ctx);
+        std::string wanted_project;
+        if (req.has_param("projectId")) wanted_project = req.get_param_value("projectId");
+
+        storage::QueryOptions wf_opts;
+        wf_opts.page_size = 1000;
+        if (!wanted_project.empty()) {
+            wf_opts.filters.push_back({"projectId", wanted_project});
+        }
+        auto workflows = storage_.query("workflows", wf_opts);
+
+        std::vector<std::string> ids;
+        if (workflows.ok()) {
+            for (const auto& wf : workflows.value().documents) {
+                const std::string project = auth::AccessControl::projectOf(wf);
+                if (!reachable.contains(project)) continue;
+                if (!wanted_project.empty() && project != wanted_project) continue;
+                ids.push_back(wf.value("_id", ""));
+            }
+        }
+
+        if (ids.empty()) {
+            // Nothing reachable means nothing to show. Answering with the whole
+            // collection instead would be the failure this exists to prevent.
+            sendJson(res, {{"executions", nlohmann::json::array()},
+                           {"total", 0}, {"page", 1}, {"pageSize", 0}, {"hasMore", false}});
+            return;
+        }
+        if (ids.size() == 1) {
+            options.filters.push_back({"workflowId", ids.front()});
+        } else {
+            options.filters.push_back({"workflowId",
+                nlohmann::json{{"op", "in"}, {"value", ids}}});
+        }
+    }
+
     // status and triggerType accept a comma separated list, because the useful
     // question on this listing is "show me failed and cancelled", not one at a
     // time.

+ 51 - 0
src/webserver/api/workflow_controller.cpp

@@ -195,6 +195,21 @@ void WorkflowController::listWorkflows(const httplib::Request& req, httplib::Res
                                         const auth::AuthContext& ctx) {
     storage::QueryOptions options;
 
+    // One project, when one is asked for. An exact match on an indexed field, so
+    // the database does the narrowing rather than the caller receiving every
+    // workflow and discarding most of them.
+    //
+    // A project the caller cannot reach filters to nothing rather than being
+    // refused: the list is a list, and whether that project exists is not
+    // something to disclose through it.
+    std::string wanted_project;
+    if (req.has_param("projectId")) {
+        wanted_project = req.get_param_value("projectId");
+        if (!wanted_project.empty()) {
+            options.filters.push_back({"projectId", wanted_project});
+        }
+    }
+
     // Parse query params
     if (req.has_param("groupId")) {
         std::string group_id = req.get_param_value("groupId");
@@ -406,6 +421,42 @@ void WorkflowController::updateWorkflow(const httplib::Request& req, httplib::Re
 
         auto body = nlohmann::json::parse(req.body);
 
+        // Moving it into a folder moves it into that folder's project.
+        //
+        // A folder belongs to a project, so a workflow inside one belongs to
+        // the same project - anything else leaves the tree saying one thing and
+        // the workflow saying another, which is how a workflow ended up in the
+        // smartbotics project inside a folder owned by somebody else's and
+        // disappeared from both listings.
+        //
+        // It follows the folder rather than being refused, because organising
+        // work across projects is a reasonable thing to want. It still needs the
+        // right to write in the project it is going to.
+        if (body.contains("groupId") && body["groupId"].is_string()) {
+            const std::string target_group = body["groupId"];
+            if (!target_group.empty()) {
+                auto group = storage_.get("workflow_groups", target_group);
+                if (group.ok()) {
+                    const std::string group_project =
+                        auth::AccessControl::projectOf(group.value());
+                    const std::string current_project =
+                        body.contains("projectId") && body["projectId"].is_string()
+                            ? body["projectId"].get<std::string>()
+                            : auth::AccessControl::projectOf(existing);
+                    if (!group_project.empty() && group_project != current_project) {
+                        if (!access_.allowed(ctx, group_project, auth::Action::Write)) {
+                            sendError(res, "That folder belongs to a project you cannot write to",
+                                      403);
+                            return;
+                        }
+                        body["projectId"] = group_project;
+                        LOG_INFO("Workflow {} follows folder {} into project {}", id,
+                                 target_group, group_project);
+                    }
+                }
+            }
+        }
+
         // Moving it to another project is a change of who can reach it, so it
         // needs the right to write in the project it is going to as well as
         // the one it is leaving.

+ 44 - 0
src/webserver/api/workflow_group_controller.cpp

@@ -94,6 +94,16 @@ void WorkflowGroupController::listGroups(const httplib::Request& req, httplib::R
                                           const auth::AuthContext& ctx) {
     storage::QueryOptions options;
 
+    // Folders belong to a project like everything else. Without this the same
+    // folders appeared whichever project was selected, so the tree said one
+    // thing and the workflows in it said another.
+    if (req.has_param("projectId")) {
+        const std::string project = req.get_param_value("projectId");
+        if (!project.empty()) {
+            options.filters.push_back({"projectId", project});
+        }
+    }
+
     // Filter by parent ID - "root" means top-level groups (no parent)
     if (req.has_param("parentId")) {
         std::string parent_id = req.get_param_value("parentId");
@@ -242,6 +252,40 @@ void WorkflowGroupController::updateGroup(const httplib::Request& req, httplib::
 
         auto body = nlohmann::json::parse(req.body);
 
+        // Moving a folder to another project takes its workflows with it.
+        //
+        // Leaving them behind is what produced the state this was written for: a
+        // folder in one project holding a workflow in another, so the workflow
+        // showed in neither listing - not in its own project, because its folder
+        // was not there, and not in the folder's project, because it was not.
+        if (body.contains("projectId") && body["projectId"].is_string()) {
+            const std::string target = body["projectId"];
+            const std::string current = auth::AccessControl::projectOf(existing);
+            if (!target.empty() && target != current) {
+                if (!access_.allowed(ctx, target, auth::Action::Write)) {
+                    sendError(res, "You cannot move this folder into a project you cannot "
+                                   "write to", 403);
+                    return;
+                }
+                storage::QueryOptions in_folder;
+                in_folder.page_size = 500;
+                in_folder.filters.push_back({"groupId", id});
+                auto contents = storage_.query("workflows", in_folder);
+                int moved = 0;
+                if (contents.ok()) {
+                    for (const auto& wf : contents.value().documents) {
+                        const std::string wf_id = wf.value("_id", "");
+                        if (wf_id.empty()) continue;
+                        nlohmann::json patch;
+                        patch["projectId"] = target;
+                        if (storage_.update("workflows", wf_id, patch, 0, true).ok()) moved++;
+                    }
+                }
+                LOG_INFO("Folder {} moved to project {}, taking {} workflows with it",
+                         id, target, moved);
+            }
+        }
+
         // Prevent updating metadata fields
         body.erase("id");
         body.erase("_id");

+ 9 - 2
webui/src/api/credentials.ts

@@ -121,8 +121,15 @@ function transformCredential(data: any): CredentialInfo {
 }
 
 export const credentialsApi = {
-  list: async (): Promise<{ credentials: CredentialInfo[] }> => {
-    const response = await api.get('/credentials')
+  /**
+   * Credentials in the selected project, or every reachable one when no project
+   * is given. A credential shared with you personally is always included -
+   * sharing is about a person, not a project.
+   */
+  list: async (projectId?: string): Promise<{ credentials: CredentialInfo[] }> => {
+    const response = await api.get('/credentials', {
+      params: projectId ? { projectId } : {},
+    })
     return {
       ...response.data,
       credentials: (response.data.credentials || []).map(transformCredential),

+ 7 - 1
webui/src/api/workflowGroups.ts

@@ -30,11 +30,17 @@ export const workflowGroupsApi = {
    * List workflow groups
    * @param parentId - Filter by parent group. Use 'root' or undefined for top-level groups
    */
-  list: async (parentId?: string) => {
+  list: async (parentId?: string, projectId?: string) => {
     const params: Record<string, string> = {}
     if (parentId !== undefined) {
       params.parentId = parentId
     }
+    // Folders belong to a project. Without this the same folders appeared
+    // whichever project was selected, so the tree and the workflows in it
+    // disagreed.
+    if (projectId) {
+      params.projectId = projectId
+    }
     const response = await api.get('/workflow-groups', { params })
     return {
       ...response.data,

+ 11 - 1
webui/src/api/workflows.ts

@@ -132,11 +132,16 @@ export const workflowsApi = {
    * @param pageSize - Number of items per page
    * @param groupId - Filter by group ID. Use 'root' or empty string for ungrouped workflows
    */
-  list: async (page = 1, pageSize = 20, groupId?: string) => {
+  list: async (page = 1, pageSize = 20, groupId?: string, projectId?: string) => {
     const params: Record<string, any> = { page, pageSize }
     if (groupId !== undefined) {
       params.groupId = groupId
     }
+    // Narrowed by the server on an indexed field, rather than fetching every
+    // workflow and discarding most of them here.
+    if (projectId) {
+      params.projectId = projectId
+    }
     const response = await api.get('/workflows', { params })
     return {
       ...response.data,
@@ -537,6 +542,8 @@ function transformExecutionDetail(data: any): ExecutionDetail {
 }
 
 export interface ExecutionQuery {
+  /** Only executions of workflows in this project. Omit for every project reachable. */
+  projectId?: string
   workflowId?: string
   /** One or more statuses. Sent as a comma separated list; the backend turns several into an IN filter. */
   status?: string[]
@@ -590,6 +597,9 @@ export const executionsApi = {
       pageSize: query.pageSize ?? 20,
     }
     if (query.workflowId) params.workflowId = query.workflowId
+    // An execution belongs to a project through its workflow, so the server
+    // resolves the project to its workflows and matches on those.
+    if (query.projectId) params.projectId = query.projectId
     if (query.status?.length) params.status = query.status.join(',')
     if (query.triggerType?.length) params.triggerType = query.triggerType.join(',')
     if (query.search?.trim()) params.search = query.search.trim()

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

@@ -1,13 +1,32 @@
 import { Outlet, NavLink, useNavigate } from 'react-router-dom'
 import { useAuthStore } from '../stores/authStore'
 import { Workflow, Play, Settings, LogOut, Menu, Database, Code, Key, FolderKanban} from 'lucide-react'
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { useProjectStore } from '../stores/projectStore'
+import { projectsApi } from '../api/users'
 import clsx from 'clsx'
 import { ThemeToggle } from './ThemeToggle'
 
 export default function Layout() {
   const { user, logout } = useAuthStore()
   const navigate = useNavigate()
+  const projectId = useProjectStore((s) => s.projectId)
+  const setProject = useProjectStore((s) => s.setProject)
+
+  const { data: projects = [] } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => projectsApi.list(),
+    staleTime: 5 * 60 * 1000,
+  })
+
+  // A project that has gone - deleted, or access removed - would otherwise
+  // leave the selector showing a blank and every page filtering to nothing.
+  useEffect(() => {
+    if (projectId && projects.length && !projects.some((p) => p._id === projectId)) {
+      setProject(null)
+    }
+  }, [projectId, projects, setProject])
   const [sidebarOpen, setSidebarOpen] = useState(false)
 
   const handleLogout = () => {
@@ -48,6 +67,30 @@ export default function Layout() {
             <span className="text-xl font-bold text-primary-600 dark:text-primary-400">SmartBotic</span>
           </div>
 
+          {/* Which project everything below is showing. Above the navigation
+              rather than inside a page, because it decides what several pages
+              show and used to be visible on only one of them. */}
+          <div className="px-4 pt-4">
+            <label className="block text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-1">
+              Project
+            </label>
+            <select
+              value={projectId ?? ''}
+              onChange={(e) => setProject(e.target.value || null)}
+              className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100"
+              title={
+                projectId
+                  ? 'Workflows, folders and executions are limited to this project'
+                  : 'Showing every project you can reach'
+              }
+            >
+              <option value="">All projects</option>
+              {projects.map((p) => (
+                <option key={p._id} value={p._id}>{p.name}</option>
+              ))}
+            </select>
+          </div>
+
           {/* Navigation */}
           <nav className="flex-1 p-4 space-y-1">
             {navItems.map((item) => (

+ 8 - 6
webui/src/pages/CredentialsPage.tsx

@@ -1,3 +1,4 @@
+import { useProjectStore } from '../stores/projectStore'
 import { useState } from 'react'
 import { projectsApi, Project, usersApi } from '../api/users'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
@@ -102,6 +103,7 @@ const CREDENTIAL_TYPE_INFO: Record<
 }
 
 export default function CredentialsPage() {
+  const projectId = useProjectStore((s) => s.projectId)
   const queryClient = useQueryClient()
   const [searchTerm, setSearchTerm] = useState('')
   const [showCreateModal, setShowCreateModal] = useState(false)
@@ -111,14 +113,14 @@ export default function CredentialsPage() {
   const [credentialToDelete, setCredentialToDelete] = useState<CredentialInfo | null>(null)
 
   const { data, isLoading, error } = useQuery({
-    queryKey: ['credentials'],
-    queryFn: () => credentialsApi.list(),
+    queryKey: ['credentials', projectId],
+    queryFn: () => credentialsApi.list(projectId || undefined),
   })
 
   const deleteMutation = useMutation({
     mutationFn: (id: string) => credentialsApi.delete(id),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['credentials'] })
+      queryClient.invalidateQueries({ queryKey: ['credentials', projectId] })
       setShowDeleteModal(false)
       setCredentialToDelete(null)
     },
@@ -127,7 +129,7 @@ export default function CredentialsPage() {
   const refreshOAuth2Mutation = useMutation({
     mutationFn: (id: string) => credentialsApi.refreshOAuth2(id),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['credentials'] })
+      queryClient.invalidateQueries({ queryKey: ['credentials', projectId] })
     },
   })
 
@@ -336,7 +338,7 @@ export default function CredentialsPage() {
           onClose={() => setSharingCredential(null)}
           onChanged={(updated) => {
             setSharingCredential(updated)
-            queryClient.invalidateQueries({ queryKey: ['credentials'] })
+            queryClient.invalidateQueries({ queryKey: ['credentials', projectId] })
           }}
         />
       )}
@@ -350,7 +352,7 @@ export default function CredentialsPage() {
             setEditingCredential(null)
           }}
           onSuccess={() => {
-            queryClient.invalidateQueries({ queryKey: ['credentials'] })
+            queryClient.invalidateQueries({ queryKey: ['credentials', projectId] })
             setShowCreateModal(false)
             setEditingCredential(null)
           }}

+ 6 - 3
webui/src/pages/ExecutionsPage.tsx

@@ -1,3 +1,4 @@
+import { useProjectStore } from '../stores/projectStore'
 import { useState, useEffect, useMemo } from 'react'
 import { useNavigate, useSearchParams } from 'react-router-dom'
 import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
@@ -88,6 +89,7 @@ function durationOf(execution: ExecutionListItem, now: number): number {
 }
 
 export default function ExecutionsPage() {
+  const projectId = useProjectStore((s) => s.projectId)
   const navigate = useNavigate()
   const queryClient = useQueryClient()
   const [searchParams, setSearchParams] = useSearchParams()
@@ -163,13 +165,14 @@ export default function ExecutionsPage() {
   const { data, isLoading, isFetching, refetch } = useQuery({
     // sort belongs in the key because "oldest first" is a different request, not
     // a different rendering of the same one.
-    queryKey: ['executions', { search, statuses, workflowId, triggerType, range, sort, page }],
+    queryKey: ['executions', { search, statuses, workflowId, triggerType, range, sort, page, projectId }],
     queryFn: () =>
       executionsApi.query({
         search,
         status: statuses,
         triggerType: triggerType ? [triggerType] : undefined,
         workflowId: workflowId || undefined,
+        projectId: projectId || undefined,
         startedAfter,
         sortOrder: sort === 'oldest' ? 'asc' : 'desc',
         page,
@@ -183,8 +186,8 @@ export default function ExecutionsPage() {
   // Names for the workflow filter. A large pageSize because this is a picker,
   // not a listing, and paging it would hide workflows from the filter.
   const { data: workflowsData } = useQuery({
-    queryKey: ['workflows', 'all-for-filter'],
-    queryFn: () => workflowsApi.list(1, 200),
+    queryKey: ['workflows', 'all-for-filter', projectId],
+    queryFn: () => workflowsApi.list(1, 200, undefined, projectId || undefined),
     staleTime: 5 * 60 * 1000,
   })
 

+ 10 - 21
webui/src/pages/WorkflowsPage.tsx

@@ -4,6 +4,7 @@ import { workflowsApi, nodesApi, Workflow } from '../api/workflows'
 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, FolderKanban} from 'lucide-react'
+import { useProjectStore } from '../stores/projectStore'
 import { useState, useMemo} from 'react'
 import { formatDistanceToNow, format } from 'date-fns'
 import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
@@ -96,17 +97,19 @@ export default function WorkflowsPage() {
   })
   const currentPath = pathData || []
 
+  const projectId = useProjectStore((s) => s.projectId)
+
   // Fetch groups in current location
   const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
-    queryKey: ['workflow-groups', currentGroupId || 'root'],
-    queryFn: () => workflowGroupsApi.list(currentGroupId || 'root'),
+    queryKey: ['workflow-groups', currentGroupId || 'root', projectId],
+    queryFn: () => workflowGroupsApi.list(currentGroupId || 'root', projectId || undefined),
   })
   const groups: WorkflowGroup[] = groupsData?.groups || []
 
   // Fetch workflows in current location
   const { data: workflowsData, isLoading: isLoadingWorkflows } = useQuery({
-    queryKey: ['workflows', currentGroupId || ''],
-    queryFn: () => workflowsApi.list(1, 100, currentGroupId || ''),
+    queryKey: ['workflows', currentGroupId || '', projectId],
+    queryFn: () => workflowsApi.list(1, 100, currentGroupId || '', projectId || undefined),
   })
   const workflows: Workflow[] = workflowsData?.workflows || []
 
@@ -259,7 +262,6 @@ export default function WorkflowsPage() {
     queryKey: ['projects'],
     queryFn: () => projectsApi.list(),
   })
-  const [projectFilter, setProjectFilter] = useState<string>('all')
   const [workflowToMoveProject, setWorkflowToMoveProject] = useState<Workflow | null>(null)
 
   const moveToProjectMutation = useMutation({
@@ -273,9 +275,9 @@ export default function WorkflowsPage() {
   })
 
   const visibleWorkflows = useMemo(() => {
-    if (projectFilter === 'all') return workflows
-    return workflows.filter((w: any) => w.projectId === projectFilter)
-  }, [workflows, projectFilter])
+    // The server has already narrowed these to the selected project.
+    return workflows
+  }, [workflows])
 
   // Get current group name for title
   const currentGroup = currentPath.length > 0 ? currentPath[currentPath.length - 1] : null
@@ -292,19 +294,6 @@ 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"

+ 43 - 0
webui/src/stores/projectStore.ts

@@ -0,0 +1,43 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+/**
+ * Which project the interface is currently showing.
+ *
+ * One choice, held in one place, because it decides what several pages show.
+ * It used to be local state on the workflows page, so nothing else knew about
+ * it: the folder tree showed every folder whatever was selected, and the
+ * executions list showed every run on the instance.
+ *
+ * `null` means all projects the user can reach. That is a real choice rather
+ * than an unset value - somebody with several projects usually wants to see
+ * everything, and a filter that cannot be turned off hides work.
+ *
+ * Persisted, so changing page or reloading does not silently move you to a
+ * different project than the one you were looking at.
+ */
+interface ProjectState {
+  projectId: string | null
+  setProject: (projectId: string | null) => void
+}
+
+export const useProjectStore = create<ProjectState>()(
+  persist(
+    (set) => ({
+      projectId: null,
+      setProject: (projectId) => set({ projectId }),
+    }),
+    { name: 'smartbotic-project' }
+  )
+)
+
+/**
+ * The value to send as a `projectId` query parameter, or undefined for all.
+ *
+ * A helper rather than each caller writing the same conditional, so no page
+ * accidentally sends the string "null" or an empty parameter that filters
+ * everything away.
+ */
+export function projectParam(projectId: string | null): string | undefined {
+  return projectId || undefined
+}