Skip to content

feat(votes): add permission check on committee Create Vote CTA click#997

Open
MRashad26 wants to merge 5 commits into
mainfrom
feat/LFXV2-2252-committee-vote-permission-check
Open

feat(votes): add permission check on committee Create Vote CTA click#997
MRashad26 wants to merge 5 commits into
mainfrom
feat/LFXV2-2252-committee-vote-permission-check

Conversation

@MRashad26

@MRashad26 MRashad26 commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Stale permission guard on Create Vote CTA: The committee votes tab shows the "Create Vote" button based on canEdit() — derived from committee.writer at page-load time. If the member's role is downgraded from Manager to Member after the page loads, the stale signal still shows the button. Clicking it without this fix would navigate directly to /votes/create and rely solely on the writerGuard redirect, with no immediate UI feedback.
  • Click handler with fresh permission check: Both Create Vote buttons (table-actions slot and empty-state) now call onCreateVote(), which fetches fresh committee permissions via fetchCommittee() before navigating. On denial, redirects to the lens-appropriate overview with _notice=votes so AppComponent.initAccessDeniedToast() shows the contextual "Access Denied" toast — consistent with the writerGuard denial flow from feat(meetings): add access-denied toast and fix meeting coordinator permissions #992.
  • Edit Vote navigation: The Edit button in votes-table now passes committee_uid and project query params so the edit route has the same context as create — consistent with fix(meetings): pass project+committee_uid queryParams on meeting edit navigation #1025 (meetings) and feat(surveys): add permission check on committee Create Survey CTA click #1000 (surveys).
  • The writerGuard on /votes/create remains as the final safety net for direct URL access.

Changed files

File Change
committee-votes.component.ts Inject CommitteeService, LensService + Router; add onCreateVote() with fresh fetchCommittee() permission check and lens-aware deny redirect; add editVoteQueryParams aliased from createVoteQueryParams
committee-votes.component.html Replace [routerLink] + [queryParams] on both Create Vote buttons with (click)="onCreateVote()"; pass [editQueryParams] to lfx-votes-table
votes-table.component.ts Add editQueryParams input (default {})
votes-table.component.html Wire [routerLink], [queryParams], and (onClick)=stopPropagation directly on the Edit <lfx-button> (no <a> wrapper)

References

Test plan

  • Log in as a committee Manager — Create Vote button is visible and clicking it navigates to /votes/create with committee_uid and project query params
  • Downgrade the member to Member role without refreshing the page — Create Vote button remains visible (stale canEdit()) but clicking it redirects to the project overview with an "Access Denied" toast
  • As a user with no committee write access navigating directly to /votes/create?committee_uid=...writerGuard blocks and shows the toast
  • Clicking Edit on a draft vote navigates to /votes/<uid>/edit?committee_uid=...&project=<slug> and does not also trigger the row-results drawer

Replace routerLink on both Create Vote buttons (table-actions slot and
empty-state) with an onCreateVote() click handler that fetches fresh
committee permissions before navigating. If the member's role was
downgraded from Manager to Member since the page loaded, the stale
canEdit() signal would still show the button; the click handler catches
this and redirects to /project/overview?_notice=votes so AppComponent
shows the "Access Denied" toast — consistent with the writerGuard
denial flow used for meetings.

Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org>
@MRashad26 MRashad26 requested a review from a team as a code owner June 20, 2026 09:29
Copilot AI review requested due to automatic review settings June 20, 2026 09:29
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Committee vote creation now uses a click handler that rechecks committee write access before routing, and the votes table edit action now forwards query parameters through a new input.

Changes

Vote navigation updates

