feat(dashboard): team members page with add and remove - #352
Conversation
Greptile SummaryThe PR adds the team-members management page and its supporting invitation and removal APIs.
Confidence Score: 4/5The PR does not yet appear safe to merge because an older overview request can still replace the currently selected team's roster. The shared team-overview resource accepts overlapping reloads without tying each result to its initiating team, so switching teams can leave the members page displaying the previous team's data. Files Needing Attention: dashboard/src/data/teams.ts, dashboard/src/pages/manage/teams/TeamMembers.vue
|
| Filename | Overview |
|---|---|
| dashboard/src/data/teams.ts | Adds member-management resources while retaining the outstanding shared overview-resource race across team switches. |
| dashboard/src/pages/manage/teams/TeamMembers.vue | Implements the members table, management controls, and refresh behavior, but still renders overview data that can belong to a previously selected team. |
| dashboard/src/components/dashboard/teams/AddMembersDialog.vue | Implements validated multi-row invitations, pasted-address expansion, role selection, and outcome reporting. |
| buzz/api/teams/invitations.py | Adds authorization and role validation before routing existing users to membership upsert and new users to email invitations. |
| buzz/api/teams/services.py | Extends team overviews with pending invitations and adds guarded soft removal of memberships. |
| e2e/tests/team-members.spec.ts | Covers route rendering and the protected owner row without introducing a blocking issue. |
Reviews (6): Last reviewed commit: "feat(dashboard): add members and show wh..." | Re-trigger Greptile
| const teamId = computed(() => route.params.teamId as string); | ||
| const isAdding = ref(false); | ||
|
|
||
| watch(teamId, (team) => team && teamOverview.fetch({ team }), { immediate: true }); |
There was a problem hiding this comment.
Stale responses replace current team
If two team overview requests overlap, the older request can finish last and overwrite the shared teamOverview.data, causing the members page to display the previous team's roster and pending invitations under the newly selected team.
Prompt To Fix With AI
This is a comment left during a code review.
Path: dashboard/src/pages/manage/teams/TeamMembers.vue
Line: 29
Comment:
**Stale responses replace current team**
If two team overview requests overlap, the older request can finish last and overwrite the shared `teamOverview.data`, causing the members page to display the previous team's roster and pending invitations under the newly selected team.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.699cfc5 to
a570feb
Compare
a58f3ab to
c6ad7db
Compare
a570feb to
50e0c6c
Compare
c6ad7db to
4ca3238
Compare
50e0c6c to
34cff5a
Compare
|
✅ UI Demo Check — a screenshot or demo is attached. |
34cff5a to
d870159
Compare
4ca3238 to
5ec71cd
Compare
d870159 to
afff9d3
Compare
remove_member disables the Buzz Team Membership rather than deleting it, so upsert_membership re-enables the same row if they are ever invited back and the history survives. Owner/Admin only, the rule the Desk add-members flow already uses; the Owner row refuses to be disabled in the membership controller, so ownership needs no second check here. Event Manager holds no write permission on the membership doctype, so the write goes through ignore_permissions with the explicit guard above it standing in for the check — the same shape as BuzzTeam.add_members. members_of now sorts Owners first so the members page can lock the top row without asking a second question. A team may hold several Owners, hence a role bucket rather than a single row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar has pointed here since the manager shell landed; the route fell through to the work-in-progress placeholder. The page reuses get_team_overview rather than adding a read endpoint — it already carries the members. A subtle table rather than a card list: shared grid for the header and the rows so the columns line up without a table element, role as plain text, and an ellipsis menu holding the one destructive action. The owner sorts first and carries no menu at all, matching the controller lock. Nobody can remove themselves either — leaving a team is its own flow. Removing a row takes it out of flow while it fades, so the rows below start closing the gap immediately instead of snapping up after the fetch. Entry is left alone; the list is read, not watched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plumbing has been in place since 00-teams/04 — the user_invitation hook, the buzz_team/buzz_team_role custom fields, on_invitation_accepted — but nothing ever called it, so no team has ever sent an invitation. invite_members routes each address by whether it already has a User: a colleague joins straight away through upsert_membership, only a stranger gets an emailed invitation. That split is not a nicety. Core's invite_by_email skips any address that ever accepted a buzz invitation, without regard to which team it was for, so inviting a colleague to a second team would silently do nothing and report success. Guarded at invite time with can_manage_members rather than leaning on the accept-time check in the hook, which would let a Manager send an invitation that throws when the recipient clicks it. Roles are validated against the membership doctype's own options so the two cannot drift, and Owner is refused outright. get_team_overview now carries the team's pending invitations, kept apart from members: these people cannot do anything on the team yet, and folding them in would inflate the member count the overview page renders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog takes rows of email and role rather than a chip field: the role is per person, and a dropdown hanging off a chip is a fiddly place to set it. A pasted comma-separated list still explodes into rows, and typing in the last row opens the next one, so neither the bulk case nor the single case needs an extra click. Rows carry an id of their own — keyed by position, a splice in the middle hands Vue the wrong node and the caret lands in the wrong input. The form is novalidate on purpose. The action button sits outside it in the dialog's actions slot, so the browser's own validation bubble would only ever fire on the Enter path, leaving two different behaviours for one action. One rule instead: bad addresses mark their own row and cost the user that row rather than the whole batch. Pending invitations join the members table with the address standing in for the name and "(Invited)" against the role. No actions menu — there is nothing to remove until they accept, and invitations expire on their own. The toast names people rather than counting them, and says out loud that an invited person will not appear in the table until they accept. Nothing new happening is reported as info, not success. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5ec71cd to
86ae417
Compare
What changed
Fourth in the
/managestack. #351 has merged, so this sits directly ondevelop.Fills in the sidebar's Members item, which fell through to the work-in-progress
placeholder until now.
/manage/team/members— the team as a subtle table: name, email, role, and anellipsis menu holding the one destructive action. Owner sorts first and carries no
menu, matching the controller lock. A legend above it spells out what each of the
five roles can actually do.
remove_member— disables the membership rather than deleting it, soupsert_membershipre-enables the same row if the person is invited back.invite_members+ an Add members dialog — rows of email and role, a pastedcomma-separated list explodes into rows, and the next row opens as you type.
(Invited)against the role.Worth knowing
Invite-by-email existed and had never been called. 00-teams/04 wired the
user_invitationhook, thebuzz_team/buzz_team_rolecustom fields andon_invitation_accepted, but the only caller wasEventTicket.send_user_invitation,and that call site is commented out. This is the first path that actually sends one.
invite_membersdoes not just forward to core.invite_by_emailskips any addressthat ever accepted a buzz invitation, filtered by
app_namewith no team filter — soinviting a colleague to a second team would silently no-op and report success. Existing
users go through
upsert_membershipinstead; only strangers get an email.Guarded at invite time, not accept time. Core's
validate_rolegates on the Frapperole, not team membership, so a Manager could otherwise create an invitation that throws
when the recipient clicks the link.
can_manage_membersruns before anything is sent.The three refusal tests were mutation-checked:
if False:breaks exactly those.Accepting a Manager/Admin/Frontdesk invitation makes the user a System User.
Event ManagerandFrontdesk Managerboth carrydesk_access, andset_system_user()flipsuser_typeon any save. Pre-existing and deliberate — 00-teams/01 deferred anEvent Managerfixture becausedesk_access: 0would revoke Desk from every current organizer— but this PR makes it one click away, so it is worth naming. Not addressed here.
Not in scope
Role changes, revoking an invitation (core's
cancel_invitationwould slot into the samemenu), leaving a team, and the team profile/settings surface — all spec 13, all separate.
Demo
https://cap.so/s/rb0k446j8jz7scj
Testing
32 new backend tests across
test_teams.pyandtest_invitations.py, plus a Playwrightspec for the route and the owner row. Verified in a browser with five members and pending
invitations: remove, paste, add, and every toast variant.
Rebased onto
developafter #351 squash-merged: 7 commits to 4, the three #351 commitsdropped. No content change —
pre-commitclean on all 15 files, typecheck clean, 70 unittests, 32 backend tests green.
🤖 Generated with Claude Code