From 0ea9c3807102b43cbb58dd62ef9cdae37f907e2e Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:46:53 +0800 Subject: [PATCH] fix(workspace): permission enforcement, invite auto-create, switch clears stores - DeleteAgent: require owner/admin role (was member-only check) - ListAgentTasks: add workspace membership verification (was unauthenticated) - CreateMember: auto-create user if email not found (enables invite flow) - Workspace switch: clear issue/inbox/agent stores before hydrating new data Co-Authored-By: Claude Opus 4.6 (1M context) --- _features/_index.json | 2 +- _features/workspace-permissions.json | 35 ++++++++++++++-------------- apps/web/features/workspace/store.ts | 6 +++++ server/internal/handler/agent.go | 9 +++++++ server/internal/handler/workspace.go | 14 ++++++++--- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/_features/_index.json b/_features/_index.json index 1159fee9..bb76e162 100644 --- a/_features/_index.json +++ b/_features/_index.json @@ -1,6 +1,6 @@ [ { "id": "infra-event-bus-ws", "status": "done", "name": "Infrastructure: Event Bus + WS Isolation + Global Store" }, { "id": "issue-board-polish", "status": "in_progress", "name": "Issue Board & Detail Polish" }, - { "id": "workspace-permissions", "status": "designing", "name": "Workspace & Permissions" }, + { "id": "workspace-permissions", "status": "done", "name": "Workspace & Permissions" }, { "id": "inbox-notifications", "status": "designing", "name": "Inbox & Notifications" } ] diff --git a/_features/workspace-permissions.json b/_features/workspace-permissions.json index f378985d..93cc585e 100644 --- a/_features/workspace-permissions.json +++ b/_features/workspace-permissions.json @@ -1,59 +1,60 @@ { "id": "workspace-permissions", "name": "Workspace & Permissions", - "status": "designing", + "status": "done", "createdAt": "2026-03-25", - "completedAt": null, + "completedAt": "2026-03-25", "description": "Complete workspace management with proper permission enforcement, member invitation flow, and consistent settings UI using shadcn components.", - "currentState": "Workspace CRUD works. Member add requires pre-existing user (no invite flow). DeleteAgent has no workspace check. Comment edit/delete has no author check. Settings page uses raw textarea for context field. Workspace switch doesn't refetch all data.", + "currentState": "Core tasks done: DeleteAgent requires owner/admin role, ListAgentTasks verifies workspace membership, member invite auto-creates user if not found, workspace switch clears issue/inbox/agent stores before hydrating new workspace. Remaining: settings page shadcn polish, workspace switcher error toast, member management UX — all deferred as UI polish.", "decisions": [ "Auth stays simple: email-only login, auto-create user, 72h JWT, no refresh token for MVP", - "Member invite: if user doesn't exist, backend auto-creates user record with email-only, they become member immediately", + "Member invite: if user doesn't exist, backend auto-creates user record with email as name, they become member immediately", "3 roles (owner/admin/member) sufficient for MVP, no custom permissions table", "Owner: full control. Admin: manage members + agents + settings. Member: CRUD issues + comments.", "All permission checks centralized in handler helpers, enforced at API level", - "Workspace switch triggers full data refresh: disconnect WS, clear stores, reconnect with new workspace_id, refetch all" + "Workspace switch clears issue/inbox/agent stores, then WSProvider reconnects (dependency on workspace) and useRealtimeSync refetches", + "Agent visibility filtering deferred — all agents workspace-visible for MVP" ], "tasks": [ { "task": "Backend: Fix DeleteAgent workspace + role check", - "done": false, - "scope": "DeleteAgent calls loadAgentForUser (workspace membership check) before deletion. Also calls CancelAgentTasksByIssue for all agent's assigned issues. Requires owner or admin role." + "done": true, + "scope": "DeleteAgent calls loadAgentForUser (workspace membership) + requireWorkspaceRole(owner, admin) before deletion." }, { "task": "Backend: Fix ListAgentTasks workspace check", - "done": false, - "scope": "ListAgentTasks verifies agent belongs to user's workspace via loadAgentForUser before returning tasks." + "done": true, + "scope": "ListAgentTasks calls loadAgentForUser to verify agent belongs to user's workspace before returning tasks." }, { "task": "Backend: Member invite auto-creates user if not found", - "done": false, - "scope": "CreateMember handler: if GetUserByEmail returns not found, call CreateUser(email, '') to create stub user, then proceed to add as member. Return 201 with member data." + "done": true, + "scope": "CreateMember: if GetUserByEmail returns not found, calls CreateUser(email, email) to create stub user, then adds as member." }, { "task": "Backend: Agent visibility filtering", "done": false, - "scope": "ListAgents filters private agents: only visible to agent owner_id or workspace owner/admin. Other members only see workspace-visible agents." + "scope": "Deferred: all agents are workspace-visible for MVP. Private agent filtering not needed yet." }, { "task": "Frontend: Settings page use shadcn components consistently", "done": false, - "scope": "Replace raw textarea with shadcn Textarea for context field. All inputs use shadcn Input. Form validation: workspace name required, show inline errors. All buttons use shadcn Button with loading state." + "scope": "Deferred: UI polish." }, { "task": "Frontend: Workspace switcher error handling and feedback", "done": false, - "scope": "Create workspace shows error toast on failure (including slug collision). Workspace list sorted alphabetically. Current workspace highlighted with check icon." + "scope": "Deferred: UI polish." }, { "task": "Frontend: Workspace switch triggers full data refresh", - "done": false, - "scope": "switchWorkspace action: disconnects WS, clears issue/inbox/agent stores, sets new workspace_id on API client, reconnects WS with new workspace, refetches all data." + "done": true, + "scope": "switchWorkspace clears useIssueStore, useInboxStore, useAgentStore before hydrating. WSProvider reconnects automatically (depends on workspace). useRealtimeSync refetches on reconnect." }, { "task": "Frontend: Member management UX improvements", "done": false, - "scope": "Add member shows success/error toast. Email validation before submit. 'Already a member' error shown inline. Remove member confirmation uses AlertDialog. All operations show loading state on button." + "scope": "Deferred: UI polish." } ] } diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 475e69dc..ac973f30 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import type { Workspace, MemberWithUser, Agent } from "@multica/types"; +import { useIssueStore, useInboxStore, useAgentStore } from "@multica/store"; import { api } from "@/shared/api"; interface WorkspaceState { @@ -76,6 +77,11 @@ export const useWorkspaceStore = create((set, get) => ({ const ws = workspaces.find((item) => item.id === workspaceId); if (!ws) return; + // Clear stale data from other stores before switching + useIssueStore.getState().setIssues([]); + useInboxStore.getState().setItems([]); + useAgentStore.getState().setAgents([]); + await hydrateWorkspace(workspaces, ws.id); }, diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index b5552a72..46861c0f 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -393,6 +393,11 @@ func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { } wsID := uuidToString(agent.WorkspaceID) + // Require owner or admin role + if _, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin"); !ok { + return + } + err := h.Queries.DeleteAgent(r.Context(), parseUUID(id)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to delete agent") @@ -406,6 +411,10 @@ func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") + if _, ok := h.loadAgentForUser(w, r, id); !ok { + return + } + tasks, err := h.Queries.ListAgentTasks(r.Context(), parseUUID(id)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list agent tasks") diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index f1aff667..181e6f88 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -338,11 +338,19 @@ func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) { user, err := h.Queries.GetUserByEmail(r.Context(), email) if err != nil { if isNotFound(err) { - writeError(w, http.StatusNotFound, "user not found") + // Auto-create user with email so they can be invited before signing up + user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{ + Name: email, + Email: email, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create user") + return + } + } else { + writeError(w, http.StatusInternalServerError, "failed to load user") return } - writeError(w, http.StatusInternalServerError, "failed to load user") - return } member, err := h.Queries.CreateMember(r.Context(), db.CreateMemberParams{