Skip to content

Commit ba8f8d2

Browse files
committed
fix(auth): align Purchaser carve-out with CR review on PR #924
Address the five Major findings from CodeRabbit's pass on PR #924 so the frontend/backend authorization contract for the carved-out money-spending verbs (execute / approve-any / retry-any on purchases) stays consistent end-to-end. F1 (migration 000058): the seed used a bare `ON CONFLICT DO NOTHING` which treated "row with seeded UUID already exists" and "some other row already owns the name Purchaser" the same way. The latter case left DefaultPurchaserGroupID absent and silently broke the admin backfill. Detect a name-collision under a different UUID and RAISE EXCEPTION; narrow the conflict target to (id) so only the intended re-run is tolerated. F2 (internal/auth/types.go): `GroupPurchaser` constant was the lowercase string "purchaser" while the seeded row carries "Purchaser". Any name-based lookup would have missed the row. Align the constant with the seeded literal and document the contract in the comment. F3 (frontend/src/recommendations.ts): the no-Purchaser banner was predicated on `isPurchaser()` while the Purchase CTA used `canAccess('execute', 'purchases')`. Custom roles that hold execute:purchases via a non-seeded group saw both a live Purchase button AND the contradictory "you can view but not execute" notice. Drive the banner from the same predicate as the CTA. F4 (frontend/src/permissions.ts): `isPurchaser()` only checked PURCHASER_GROUP_ID membership, so the helper denied users who hold the carved-out verbs via custom groups. Extend it to consult effectivePermissions when present (true if any of the three carved-out verbs is granted), keeping the seeded-group fallback for the loading window so it agrees with canAccess()'s carve-out path. F5 (frontend/src/history.ts): canApprovePendingRow / canRetryFailedRow and the read-only banner all hard-coded isPurchaser(). A user granted approve-any:purchases or retry-any:purchases via another group was rejected by the UI even though the backend allowed the action. Gate each path on the exact canAccess(verb, 'purchases') the buttons need, and derive the banner from the same predicate so UI and enforcement stay in lockstep. Regression coverage: * permissions.test.ts: new isPurchaser() suite (seeded-group fallback, explicit-grant via execute / approve-any / retry-any in effectivePermissions, admin-without-explicit-carve-out denied, empty-effective-permissions denied, null-user denied) + custom-group carve-out coverage in the canAccess suite. * history-approve-button.test.ts: admin-without-Purchaser MUST NOT see Approve on rows they did not create (catches a future regression to isAdmin() / isPurchaser() group-only gates). * history-retry-button.test.ts: matching admin-without-Purchaser retry-any negative case. * recommendations-permissions.test.ts: admin-without-Purchaser hides Purchase, keeps Create Plan, shows the no-Purchaser banner; custom non-seeded group with explicit execute:purchases shows Purchase and no banner (F3 + F4 together).
1 parent fd13e70 commit ba8f8d2

9 files changed

Lines changed: 341 additions & 36 deletions

File tree

