feat(web): full CRUD for workspace member management, real-time sync, and multi-workspace fallback (closes #224) - #231
Conversation
ForgeGuard
🔴 blocks the merge · 🟡 advisory |
resincode
left a comment
There was a problem hiding this comment.
Tested locally (isolated worktree off main, full uv sync --all-packages + pnpm install, real Postgres+pgvector and Redis instances, no mocks disabled). All claimed checks verified accurate:
| Claim | Result |
|---|---|
| mypy 0 errors, 7 packages | ✅ |
| ruff format/check | ✅ |
packages/shared/openapi.json up to date |
✅ no diff |
tsc --noEmit |
✅ |
eslint . --max-warnings=0 |
✅ |
| vitest | ✅ 82/82 files, 630/630 tests |
| pytest (backend) | ✅ full suite green (2 initial failures in test_agent_chat.py / test_runs_create.py were my sandbox missing Redis, not this PR — re-ran with Redis up, both pass) |
🔴 Blocking: the flagship "seamless fallback" never actually happens
apps/web/src/routes/_app.tsx, handleWorkspaceFallback (~line 145-197):
if (remaining.length > 0) {
const nextWs = remaining[0]!;
setWorkspaceId(nextWs.workspace_id);
broadcastWorkspaceSwitch(nextWs.workspace_id);
...
window.location.assign("/dashboard");
const safeReason: "removed" | "left" = reason === "left" ? "left" : "removed";
void logoutAndRedirect(safeReason); // <-- runs unconditionally inside this branch
}logoutAndRedirect (apps/web/src/lib/auth-session.ts:36-77) clears useActiveWorkspace/useActiveProject, broadcasts a logout message to every other tab, POST /auth/cookie/logout (kills the real session cookie server-side), then window.location.assign("/login?reason=..."). It runs every time handleWorkspaceFallback is called, including the remaining.length > 0 branch that just switched the user to nextWs — so the "Remaining ≥ 1 → switch workspace, keep session" path in your own architecture diagram never survives: the switch is immediately undone by the unconditional logout call right after it. End-to-end the user is bounced to /login, not /dashboard, exactly the behavior this PR sets out to fix.
Grep confirms there's no test coverage for this path (workspace.member.removed / handleWorkspaceFallback don't appear in any *.test.tsx), which is how it shipped without being caught by the 630 passing tests.
Fix: move logoutAndRedirect into the else branch (0 remaining), or an early return after the remaining.length > 0 block completes.
🟡 Merge-order conflict with another open PR
apps/web/src/routes/_app/settings.tsx — this PR changes:
-{workspaceId ? <TabsTrigger value="api-keys">API Keys</TabsTrigger> : null}
+{showMembers && workspaceId ? <TabsTrigger value="api-keys">API Keys</TabsTrigger> : null}and wraps the whole API Keys TabsContent in showMembers && workspaceId too. #229 (open, same base) changes the same area to let QA write API keys (canWriteApiKeys = role in {OWNER, ADMIN, QA}), passed to <ApiKeysSettingsPanel canWrite={canWriteApiKeys}>. Whichever of these two merges second will silently re-hide the API Keys tab from QA regardless of the other PR's intent — worth a heads-up for whoever merges last, not a request to resolve it in either PR.
Sidebar.tsx is also touched by both PRs but on unrelated lines (Eval nav gating here vs. the profile-avatar link in #230) — no conflict there.
|
Update after #229 merged into {showMembers && workspaceId ? <TabsTrigger value="api-keys">API Keys</TabsTrigger> : null}
...
{showMembers && workspaceId ? (
<TabsContent value="api-keys" className="pt-4">
<ApiKeysSettingsPanel canWrite={canWriteApiKeys} /> {/* from #229, now dead code */}
</TabsContent>
) : null}
Separately: this PR also has real (not silent) git conflicts with #223 (open, same base, |
|
Thanks @resincode for the detailed and sharp review! 🙏 We have pushed an update addressing all points raised, plus preemptively hardened related touchpoints identified during our root cause sweep. 1. Fix Workspace Fallback Logout Bug (
|
resincode
left a comment
There was a problem hiding this comment.
Both issues addressed — updating review status:
-
handleWorkspaceFallback✅ —logoutAndRedirectnow only fires whenremaining.length === 0(no workspaces left) or incatch. Idempotency guard at top is a nice addition. The "seamless fallback" path now actually falls back to the next workspace without logging out. -
settings.tsxapi-keys gating ✅ —showMembers &&removed from the API Keys tab trigger and content wrapper. Now{workspaceId ? ...}renders for any authenticated member; write access is controlled bycanWriteApiKeysinsideApiKeysSettingsPanelas intended.
No remaining objections from my side. Ready for maintainer review.
|
Heads up @connaners — after #223 (inbox aggregators/WS rework, merged yesterday) landed, this PR is now conflicting with Both PRs reworked the invitations/WS/MembersPanel areas, so the merge needs a manual reconciliation (in particular: keep the audit-log + WS live-refresh changes from #223 while preserving this PR's CRUD/fallback behavior). Could you rebase onto the latest |
|
conflict bro @connaners |
… and multi-workspace fallback (closes suiflex#224) - Implemented full CRUD lifecycle for workspace members in MembersPanel - Added immediate real-time role gating and unsaved draft protection - Implemented multi-workspace seamless fallback and multi-tab BroadcastChannel sync - Added target workspace awareness badge and duplicate member prevention in InviteModal - Added auto-switch active workspace on inbox invite acceptance - Added unit and integration tests across MembersPanel, Inbox, and permissions hook
…and centralize workspace event sync
5d37b45 to
4306b84
Compare
|
Rebase complete! Reconciled all 7 conflicting files with latest
Verification
cc @resincode @wahyuakbarwibowo — ready for final merge! |
wahyuakbarwibowo
left a comment
There was a problem hiding this comment.
Thanks for this — role gating and owner protections look solid, and CI is green. A few things need to be addressed before merge:
Blockers
- Hardcoded Indonesian strings — CLAUDE.md §3.4 requires non-English copy to go through i18next (
en/id). Found in_app.tsx,MembersPanel.tsx,StepEditor.tsx,login.tsx(e.g."Akses workspace dicabut","Anda telah keluar dari ...","Peran Anda telah diubah menjadi VIEWER..."). Tests assert these literals too. - No audit log on
PATCH /invitations/{id}—InvitationService.update_roledoesn't callwrite_audit, unlikerevoke/resend. Every mutation must be audited (CLAUDE.md §2.2/2.3). update_roleignores invite state — it will change the role of an already accepted/revoked/declined invite. Please return 409 (or 404) when the invite is no longer pending.
Non-blocking
scope.pychanges the 403detailfrom a string to{code, message}. Web client is updated, but other API consumers (MCP/CLI/3rd party) readingdetailas a string will see an object — worth noting in the PR.decline_invitationdoes an extrarepo.get_by_idbefore the service call;invitation.workspace_idfrom the service result is enough.- Unrelated changes:
sync-python.js(comment-only), lifecyclepublish.pyerror formatting, andtenancy.pyUUID→GUID. Please split into separate PRs or explain the reason (GUID is schema-equivalent on Postgres, so no migration needed).
|
Thanks for the thorough review @wahyuakbarwibowo! 🙏 Here is our plan to address the feedback: Blockers
Non-blocking & Cleanup
Please let us know if you'd like us to proceed with all of the above right away, or if there's anything else you'd prefer adjusted first! |
|
Thanks @connaners, the plan looks solid! Yes, please proceed with those changes. A couple of quick additions to keep in mind while implementing:
Looking forward to the update! |
|
All requested changes have been addressed and pushed in 🔴 Blockers Resolved
🟡 Non-blocking & Cleanup Addressed
✅ Verification
|
…and card footer
- Localize hardcoded Indonesian strings via i18next dictionaries (en.json, id.json) with English defaults in _app.tsx, MembersPanel.tsx, StepEditor.tsx, and login.tsx
- Audit PATCH /invitations/{id} role updates with write_audit in InvitationService
- Guard update_role against non-pending invites and map InvitationConflictError to HTTP 409 Conflict in router
- Remove redundant DB query in decline_invitation router
- Suppress raw workspace UUID in WORKSPACE_INVITE inbox card footer
- Revert unrelated changes in sync-python.js, publish.py, and tenancy.py
2680916 to
7b26ef7
Compare
Summary
Closes #224
This PR implements complete, robust lifecycle management for workspace members in the web UI (
MembersPanel.tsx), resolves all missing frontend client wrappers, and extends the architecture with real-time WebSocket synchronization, seamless multi-workspace fallback (Slack/Linear-style), cross-tab sync viaBroadcastChannel, and unsaved draft protections on role demotion.Architectural Flows
1. Member Removal & Multi-Workspace Fallback (Option 2)
flowchart TD A[Member Removed / Leaves Workspace] --> B{Check remaining memberships via GET /auth/me} B -->|Remaining >= 1| C[Cancel running queries / polling] C --> D[Set active workspace to next valid workspace] D --> E[Reset projectId to null - avoid 404/422] E --> F[Broadcast switch across open tabs via BroadcastChannel] F --> G[Refetch capabilities & navigate to /dashboard] G --> H[Show Toast: Akses workspace dicabut / Anda telah keluar] B -->|Remaining = 0| I[Clear stores & call POST /auth/cookie/logout] I --> J[Redirect to /login?reason=removed or /login?reason=left]2. Real-Time Role Gating & Draft Step Protection
sequenceDiagram participant Admin as Admin / Owner participant WS as WebSocket Server participant App as App Shell (_app.tsx) participant Perms as usePermissions Hook participant Editor as StepEditor / Cases Admin->>WS: Demote QA member to VIEWER WS-->>App: workspace.member.role_changed event App->>App: Synchronously update ["auth", "me"] query cache Perms-->>Editor: canWrite transitions: true -> false Editor->>Editor: Revert uncommitted steps to last server snapshot Editor->>Editor: Dismiss modal & show warning toastWhat Was Implemented
1. Full CRUD Member Lifecycle in
MembersPanel.tsx(Core #224)changeWorkspaceMemberRoleandremoveWorkspaceMemberinapps/web/src/lib/api-client.ts.OWNER/ADMINroles. Read-only view forQAandVIEWER.OWNERcan promote someone toOWNER.OWNER.OWNERcannot be demoted (disabled with tooltip: "Cannot demote the only owner").OWNERcannot leave or be removed (disabled with tooltip: "Cannot remove the only owner").2. Multi-Workspace Seamless Fallback & Cross-Tab Sync
/auth/me.projectId = null, and routes to/dashboard.logoutAndRedirect(reason)to/login?reason=[removed|left].BroadcastChannel):suitest_auth_channelbroadcasts{ type: "workspace_switched", newWorkspaceId }and{ type: "logout", reason }./loginfor?reason=removed("Akses Anda ke workspace telah dicabut oleh administrator") and?reason=left("Anda telah keluar dari workspace").3. Target Workspace Clarity & Duplicate Member Prevention
InviteModaldisplayingTarget Workspace: {workspaceName}."Invite a member to {workspaceName}"/"Re-invite member to {workspaceName}"."Invite to {workspaceName}"/"Re-invite to {workspaceName}"."Personal link for {email} to join {workspaceName}".activeMemberEmails.accepted (past)status and aRe-inviteaction.✕button oninvite-link-paneland auto-dismisses when the link is revoked.4. Real-Time WebSocket Events & Immediate UI Gating
workspace:{workspace_id}:workspace.member.joined,workspace.member.role_changed,workspace.member.removedworkspace.invitation.created,workspace.invitation.updated,workspace.invitation.revoked,workspace.invitation.accepted_app.tsxinterceptsrole_changedfor the active user and immediately updates query cache, causing all buttons, dialogs, and controls across the app to transition instantly without a page reload.5. Unsaved Draft Reversion on Demotion
StepEditor.tsx/cases.tsxand their role is demoted toVIEWER:6. Auto-Switch Workspace on Inbox Approval
GET /inboxincludesref=str(invitation.workspace_id)./inbox, the active workspace automatically switches to the new workspace, resets active project, and redirects cleanly to/dashboard.Role Permissions Matrix
OWNERADMINQAVIEWERADMIN/QA/VIEWEROWNEROWNERTest Coverage & Verification
1. Frontend Unit & Integration Tests (
vitest)MembersPanel.test.tsx(21/21 passing):useActiveWorkspaceswitches to next workspace without logout).accepted (past)status.inbox.test.tsx(9/9 passing):refswitches active workspace and resetsprojectId.2. Strict Typechecking & Static Analysis
tsc --noEmit): 0 errors withexactOptionalPropertyTypes: trueand strict mode.eslint . --max-warnings=0): 0 errors, 0 warnings.apps/api,apps/runner,packages/agent,packages/core,packages/db,packages/mcp,packages/shared) — 0 errors.ruff checkandruff format— 0 errors.packages/shared/openapi.json.