fix(audit-final): pre-merge hardening + expense receipt UI
Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ export interface ExpenseRow {
|
||||
description: string | null;
|
||||
payer: string | null;
|
||||
receiptFileIds: string[] | null;
|
||||
noReceiptAcknowledged?: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
/** Set by the dedup engine when this expense looks like a duplicate of another. */
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AlertTriangle, Loader2, Upload, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -17,18 +18,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
|
||||
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
|
||||
import type { ExpenseRow } from './expense-columns';
|
||||
|
||||
interface UploadedReceipt {
|
||||
id: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface ExpenseFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -38,6 +38,12 @@ interface ExpenseFormDialogProps {
|
||||
export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!expense;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadedReceipt, setUploadedReceipt] = useState<UploadedReceipt | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [noReceipt, setNoReceipt] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -65,15 +71,47 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
expenseDate: new Date(expense.expenseDate),
|
||||
paymentStatus: (expense.paymentStatus as CreateExpenseInput['paymentStatus']) ?? 'unpaid',
|
||||
});
|
||||
setUploadedReceipt(null);
|
||||
setPreviewUrl(null);
|
||||
setNoReceipt(Boolean(expense.noReceiptAcknowledged));
|
||||
setUploadError(null);
|
||||
} else if (open && !expense) {
|
||||
reset({
|
||||
currency: 'USD',
|
||||
paymentStatus: 'unpaid',
|
||||
expenseDate: new Date(),
|
||||
});
|
||||
setUploadedReceipt(null);
|
||||
setPreviewUrl(null);
|
||||
setNoReceipt(false);
|
||||
setUploadError(null);
|
||||
}
|
||||
}, [open, expense, reset]);
|
||||
|
||||
// Capture the URL inside the effect closure so the cleanup revokes the
|
||||
// URL it observed at mount, not the one captured by a later render.
|
||||
// Audit caught a bug where the cleanup ran on every change and revoked
|
||||
// the URL that was still being shown.
|
||||
useEffect(() => {
|
||||
const url = previewUrl;
|
||||
return () => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
// Reset upload state whenever the sheet closes — re-opening on the same
|
||||
// expense was carrying stale state from the prior session.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUploadedReceipt(null);
|
||||
setPreviewUrl(null);
|
||||
setNoReceipt(false);
|
||||
setUploadError(null);
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CreateExpenseInput) => {
|
||||
if (isEdit) {
|
||||
@@ -90,10 +128,52 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: CreateExpenseInput) {
|
||||
mutation.mutate(data);
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadError(null);
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', 'receipt');
|
||||
const res = await fetch('/api/v1/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
const json = (await res.json()) as { data: { id: string; filename: string } };
|
||||
setUploadedReceipt({ id: json.data.id, filename: json.data.filename });
|
||||
setNoReceipt(false);
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : 'Upload failed');
|
||||
setUploadedReceipt(null);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function clearReceipt() {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(null);
|
||||
setUploadedReceipt(null);
|
||||
setUploadError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
function onSubmit(data: CreateExpenseInput) {
|
||||
mutation.mutate({
|
||||
...data,
|
||||
receiptFileIds: uploadedReceipt ? [uploadedReceipt.id] : undefined,
|
||||
noReceiptAcknowledged: Boolean(noReceipt && !uploadedReceipt),
|
||||
});
|
||||
}
|
||||
|
||||
const canSubmit = isEdit || Boolean(uploadedReceipt) || noReceipt;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
@@ -110,9 +190,11 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
{...register('expenseDate', {
|
||||
setValueAs: (v) => (v ? new Date(v) : undefined),
|
||||
})}
|
||||
defaultValue={expense?.expenseDate
|
||||
? new Date(expense.expenseDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0]}
|
||||
defaultValue={
|
||||
expense?.expenseDate
|
||||
? new Date(expense.expenseDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
/>
|
||||
{errors.expenseDate && (
|
||||
<p className="text-xs text-destructive">{errors.expenseDate.message}</p>
|
||||
@@ -130,19 +212,12 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
placeholder="0.00"
|
||||
{...register('amount', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.amount && (
|
||||
<p className="text-xs text-destructive">{errors.amount.message}</p>
|
||||
)}
|
||||
{errors.amount && <p className="text-xs text-destructive">{errors.amount.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input
|
||||
id="currency"
|
||||
placeholder="USD"
|
||||
maxLength={3}
|
||||
{...register('currency')}
|
||||
/>
|
||||
<Input id="currency" placeholder="USD" maxLength={3} {...register('currency')} />
|
||||
{errors.currency && (
|
||||
<p className="text-xs text-destructive">{errors.currency.message}</p>
|
||||
)}
|
||||
@@ -180,7 +255,9 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentMethod">Payment Method</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue('paymentMethod', v as CreateExpenseInput['paymentMethod'])}
|
||||
onValueChange={(v) =>
|
||||
setValue('paymentMethod', v as CreateExpenseInput['paymentMethod'])
|
||||
}
|
||||
defaultValue={expense?.paymentMethod ?? undefined}
|
||||
>
|
||||
<SelectTrigger id="paymentMethod">
|
||||
@@ -198,11 +275,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payer">Payer</Label>
|
||||
<Input
|
||||
id="payer"
|
||||
placeholder="Who paid?"
|
||||
{...register('payer')}
|
||||
/>
|
||||
<Input id="payer" placeholder="Who paid?" {...register('payer')} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -232,21 +305,93 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<Label className="text-sm font-medium">Receipt</Label>
|
||||
{previewUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Receipt preview"
|
||||
className="max-h-48 rounded border object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearReceipt}
|
||||
aria-label="Remove receipt"
|
||||
className="absolute top-1 right-1 rounded-full bg-background/90 hover:bg-background border p-1 shadow-sm"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{isUploading
|
||||
? 'Uploading...'
|
||||
: uploadedReceipt
|
||||
? `Uploaded: ${uploadedReceipt.filename}`
|
||||
: 'Selecting...'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={noReceipt}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload receipt image or PDF
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{uploadError && <p className="text-xs text-destructive">{uploadError}</p>}
|
||||
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="noReceipt"
|
||||
checked={noReceipt}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked === true;
|
||||
setNoReceipt(next);
|
||||
if (next) clearReceipt();
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="noReceipt" className="text-sm font-normal leading-tight">
|
||||
I have no receipt for this expense
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{noReceipt && (
|
||||
<div className="flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
Expenses without a receipt may not be reimbursed by the parent company. The PDF
|
||||
export will flag this expense.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(mutation.error as Error).message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{(mutation.error as Error).message}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || mutation.isPending || isUploading || !canSubmit}
|
||||
>
|
||||
{(isSubmitting || mutation.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user