‎frontend/src/__tests__/history-approve-button.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions';
6969
// existing admins on first deploy of issue #923). approve-any:purchases is
7070
// carved out of admin:* and requires Purchaser group membership.
7171
const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID] };
72+
// Admin without Purchaser membership and without effectivePermissions
73+
// for any carved-out spending verb. Issue #923 explicitly carves
74+
// approve-any:purchases / retry-any:purchases / execute:purchases OUT
75+
// of admin:*, so this user MUST NOT see Approve / Retry buttons on
76+
// rows they did not create. Regression guard for CR #924 F5 — if a
77+
// future refactor reintroduces isAdmin() as the gate, this test
78+
// catches it.
79+
const ADMIN_NO_PURCH = { id: 'admin-no-purch-uuid', email: 'admin-no-purch@example.com', groups: [ADMINISTRATORS_GROUP_ID] };
7280
// REG_USER carries the default-user effective permission set (approve-own
7381
// + cancel-own + retry-own on purchases) so canAccess returns true for
7482
// own-row actions without needing the bootstrap fetch. The previous
@@ -370,4 +378,33 @@ describe('History inline Approve button (issue #286)', () => {
370378
expect(cancelBtn?.disabled).toBe(false);
371379
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
372380
});
381+
382+
test('admin WITHOUT Purchaser membership does not see Approve on rows they did not create (CR #924 F5)', async () => {
383+
// Issue #923 + CR #924 F5: approve-any:purchases is carved out of
384+
// admin:*. canApprovePendingRow must gate on
385+
// canAccess('approve-any', 'purchases'), NOT on isAdmin() or
386+
// isPurchaser() group membership alone. A bare admin (no Purchaser
387+
// group, no effectivePermissions yet) is exactly the case where
388+
// the carve-out matters: the legacy implementation would have
389+
// shown Approve on every pending row.
390+
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_NO_PURCH);
391+
(api.getHistory as jest.Mock).mockResolvedValue({
392+
summary: {},
393+
purchases: [
394+
makeRow({ purchase_id: 'exec-mine', created_by_user_id: ADMIN_NO_PURCH.id }),
395+
makeRow({ purchase_id: 'exec-other', created_by_user_id: OTHER_UUID }),
396+
makeRow({ purchase_id: 'exec-legacy', created_by_user_id: undefined }),
397+
],
398+
});
399+
400+
await loadHistory();
401+
402+
// Scope to the history list (not the approval queue card).
403+
const list = document.getElementById('history-list')!;
404+
const buttons = list.querySelectorAll<HTMLButtonElement>('.history-approve-btn');
405+
const ids = Array.from(buttons).map((b) => b.dataset['approveId']);
406+
// Approve renders only via the approve-own fallback (matching
407+
// created_by_user_id), NOT approve-any.
408+
expect(ids).toEqual(['exec-mine']);
409+
});
373410
});

‎frontend/src/__tests__/history-retry-button.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions';
7676
// carved out of admin:* and requires Purchaser group membership.
7777
const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID] };
7878
const REG_USER = { id: 'user-uuid', email: 'user@example.com', groups: [] };
79+
// Admin WITHOUT Purchaser membership. retry-any:purchases is carved
80+
// out of admin:* by issue #923, so this user MUST NOT see Retry on
81+
// rows they did not create. Regression guard for CR #924 F5 -- if a
82+
// future refactor reintroduces isAdmin() as the gate, this catches it.
83+
const ADMIN_NO_PURCH = { id: 'admin-no-purch-uuid', email: 'admin-no-purch@example.com', groups: [ADMINISTRATORS_GROUP_ID] };
7984
const OTHER_UUID = 'other-uuid';
8085

8186
function setupDOM(): void {
@@ -433,4 +438,31 @@ describe('History inline Retry button (issue #47)', () => {
433438
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
434439
expect(btn?.disabled).toBe(false);
435440
});
441+
442+
test('admin WITHOUT Purchaser membership does not see Retry on rows they did not create (CR #924 F5)', async () => {
443+
// Issue #923 + CR #924 F5: retry-any:purchases is carved out of
444+
// admin:*. canRetryFailedRow must gate on
445+
// canAccess('retry-any', 'purchases'), NOT on isAdmin() or
446+
// isPurchaser() group membership alone. A bare admin (no Purchaser
447+
// group, no effectivePermissions yet) is exactly the case where
448+
// the carve-out matters: the legacy implementation would have
449+
// shown Retry on every failed row.
450+
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_NO_PURCH);
451+
(api.getHistory as jest.Mock).mockResolvedValue({
452+
summary: {},
453+
purchases: [
454+
makeRow({ purchase_id: 'fail-mine', created_by_user_id: ADMIN_NO_PURCH.id }),
455+
makeRow({ purchase_id: 'fail-other', created_by_user_id: OTHER_UUID }),
456+
makeRow({ purchase_id: 'fail-legacy', created_by_user_id: undefined }),
457+
],
458+
});
459+
460+
await loadHistory();
461+
462+
const buttons = document.querySelectorAll<HTMLButtonElement>('.history-retry-btn');
463+
const ids = Array.from(buttons).map((b) => b.dataset['retryId']);
464+
// Retry renders only via the retry-own fallback (matching
465+
// created_by_user_id), NOT retry-any.
466+
expect(ids).toEqual(['fail-mine']);
467+
});
436468
});

