fix(projects): stop the cockpit offering email controls the API rejects (stable) (#977)

Closes #969 on stable. Backport of #976.

The cockpit's email feed rendered preview/resend/cancel/retry/send-now for every mail regardless of role or permission, producing 404s (CRM document mail has no event_id; project ownership does not imply event ownership) and 403s (preview needs events.view, the write actions need email.send).

getProjectOverview now stamps each email with an authoritative canAct, mirroring filterOwnedEventIds; created_by is selected only for that check and stripped before the response. A missing canAct reads as false.
This commit is contained in:
Paul Nothaft
2026-08-03 14:49:04 +02:00
committed by GitHub
parent cc49f6997a
commit 2d0e6ab2dc
6 changed files with 379 additions and 9 deletions
@@ -27,6 +27,7 @@ import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../../hooks';
import { formatMoneyMinor } from '../../../utils/money';
import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext';
import { usePermissions } from '../../../contexts/PermissionsContext';
type FeedKind = 'email' | 'quote' | 'contract' | 'invoice' | 'gallery' | 'hours';
@@ -42,6 +43,9 @@ interface FeedItem {
emailId?: number;
emailStatus?: string;
reRendered?: boolean;
/** Server's verdict on whether the queued-mail routes would accept an action
* on this row. Not derivable client-side — see canActOnEmail. */
emailCanAct?: boolean;
}
/** The feature flag that gates each document's detail ROUTE (RequireFeature
@@ -109,8 +113,23 @@ export const ProjectCockpitPage: React.FC = () => {
const navigate = useNavigate();
const qc = useQueryClient();
const { flags } = useFeatureFlags();
const { hasPermission } = usePermissions();
const { format, formatTime } = useLocalizedDate();
// Which email controls are actually reachable for THIS admin, so the feed
// stops offering buttons the API will reject (#969):
// 404 — requireOwnedQueuedEmail (adminProjects.js) scopes queued mail
// through email_queue.event_id AND ownership of that event. CRM
// document mail carries no event_id at all. The server decides this
// for us (`canAct`) because neither half is derivable here: the
// overview deliberately does not expose who owns a sibling event.
// Absent (older backend) → treat as not actionable, so we fail to
// hiding a live control rather than offering a dead one.
// 403 — preview needs `events.view`, but resend/cancel/retry/send-now
// need `email.send`; the feed used to render all four regardless.
const canActOnEmail = (item: FeedItem) => item.emailCanAct === true;
const canSendEmail = hasPermission('email.send');
const [editName, setEditName] = useState<string | null>(null);
const [preview, setPreview] = useState<EmailPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
@@ -189,6 +208,7 @@ export const ProjectCockpitPage: React.FC = () => {
title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`,
subtitle: e.recipient + (e.error ? `${e.error}` : ''),
status: e.status, emailId: e.id, emailStatus: e.status, reRendered: !e.stored,
emailCanAct: e.canAct,
});
}
for (const q of data.quotes) {
@@ -376,7 +396,9 @@ export const ProjectCockpitPage: React.FC = () => {
// not a dead strip next to them). Hours have neither → static.
const onRowClick = item.href
? () => navigate(item.href as string)
: (item.kind === 'email' && item.emailId != null ? () => openPreview(item.emailId as number) : undefined);
: (item.kind === 'email' && item.emailId != null && canActOnEmail(item)
? () => openPreview(item.emailId as number)
: undefined);
return (
<li
key={item.key}
@@ -397,7 +419,7 @@ export const ProjectCockpitPage: React.FC = () => {
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300">{item.status}</span>
)}
{item.amount && <span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">{item.amount}</span>}
{item.kind === 'email' && item.emailId != null && (
{item.kind === 'email' && item.emailId != null && canActOnEmail(item) && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
<Eye className="w-3 h-3" />{t('projects.email.preview', 'Preview')}
@@ -410,12 +432,12 @@ export const ProjectCockpitPage: React.FC = () => {
{t('projects.email.reRenderedTag', '≈ re-rendered')}
</span>
)}
{item.emailStatus === 'sent' && (
{canSendEmail && item.emailStatus === 'sent' && (
<button onClick={() => emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
<Send className="w-3 h-3" />{t('projects.email.resend', 'Resend')}
</button>
)}
{item.emailStatus === 'pending' && (
{canSendEmail && item.emailStatus === 'pending' && (
<>
<button onClick={() => emailActionMutation.mutate({ action: 'sendNow', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
<Send className="w-3 h-3" />{t('projects.email.sendNow', 'Send now')}
@@ -425,7 +447,7 @@ export const ProjectCockpitPage: React.FC = () => {
</button>
</>
)}
{item.emailStatus === 'failed' && (
{canSendEmail && item.emailStatus === 'failed' && (
<button onClick={() => emailActionMutation.mutate({ action: 'retry', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-amber-600 hover:underline">
<RotateCw className="w-3 h-3" />{t('projects.email.retry', 'Retry')}
</button>
@@ -0,0 +1,158 @@
/**
* The cockpit's email feed used to render preview/resend/cancel/retry/send-now
* for every mail, consulting neither the caller's role nor their permissions.
* Two ways that produced dead controls (#969):
*
* 404 — requireOwnedQueuedEmail scopes queued mail through
* email_queue.event_id. CRM document mail (quote/contract/invoice/
* storno) is queued with event_id = null, so for a non-super_admin it
* has no ownable parent and always 404s. Introduced by the GHSA-93x4
* fix in #960/#966, which added the ownership middleware.
* 403 — preview requires `events.view` but the four write actions require
* `email.send`; the feed rendered all of them regardless.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
// An event mail (actionable by anyone who owns the event) and a CRM document
// mail (event_id null → only a super_admin can act on it). Both 'sent', so the
// Resend button is the one under test in each row.
const OVERVIEW = {
project: {
id: 1, name: 'Hochzeit Müller', customerAccountId: 1,
customerEmail: '[email protected]', status: 'active',
createdAt: '2026-07-01T00:00:00Z', updatedAt: '2026-08-01T00:00:00Z',
},
events: [], quotes: [], contracts: [], invoices: [],
hours: { entries: [], totalMinutes: 0 },
milestones: [],
valuation: { byCurrency: [] },
emails: [
// Event mail the caller owns → the server says actionable.
{ id: 11, recipient: '[email protected]', type: 'gallery_ready', status: 'sent',
queuedAt: '2026-08-01T10:00:00Z', sentAt: '2026-08-01T10:00:05Z', error: null,
eventId: 42, stored: true, canAct: true },
// CRM document mail — no event to own, so 404 for a non-super_admin.
{ id: 12, recipient: '[email protected]', type: 'invoice_sent', status: 'sent',
queuedAt: '2026-08-01T11:00:00Z', sentAt: '2026-08-01T11:00:05Z', error: null,
eventId: null, stored: true, canAct: false },
// Event mail for a FOREIGN event: a super_admin can attach admin B's event
// to admin A's project, and project ownership does not imply event
// ownership — so eventId is non-null yet the action still 404s.
{ id: 13, recipient: '[email protected]', type: 'gallery_ready', status: 'sent',
queuedAt: '2026-08-01T12:00:00Z', sentAt: '2026-08-01T12:00:05Z', error: null,
eventId: 99, stored: true, canAct: false },
],
};
/** The same payload as a super_admin sees it — the server stamps every row. */
const OVERVIEW_SUPER = {
...OVERVIEW,
emails: OVERVIEW.emails.map((e) => ({ ...e, canAct: true })),
};
let currentOverview: typeof OVERVIEW = OVERVIEW;
vi.mock('../../../../services/projects.service', () => ({
projectsService: {
overview: vi.fn(async () => currentOverview),
emailPreview: vi.fn(async () => ({ html: '<p>x</p>', subject: 's' })),
resendEmail: vi.fn(), cancelEmail: vi.fn(), retryEmail: vi.fn(), sendEmailNow: vi.fn(),
update: vi.fn(), assignEvent: vi.fn(),
},
}));
vi.mock('../../../../services/events.service', () => ({
eventsService: { getEvents: vi.fn(async () => []) },
}));
vi.mock('../../../../contexts/FeatureFlagsContext', () => ({
useFeatureFlags: () => ({ flags: { projects: true, quotes: true, contracts: true, bills: true } }),
}));
let mockPerms = { isSuperAdmin: false, permissions: ['events.view'] };
vi.mock('../../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({
isSuperAdmin: mockPerms.isSuperAdmin,
hasPermission: (p: string) => mockPerms.isSuperAdmin || mockPerms.permissions.includes(p),
}),
}));
import { ProjectCockpitPage } from '../ProjectCockpitPage';
const renderPage = async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/projects/1']}>
<Routes>
<Route path="/admin/projects/:id" element={<ProjectCockpitPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getAllByText(/Email/).length).toBeGreaterThan(0));
};
describe('ProjectCockpitPage email controls (#969)', () => {
beforeEach(() => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view'] };
currentOverview = OVERVIEW;
});
it('super_admin sees controls on every row', async () => {
mockPerms = { isSuperAdmin: true, permissions: [] };
currentOverview = OVERVIEW_SUPER;
await renderPage();
// All three rows, Resend on all (super_admin implies email.send).
expect(screen.getAllByText('Preview')).toHaveLength(3);
expect(screen.getAllByText('Resend')).toHaveLength(3);
});
it('a scoped admin gets controls only on mail the server says it can act on', async () => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view', 'email.send'] };
await renderPage();
// Only the owned event mail. The CRM row (no event) and the foreign-event
// row both 404, so neither offers anything.
expect(screen.getAllByText('Preview')).toHaveLength(1);
expect(screen.getAllByText('Resend')).toHaveLength(1);
});
it('an admin without email.send sees preview but no write actions (would 403)', async () => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view'] };
await renderPage();
expect(screen.getAllByText('Preview')).toHaveLength(1);
expect(screen.queryByText('Resend')).toBeNull();
expect(screen.queryByText('Cancel')).toBeNull();
expect(screen.queryByText('Retry')).toBeNull();
expect(screen.queryByText('Send now')).toBeNull();
});
it('offers nothing when the server omits canAct (older backend)', async () => {
mockPerms = { isSuperAdmin: false, permissions: ['events.view', 'email.send'] };
currentOverview = {
...OVERVIEW,
emails: OVERVIEW.emails.map(({ canAct: _drop, ...e }) => e as typeof OVERVIEW.emails[number]),
};
await renderPage();
// Fail to hiding a live control rather than offering a dead one.
expect(screen.queryByText('Preview')).toBeNull();
expect(screen.queryByText('Resend')).toBeNull();
});
});
@@ -49,6 +49,10 @@ export interface ProjectEmail {
eventId: number | null;
/** true = exact HTML stored at send time; false = preview re-rendered. */
stored: boolean;
/** Authoritative: would the queued-mail routes accept an action on this row?
* False for CRM document mail (no event to own) and for event mail the
* caller does not own. Absent on older backends → treated as false. */
canAct?: boolean;
}
export interface ProjectInvoice {