mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat(companies): enforce customers:* permissions on customer endpoints
The customers:* keys were seeded and present in the backoffice constants but
enforced nowhere except reset-password. Customer CRUD sat behind the coarse
edr_freight_app:admin umbrella, and every company read endpoint was unguarded.
Two routes could not be gated on the route alone, because the authority they
need depends on the request BODY, not the path:
- PATCH /companies/:id carries `status` (UpdateCompanyDto extends
PartialType(CreateCompanyDto)), so it both edits fields and blacklists.
- PATCH /company-profiles/:profileId/status is approve, reject, suspend and
blacklist on one route.
Both now take a one-of route guard and assert per-status against a shared
STATUS_PERM map: approving/reactivating needs customers:verify, suspending or
blacklisting needs customers:deactivate. PATCH /companies/:id additionally
requires customers:update when any non-status field is present, so a caller
holding only deactivate cannot rename a company. The backoffice mirrors the
same map so no button is offered that the server would reject.
GET /companies/:companyId/documents is left authenticated-only with the split
in the handler: it is dual-audience. The portal reads its own documents during
onboarding, and the contract-request detail page (gated on contracts:view)
reads the applicant's. Gating it on customers:view alone would have 403'd
customers on their own documents and blanked the contract reviewer's panel.
The two by-company customer-view reads take a one-of guard for the same reason
— otherwise a staffer granted only customers:view gets a detail page whose tabs
403 individually.
Frontend: the customers routes were sidebar-filtered but not wrapped in
RequirePermission, so direct URL navigation rendered them for anyone.
Verified: freight-api type-check clean; backoffice type-check unchanged from
HEAD (pre-existing errors only); 25 tests pass across the companies and
freight-permission suites. Not exercised against a running API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -252,8 +252,11 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findAll(filter, companyId);
|
return this.bookingsService.findAll(filter, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Powers the customer-detail bookings tab, so `customers:view` reaches it too
|
||||||
|
// — otherwise a staffer granted only the customer permission gets a page whose
|
||||||
|
// tabs 403 individually.
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
@BookingView()
|
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,13 +11,22 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
UseGuards,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import {
|
||||||
|
assertFreightPermission,
|
||||||
|
hasFreightPermission,
|
||||||
|
} from "../../common/freight-permission.util";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
@@ -59,6 +68,23 @@ interface CurrentIamUser {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which permission a status write needs. Approving/reactivating is a different
|
||||||
|
* authority from suspending, but both arrive on the same route with the target
|
||||||
|
* in the BODY — a route-level guard can't tell them apart, so the handlers
|
||||||
|
* assert against this map instead.
|
||||||
|
*
|
||||||
|
* Keyed by string so it serves both `CompanyStatus` and `ProfileStatus`
|
||||||
|
* (a superset: it adds `rejected`).
|
||||||
|
*/
|
||||||
|
const STATUS_PERM: Record<string, string> = {
|
||||||
|
active: FREIGHT_PERMS.customers.verify,
|
||||||
|
pending: FREIGHT_PERMS.customers.verify,
|
||||||
|
rejected: FREIGHT_PERMS.customers.verify,
|
||||||
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
};
|
||||||
|
|
||||||
@ApiTags("Companies")
|
@ApiTags("Companies")
|
||||||
@Controller("companies")
|
@Controller("companies")
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
@@ -410,7 +436,7 @@ export class CompaniesController {
|
|||||||
|
|
||||||
// Used by backoffice
|
// Used by backoffice
|
||||||
@Post()
|
@Post()
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.create)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||||
@@ -421,12 +447,14 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
||||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||||
return this.companiesService.getCompanyStats();
|
return this.companiesService.getCompanyStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
||||||
async findAll(
|
async findAll(
|
||||||
@Query() query: ListCompaniesQueryDto,
|
@Query() query: ListCompaniesQueryDto,
|
||||||
@@ -436,6 +464,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Get company by ID" })
|
@ApiOperation({ summary: "Get company by ID" })
|
||||||
async findById(
|
async findById(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -446,30 +475,77 @@ export class CompaniesController {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits fields AND carries `status`, so it spans two authorities. The route
|
||||||
|
* guard is one-of (a status-only caller must get in); the asserts below are
|
||||||
|
* what actually authorize: touching `status` needs the permission
|
||||||
|
* {@link STATUS_PERM} maps it to, touching anything else needs
|
||||||
|
* `customers:update`. Both checks are required — without the second, a
|
||||||
|
* caller holding only `customers:deactivate` could rename the company.
|
||||||
|
*/
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@FreightAdmin()
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.customers.update,
|
||||||
|
FREIGHT_PERMS.customers.verify,
|
||||||
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
|
])
|
||||||
@ApiOperation({ summary: "Update a company" })
|
@ApiOperation({ summary: "Update a company" })
|
||||||
async update(
|
async update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateCompanyDto,
|
@Body() dto: UpdateCompanyDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
): Promise<ResponseCompanyDto> {
|
): Promise<ResponseCompanyDto> {
|
||||||
|
const { status, ...fields } = dto;
|
||||||
|
if (status) assertFreightPermission(user, STATUS_PERM[status]);
|
||||||
|
if (Object.keys(fields).length > 0) {
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.customers.update);
|
||||||
|
}
|
||||||
const company = await this.companiesService.updateCompany(id, dto);
|
const company = await this.companiesService.updateCompany(id, dto);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.deactivate)
|
||||||
@ApiOperation({ summary: "Soft-delete a company" })
|
@ApiOperation({ summary: "Soft-delete a company" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||||
await this.companiesService.deleteCompany(id);
|
await this.companiesService.deleteCompany(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-audience: staff read any customer's documents, and the portal reads
|
||||||
|
* its OWN during onboarding (`companiesService.getDocuments`). So the route
|
||||||
|
* is authenticated-only and the split happens here — same shape as
|
||||||
|
* `GET /contracts/:id`. Gating it on a staff permission alone would 403 every
|
||||||
|
* customer on their own documents.
|
||||||
|
*
|
||||||
|
* The staff arm is one-of because two pages consume it: the customer detail
|
||||||
|
* page (`customers:view`) and the contract-request detail page, whose route
|
||||||
|
* is gated on `contracts:view` — a contract reviewer without the customer
|
||||||
|
* permission still needs the applicant's documents.
|
||||||
|
*/
|
||||||
@Get(":companyId/documents")
|
@Get(":companyId/documents")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: "List documents uploaded for a company" })
|
@ApiOperation({ summary: "List documents uploaded for a company" })
|
||||||
async listDocuments(
|
async listDocuments(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
|
const isStaff = [
|
||||||
|
FREIGHT_PERMS.customers.view,
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
].some((p) => hasFreightPermission(user, p));
|
||||||
|
|
||||||
|
if (!isStaff) {
|
||||||
|
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
// Hidden as NotFound rather than Forbidden so company ids can't be probed.
|
||||||
|
if (company.id !== companyId) {
|
||||||
|
throw new NotFoundException(`Company ${companyId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const files = await this.filesService.findByResource(companyId, "companies");
|
const files = await this.filesService.findByResource(companyId, "companies");
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
files.map(async (f) => ({
|
files.map(async (f) => ({
|
||||||
@@ -490,7 +566,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("documents/:fileId/request-change")
|
@Post("documents/:fileId/request-change")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Ask the customer to correct one uploaded document",
|
summary: "Ask the customer to correct one uploaded document",
|
||||||
description:
|
description:
|
||||||
@@ -532,14 +608,23 @@ export class CompaniesController {
|
|||||||
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve / reject / suspend / blacklist all arrive here with the target in
|
||||||
|
* the body, so authorization is per-status via {@link STATUS_PERM} rather
|
||||||
|
* than on the route (the guard is only the one-of gate).
|
||||||
|
*/
|
||||||
@Patch("company-profiles/:profileId/status")
|
@Patch("company-profiles/:profileId/status")
|
||||||
@FreightAdmin()
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.customers.verify,
|
||||||
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
|
])
|
||||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||||
async updateCompanyProfileStatus(
|
async updateCompanyProfileStatus(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||||
): Promise<ResponseCompanyProfileDto> {
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
|
assertFreightPermission(user, STATUS_PERM[dto.status]);
|
||||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||||
profileId,
|
profileId,
|
||||||
dto.status,
|
dto.status,
|
||||||
@@ -550,7 +635,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":companyId/change-requests")
|
@Get(":companyId/change-requests")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List a company's profile change requests" })
|
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||||
async listChangeRequests(
|
async listChangeRequests(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -560,7 +645,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("change-requests/:id/approve")
|
@Post("change-requests/:id/approve")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Approve a pending profile change request (applies the changes)",
|
summary: "Approve a pending profile change request (applies the changes)",
|
||||||
})
|
})
|
||||||
@@ -576,7 +661,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("change-requests/:id/reject")
|
@Post("change-requests/:id/reject")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reject a pending profile change request with a note",
|
summary: "Reject a pending profile change request with a note",
|
||||||
})
|
})
|
||||||
@@ -594,7 +679,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":companyId/profiles")
|
@Post(":companyId/profiles")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
async createProfile(
|
async createProfile(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -608,6 +693,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":companyId/profiles")
|
@Get(":companyId/profiles")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List profiles for a company" })
|
@ApiOperation({ summary: "List profiles for a company" })
|
||||||
async listProfiles(
|
async listProfiles(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -618,6 +704,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("profile/user/:userId")
|
@Get("profile/user/:userId")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||||
async findProfileByUser(
|
async findProfileByUser(
|
||||||
@Param("userId", ParseUUIDPipe) userId: string,
|
@Param("userId", ParseUUIDPipe) userId: string,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
import { BookingView } from "../../common/booking-guards";
|
import { BookingStaff, BookingView } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { IntentStatusDto } from "./payments.dto";
|
import { IntentStatusDto } from "./payments.dto";
|
||||||
|
|
||||||
@@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto";
|
|||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
|
// Customer-detail payments tab — same one-of rule as the bookings tab.
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
|
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view])
|
||||||
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
||||||
findByCompanyCustomerView(
|
findByCompanyCustomerView(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
|||||||
@@ -802,8 +802,22 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="support" element={<SupportInboxPage />} />
|
<Route path="support" element={<SupportInboxPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
path="customers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||||
|
<CustomersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="customers/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||||
|
<CustomerDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="invoices"
|
path="invoices"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFileViewer } from "@edr/ui-common";
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { fetchViewableFile } from "@/services/files.service";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||||
@@ -128,6 +130,8 @@ function DiffRow({
|
|||||||
* (with note) actions, plus a short history of past decisions.
|
* (with note) actions, plus a short history of past decisions.
|
||||||
*/
|
*/
|
||||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||||
);
|
);
|
||||||
@@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
{/* Reviewing the diff is `customers:view`; deciding on it is
|
||||||
<Button
|
`customers:verify`. Without it the request stays readable but
|
||||||
variant="light"
|
un-actionable. */}
|
||||||
color="red"
|
{canReview && (
|
||||||
onClick={() => {
|
<Group justify="flex-end" gap="sm">
|
||||||
setRejectId(pending.id);
|
<Button
|
||||||
setNote("");
|
variant="light"
|
||||||
}}
|
color="red"
|
||||||
>
|
onClick={() => {
|
||||||
Reject
|
setRejectId(pending.id);
|
||||||
</Button>
|
setNote("");
|
||||||
<Button
|
}}
|
||||||
color="edr-green"
|
>
|
||||||
loading={approve.isPending}
|
Reject
|
||||||
onClick={() => approve.mutate({ id: pending.id })}
|
</Button>
|
||||||
>
|
<Button
|
||||||
Approve changes
|
color="edr-green"
|
||||||
</Button>
|
loading={approve.isPending}
|
||||||
</Group>
|
onClick={() => approve.mutate({ id: pending.id })}
|
||||||
|
>
|
||||||
|
Approve changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -286,6 +288,19 @@ export function InvoiceStatusBadge({
|
|||||||
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
||||||
* an already-active profile is still managable.
|
* an already-active profile is still managable.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Which permission each status write needs. Mirrors `STATUS_PERM` in the API's
|
||||||
|
* `companies.controller.ts` — approving is a different authority from
|
||||||
|
* suspending, and both go through the same endpoint. Keep the two in step.
|
||||||
|
*/
|
||||||
|
const STATUS_PERM: Record<ProfileStatus, string> = {
|
||||||
|
active: FREIGHT_PERMS.customers.verify,
|
||||||
|
pending: FREIGHT_PERMS.customers.verify,
|
||||||
|
rejected: FREIGHT_PERMS.customers.verify,
|
||||||
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
};
|
||||||
|
|
||||||
export function ProfileApprovalActions({
|
export function ProfileApprovalActions({
|
||||||
profileId,
|
profileId,
|
||||||
status,
|
status,
|
||||||
@@ -295,6 +310,10 @@ export function ProfileApprovalActions({
|
|||||||
status: ProfileStatus;
|
status: ProfileStatus;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
/** The API rejects these anyway — hide rather than offer a button that 403s. */
|
||||||
|
const canSet = (next: ProfileStatus) =>
|
||||||
|
hasPermission(user, STATUS_PERM[next]);
|
||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
@@ -414,35 +433,41 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
|
if (!canSet("active") && !canSet("rejected")) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Button
|
{canSet("active") && (
|
||||||
size="xs"
|
<Button
|
||||||
variant="light"
|
size="xs"
|
||||||
color="edr-green"
|
variant="light"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
loading={isPending}
|
radius="md"
|
||||||
onClick={() => act("active")}
|
loading={isPending}
|
||||||
>
|
onClick={() => act("active")}
|
||||||
Approve
|
>
|
||||||
</Button>
|
Approve
|
||||||
<Button
|
</Button>
|
||||||
size="xs"
|
)}
|
||||||
variant="light"
|
{canSet("rejected") && (
|
||||||
color="red"
|
<Button
|
||||||
radius="md"
|
size="xs"
|
||||||
onClick={() => openDecision("reject")}
|
variant="light"
|
||||||
>
|
color="red"
|
||||||
Reject
|
radius="md"
|
||||||
</Button>
|
onClick={() => openDecision("reject")}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "rejected") {
|
if (status === "rejected") {
|
||||||
|
if (!canSet("active")) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -458,6 +483,7 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "active") {
|
if (status === "active") {
|
||||||
|
if (!canSet("suspended")) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
@@ -476,34 +502,40 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "suspended") {
|
if (status === "suspended") {
|
||||||
|
if (!canSet("active") && !canSet("blacklisted")) return null;
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
<Button
|
{canSet("active") && (
|
||||||
size="xs"
|
<Button
|
||||||
variant="light"
|
size="xs"
|
||||||
color="edr-green"
|
variant="light"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
loading={isPending}
|
radius="md"
|
||||||
onClick={() => openDecision("reactivate")}
|
loading={isPending}
|
||||||
>
|
onClick={() => openDecision("reactivate")}
|
||||||
Reactivate
|
>
|
||||||
</Button>
|
Reactivate
|
||||||
<Button
|
</Button>
|
||||||
size="xs"
|
)}
|
||||||
variant="light"
|
{canSet("blacklisted") && (
|
||||||
color="red"
|
<Button
|
||||||
radius="md"
|
size="xs"
|
||||||
loading={isPending}
|
variant="light"
|
||||||
onClick={() => act("blacklisted")}
|
color="red"
|
||||||
>
|
radius="md"
|
||||||
Blacklist
|
loading={isPending}
|
||||||
</Button>
|
onClick={() => act("blacklisted")}
|
||||||
|
>
|
||||||
|
Blacklist
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "blacklisted") {
|
if (status === "blacklisted") {
|
||||||
|
if (!canSet("pending")) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import {
|
|||||||
humanize,
|
humanize,
|
||||||
} from "@/components/customers";
|
} from "@/components/customers";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import {
|
import {
|
||||||
downloadBookingFile,
|
downloadBookingFile,
|
||||||
fetchViewableFile,
|
fetchViewableFile,
|
||||||
@@ -108,6 +110,7 @@ export default function CustomerDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { data: company, isLoading } = useQuery(
|
const { data: company, isLoading } = useQuery(
|
||||||
api.customers.getById.queryOptions({
|
api.customers.getById.queryOptions({
|
||||||
@@ -180,6 +183,11 @@ export default function CustomerDetailPage() {
|
|||||||
// API's rule exactly, so no button is offered that the server would reject.
|
// API's rule exactly, so no button is offered that the server would reject.
|
||||||
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
||||||
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
||||||
|
// Workflow gate (above) AND authority: asking the customer to correct a
|
||||||
|
// document is a `customers:verify` action, so a view-only reviewer reads the
|
||||||
|
// documents but is not offered the request-change control.
|
||||||
|
const canRequestDocChange =
|
||||||
|
canReview && hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||||
|
|
||||||
/** Document the reviewer is asking the customer to correct; null = closed. */
|
/** Document the reviewer is asking the customer to correct; null = closed. */
|
||||||
const [changeRequestDoc, setChangeRequestDoc] =
|
const [changeRequestDoc, setChangeRequestDoc] =
|
||||||
@@ -446,7 +454,7 @@ export default function CustomerDetailPage() {
|
|||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
{canReview && (
|
{canRequestDocChange && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -468,7 +476,7 @@ export default function CustomerDetailPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[view, canReview],
|
[view, canRequestDocChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||||
|
|||||||
Reference in New Issue
Block a user