‎frontend/src/__tests__/permissions.test.ts‎

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* getRolePermissions() is kept for the effective-permissions display in
1515
* the admin Users page and still returns the same sets as before.
1616
*/
17-
import { canAccess, getRolePermissions, isAdmin, ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions';
17+
import { canAccess, getRolePermissions, isAdmin, isPurchaser, ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions';
1818
import type { PermissionEntry } from '../api/types';
1919

2020
jest.mock('../state', () => ({
@@ -343,5 +343,117 @@ describe('permissions', () => {
343343
expect(canAccess('view', 'recommendations')).toBe(false);
344344
expect(canAccess('admin', '*')).toBe(false);
345345
});
346+
347+
test('custom (non-seeded) group with carved-out grants in effectivePermissions allows spending', () => {
348+
// CR #924 F4 + F5 regression: a custom group that does not include
349+
// PURCHASER_GROUP_ID but DOES carry execute/approve-any/retry-any
350+
// on purchases must satisfy canAccess for those verbs. The seeded
351+
// Purchaser group is not the only legitimate source of the
352+
// carve-out; the backend already allows this shape via the
353+
// group's effectivePermissions, so the frontend must agree.
354+
const customGid = '00000000-0000-5000-8000-00000000abcd';
355+
const perms: PermissionEntry[] = [
356+
{ action: 'execute', resource: 'purchases' },
357+
{ action: 'approve-any', resource: 'purchases' },
358+
{ action: 'retry-any', resource: 'purchases' },
359+
{ action: 'view', resource: 'recommendations' },
360+
{ action: 'view', resource: 'plans' },
361+
{ action: 'view', resource: 'purchases' },
362+
{ action: 'view', resource: 'history' },
363+
];
364+
mockUserWithGroups([customGid], perms);
365+
// The three carved-out spending verbs are granted by the explicit
366+
// entries even without PURCHASER_GROUP_ID membership.
367+
expect(canAccess('execute', 'purchases')).toBe(true);
368+
expect(canAccess('approve-any', 'purchases')).toBe(true);
369+
expect(canAccess('retry-any', 'purchases')).toBe(true);
370+
expect(canAccess('view', 'recommendations')).toBe(true);
371+
expect(canAccess('view', 'history')).toBe(true);
372+
// Non-purchaser admin actions remain denied.
373+
expect(canAccess('admin', '*')).toBe(false);
374+
expect(canAccess('delete', 'plans')).toBe(false);
375+
expect(canAccess('view', 'users')).toBe(false);
376+
expect(canAccess('cancel-any', 'purchases')).toBe(false);
377+
});
378+
});
379+
380+
describe('isPurchaser', () => {
381+
test('seeded Purchaser group member (no effectivePermissions yet) returns true via fallback', () => {
382+
// Pre-bootstrap loading window: effectivePermissions not yet
383+
// populated. The helper falls back to seeded group membership.
384+
mockUserWithGroups([PURCHASER_GROUP_ID]);
385+
expect(isPurchaser()).toBe(true);
386+
});
387+
388+
test('user without Purchaser group and no effectivePermissions returns false', () => {
389+
mockUserWithGroups([ADMIN_GID]); // admin only
390+
expect(isPurchaser()).toBe(false);
391+
});
392+
393+
test('explicit execute:purchases grant in effectivePermissions (custom group) returns true', () => {
394+
// CR #924 F4: isPurchaser() must reflect effective permissions,
395+
// not just seeded group ID. A user whose custom group grants
396+
// execute:purchases is a spender even without PURCHASER_GROUP_ID.
397+
const customGid = '00000000-0000-5000-8000-00000000beef';
398+
mockUserWithGroups([customGid], [
399+
{ action: 'execute', resource: 'purchases' },
400+
{ action: 'view', resource: 'recommendations' },
401+
]);
402+
expect(isPurchaser()).toBe(true);
403+
});
404+
405+
test('explicit approve-any:purchases grant returns true', () => {
406+
const customGid = '00000000-0000-5000-8000-00000000cafe';
407+
mockUserWithGroups([customGid], [
408+
{ action: 'approve-any', resource: 'purchases' },
409+
]);
410+
expect(isPurchaser()).toBe(true);
411+
});
412+
413+
test('explicit retry-any:purchases grant returns true', () => {
414+
const customGid = '00000000-0000-5000-8000-00000000face';
415+
mockUserWithGroups([customGid], [
416+
{ action: 'retry-any', resource: 'purchases' },
417+
]);
418+
expect(isPurchaser()).toBe(true);
419+
});
420+
421+
test('wildcard resource on a carved-out action grants Purchaser (matches canAccess semantics)', () => {
422+
// canAccess('execute', 'purchases') returns true for a
423+
// {execute, *} entry. isPurchaser() must agree so the two helpers
424+
// do not diverge when an unusual-but-legal permission shape
425+
// arrives from the backend.
426+
const customGid = '00000000-0000-5000-8000-00000000d00d';
427+
mockUserWithGroups([customGid], [
428+
{ action: 'execute', resource: '*' },
429+
]);
430+
expect(isPurchaser()).toBe(true);
431+
});
432+
433+
test('admin:* in effectivePermissions WITHOUT explicit carved-out grants returns false', () => {
434+
// The backend carves the three spending verbs OUT of admin:*.
435+
// isPurchaser() must reflect that carve-out: a user whose
436+
// effectivePermissions are admin:* only (no explicit carve-out
437+
// entries from Purchaser membership) is NOT a spender. Note that
438+
// {admin, *} does NOT match {execute, *} / {approve-any, *} /
439+
// {retry-any, *} because the actions differ.
440+
mockUserWithGroups([ADMIN_GID], [
441+
{ action: 'admin', resource: '*' },
442+
]);
443+
expect(isPurchaser()).toBe(false);
444+
});
445+
446+
test('empty effectivePermissions array returns false even with PURCHASER_GROUP_ID', () => {
447+
// Loading is complete (effectivePermissions is defined and
448+
// empty); group membership without backend confirmation is not
449+
// enough.
450+
mockUserWithGroups([PURCHASER_GROUP_ID], []);
451+
expect(isPurchaser()).toBe(false);
452+
});
453+
454+
test('null user (logged out) returns false', () => {
455+
mockNoUser();
456+
expect(isPurchaser()).toBe(false);
457+
});
346458
});
347459
});

‎frontend/src/__tests__/recommendations-permissions.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ const mockUser = (role: string | null) => {
9494
);
9595
};
9696

