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
63 changes: 63 additions & 0 deletions src/lib/utils/order-menu-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { orderMenuItems } from './order-menu-permissions';

const own = (role: string) => ({ isBuyer: false, isFederatedView: false, role });
const federated = (role: string) => ({ isBuyer: false, isFederatedView: true, role });

describe('orderMenuItems', () => {
describe('own-org order', () => {
it.each(['admin', 'owner', 'member', 'sales'])('offers every item to %s', (role) => {
expect(orderMenuItems(own(role), 'submitted')).toEqual({
clone: true,
downloadPdf: true,
cancel: true
});
});

it('offers nothing to a guest', () => {
expect(orderMenuItems(own('guest'), 'submitted')).toEqual({
clone: false,
downloadPdf: false,
cancel: false
});
});

it('offers nothing to a caller with no role', () => {
expect(orderMenuItems(own(''), 'draft')).toEqual({
clone: false,
downloadPdf: false,
cancel: false
});
});

it.each(['shipped', 'delivered', 'cancelled'])('hides cancel once %s', (status) => {
expect(orderMenuItems(own('admin'), status).cancel).toBe(false);
});
});

describe('federated order (brand viewing a rep-owned order)', () => {
it('never offers clone, since the clone endpoint is own-org only', () => {
expect(orderMenuItems(federated('admin'), 'submitted').clone).toBe(false);
});

it.each(['admin', 'owner'])('lets %s cancel', (role) => {
expect(orderMenuItems(federated(role), 'submitted').cancel).toBe(true);
});

it.each(['member', 'sales'])('does not let %s cancel', (role) => {
expect(orderMenuItems(federated(role), 'submitted').cancel).toBe(false);
});

it('still offers the PDF to a writing role', () => {
expect(orderMenuItems(federated('sales'), 'submitted').downloadPdf).toBe(true);
});
});

describe('buyer', () => {
it('offers nothing, even on their own draft', () => {
expect(
orderMenuItems({ isBuyer: true, isFederatedView: false, role: 'admin' }, 'draft')
).toEqual({ clone: false, downloadPdf: false, cancel: false });
});
});
});
49 changes: 49 additions & 0 deletions src/lib/utils/order-menu-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Which items the order detail "More actions" menu offers.
*
* Each item mirrors the rule the server applies to the same action, so the
* menu never offers something guaranteed to fail (SCO-173):
*
* - Clone: `/api/orders/[id]/clone` loads the order own-org only
* (`loadOwnOrgOrder`) and requires a writing role. A buyer or a federated
* viewer gets 404, a guest 403.
* - Download PDF: `/api/orders/[id]/pdf` loads own-org or federated
* (`loadOrderForOrg`) and requires `ORDER_PDF_ROLES`.
* - Cancel: a client-side update, so RLS decides. Own-org needs a writing role
* (`Admin/owner/member/sales can update orders`); federated needs admin or
* owner (`Brand admin updates federated order status`). The buyer policy's
* WITH CHECK only allows `draft` or `submitted`, so a buyer can never cancel.
*
* Return is resolved server-side (`resolveReturnEntry`) and is not decided here.
*/
import { FEDERATED_ORDER_STATUS_ROLES, ORDER_WRITE_ROLES } from './order-convert-permissions';

/** Roles that may generate an order PDF. */
export const ORDER_PDF_ROLES = new Set(['admin', 'owner', 'member', 'sales']);

const CLOSED_STATUSES = new Set(['shipped', 'delivered', 'cancelled']);

export interface OrderMenuViewer {
isBuyer: boolean;
isFederatedView: boolean;
/** The viewer's role in their active org; empty when there is none. */
role: string;
}

export interface OrderMenuItems {
clone: boolean;
downloadPdf: boolean;
cancel: boolean;
}

export function orderMenuItems(viewer: OrderMenuViewer, status: string): OrderMenuItems {
const { isBuyer, isFederatedView, role } = viewer;
if (isBuyer) return { clone: false, downloadPdf: false, cancel: false };

const cancelRoles = isFederatedView ? FEDERATED_ORDER_STATUS_ROLES : ORDER_WRITE_ROLES;
return {
clone: !isFederatedView && ORDER_WRITE_ROLES.has(role),
downloadPdf: ORDER_PDF_ROLES.has(role),
cancel: !CLOSED_STATUSES.has(status) && cancelRoles.has(role)
};
}
5 changes: 4 additions & 1 deletion src/routes/api/orders/[id]/clone/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
import { notifyOrgMembers } from '$lib/server/notifications';
import { loadOwnOrgOrder, type OrderRow } from '$lib/server/orders/authorize-order';
import { ORDER_WRITE_ROLES } from '$lib/utils/order-convert-permissions';

type SourceOrder = OrderRow & {
order_number: string | null;
Expand All @@ -23,7 +24,9 @@ type SourceOrder = OrderRow & {
// A clone is an order creation. The Roles & Access matrix (§4, "Orders:
// create / edit / submit") excludes guest.
// https://app.notion.com/p/3a70c8824fed81eeb9e2e25bf21e51d6
const CLONE_ROLES = new Set(['admin', 'owner', 'member', 'sales']);
// Shared with the order detail menu (orderMenuItems) so the UI never offers a
// clone this endpoint would refuse.
const CLONE_ROLES = ORDER_WRITE_ROLES;

export const POST: RequestHandler = async ({ locals, params }) => {
if (!locals.session || !locals.user || !locals.organization) {
Expand Down
4 changes: 3 additions & 1 deletion src/routes/api/orders/[id]/pdf/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import type { RequestHandler } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
import { generateOrderPdf, type OrderData } from '$lib/server/pdf';
import { loadOrderForOrg, type OrderRow } from '$lib/server/orders/authorize-order';
import { ORDER_PDF_ROLES } from '$lib/utils/order-menu-permissions';

type OrderForPdf = OrderRow & OrderData;

// Roles & Access §8.5: generating the order PDF follows order-edit scope
// (Yes/Yes/Scoped/Scoped/No), so guest is excluded.
// https://app.notion.com/p/3a70c8824fed81eeb9e2e25bf21e51d6
// Brand scoping for member/sales is enforced by RLS on the underlying order.
const PDF_ROLES = new Set(['admin', 'owner', 'member', 'sales']);
// Shared with the order detail menu (orderMenuItems).
const PDF_ROLES = ORDER_PDF_ROLES;

export const GET: RequestHandler = async ({ locals, params }) => {
if (!locals.session || !locals.user || !locals.organization) {
Expand Down
Loading
Loading