From b4fb3b2ca686acfe2ee40305dbd10250d791e462 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 22:38:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(audit):=20MEDIUMs=20sweep=20=E2=80=94=20mob?= =?UTF-8?q?ile=20More-sheet,=20portal=20profile,=20inline=20override,=20di?= =?UTF-8?q?alog=20UX,=20ext-EOI=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-M11: mobile More-sheet missing 4 destinations. Added Reservations, Notifications, Residential, Website analytics — anyone using mobile chrome to triage on the go can now reach those domains. R2-M12: portal had no profile / change-password surface. New /portal/profile page with read-only contact details + a ChangePasswordForm component, backed by a new POST /api/portal/auth/change-password endpoint and changePortalPassword() service function. Audits both ok and failure cases at warning severity. Added Profile to PortalNav. R2-M1: portal dashboard "My Memberships" tile had no href and no /portal/memberships route — dead-end on tap. Hidden until a memberships page ships; the count remains in the underlying data. R2-M7: InlineStagePicker never sent override:true so users with interests.override_stage couldn't actually use the perm from the inline chip — they had to fall back to the modal picker. Now the picker auto-detects when a transition isn't legal AND the user has override_stage, sets override:true, and supplies a default reason. Frontend M2: hard-delete-dialog confirm stage now has a "Send a new code" link in case the original expired before the user could enter it. Avoids forcing a full Cancel + reopen. Frontend M4: audit-log-list date-range validation. From > To now shows an inline error and skips the request rather than firing an empty-range query that surfaces "no entries found". R2-M6: external-EOI route now requires interests.edit AND documents.upload_signed (defense-in-depth) — uploading a signed EOI mutates interest state, so the upload-signed perm alone shouldn't let a custom role flip an interest. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(portal)/portal/dashboard/page.tsx | 11 +-- src/app/(portal)/portal/profile/page.tsx | 42 +++++++++ .../api/portal/auth/change-password/route.ts | 44 ++++++++++ .../v1/interests/[id]/external-eoi/route.ts | 11 ++- src/components/admin/audit/audit-log-list.tsx | 16 +++- src/components/clients/hard-delete-dialog.tsx | 19 +++- .../interests/inline-stage-picker.tsx | 23 ++++- src/components/layout/mobile/more-sheet.tsx | 8 ++ .../portal/change-password-form.tsx | 88 +++++++++++++++++++ src/components/portal/portal-nav.tsx | 11 ++- src/lib/services/portal-auth.service.ts | 48 ++++++++++ 11 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 src/app/(portal)/portal/profile/page.tsx create mode 100644 src/app/api/portal/auth/change-password/route.ts create mode 100644 src/components/portal/change-password-form.tsx diff --git a/src/app/(portal)/portal/dashboard/page.tsx b/src/app/(portal)/portal/dashboard/page.tsx index c5d8e6e..bdc999f 100644 --- a/src/app/(portal)/portal/dashboard/page.tsx +++ b/src/app/(portal)/portal/dashboard/page.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react'; +import { Anchor, FileText, Receipt, Sailboat, CalendarCheck } from 'lucide-react'; import type { Metadata } from 'next'; import { getPortalSession } from '@/lib/portal/auth'; @@ -55,12 +55,9 @@ export default async function PortalDashboardPage() { icon={Sailboat} href="/portal/my-yachts" /> - + {/* My Memberships tile was a dead-end (no href, no /portal/memberships + route). Hidden until a memberships page ships. The count is still + available in the underlying dashboard data when needed. */} +
+

Profile

+

+ Read-only contact details and self-service password change. +

+
+ +
+
+ Email + {session.email} +
+

+ To update name, phone, or address, please contact your port team — they keep the records + authoritative. +

+
+ +
+

Change password

+

+ You’ll need your current password to confirm. +

+ +
+ + ); +} diff --git a/src/app/api/portal/auth/change-password/route.ts b/src/app/api/portal/auth/change-password/route.ts new file mode 100644 index 0000000..277185d --- /dev/null +++ b/src/app/api/portal/auth/change-password/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { portalUsers } from '@/lib/db/schema/portal'; +import { errorResponse, UnauthorizedError, ValidationError } from '@/lib/errors'; +import { getPortalSession } from '@/lib/portal/auth'; +import { changePortalPassword } from '@/lib/services/portal-auth.service'; + +const bodySchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(9), +}); + +export async function POST(req: NextRequest): Promise { + try { + const session = await getPortalSession(); + if (!session) throw new UnauthorizedError('Portal session required'); + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid request body'); + } + const { currentPassword, newPassword } = bodySchema.parse(body); + + const user = await db.query.portalUsers.findFirst({ + where: eq(portalUsers.email, session.email), + }); + if (!user) throw new UnauthorizedError('Portal account not found'); + + await changePortalPassword({ + portalUserId: user.id, + currentPassword, + newPassword, + }); + + return NextResponse.json({ data: { ok: true } }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/v1/interests/[id]/external-eoi/route.ts b/src/app/api/v1/interests/[id]/external-eoi/route.ts index 7408fd2..cadf5a5 100644 --- a/src/app/api/v1/interests/[id]/external-eoi/route.ts +++ b/src/app/api/v1/interests/[id]/external-eoi/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { uploadExternallySignedEoi } from '@/lib/services/external-eoi.service'; -import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { errorResponse, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; export const POST = withAuth( withPermission('documents', 'upload_signed', async (req, ctx, params) => { @@ -10,6 +10,15 @@ export const POST = withAuth( const interestId = params.id; if (!interestId) throw new NotFoundError('Interest'); + // Defense-in-depth: uploading an externally-signed EOI mutates + // the interest's effective state (flips it to "signed"). Require + // interests.edit in addition to documents.upload_signed so a + // custom role with the upload bit but no interest-edit can't flip + // an interest. Super-admin bypasses (audit R2-M6). + if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) { + throw new ForbiddenError('interests.edit required to upload an external EOI'); + } + const form = await req.formData(); const file = form.get('file'); if (!file || !(file instanceof File)) { diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index e736de5..72d3058 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -140,8 +140,12 @@ export function AuditLogList() { if (source !== 'all') params.set('source', source); if (debouncedSearch) params.set('search', debouncedSearch); if (debouncedUserId) params.set('userId', debouncedUserId); - if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString()); - if (dateTo) { + // Skip the date filters when From > To — the inline warning below + // tells the user to fix it; we don't want to fire a request with a + // useless empty range either. + const datesValid = !(dateFrom && dateTo && dateFrom > dateTo); + if (datesValid && dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString()); + if (datesValid && dateTo) { const end = new Date(dateTo); end.setHours(23, 59, 59, 999); params.set('dateTo', end.toISOString()); @@ -207,6 +211,8 @@ export function AuditLogList() { Boolean(dateFrom) || Boolean(dateTo); + const dateRangeInvalid = Boolean(dateFrom && dateTo && dateFrom > dateTo); + const columns: ColumnDef[] = [ { accessorKey: 'createdAt', @@ -475,6 +481,12 @@ export function AuditLogList() { ) : null} + {dateRangeInvalid && ( +

+ From date must be on or before To date — date filter ignored. +

+ )} + {loadError && !loading && entries.length === 0 ? (

Couldn’t load audit log: {loadError}

diff --git a/src/components/clients/hard-delete-dialog.tsx b/src/components/clients/hard-delete-dialog.tsx index fec08eb..5c219a2 100644 --- a/src/components/clients/hard-delete-dialog.tsx +++ b/src/components/clients/hard-delete-dialog.tsx @@ -122,9 +122,22 @@ export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onD
-
- Code sent to {maskedEmail}. It expires in 10 - minutes. Check your inbox and enter both fields below. +
+
+ Code sent to {maskedEmail}. It expires in 10 + minutes. +
+
diff --git a/src/components/interests/inline-stage-picker.tsx b/src/components/interests/inline-stage-picker.tsx index f385b86..edc90ca 100644 --- a/src/components/interests/inline-stage-picker.tsx +++ b/src/components/interests/inline-stage-picker.tsx @@ -18,6 +18,8 @@ import { safeStage, type PipelineStage, } from '@/components/clients/pipeline-constants'; +import { canTransitionStage } from '@/lib/constants'; +import { usePermissions } from '@/hooks/use-permissions'; interface InlineStagePickerProps { interestId: string; @@ -47,15 +49,28 @@ export function InlineStagePicker({ const [open, setOpen] = useState(false); const [reason, setReason] = useState(''); const [pendingStage, setPendingStage] = useState(null); + const { can } = usePermissions(); + const canOverride = can('interests', 'override_stage'); const stage = safeStage(currentStage); const mutation = useMutation({ - mutationFn: async (next: PipelineStage) => - apiFetch(`/api/v1/interests/${interestId}/stage`, { + mutationFn: async (next: PipelineStage) => { + // Auto-set override:true when the picked stage isn't a legal + // transition AND the user has override_stage. Without this, the + // permission was unreachable from the inline picker (audit R2-M7) + // and users had to fall back to the modal InterestStagePicker. + const needsOverride = !canTransitionStage(stage, next); + const useOverride = needsOverride && canOverride; + return apiFetch(`/api/v1/interests/${interestId}/stage`, { method: 'PATCH', - body: { pipelineStage: next, reason: reason.trim() || undefined }, - }), + body: { + pipelineStage: next, + reason: reason.trim() || (useOverride ? 'Manual override (inline)' : undefined), + override: useOverride || undefined, + }, + }); + }, onSuccess: (_data, next) => { queryClient.invalidateQueries({ queryKey: ['interests', interestId] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); diff --git a/src/components/layout/mobile/more-sheet.tsx b/src/components/layout/mobile/more-sheet.tsx index 5dc5db4..2657893 100644 --- a/src/components/layout/mobile/more-sheet.tsx +++ b/src/components/layout/mobile/more-sheet.tsx @@ -3,11 +3,15 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { + Anchor, BarChart3, Bell, + BellRing, Bookmark, Building2, FileText, + Globe, + Home, Mail, Receipt, Settings, @@ -42,6 +46,10 @@ const MORE_ITEMS: MoreItem[] = [ { label: 'Invoices', icon: FileText, segment: 'invoices' }, { label: 'Expenses', icon: Receipt, segment: 'expenses' }, { label: 'Inbox', icon: Mail, segment: 'email' }, + { label: 'Reservations', icon: Anchor, segment: 'berth-reservations' }, + { label: 'Notifications', icon: BellRing, segment: 'notifications' }, + { label: 'Residential', icon: Home, segment: 'residential/clients' }, + { label: 'Website analytics', icon: Globe, segment: 'website-analytics' }, { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' }, { label: 'Reports', icon: BarChart3, segment: 'reports' }, { label: 'Reminders', icon: Bell, segment: 'reminders' }, diff --git a/src/components/portal/change-password-form.tsx b/src/components/portal/change-password-form.tsx new file mode 100644 index 0000000..9538fa5 --- /dev/null +++ b/src/components/portal/change-password-form.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export function ChangePasswordForm() { + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [pending, setPending] = useState(false); + + const valid = current.length > 0 && next.length >= 9 && next === confirm; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!valid) return; + setPending(true); + try { + const res = await fetch('/api/portal/auth/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword: current, newPassword: next }), + }); + const body = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) { + throw new Error(body.error || 'Password change failed'); + } + toast.success('Password updated.'); + setCurrent(''); + setNext(''); + setConfirm(''); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Password change failed'); + } finally { + setPending(false); + } + } + + return ( +
+
+ + setCurrent(e.target.value)} + required + /> +
+
+ + setNext(e.target.value)} + minLength={9} + required + /> +

At least 9 characters.

+
+
+ + setConfirm(e.target.value)} + required + /> + {confirm && next !== confirm && ( +

Passwords don’t match.

+ )} +
+ +
+ ); +} diff --git a/src/components/portal/portal-nav.tsx b/src/components/portal/portal-nav.tsx index a6f156d..0c62e6f 100644 --- a/src/components/portal/portal-nav.tsx +++ b/src/components/portal/portal-nav.tsx @@ -2,7 +2,15 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { LayoutDashboard, Anchor, FileText, Receipt, Sailboat, CalendarCheck } from 'lucide-react'; +import { + LayoutDashboard, + Anchor, + FileText, + Receipt, + Sailboat, + CalendarCheck, + User, +} from 'lucide-react'; import { cn } from '@/lib/utils'; const navItems = [ @@ -12,6 +20,7 @@ const navItems = [ { label: 'Reservations', href: '/portal/my-reservations', icon: CalendarCheck }, { label: 'Documents', href: '/portal/documents', icon: FileText }, { label: 'Invoices', href: '/portal/invoices', icon: Receipt }, + { label: 'Profile', href: '/portal/profile', icon: User }, ]; export function PortalNav() { diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index c7194d7..38a35a2 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -159,6 +159,54 @@ export async function resendActivation(portalUserId: string, portId: string): Pr }); } +// ─── Self-service password change (logged-in portal user) ─────────────────── + +export async function changePortalPassword(args: { + portalUserId: string; + currentPassword: string; + newPassword: string; +}): Promise { + if (args.newPassword.length < MIN_PASSWORD_LENGTH) { + throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`); + } + const user = await db.query.portalUsers.findFirst({ + where: eq(portalUsers.id, args.portalUserId), + }); + if (!user || !user.isActive || !user.passwordHash) { + throw new UnauthorizedError('Account not found'); + } + const ok = await verifyPassword(args.currentPassword, user.passwordHash); + if (!ok) { + void createAuditLog({ + userId: null, + portId: user.portId, + action: 'password_change', + entityType: 'portal_user', + entityId: user.id, + metadata: { ok: false, reason: 'wrong_current_password' }, + severity: 'warning', + source: 'auth', + }); + throw new UnauthorizedError('Current password is incorrect'); + } + const passwordHash = await hashPassword(args.newPassword); + await db + .update(portalUsers) + .set({ passwordHash, updatedAt: new Date() }) + .where(eq(portalUsers.id, user.id)); + + void createAuditLog({ + userId: null, + portId: user.portId, + action: 'password_change', + entityType: 'portal_user', + entityId: user.id, + metadata: { ok: true }, + severity: 'info', + source: 'auth', + }); +} + // ─── Activation: client sets their initial password ────────────────────────── export async function activateAccount(rawToken: string, password: string): Promise {