97+
// Direct group-set mocking for tests that need to assert specific
98+
// group combinations (e.g. admin WITHOUT Purchaser for the carve-out
99+
// regression checks per CR #924 F5).
100+
const mockUserWithGroups = (groups: string[], effectivePermissions?: { action: string; resource: string }[]) => {
101+
(state.getCurrentUser as jest.Mock).mockReturnValue(
102+
{ id: 'u', email: 'u@example.com', groups, effectivePermissions },
103+
);
104+
};
105+
97106
const setupDom = () => {
98107
const recsTab = document.createElement('div');
99108
recsTab.id = 'opportunities-tab';
@@ -176,6 +185,49 @@ describe('Recommendations action-box permission gating (issue #365)', () => {
176185
expect(plan.hidden).toBe(true);
177186
});
178187

188+
test('admin WITHOUT Purchaser hides Purchase, keeps Create Plan, shows no-Purchaser notice (CR #924 F3)', async () => {
189+
// Issue #923 + CR #924 F3: execute:purchases is carved out of
190+
// admin:*. A bare admin (no Purchaser group, no effectivePermissions
191+
// yet) sees the Create Plan button (create:plans is NOT carved out)
192+
// but NOT the Purchase one-off button. The no-Purchaser banner must
193+
// appear, driven by canAccess('execute', 'purchases') so it stays
194+
// in lockstep with the Purchase CTA.
195+
mockUserWithGroups([ADMINISTRATORS_GROUP_ID]);
196+
await loadRecommendations();
197+
const purchase = document.getElementById('bulk-purchase-btn') as HTMLButtonElement;
198+
const plan = document.getElementById('create-plan-btn') as HTMLButtonElement;
199+
expect(purchase).not.toBeNull();
200+
expect(plan).not.toBeNull();
201+
expect(purchase.hidden).toBe(true);
202+
expect(plan.hidden).toBe(false);
203+
// No-Purchaser banner present.
204+
const actionBox = document.getElementById('recommendations-action-box')!;
205+
const banners = actionBox.querySelectorAll('.info-banner');
206+
expect(banners.length).toBe(1);
207+
expect(banners[0]?.textContent).toContain('You can view but not execute purchases');
208+
});
209+
210+
test('custom (non-seeded) group with explicit execute:purchases shows Purchase + no banner (CR #924 F3/F4)', async () => {
211+
// CR #924 F3 + F4: the banner must NOT appear when a custom group
212+
// (not PURCHASER_GROUP_ID) carries execute:purchases via
213+
// effectivePermissions. The previous isPurchaser()-only predicate
214+
// would have shown the contradictory "you can view but not execute"
215+
// notice alongside a live Purchase CTA.
216+
const customGid = '00000000-0000-5000-8000-00000000abcd';
217+
mockUserWithGroups([customGid], [
218+
{ action: 'execute', resource: 'purchases' },
219+
{ action: 'create', resource: 'plans' },
220+
{ action: 'view', resource: 'recommendations' },
221+
]);
222+
await loadRecommendations();
223+
const purchase = document.getElementById('bulk-purchase-btn') as HTMLButtonElement;
224+
expect(purchase.hidden).toBe(false);
225+
// No banner -- the predicate matches the live CTA.
226+
const actionBox = document.getElementById('recommendations-action-box')!;
227+
const banners = actionBox.querySelectorAll('.info-banner');
228+
expect(banners.length).toBe(0);
229+
});
230+
179231
test('the action-box capacity input stays visible for all sessions', async () => {
180232
// Non-mutating elements stay visible regardless of group membership;
181233
// only the action CTAs gate on permissions.

‎frontend/src/history.ts‎

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { confirmDialog } from './confirmDialog';
1111
import { buildApprovalDetailsBody } from './approval-details';
1212
import { showToast } from './toast';
1313
import { getCurrentUser } from './state';
14-
import { isAdmin, canAccess, isPurchaser } from './permissions';
14+
import { canAccess } from './permissions';
1515
import { showSkeletonRows, teardownSkeleton } from './lib/skeleton';
1616
import { getAccountName } from './recommendations';
1717

@@ -408,18 +408,24 @@ function canCancelPendingRow(p: HistoryPurchase): boolean {
408408
//
409409
// Heuristic:
410410
// * status must be "pending" or "notified";
411-
// * Purchaser-group member → approve-any (issue #923: approve-any is
412-
// carved out of admin:* and requires explicit Purchaser membership);
413-
// * non-Purchaser matching the row's created_by_user_id → approve-own;
414-
// * legacy rows with NULL created_by_user_id → no (the email-token path
415-
// remains the escape hatch).
411+
// * any session with approve-any:purchases (carved-out admin verb,
412+
// seeded on Purchaser group; can also come from a custom group via
413+
// effectivePermissions) → approve-any;
414+
// * otherwise the row's created_by_user_id must match the current
415+
// user (approve-own);
416+
// * legacy rows with NULL created_by_user_id → no (the email-token
417+
// path remains the escape hatch).
416418
function canApprovePendingRow(p: HistoryPurchase): boolean {
417419
const status = (p.status || '').toLowerCase();
418420
if (status !== 'pending' && status !== 'notified') return false;
419421
const user = getCurrentUser();
420422
if (!user) return false;
421-
// Purchaser group membership grants approve-any (issue #923).
422-
if (isPurchaser()) return true;
423+
// approve-any:purchases is carved out of admin:* (issue #923) and is
424+
// granted by the seeded Purchaser group OR any custom group that
425+
// explicitly lists the verb in effectivePermissions. Gate on the
426+
// verb directly so a non-seeded role with the same grant still
427+
// approves rows the backend would also let through.
428+
if (canAccess('approve-any', 'purchases')) return true;
423429
if (!p.created_by_user_id) return false;
424430
return p.created_by_user_id === user.id;
425431
}
@@ -436,18 +442,24 @@ function canApprovePendingRow(p: HistoryPurchase): boolean {
436442
// * row must NOT already have a retry_execution_id (we don't allow
437443
// retrying the same failure twice — the user should retry the
438444
// latest descendant in the chain);
439-
// * Purchaser-group member → retry-any (issue #923: retry-any is
440-
// carved out of admin:* and requires explicit Purchaser membership);
441-
// * non-Purchaser matching the row's created_by_user_id → retry-own.
445+
// * any session with retry-any:purchases (carved-out admin verb,
446+
// seeded on Purchaser group; can also come from a custom group via
447+
// effectivePermissions) → retry-any;
448+
// * otherwise the row's created_by_user_id must match the current
449+
// user (retry-own).
442450
function canRetryFailedRow(p: HistoryPurchase): boolean {
443451
const status = (p.status || '').toLowerCase();
444452
if (status !== 'failed') return false;
445453
if (p.ops_hint) return false;
446454
if (p.retry_execution_id) return false; // already retried — user should act on the descendant
447455
const user = getCurrentUser();
448456
if (!user) return false;
449-
// Purchaser group membership grants retry-any (issue #923).
450-
if (isPurchaser()) return true;
457+
// retry-any:purchases is carved out of admin:* (issue #923) and is
458+
// granted by the seeded Purchaser group OR any custom group that
459+
// explicitly lists the verb in effectivePermissions. Gate on the
460+
// verb directly so a non-seeded role with the same grant still
461+
// retries rows the backend would also let through.
462+
if (canAccess('retry-any', 'purchases')) return true;
451463
if (!p.created_by_user_id) return false;
452464
return p.created_by_user_id === user.id;
453465
}
@@ -579,12 +591,19 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {
579591

580592
lastPurchases = purchases;
581593

582-
// Issue #923: inject a read-only notice for sessions that lack Purchaser
583-
// group membership. The Approve / Retry buttons in the table are gated by
584-
// canApprovePendingRow / canRetryFailedRow (which use isPurchaser), so this
585-
// banner explains why those buttons are absent for admin-only sessions.
594+
// Issue #923: inject a read-only notice for sessions that lack the
595+
// carved-out spending verbs the buttons in this table need. Approve /
596+
// Retry are gated by canApprovePendingRow / canRetryFailedRow which
597+
// call canAccess('approve-any','purchases') and
598+
// canAccess('retry-any','purchases'); use the same predicate here so
599+
// the banner stays in lockstep with the visible buttons (a user who
600+
// holds either verb via a custom group sees no contradictory
601+
// notice).
602+
const canApproveAny = canAccess('approve-any', 'purchases');
603+
const canRetryAny = canAccess('retry-any', 'purchases');
604+
const hasAnyCarvedOut = canApproveAny || canRetryAny;
586605
const existingBanner = document.getElementById('history-no-purchaser-banner');
587-
if (!existingBanner && !isPurchaser()) {
606+
if (!existingBanner && !hasAnyCarvedOut) {
588607
const banner = document.createElement('div');
589608
banner.id = 'history-no-purchaser-banner';
590609
banner.className = 'info-banner';
@@ -593,7 +612,7 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {
593612
'You can view but not execute purchases. ' +
594613
'Ask an admin to add you to the Purchaser group, or add yourself in Settings → Users.';
595614
container.parentElement?.insertBefore(banner, container);
596-
} else if (existingBanner && isPurchaser()) {
615+
} else if (existingBanner && hasAnyCarvedOut) {
597616
existingBanner.remove();
598617
}
599618

0 commit comments

Comments
 (0)