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 (
+
+ );
+}
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 {