Layer / File(s) Summary
Edit query params support
apps/lfx-one/src/app/modules/votes/components/votes-table/votes-table.component.ts, apps/lfx-one/src/app/modules/votes/components/votes-table/votes-table.component.html
Adds an editQueryParams input to VotesTableComponent and passes it to the disabled-vote edit control as query parameters.
Create Vote wiring
apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.ts, apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.html
Injects CommitteeService and Router, adds finalize support, wires both Create Vote buttons to onCreateVote(), and passes edit query params into lfx-votes-table.
Create Vote permission check
apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.ts
Implements onCreateVote() to fetch the latest committee by UID, build overview redirect query parameters, and navigate either to create-vote or the overview redirect based on the refreshed writer flag.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed It accurately summarizes the main change: adding a permission check when clicking the committee Create Vote CTA.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description matches the code changes: it covers the fresh permission check, redirect behavior, and edit query params updates.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/LFXV2-2252-committee-vote-permission-check

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.ts (1)

45-67: 💤 Low value

Consider disabling the button during the permission check to prevent duplicate requests.

While the implementation correctly fetches fresh permissions and handles denial/error cases, rapid clicks could trigger multiple concurrent API calls before navigation completes. Adding a brief loading state would improve UX.

♻️ Optional: Add loading guard
+ public creatingVote = signal<boolean>(false);
+
  public onCreateVote(): void {
+   if (this.creatingVote()) return;
+   this.creatingVote.set(true);
    const committee = this.committee();
    const denyParams: Record<string, string> = { _notice: 'votes' };
    if (committee.project_slug) denyParams['project'] = committee.project_slug;
-   const deny = () => void this.router.navigate(['/project/overview'], { queryParams: denyParams });
+   const deny = () => {
+     this.creatingVote.set(false);
+     void this.router.navigate(['/project/overview'], { queryParams: denyParams });
+   };

    this.committeeService
      .getCommittee(committee.uid)
      .pipe(take(1))
      .subscribe({
        next: (fresh) => {
          if (fresh?.writer !== true) {
            deny();
            return;
          }
          void this.router.navigate(['/votes', 'create'], { queryParams: this.createVoteQueryParams() });
        },
        error: () => deny(),
      });
  }

Then in template: [disabled]="creatingVote()"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.ts`
around lines 45 - 67, To prevent duplicate API requests from rapid button clicks
during the permission check in the onCreateVote method, add a loading state
signal (named something like creatingVote) initialized to false. Set this signal
to true before the getCommittee API call starts subscribing, then set it back to
false in both the next and error callbacks of the subscribe handler to ensure it
resets regardless of outcome. Finally, update the template to disable the button
by binding [disabled]="creatingVote()" to the create vote button element.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.ts`:
- Around line 45-67: To prevent duplicate API requests from rapid button clicks
during the permission check in the onCreateVote method, add a loading state
signal (named something like creatingVote) initialized to false. Set this signal
to true before the getCommittee API call starts subscribing, then set it back to
false in both the next and error callbacks of the subscribe handler to ensure it
resets regardless of outcome. Finally, update the template to disable the button
by binding [disabled]="creatingVote()" to the create vote button element.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 98e32e74-2730-4773-8207-41ce2e0eecd4

📥 Commits

Reviewing files that changed from the base of the PR and between c2fea04 and 4f240f6.

📒 Files selected for processing (2)
  • apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.html
  • apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.ts

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown

🚀 Deployment Status

Your branch has been deployed to: https://ui-pr-997.dev.v2.cluster.linuxfound.info

Deployment Details:

  • Environment: Development
  • Namespace: ui-pr-997
  • ArgoCD App: ui-pr-997

The deployment will be automatically removed when this PR is closed.

Copilot AI 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.

Pull request overview

Adds a “fresh permission” check when a user clicks the Committee “Create Vote” CTA so that if their committee writer access is revoked after page load, they’re redirected through the same _notice=votes access-denied toast flow rather than relying solely on the route guard.

Changes:

  • Added onCreateVote() to re-fetch committee permissions via CommitteeService.getCommittee() before navigating to the create vote route.
  • Updated both “Create Vote” buttons to call (click)="onCreateVote()" instead of using [routerLink] + [queryParams].

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.ts Adds click handler that re-checks committee writer permission and redirects with _notice=votes on denial.
apps/lfx-one/src/app/modules/committees/components/committee-votes/committee-votes.component.html Routes both CTAs through the new click handler.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…rect

