Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions docs/security/route-security-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2774,6 +2774,19 @@
"storage_boundary": "process-observability:no-tenant-data",
"test_adapter": "workload_metrics_key"
},
{
"operation": "admin:PUT:/v1/supplier/admin/facilities/{facility_id}/agreed-sending-hours",
"owner": "admin",
"method": "PUT",
"path": "/v1/supplier/admin/facilities/{facility_id}/agreed-sending-hours",
"classification": "tenant-scoped",
"authentication": "bearer-token-or-opaque-session-cookie",
"required_permissions": [
"tenant.admin"
],
"storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant",
"test_adapter": "tenant_dual_session_csrf"
},
{
"operation": "admin:POST:/v1/supplier/bulk-upload/commit",
"owner": "admin",
Expand Down Expand Up @@ -2956,19 +2969,6 @@
"storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant",
"test_adapter": "tenant_dual_session_csrf"
},
{
"operation": "admin:PUT:/v1/supplier/facilities/{facility_id}/agreed-sending-hours",
"owner": "admin",
"method": "PUT",
"path": "/v1/supplier/facilities/{facility_id}/agreed-sending-hours",
"classification": "tenant-scoped",
"authentication": "bearer-token-or-opaque-session-cookie",
"required_permissions": [
"authenticated"
],
"storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant",
"test_adapter": "tenant_dual_session_csrf"
},
{
"operation": "admin:POST:/v1/supplier/facilities/{facility_id}/cte-events",
"owner": "admin",
Expand Down
54 changes: 8 additions & 46 deletions frontend/src/__tests__/components/supplier-data-quality.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const lateMeasured = summary(
AGREED_HOURS,
);

function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false, onSendingHoursSaved?: () => void) {
function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false) {
return render(
<DataQuality
facilityId={FACILITY_ID}
Expand All @@ -77,7 +77,6 @@ function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailab
windowDays={WINDOW_DAYS}
minRecords={MIN_RECORDS}
lateCteLabel={LATE_LABEL}
onSendingHoursSaved={onSendingHoursSaved}
/>,
);
}
Expand Down Expand Up @@ -266,50 +265,13 @@ describe('DataQuality', () => {
expect(screen.queryByRole('button', { name: 'View records' })).not.toBeInTheDocument();
});

it('saves a new agreed sending time and reloads the measures', async () => {
const onSaved = vi.fn();
mocks.setFacilityAgreedSendingHours.mockResolvedValueOnce({
facility_id: FACILITY_ID,
agreed_sending_hours: AGREED_HOURS,
});
renderQuality(summary([fourMetrics.metrics[2]]), false, onSaved);

fireEvent.click(screen.getByRole('button', { name: 'Set' }));
fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: String(AGREED_HOURS) } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
expect(mocks.setFacilityAgreedSendingHours).toHaveBeenCalledWith(FACILITY_ID, AGREED_HOURS);
});

