224 lines
10 KiB
Markdown
224 lines
10 KiB
Markdown
|
|
# Frontend audit — 2026-05-06
|
||
|
|
|
||
|
|
Scope: new archive/restore/hard-delete dialogs, bulk archive wizard, client
|
||
|
|
detail header, audit log inspector, webhook delivery log, client list bulk
|
||
|
|
section. Companion to `docs/audit-comprehensive-2026-05-06.md` (does NOT
|
||
|
|
re-flag the Files-tab / reservations / berth-tab "coming soon" stubs already
|
||
|
|
covered there).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Critical
|
||
|
|
|
||
|
|
### C1 — `client-detail-header` opens restore dialog from the Archive icon for archived clients
|
||
|
|
|
||
|
|
**File:** `src/components/clients/client-detail-header.tsx:174-186`
|
||
|
|
|
||
|
|
**Scenario:** On an archived client the icon button still renders `<Archive>`
|
||
|
|
when `isArchived` is true (`isArchived ? <RotateCcw /> : <Archive />` is
|
||
|
|
correct), BUT both states use the same `setArchiveOpen(true)` handler and
|
||
|
|
the conditional below routes `<SmartRestoreDialog>` vs `<SmartArchiveDialog>`
|
||
|
|
off of `isArchived`. That part is fine. The real problem: the destructive
|
||
|
|
hover colour `hover:text-destructive` is applied via
|
||
|
|
`isArchived ? 'hover:text-foreground' : 'hover:text-destructive'` — but the
|
||
|
|
preceding class string already sets `hover:text-foreground` unconditionally,
|
||
|
|
so the conditional is dead and the restore button hovers red the same as
|
||
|
|
archive. Misleading colour signal on a reversible action; users hesitate to
|
||
|
|
click it.
|
||
|
|
|
||
|
|
**Fix:** Drop the always-applied `hover:text-foreground` from the base class
|
||
|
|
list and let the conditional own the hover colour, or just colour the
|
||
|
|
restore icon emerald to differentiate.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## High
|
||
|
|
|
||
|
|
### H1 — `bulk-archive-wizard` lets users skip the reasons step by clicking Continue while preflight is loading then Cancel/reopen
|
||
|
|
|
||
|
|
**File:** `src/components/clients/bulk-archive-wizard.tsx:253-267, 80-107`
|
||
|
|
|
||
|
|
**Scenario:** In the `preflight` stage the Continue button is only disabled
|
||
|
|
when `archivable.length === 0 || preflight.isLoading`. But `archivable` is
|
||
|
|
derived from `items = preflight.data ?? []`. While loading, `archivable` is
|
||
|
|
`[]` so Continue is disabled — good. After load with all-blocked selection,
|
||
|
|
`archivable.length === 0` so still disabled — good. However, the
|
||
|
|
`reasonsByClientId: reasons` payload is sent verbatim, so a user who advances
|
||
|
|
to "reasons", types into one client's box, then uses the carousel back arrow
|
||
|
|
and edits another, can submit reasons for clients NOT in `archivable` (e.g.
|
||
|
|
if the preflight is refetched on stale-time). Reasons for blocked or removed
|
||
|
|
client IDs are forwarded to the API. Minor data-quality issue.
|
||
|
|
|
||
|
|
**Fix:** Filter `reasons` to `archivable` IDs before mutating:
|
||
|
|
`reasonsByClientId: Object.fromEntries(Object.entries(reasons).filter(([id]) => archivable.some(a => a.clientId === id)))`.
|
||
|
|
|
||
|
|
### H2 — `client-list` bulk tag mutation uses `alert()` for partial failures and has no `onError`
|
||
|
|
|
||
|
|
**File:** `src/components/clients/client-list.tsx:88-106`
|
||
|
|
|
||
|
|
**Scenario:** User bulk-adds a tag to 50 clients; backend returns 200 with
|
||
|
|
`{succeeded: 30, failed: 20}` → user sees a native browser `alert()` blocking
|
||
|
|
the page. If the request itself errors (network drop, 500), there is no
|
||
|
|
`onError` so the dialog closes via `onSettled` and the user sees nothing —
|
||
|
|
silent failure. Inconsistent UX vs. every other mutation in this audit which
|
||
|
|
uses `toast`.
|
||
|
|
|
||
|
|
**Fix:** Replace `alert(...)` with `toast.warning(...)`, add an
|
||
|
|
`onError: (err) => toast.error(...)` branch matching the pattern used in
|
||
|
|
`bulk-archive-wizard.tsx` and `bulk-hard-delete-dialog.tsx`.
|
||
|
|
|
||
|
|
### H3 — `webhook-delivery-log` swallows fetch errors silently
|
||
|
|
|
||
|
|
**File:** `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`
|
||
|
|
|
||
|
|
**Scenario:** Admin opens a webhook detail page while the API is down or the
|
||
|
|
webhook was just deleted. `load()` catches and discards the error
|
||
|
|
(`} catch { /* ignore */ }`). UI shows "Loading deliveries…" forever on the
|
||
|
|
first load, or stays on the last successful page on subsequent loads, with
|
||
|
|
no indication that anything failed. No error state, no toast, no retry.
|
||
|
|
|
||
|
|
**Fix:** Surface errors via `toast.error` and show an inline error state
|
||
|
|
("Couldn't load deliveries — Retry") instead of swallowing.
|
||
|
|
|
||
|
|
### H4 — `audit-log-list` first-page fetch swallows errors and shows no error state
|
||
|
|
|
||
|
|
**File:** `src/components/admin/audit/audit-log-list.tsx:150-175`
|
||
|
|
|
||
|
|
**Scenario:** Filter form is fully interactive, user changes a date — request
|
||
|
|
fires, server 500s. The `try/finally` has no `catch`, so the rejected promise
|
||
|
|
becomes an unhandled rejection. The list shows whatever was previously
|
||
|
|
loaded (or empty state), and the user has no idea their filter didn't apply.
|
||
|
|
Same applies to `loadMore`.
|
||
|
|
|
||
|
|
**Fix:** Add `catch` blocks that set an error state and render an inline
|
||
|
|
error banner above the table, with a Retry button.
|
||
|
|
|
||
|
|
### H5 — `audit-log-card` renders as a link to `href="#"` — clicking jumps the page
|
||
|
|
|
||
|
|
**File:** `src/components/admin/audit/audit-log-card.tsx:96`
|
||
|
|
|
||
|
|
**Scenario:** On mobile / card view the audit log entries become clickable
|
||
|
|
cards with `href="#"`. Tapping any card scrolls the page to top and inserts
|
||
|
|
`#` in the URL (back-button trap). There's no detail view to navigate to.
|
||
|
|
|
||
|
|
**Fix:** Either render a non-link wrapper (button or div) when no detail
|
||
|
|
target exists, or link to a useful destination like
|
||
|
|
`/{portSlug}/{entityType}/{entityId}` when the entity is resolvable.
|
||
|
|
|
||
|
|
### H6 — `smart-archive-dialog` `archiveMutation` doesn't invalidate the dossier or single-client query
|
||
|
|
|
||
|
|
**File:** `src/components/clients/smart-archive-dialog.tsx:197-212`
|
||
|
|
|
||
|
|
**Scenario:** User archives a client successfully. The dialog invalidates
|
||
|
|
`['clients']`, `['berths']`, `['interests']` but NOT
|
||
|
|
`['client-archive-dossier', clientId]` nor `['clients', clientId]`. If the
|
||
|
|
parent screen (e.g. detail page) keeps the client query mounted, the
|
||
|
|
detail header continues to show the client as un-archived until a hard
|
||
|
|
reload. The Restore icon won't appear.
|
||
|
|
|
||
|
|
**Fix:** Add `qc.invalidateQueries({queryKey: ['clients', clientId]})` and
|
||
|
|
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})` so a
|
||
|
|
re-open re-fetches a fresh dossier (e.g. if user re-archives after restoring
|
||
|
|
in the same session).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Medium
|
||
|
|
|
||
|
|
### M1 — `smart-archive-dialog` derives `interestId` from a name match against `primaryBerthMooring` — wrong key
|
||
|
|
|
||
|
|
**File:** `src/components/clients/smart-archive-dialog.tsx:158-167`
|
||
|
|
|
||
|
|
**Scenario:** When building per-berth decisions the code does
|
||
|
|
`dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId`.
|
||
|
|
Multiple interests can share the same primary mooring (rare, but possible
|
||
|
|
historically), and worse, when no interest has this berth as primary it
|
||
|
|
falls back to `dossier.interests[0]?.interestId` regardless of which berth
|
||
|
|
is being decided. The wrong interest gets credited with the release, which
|
||
|
|
is then audit-logged.
|
||
|
|
|
||
|
|
**Fix:** Have the dossier API return `interestId` per berth row (it already
|
||
|
|
joins `interest_berths`), or look up by membership not by primary flag.
|
||
|
|
|
||
|
|
### M2 — `hard-delete-dialog` doesn't reset state when switching from intent → confirm if request fails midway
|
||
|
|
|
||
|
|
**File:** `src/components/clients/hard-delete-dialog.tsx:39-46, 64-79`
|
||
|
|
|
||
|
|
**Scenario:** User submits hard delete with wrong code → backend returns 400
|
||
|
|
→ toast fires, but the dialog stays on `confirm` stage with the bad code
|
||
|
|
still in the input and no clear cue. If the user then closes (X) and
|
||
|
|
reopens, the `useEffect` resets correctly. But if the email code expired
|
||
|
|
(10 min) and they request a fresh one, there's no "Resend code" button —
|
||
|
|
they must cancel and start over from intent. Minor.
|
||
|
|
|
||
|
|
**Fix:** Add a "Send a new code" link in the confirm stage that calls
|
||
|
|
`requestCode.mutate()` again and clears `code`.
|
||
|
|
|
||
|
|
### M3 — `bulk-hard-delete-dialog` doesn't refetch / invalidate after partial failure shows totals
|
||
|
|
|
||
|
|
**File:** `src/components/clients/bulk-hard-delete-dialog.tsx:64-85`
|
||
|
|
|
||
|
|
**Scenario:** Bulk delete returns `{deletedCount: 7}` for 10 selected; toast
|
||
|
|
warns but `qc.invalidateQueries({queryKey: ['clients']})` is invalidated
|
||
|
|
unconditionally — fine. However, the dialog closes immediately
|
||
|
|
(`onOpenChange(false)`), so the user can't see WHICH 3 failed. The toast
|
||
|
|
just says "see audit log". For a destructive bulk op this is too sparse;
|
||
|
|
users will repeat the action thinking it didn't work.
|
||
|
|
|
||
|
|
**Fix:** Stay open on partial failure and render a list of failed IDs (the
|
||
|
|
API likely already returns per-item results — if not, return them).
|
||
|
|
|
||
|
|
### M4 — `audit-log-list` doesn't validate that `dateFrom <= dateTo`
|
||
|
|
|
||
|
|
**File:** `src/components/admin/audit/audit-log-list.tsx:142-146`
|
||
|
|
|
||
|
|
**Scenario:** User picks From=2026-06-01, To=2026-05-01. Query fires with an
|
||
|
|
empty result range; user sees "No audit log entries found" and assumes
|
||
|
|
their data isn't there. No client-side validation hint.
|
||
|
|
|
||
|
|
**Fix:** Show an inline warning "From date must be before To date" and skip
|
||
|
|
the request when invalid.
|
||
|
|
|
||
|
|
### M5 — `bulk-archive-wizard` `Cancel` during `archiveMutation.isPending` discards mutation tracking
|
||
|
|
|
||
|
|
**File:** `src/components/clients/bulk-archive-wizard.tsx:248-251, 293-307`
|
||
|
|
|
||
|
|
**Scenario:** User clicks "Archive 50" → mutation in flight (10s) → user
|
||
|
|
clicks Cancel. The dialog closes; the mutation continues server-side and
|
||
|
|
its onSuccess fires later, showing a toast for an action the user thought
|
||
|
|
they cancelled. Worse, the dialog is gone so they can't tell which clients
|
||
|
|
got archived.
|
||
|
|
|
||
|
|
**Fix:** Disable Cancel while `archiveMutation.isPending`, or relabel to
|
||
|
|
"Cancel (won't stop in-progress)" and keep the mutation visible.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Low
|
||
|
|
|
||
|
|
### L1 — `audit-log-list` filter row overflows on narrow viewports
|
||
|
|
|
||
|
|
**File:** `src/components/admin/audit/audit-log-list.tsx:321-467`
|
||
|
|
|
||
|
|
**Scenario:** 8 filter controls (`Search` 288px, `Entity` 144px, `Action`
|
||
|
|
176px, `Severity` 128px, `Source` 128px, `User id` 176px, `From` 144px,
|
||
|
|
`To` 144px, total ~1330px) sit in a single `flex-wrap` row. At <1280px
|
||
|
|
viewports they wrap onto multiple lines pushing the table down 200+px;
|
||
|
|
at <640px (mobile) each control wraps onto its own line and the "Clear"
|
||
|
|
button (`ml-auto`) lands on the wrong row.
|
||
|
|
|
||
|
|
**Fix:** Collapse rarely-used filters (User id / Severity / Source) into a
|
||
|
|
"More filters" Popover for sm: viewports.
|
||
|
|
|
||
|
|
### L2 — `audit-log-card` action map missing entries silently fall back to grey "Activity" icon and grey badge
|
||
|
|
|
||
|
|
**File:** `src/components/admin/audit/audit-log-card.tsx:27-44, 46-52`
|
||
|
|
|
||
|
|
**Scenario:** New webhook/cron/job actions are in `audit-log-list.tsx`
|
||
|
|
ACTION_COLORS but absent from `audit-log-card.tsx` ACTION_BADGE_COLORS and
|
||
|
|
ACTION_ACCENT. Card view of these entries looks identical to a generic
|
||
|
|
"unknown" entry — visual loss vs. table view.
|
||
|
|
|
||
|
|
**Fix:** Sync the two maps; consider extracting to a shared module so they
|
||
|
|
can't drift.
|