Inject LensService and derive the overview path from the active lens so
the access-denied redirect lands on /foundation/overview when the component
is rendered under the foundation lens (/foundation/groups/...) rather than
always redirecting to /project/overview — consistent with writerGuard's
lens-aware denial behavior (per @copilot-pull-request-reviewer).

Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org>
@MRashad26

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed

Commit: 1b0e903

Changes Made

  • committee-votes.component.ts: Injected LensService; deny redirect now derives overviewPath from activeLens()/foundation/overview for the foundation lens, /project/overview otherwise. Matches writerGuard's lens-aware behavior exactly (per @copilot-pull-request-reviewer)

No Change Needed

  • CodeRabbit nitpick (loading guard for double-click prevention): Marked "💤 Low value / Optional" by CodeRabbit — navigation completes after the first successful/failed check, making a double-fire harmless in practice. Skipped.

Threads Resolved

1 of 1 unresolved threads addressed in this iteration.

Follow-up

committee-meetings.component.ts (onScheduleMeeting) has the same hard-coded /project/overview — shipped in #992. Will fix in a follow-up.

Copilot AI commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

@MRashad26 I've opened a new pull request, #998, to work on those changes. Once the pull request is ready, I'll request review from you.

Add editQueryParams input to lfx-votes-table and wire [queryParams] onto
the edit routerLink so writerGuard receives project and committee_uid
when a committee manager navigates to edit a draft vote — mirrors the
meetings fix (PR #1025).

- votes-table: add editQueryParams input (defaults to {}; non-breaking
  for all existing usages outside committee context)
- votes-table: bind [queryParams]="editQueryParams()" on edit <a>
- committee-votes: add editVoteQueryParams computed signal via
  buildCommitteeCreateQueryParams; pass to [editQueryParams] binding

Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings June 26, 2026 15:15
@MRashad26

Copy link
Copy Markdown
Contributor Author

Edit permission handling added (commit `e4839f644`)

Follow-up to the create-vote permission check: added the same committee context to the Edit button navigation, consistent with the meetings fix (PR #1025).

Changes

  • votes-table: added editQueryParams input (Record<string, string>, defaults to {}) — non-breaking for all non-committee usages. Edit <a> now binds [queryParams]="editQueryParams()".
  • committee-votes: added editVoteQueryParams computed signal (same buildCommitteeCreateQueryParams as create); passed as [editQueryParams] to lfx-votes-table.

Effect

When a committee manager clicks Edit on a draft vote in the committee tab, the navigation to /votes/:id/edit now carries ?project=<slug>&committee_uid=<uid> — giving writerGuard the project slug it needs to resolve the project check, and committee_uid for any future guard extension.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

@luismoriguerra

Copy link
Copy Markdown
Contributor

Audit: PR #997 — feat(votes): add permission check on committee Create Vote CTA click

MRashad26 · feat/LFXV2-2252-committee-vote-permission-checkmain · OPEN · 4 files, +38/−6 · LFXV2-2252

Scope: Fresh committee.writer check on Create Vote click; lens-aware _notice=votes deny redirect; editQueryParams on votes-table Edit links.

Lanes run: yarn lint · yarn check-types · yarn format:check · yarn build · ui-reviewer · bugbot · secrets grep (6a security-review unavailable — manual pass)


1. Why this change

Create Vote stays visible via stale canEdit() (committee.writer at page load). After a mid-session demotion to Member, direct [routerLink] navigation had no immediate feedback — users only hit writerGuard on /votes/create.

2. How it was done

onCreateVote() re-fetches via CommitteeService.getCommittee() before navigating. Denial → lens-aware overview with _notice=votes + project slug → AppComponent.initAccessDeniedToast(). Follow-up commit adds editQueryParams so Edit links carry committee context. Matches the meetings-tab pattern from #992, with lens-aware deny (improvement over meetings).

3. Is it correct

Yes — merge-ready. Gates green in isolated worktree. _notice=votes is registered in ACCESS_DENIED_MESSAGES. writerGuard remains the direct-URL safety net.

Prior review: Lens hard-code → fixed (1b0e903). CodeRabbit loading guard → open (optional UX nit). Copilot duplicate-computed / error-path threads → open (non-blocking).

4. Missing / gaps

  1. Non-blockingeditVoteQueryParams duplicates createVoteQueryParams (committee-votes.component.ts:46,82-84). Bind [editQueryParams]="createVoteQueryParams()" instead.
  2. Non-blocking — No in-flight guard on Create (double-click → duplicate getCommittee calls). Optional creatingVote signal; same gap exists on meetings sibling.
  3. Non-blocking — Edit links still use direct routerLink without fresh committee check. In scope for this PR; writerGuard for votes checks project.writer only (not committee.writer).

5. Q&A this PR resolves

  • Why click handler instead of guard-only? — Immediate deny toast on stale CTA; guard still covers direct URL access.
  • Why lens-aware deny? — Foundation-lens users should land on /foundation/overview.
  • Why editQueryParams? — Edit route + writerGuard need ?project= and ?committee_uid= from the committee tab.

6. Business rules & ADRs

  • Business rule: Create Vote CTA must re-fetch committee.writer on click before navigation; denial uses _notice=votes toast contract shared with writerGuard.
  • ADR status: None added/updated — extends existing client-side _notice + writerGuard pattern.

7. Open questions & confirmations

  • (confirm) Should writerGuard accept committee_uid + committee.writer for writeFeature: 'votes' (today only 'meetings' gets that committee fallback)? Component-level check covers Create; Edit/direct URL still rely on project.writer only.

Verdict: 🟡 Minor comments
Merge readiness: safe-to-merge (optional DRY + loading guard polish)

@ahmedomosanya ahmedomosanya 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.

Audit result: 🟡 Minor comments

The referenced Jira looks too broad for this PR. LFXV2-2252 is the parent epic for Org Lens parity; the specific ticket appears to be LFXV2-2470 (“Allow committee writers to create/manage votes for their committee”). Please update the PR description to reference LFXV2-2470 directly, optionally keeping LFXV2-2252 as the parent epic.

A few non-blocking implementation notes:

  • onCreateVote() calls CommitteeService.getCommittee(), which mutates shared committee service state via tap(). Since this is only a permission probe, fetchCommittee() is the safer no-side-effect API.
  • The error path treats every getCommittee() failure as access denied. A transient 5xx/network failure would redirect with _notice=votes, which can mislead a legitimate writer.
  • The broader permission contract still needs confirmation: writerGuard accepts committee writer access only for meetings today; votes still require project writer access.

Local checks passed: yarn lint:check, yarn check-types, and yarn format:check.

Address Copilot review comments:

- committee-votes.component.ts: switch getCommittee() to fetchCommittee()
  in onCreateVote() to avoid mutating shared committee service state during
  a permission-only check (per T1)
- committee-votes.component.ts: alias editVoteQueryParams to
  createVoteQueryParams instead of duplicating the computed (per T3)
- votes-table.component.html: replace <a> wrapper with direct [routerLink]
  and [queryParams] inputs on <lfx-button> — ButtonComponent already
  supports these inputs; wrapper created invalid nested interactive elements
- votes-table.component.html: add (onClick)=stopPropagation to prevent
  row-select from firing alongside edit route navigation in selectionMode table
- votes-table.component.ts: remove now-unused RouterLink import

Resolves 3 review threads (T1, T3, implicit <a> wrapper issue).

Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org>
@MRashad26

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed

Commit: 7268ddc

Changes Made

  • committee-votes.component.ts: switched getCommittee()fetchCommittee() in onCreateVote() to avoid unintended shared service state mutation (T1)
  • committee-votes.component.ts: aliased editVoteQueryParams to createVoteQueryParams instead of duplicating the computed() (T3)
  • votes-table.component.html: replaced <a> wrapper with direct [routerLink], [queryParams], and (onClick)=$event.stopPropagation() on <lfx-button>ButtonComponent already supports these inputs; wrapper created nested interactive elements (invalid HTML)
  • votes-table.component.ts: removed now-unused RouterLink import
  • PR description: updated to document votes-table changes (T4)

Questions Answered

  • T2 (error handler deny-on-any-error): No code change — the deny-on-any-error pattern is intentional and consistent across all LFXV2-2252 components. Any failure means we cannot confirm writer status, so denying is the safe path. The writerGuard re-evaluates on arrival anyway.

Threads Resolved

3 of 4 unresolved threads resolved (T1, T3, T4). T2 left open for discussion — no code change taken.

@MRashad26 MRashad26 requested a review from ahmedomosanya June 26, 2026 16:45
Copilot AI review requested due to automatic review settings June 27, 2026 00:23

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +58 to +61
this.committeeService
.fetchCommittee(committee.uid)
.pipe(take(1))
.subscribe({
import { VoteService } from '@services/vote.service';
import { MessageService } from 'primeng/api';
import { catchError, filter, finalize, of, switchMap } from 'rxjs';
import { catchError, filter, finalize, of, switchMap, take } from 'rxjs';

@dealako dealako 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.

@MRashad26 — solid iteration here. The lens-aware redirect, the fetchCommittee() switch, the editVoteQueryParams alias, and the PR description update all landed cleanly. The permission-gate pattern matches the meetings approach well.

Two open Copilot comments from the latest review round still need a commit:

🔴 Blocking: 0 issues
🟡 Minor: 2 issues — redundant take(1) + unused take import (Copilot flagged both; not yet addressed)
Nit: 2 issues — double-click guard, publicprotected visibility on onCreateVote

Revision tracking — previous rounds:

  • ✅ Hard-coded /project/overview → fixed (LensService injection, commit 1b0e903)
  • getCommittee() tap side-effect → switched to fetchCommittee() (commit 7268ddcb)
  • editVoteQueryParams duplication → aliased (commit 7268ddcb)
  • ✅ PR description missing votes-table context → updated
  • ✅ Error-path treat-all-as-denied → accepted trade-off, reasoning documented (consistent with other LFXV2-2252 components)

Context note (no code change needed): The writerGuard for /votes/create uses writeFeature: 'votes' with no committee.writer fast-path (only project.writer passes). So the fresh check in onCreateVote() catches revoked committee writers and denies immediately; project-writer-only denials still flow through the guard redirect. Defense-in-depth is intact. Worth tracking as a follow-up if committee writers should be able to create votes without project.writer.

🔴 Needs changes before approval — two minor Copilot comments in a single fix(review): commit, then this is ready.


this.committeeService
.fetchCommittee(committee.uid)
.pipe(take(1))

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.

[minor] fetchCommittee() already applies take(1) internally (CommitteeService line 77: .pipe(take(1))). The extra .pipe(take(1)) here is a no-op but signals to readers that this observable might be long-lived, which is misleading. Remove it.

Copilot flagged this in the latest review round (comment #3484723918) — not yet addressed.

import { VoteService } from '@services/vote.service';
import { MessageService } from 'primeng/api';
import { catchError, filter, finalize, of, switchMap } from 'rxjs';
import { catchError, filter, finalize, of, switchMap, take } from 'rxjs';

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.

[minor] Once .pipe(take(1)) is removed from onCreateVote(), take becomes an unused import. Drop it from this line to keep the import list clean.

Copilot flagged this in the latest review round (comment #3484723928) — not yet addressed.

/** Checks committee write permission fresh before navigating to the create-vote route.
* Redirects to project overview with _notice=votes if permission has been revoked
* since the page loaded — consistent with the writerGuard denial flow. */
public onCreateVote(): void {

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.

[nit] onCreateVote is bound in the template but declared public. Angular 20 convention is protected for template-only methods — it makes the intent clear and prevents unintended external calls.

protected onCreateVote(): void {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants