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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-25 10:46:53 +08:00
parent 2c02aa357d
commit 0ea9c38071
5 changed files with 45 additions and 21 deletions

View file

@ -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" }
]

View file

@ -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."
}
]
}

View file

@ -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<WorkspaceStore>((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);
},

View file

@ -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")

View file

@ -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{