Skip to content

feat(web): full CRUD for workspace member management, real-time sync, and multi-workspace fallback (closes #224) - #231

Merged
mulhamna merged 4 commits into
suiflex:mainfrom
connaners:feat/workspace-member-management
Sep 22, 2026
Merged

mulhamna merged 4 commits into
suiflex:mainfrom
connaners:feat/workspace-member-management

Conversation

@connaners

Copy link
Copy Markdown
Collaborator

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 via BroadcastChannel, 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]
Loading

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 toast
Loading

What Was Implemented

1. Full CRUD Member Lifecycle in MembersPanel.tsx (Core #224)

  • API Client: Implemented changeWorkspaceMemberRole and removeWorkspaceMember in apps/web/src/lib/api-client.ts.
  • Actions Column: Added dedicated actions for users with OWNER / ADMIN roles. Read-only view for QA and VIEWER.
  • Role Updates:
    • Inline role selector honoring hierarchy rules: only OWNER can promote someone to OWNER.
    • Non-owner admins cannot edit role of an OWNER.
    • Sole OWNER cannot be demoted (disabled with tooltip: "Cannot demote the only owner").
  • Member Removal & Leave:
    • Red "Remove" action button with confirmation dialog displaying the target member's email.
    • Distinct "Leave" action for the current user.
    • Sole OWNER cannot leave or be removed (disabled with tooltip: "Cannot remove the only owner").

2. Multi-Workspace Seamless Fallback & Cross-Tab Sync

  • Fallback on Removal / Leave:
    • When user is removed or leaves a workspace: checks fresh memberships from /auth/me.
    • If user belongs to $\ge 1$ other workspace: automatically switches to the next workspace without dropping session, cancels background polling, resets projectId = null, and routes to /dashboard.
    • If 0 workspaces remain: triggers logoutAndRedirect(reason) to /login?reason=[removed|left].
  • Multi-Tab Sync (BroadcastChannel):
    • suitest_auth_channel broadcasts { type: "workspace_switched", newWorkspaceId } and { type: "logout", reason }.
    • Switching workspace or logging out in Tab 1 immediately updates all other open tabs in the browser.
  • Login Banners:
    • Contextual alerts on /login for ?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

  • Target Workspace Badge: Added visual badge in InviteModal displaying Target Workspace: {workspaceName}.
  • Dynamic Contextual Copy:
    • Modal title: "Invite a member to {workspaceName}" / "Re-invite member to {workspaceName}".
    • Submit button: "Invite to {workspaceName}" / "Re-invite to {workspaceName}".
    • Link card: "Personal link for {email} to join {workspaceName}".
  • Duplicate Member Prevention:
    • Real-time client-side validation against activeMemberEmails.
    • If email is already active in this workspace, displays an amber callout ("This user is already an active member of {workspaceName}") and disables the submit button to prevent redundant requests and 409 errors.
  • Invitation History Clarity:
    • Distinguishes active members from past accepted members whose membership was subsequently removed. Shows accepted (past) status and a Re-invite action.
    • Added manual dismiss button on invite-link-panel and auto-dismisses when the link is revoked.

4. Real-Time WebSocket Events & Immediate UI Gating

  • Backend emits real-time events on workspace:{workspace_id}:
    • workspace.member.joined, workspace.member.role_changed, workspace.member.removed
    • workspace.invitation.created, workspace.invitation.updated, workspace.invitation.revoked, workspace.invitation.accepted
  • _app.tsx intercepts role_changed for 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

  • If a user is actively composing or repairing test steps in StepEditor.tsx / cases.tsx and their role is demoted to VIEWER:
    • Automatically reverts unpersisted step changes to the latest server snapshot.
    • Closes active step editor / strategy dialogs.
    • Displays a warning toast informing the user that uncommitted drafts were reverted.

6. Auto-Switch Workspace on Inbox Approval

  • GET /inbox includes ref=str(invitation.workspace_id).
  • When approving an invite in /inbox, the active workspace automatically switches to the new workspace, resets active project, and redirects cleanly to /dashboard.

Role Permissions Matrix

Feature / Action OWNER ADMIN QA VIEWER
View Member & Invitation Lists
Change Member Role to ADMIN / QA / VIEWER ✅ (non-owners only)
Change Member Role to OWNER
Demote or Remove Sole OWNER ❌ (Protected) ❌ (Protected)
Remove Member / Kick ✅ (non-owners only)
Leave Workspace ✅ (if >1 owner)
Send / Resend / Revoke Invitations
Create / Edit / Delete Test Cases
Run / Rerun / Cancel Tests

Test Coverage & Verification

1. Frontend Unit & Integration Tests (vitest)

  • MembersPanel.test.tsx (21/21 passing):
    • Actions column rendering and role hierarchy rules.
    • Sole OWNER protection (disabled role selector & remove button).
    • Member removal with confirmation dialog.
    • Multi-workspace leave fallback (useActiveWorkspace switches to next workspace without logout).
    • Pending vs All invitation filtering and accepted (past) status.
    • Role updating for pending invitations.
    • Target workspace badge and contextual titles in invite modal.
    • Client-side duplicate member validation disabling submit button.
    • Dismissing link panel manually and on revoke.
  • inbox.test.tsx (9/9 passing):
    • Approve invite with ref switches active workspace and resets projectId.
    • Role-gated card filtering.
  • Full Frontend Suite:
    • 82 / 82 test files passed (630 / 630 tests passed).

2. Strict Typechecking & Static Analysis

  • TypeScript (tsc --noEmit): 0 errors with exactOptionalPropertyTypes: true and strict mode.
  • ESLint (eslint . --max-warnings=0): 0 errors, 0 warnings.
  • Python Mypy: Checked across all 7 packages (apps/api, apps/runner, packages/agent, packages/core, packages/db, packages/mcp, packages/shared) — 0 errors.
  • Python Ruff: ruff check and ruff format — 0 errors.
  • OpenAPI Schema: Snapshot verified up-to-date with packages/shared/openapi.json.
  • Pre-Push Maintainer Reviewer Audit: Passed all automated checks.

@connaners
connaners requested a review from a team September 20, 2026 23:26
@suiflex-bot suiflex-bot Bot added commit: feat Contains a feat commit · suiflex-bot area: api Changes under apps/api (FastAPI backend) · suiflex-bot area: web Changes under apps/web (Vite/React frontend) · suiflex-bot area: db Changes under packages/db (models, repositories, migrations) · suiflex-bot area: mcp Changes under packages/mcp or packages/mcp-npx · suiflex-bot area: shared Changes under packages/shared (cross-package schemas) · suiflex-bot area: launcher Changes to the npx launcher, lifecycle or cli/ · suiflex-bot cla: signed Contributor has signed the CLA · suiflex-bot labels Sep 20, 2026
@guardener-bot

guardener-bot Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

ForgeGuard

Rule Where What
🟡 FG-AUTH-001 apps/api/src/suitest_api/routers/invitations.py:287 Mutating route requires access-control review
🟡 FG-AUTH-001 apps/web/src/lib/api-client.ts:1140 Mutating route requires access-control review
🟡 FG-DRY-001 apps/web/src/routes/_app/runs.tsx:550 Potential duplicated implementation

🔴 blocks the merge · 🟡 advisory

@suiflex-bot suiflex-bot Bot added the commit: refactor Contains a refactor commit · suiflex-bot label Sep 20, 2026

@resincode resincode left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@resincode

Copy link
Copy Markdown
Collaborator

Update after #229 merged into main: I tried an actual local merge of this branch against current main (not just eyeballing the diff) and the settings.tsx regression I flagged above is now confirmed, not hypotheticalgit merge resolves it silently (no conflict marker) into a broken state:

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

canWriteApiKeys (computed true for QA) never has an effect — the whole tab is unreachable for QA because this PR's showMembers && wrapper gates it first. This needs a manual fix on rebase, not just an auto-merge.

Separately: this PR also has real (not silent) git conflicts with #223 (open, same base, M1e-10 inbox/invitations follow-up) in 7 files — both PRs independently extend the same surface:
apps/api/src/suitest_api/routers/inbox.py, routers/invitations.py, services/invitation_service.py, apps/web/src/components/settings/MembersPanel.tsx (+ .test.tsx), apps/web/src/lib/ws-client.ts, apps/web/src/routes/_app/inbox.tsx. Whichever of #223 / #231 merges second will need a real conflict-resolution pass across all of these, on top of the handleWorkspaceFallback fix and the settings.tsx fix above.

@suiflex-bot suiflex-bot Bot added commit: chore Contains a chore commit · suiflex-bot commit: fix Contains a fix commit · suiflex-bot labels Sep 21, 2026
@connaners

Copy link
Copy Markdown
Collaborator Author

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 (_app.tsx)

  • Root Cause: handleWorkspaceFallback was evaluating if (remaining.length > 0), redirecting, and then proceeding unconditionally into logoutAndRedirect(safeReason) due to a missing early return.
  • Resolution:
    • Added an explicit return inside if (remaining.length > 0) after setting the active workspace and redirecting to /dashboard.
    • Confined logoutAndRedirect(safeReason) strictly to the else (0 remaining) and exception fallback branches.
    • Added 3 regression unit tests in _app.test.tsx verifying:
      1. Switching to the next active workspace seamlessly without invoking logout when multiple workspaces exist.
      2. Gracefully logging out when the user has 0 remaining workspaces.
      3. Triggering fallback upon suitest:workspace_membership_revoked custom event.

2. Main Sync & QA API Keys Access (settings.tsx)

  • Root Cause: PR feat(api-keys): allow QA to mint and manage its own keys #229 introduced canWriteApiKeys allowing QA members to mint/manage their own API keys. During upstream merge, showMembers && workspaceId had masked the API keys tab for QA roles.
  • Resolution:
    • Synced branch with upstream/main.
    • Re-introduced const canWriteApiKeys = role === "OWNER" || role === "ADMIN" || role === "QA".
    • Rendered <TabsTrigger value="api-keys"> and <ApiKeysSettingsPanel canWrite={canWriteApiKeys} /> directly under workspaceId, restoring QA key management.
    • Added unit test in settings.test.tsx verifying QA sees and can write to API Keys while having Members hidden.

3. Deep Root-Cause Analysis & Hardening Similar Issues

While auditing the codebase around membership changes and multi-workspace lifecycle, we addressed two similar issues:

  1. 403 Interceptor Hard Logout (apps/web/src/lib/api-client.ts):
    • api-client.ts had a direct call to logoutAndRedirect("removed") upon receiving HTTP 403 WORKSPACE_MEMBERSHIP_REVOKED. This bypassed multi-workspace fallback and killed active sessions prematurely if an API call failed before the WebSocket event arrived.
    • Replaced with window.dispatchEvent(new CustomEvent("suitest:workspace_membership_revoked")), delegating tenant fallback to _app.tsx to preserve access to remaining workspaces.
  2. Real-time UI Sync for Invitations / Joins (apps/web/src/routes/_app.tsx & MembersPanel.tsx):
    • Previously, WebSocket listeners were tied to leaf components, causing missed events if the owner was on another tab/route when an invitee accepted.
    • Centralized listeners in _app.tsx for workspace.member.joined, workspace.invitation.accepted, and member role changes to invalidate TanStack query keys globally.
    • Added refetchInterval: 10_000 and staleTime: 5_000 to MembersPanel.tsx as a fallback safety net for offline / non-Redis environments.

4. Verification

  • Web Unit Tests: pnpm --filter @suiflex/web test passed (82/82 test files, 634/634 tests).
  • Typecheck & Lint:
    • pnpm --filter @suiflex/web typecheck (clean, 0 errors).
    • pnpm --filter @suiflex/web lint (clean, 0 errors).
    • make typecheck (Mypy clean across all 7 packages).
    • uv run ruff check & ruff format --check (clean).
  • Pre-push hooks: All 5 verification steps passed cleanly.

resincode
resincode previously approved these changes Sep 21, 2026

@resincode resincode left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both issues addressed — updating review status:

  1. handleWorkspaceFallback ✅ — logoutAndRedirect now only fires when remaining.length === 0 (no workspaces left) or in catch. Idempotency guard at top is a nice addition. The "seamless fallback" path now actually falls back to the next workspace without logging out.

  2. settings.tsx api-keys gating ✅ — showMembers && removed from the API Keys tab trigger and content wrapper. Now {workspaceId ? ...} renders for any authenticated member; write access is controlled by canWriteApiKeys inside ApiKeysSettingsPanel as intended.

No remaining objections from my side. Ready for maintainer review.

@mulhamna mulhamna added this to the Suitest v0.15.0 milestone Sep 21, 2026
@resincode

Copy link
Copy Markdown
Collaborator

Heads up @connaners — after #223 (inbox aggregators/WS rework, merged yesterday) landed, this PR is now conflicting with main in 7 files:

apps/api/src/suitest_api/routers/inbox.py
apps/api/src/suitest_api/routers/invitations.py
apps/api/src/suitest_api/services/invitation_service.py
apps/web/src/components/settings/MembersPanel.test.tsx
apps/web/src/components/settings/MembersPanel.tsx
apps/web/src/lib/ws-client.ts
apps/web/src/routes/_app/inbox.tsx

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 main before this gets merged? Reviewed the updated logic in 5d37b45 — the two functional bugs I flagged earlier are fixed, so it's just the rebase that's left.

@wahyuakbarwibowo

Copy link
Copy Markdown
Contributor

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
@connaners
connaners force-pushed the feat/workspace-member-management branch from 5d37b45 to 4306b84 Compare September 22, 2026 07:58
@suiflex-bot suiflex-bot Bot removed the commit: chore Contains a chore commit · suiflex-bot label Sep 22, 2026
@connaners

Copy link
Copy Markdown
Collaborator Author

Rebase complete! Reconciled all 7 conflicting files with latest main:

  • Audit & WS Live Refresh: Preserved audit logs and invitation.resolved live-refresh from feat(inbox): audit logging, WS live-refresh, expiry UX, real aggregators (M1e-10) #223 alongside full CRUD/fallback and real-time workspace member/invitation events (workspace.member.*, workspace.invitation.*).
  • Settings & API Keys: Kept API Keys tab accessible for QA members while maintaining member management role gating.
  • MembersPanel UI & Tests: Preserved Pending/All filter tabs, re-invite flow, dismissible link panel, and full unit test coverage.

Verification

  • Web Tests (vitest): 83 / 83 files passed (644 / 644 tests)
  • TypeScript & ESLint: 0 errors, 0 warnings
  • Python Static Analysis: Mypy clean across all 7 packages, Ruff check & format clean
  • PR Status: MERGEABLE (no conflicts)

cc @resincode @wahyuakbarwibowo — ready for final merge!

@wahyuakbarwibowo wahyuakbarwibowo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — role gating and owner protections look solid, and CI is green. A few things need to be addressed before merge:

Blockers

  1. 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.
  2. No audit log on PATCH /invitations/{id}InvitationService.update_role doesn't call write_audit, unlike revoke/resend. Every mutation must be audited (CLAUDE.md §2.2/2.3).
  3. update_role ignores 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.py changes the 403 detail from a string to {code, message}. Web client is updated, but other API consumers (MCP/CLI/3rd party) reading detail as a string will see an object — worth noting in the PR.
  • decline_invitation does an extra repo.get_by_id before the service call; invitation.workspace_id from the service result is enough.
  • Unrelated changes: sync-python.js (comment-only), lifecycle publish.py error formatting, and tenancy.py UUIDGUID. Please split into separate PRs or explain the reason (GUID is schema-equivalent on Postgres, so no migration needed).

@connaners

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review @wahyuakbarwibowo! 🙏

Here is our plan to address the feedback:

Blockers

  1. i18n & Default English Copy (CLAUDE.md §3.4): Extract all hardcoded Indonesian strings in _app.tsx, MembersPanel.tsx, StepEditor.tsx, and login.tsx into i18next keys in en.json and id.json, keeping English as default UI copy and updating test assertions.
  2. Audit Log on PATCH /invitations/{id}: Add write_audit in InvitationService.update_role with action="invitation.update_role" and metadata.
  3. Invite State Validation: Check invite status in update_role and raise InvitationConflictError (HTTP 409 Conflict) if it is no longer pending (already accepted, revoked, declined, or expired).

Non-blocking & Cleanup

  • Redundant query: Drop redundant repo.get_by_id in decline_invitation and reuse the service result.
  • 403 Detail Object: Document the {code, message} format in the PR description for non-web clients.
  • Unrelated changes: Revert sync-python.js (comment-only) and publish.py.
    • For tenancy.py (UUIDGUID): should we revert it back to UUID in this PR as well to keep the diff strictly scoped to member management?

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!

@mulhamna

Copy link
Copy Markdown
Member

Thanks @connaners, the plan looks solid! Yes, please proceed with those changes.

A couple of quick additions to keep in mind while implementing:

  • Router error handling for 409: When raising InvitationConflictError in InvitationService.update_role, please make sure to also catch InvitationConflictError in apps/api/src/suitest_api/routers/invitations.py (update_invitation_role) so it maps cleanly to HTTP 409 Conflict instead of bubbling up as a 500.
  • tenancy.py: Yes, please revert GUID back to UUID so this PR stays strictly scoped to member management.
  • Inbox card footer: Setting ref=str(invitation.workspace_id) in inbox.py causes the raw workspace UUID to render in the card footer next to the timestamp (Workspace Invite · <uuid>). We should avoid displaying the raw UUID in inbox.tsx when item.kind === "WORKSPACE_INVITE".

Looking forward to the update!

@connaners

Copy link
Copy Markdown
Collaborator Author

All requested changes have been addressed and pushed in 2680916:

🔴 Blockers Resolved

  1. i18n & Default English UI Copy (CLAUDE.md §3.4):
    • Added translation keys to apps/web/src/locales/en.json and apps/web/src/locales/id.json for workspace access revocation, workspace leave fallback, role change notification, VIEWER demotion warning, and logout reason banners.
    • Refactored _app.tsx, MembersPanel.tsx, StepEditor.tsx, and login.tsx to default to English UI copy and consume t(...) keys.
    • Updated assertions in login.test.tsx accordingly.
  2. Audit Logging on PATCH /invitations/{id}:
    • Added write_audit with action="invitation.update_role" and metadata {"role": role.value, "email": invitation.email} to InvitationService.update_role.
    • Added automated test in apps/api/tests/test_m1e_invitations.py verifying the audit log entry is written upon successful role update.
  3. Invitation State Guard & 409 Conflict Handling:
    • InvitationService.update_role checks if the invitation is pending (accepted_at, revoked_at, declined_at, expires_at <= now), raising InvitationConflictError("Invitation is no longer pending.") if not.
    • apps/api/src/suitest_api/routers/invitations.py catches InvitationConflictError and returns HTTP 409 Conflict.
    • Added automated tests in test_m1e_invitations.py verifying that updating role on revoked, declined, or expired invitations returns 409.

🟡 Non-blocking & Cleanup Addressed

  • Inbox Card Footer:
    • In apps/web/src/routes/_app/inbox.tsx, suppressed displaying the raw workspace UUID in the card footer next to the timestamp for WORKSPACE_INVITE, while preserving item.ref for workspace auto-switch upon approval.
  • Redundant Query:
    • Dropped redundant repo.get_by_id in decline_invitation and reused invitation.workspace_id from the service result directly.
  • Unrelated Changes Reverted:
    • Reverted packages/db/src/suitest_db/models/tenancy.py (GUIDUUID).
    • Reverted packages/lifecycle/ and packages/mcp-npx/scripts/sync-python.js completely back to upstream/main so this PR is strictly scoped to workspace member management.
  • Scope 403 Response Note:
    • Noted in the PR description that detail in scope.py returns {code, message} for fine-grained client error distinction.

✅ Verification

  • Web Unit Tests: pnpm --filter @suiflex/web test passed (83/83 test files, 644/644 tests).
  • TypeScript & ESLint: Clean (0 errors, 0 warnings with eslint . --max-warnings=0 and tsc --noEmit).
  • Python Static Analysis: Clean across all packages (make typecheck and ruff check/format).
  • Pre-Push Maintainer Audit: Passed all automated checks.

cc @wahyuakbarwibowo @mulhamna

@suiflex-bot suiflex-bot Bot removed area: mcp Changes under packages/mcp or packages/mcp-npx · suiflex-bot area: launcher Changes to the npx launcher, lifecycle or cli/ · suiflex-bot labels Sep 22, 2026
mulhamna
mulhamna previously approved these changes Sep 22, 2026
…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
@mulhamna mulhamna self-assigned this Sep 22, 2026
@mulhamna
mulhamna merged commit 42610a6 into suiflex:main Sep 22, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: api Changes under apps/api (FastAPI backend) · suiflex-bot area: db Changes under packages/db (models, repositories, migrations) · suiflex-bot area: shared Changes under packages/shared (cross-package schemas) · suiflex-bot area: web Changes under apps/web (Vite/React frontend) · suiflex-bot cla: signed Contributor has signed the CLA · suiflex-bot commit: feat Contains a feat commit · suiflex-bot commit: fix Contains a fix commit · suiflex-bot commit: refactor Contains a refactor commit · suiflex-bot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web): full CRUD for workspace member management (role update & removal)

4 participants