it.each(['0', `-${AGREED_HOURS}`, `${AGREED_HOURS}.5`, ''])('refuses %j as an agreed sending time', (draft) => {
renderQuality(summary([fourMetrics.metrics[2]]));
fireEvent.click(screen.getByRole('button', { name: 'Set' }));
fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: draft } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole number of hours above zero');
it.each([null, AGREED_HOURS])('shows the agreed sending time %j read only, with no way to change it', (hours) => {
renderQuality(summary([fourMetrics.metrics[2]], hours));
expect(screen.getByText('Set by your customer')).toBeInTheDocument();
for (const name of ['Set', 'Edit', 'Save', 'Clear']) {
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument();
}
expect(screen.queryByLabelText('Hours after the event')).not.toBeInTheDocument();
expect(mocks.setFacilityAgreedSendingHours).not.toHaveBeenCalled();
});

it('clears the agreed sending time with null', async () => {
const onSaved = vi.fn();
mocks.setFacilityAgreedSendingHours.mockResolvedValueOnce({ facility_id: FACILITY_ID, agreed_sending_hours: null });
renderQuality(lateMeasured, false, onSaved);
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
expect(screen.getByLabelText('Hours after the event')).toHaveValue(AGREED_HOURS);
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
expect(mocks.setFacilityAgreedSendingHours).toHaveBeenCalledWith(FACILITY_ID, null);
});

it('says so when saving the agreed sending time fails', async () => {
const onSaved = vi.fn();
mocks.setFacilityAgreedSendingHours.mockRejectedValueOnce(new Error(crypto.randomUUID()));
renderQuality(summary([fourMetrics.metrics[2]]), false, onSaved);
fireEvent.click(screen.getByRole('button', { name: 'Set' }));
fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: String(AGREED_HOURS) } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('The agreed sending time could not be saved'));
expect(onSaved).not.toHaveBeenCalled();
});
});
104 changes: 8 additions & 96 deletions frontend/src/app/dashboard/suppliers/data-quality.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useId, useState, type FormEvent, type ReactNode } from 'react';
import { useState, type ReactNode } from 'react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { apiClient } from '@/lib/api-client';
import type {
SupplierDataQuality,
Expand Down Expand Up @@ -78,94 +77,13 @@ function DrilldownList({ metric, state }: { metric: SupplierQualityDrilldownMetr
);
}

function SendingHoursEditor({
facilityId,
hours,
onSaved,
}: {
facilityId: string;
hours: number | null;

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.

Tenant page cannot edit sending hours

High Severity

SendingHoursNote makes agreed sending hours read-only with Set by your customer on the tenant Supplier Management page. Owner and Admin users are now the only roles that can write agreed_sending_hours, but they have no in-app editor, so the late CTE threshold cannot be set or cleared from the product.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0e6854d. Configure here.

onSaved?: () => void;
}) {
const inputId = useId();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const start = () => {
setDraft(hours === null ? '' : String(hours));
setError(null);
setEditing(true);
};

const save = async (value: number | null) => {
setSaving(true);
setError(null);
try {
await apiClient.setFacilityAgreedSendingHours(facilityId, value);
setEditing(false);
onSaved?.();
} catch {
setError('The agreed sending time could not be saved');
} finally {
setSaving(false);
}
};

const submit = (event: FormEvent) => {
event.preventDefault();
const trimmed = draft.trim();
const value = Number(trimmed);
if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(value) || value <= 0) {
setError('Enter a whole number of hours above zero');
return;
}
void save(value);
};

if (!editing) {
return (
<div className="flex flex-wrap items-center gap-x-2">
<span>
Agreed sending time: {hours === null ? 'Not set' : `${formatHours(hours)} after the event`}
</span>
<Button variant="link" size="sm" onClick={start}>
{hours === null ? 'Set' : 'Edit'}
</Button>
</div>
);
}
/** Read only: the customer (tenant admin) sets the agreed sending time, not the supplier. */
function SendingHoursNote({ hours }: { hours: number | null }) {
return (
<form onSubmit={submit} noValidate className="flex flex-wrap items-center gap-2" aria-label="Agreed sending time">
<label htmlFor={inputId}>Hours after the event</label>
<Input
id={inputId}
type="number"
inputMode="numeric"
min={1}
step={1}
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="w-24"
/>
<Button type="submit" size="sm" disabled={saving}>
Save
</Button>
{hours !== null && (
<Button type="button" variant="secondary" size="sm" disabled={saving} onClick={() => void save(null)}>
Clear
</Button>
)}
<Button type="button" variant="ghost" size="sm" disabled={saving} onClick={() => setEditing(false)}>
Cancel
</Button>
{error && (
<p role="alert" className="w-full text-re-text-tertiary">
{error}
</p>
)}
</form>
<div className="flex flex-wrap items-center gap-x-2">
<span>Agreed sending time: {hours === null ? 'Not set' : `${formatHours(hours)} after the event`}</span>
<span className="text-re-text-tertiary">Set by your customer</span>
</div>
);
}

Expand All @@ -176,15 +94,13 @@ function MetricRow({
minRecords,
lateCteLabel,
agreedSendingHours,
onSendingHoursSaved,
}: {
facilityId: string;
metric: SupplierQualityMetric;
windowDays: number;
minRecords: number;
lateCteLabel: string;
agreedSendingHours: number | null;
onSendingHoursSaved?: () => void;
}) {
const copy = METRIC_COPY[metric.key];
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -251,7 +167,7 @@ function MetricRow({
{metric.key === 'late_cte' && (
<>
<p className="text-re-text-tertiary">{lateCteLabel}</p>
<SendingHoursEditor facilityId={facilityId} hours={agreedSendingHours} onSaved={onSendingHoursSaved} />
<SendingHoursNote hours={agreedSendingHours} />
</>
)}
{open && isDrilldown(metric.key) && (
Expand Down Expand Up @@ -281,8 +197,6 @@ interface DataQualityProps {
windowDays: number;
minRecords: number;
lateCteLabel: string;
/** Called after the agreed sending time is saved, to reload the measures. */
onSendingHoursSaved?: () => void;
}

/** Four separate data quality measures for one facility. No combined score. */
Expand All @@ -293,7 +207,6 @@ export function DataQuality({
windowDays,
minRecords,
lateCteLabel,
onSendingHoursSaved,
}: DataQualityProps) {
if (unavailable) {
return <p className="mt-2 text-small text-re-text-tertiary">Data quality unavailable</p>;
Expand All @@ -314,7 +227,6 @@ export function DataQuality({
minRecords={minRecords}
lateCteLabel={lateCteLabel}
agreedSendingHours={summary.agreed_sending_hours ?? null}
onSendingHoursSaved={onSendingHoursSaved}
/>
))}
</ul>
Expand Down
1 change: 0 additions & 1 deletion frontend/src/app/dashboard/suppliers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,6 @@ export default function SupplierDashboardPage() {
windowDays={dataQuality?.window_days ?? 30}
minRecords={dataQuality?.min_records ?? 20}
lateCteLabel={dataQuality?.late_cte_label ?? ''}
onSendingHoursSaved={loadDataQuality}
/>
)}
{comp.gaps.length > 0 && (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ class APIClient {
agreedSendingHours: number | null,
): Promise<AgreedSendingHoursResponse> {
const { data } = await this.adminClient.put<AgreedSendingHoursResponse>(
`/v1/supplier/facilities/${encodeURIComponent(facilityId)}/agreed-sending-hours`,
`/v1/supplier/admin/facilities/${encodeURIComponent(facilityId)}/agreed-sending-hours`,
{ agreed_sending_hours: agreedSendingHours },
);
return data;
Expand Down
46 changes: 37 additions & 9 deletions services/admin/app/supplier_compliance_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from .dependencies import get_current_user
from .models import TenantContext
from .sqlalchemy_models import SupplierFacilityModel, UserModel
from .tenant_settings_routes import _ADMIN_ROLE_NAMES, _get_user_role_name
from .supplier_onboarding_routes import (
SupplierComplianceGap,
SupplierComplianceGapsResponse,
Expand Down Expand Up @@ -356,27 +357,54 @@ async def get_supplier_data_quality_records(
)


@router.put("/facilities/{facility_id}/agreed-sending-hours", response_model=AgreedSendingHoursResponse)
class TenantAdminChecker:
"""Allow only an Owner or Admin membership in the bound tenant.

Reuses the tenant settings role gate (#1386). ``required_permission``
is read by the route security manifest.
"""

required_permission = "tenant.admin"

def __call__(self, user: UserModel = Depends(get_current_user), db: Session = Depends(get_session)) -> UserModel:
tenant_id = TenantContext.get_tenant_context(db)
if not tenant_id:
raise HTTPException(status_code=403, detail="E_NO_TENANT_CONTEXT: tenant-scoped endpoint requires X-Tenant-ID")
if _get_user_role_name(tenant_id, user, db) not in _ADMIN_ROLE_NAMES:
raise HTTPException(status_code=403, detail="Only tenant Owner or Admin roles may set agreed sending hours")
return user


require_tenant_admin = TenantAdminChecker()


@router.put("/admin/facilities/{facility_id}/agreed-sending-hours", response_model=AgreedSendingHoursResponse)
async def set_agreed_sending_hours(
facility_id: str,
request: AgreedSendingHoursRequest,
current_user: UserModel = Depends(get_current_user),
_admin: UserModel = Depends(require_tenant_admin),
db: Session = Depends(get_session),
) -> AgreedSendingHoursResponse:
"""Set or clear (null) the agreed sending time for one owned facility."""
"""Tenant admin sets or clears (null) the agreed sending time for a facility in the tenant.

Suppliers can read it in their data quality summary but cannot change it.
"""
tenant_id = TenantContext.get_tenant_context(db)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant context required")
try:
scoped_facility_id = uuid_module.UUID(facility_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid facility id") from exc
facility = _get_supplier_facility_or_404(
db,
tenant_id=tenant_id,
supplier_user_id=current_user.id,
facility_id=scoped_facility_id,
)
# Tenant scoping lives in the query: a facility in another tenant is never loaded.
facility = db.execute(
select(SupplierFacilityModel).where(
SupplierFacilityModel.id == scoped_facility_id,
SupplierFacilityModel.tenant_id == tenant_id,
)
).scalar_one_or_none()
if facility is None:
raise HTTPException(status_code=404, detail="Facility not found")

facility.agreed_sending_hours = request.agreed_sending_hours
db.commit()
Expand Down
Loading
Loading