Files
pn-new-crm/src/components/portal/password-set-form.tsx
Matt Ciaccio e8d61c91c4
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
  marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint

Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)

Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
  handlers.ts files (Next.js 15 route.ts only allows specific exports)

Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
  (apiFetch already JSON.stringifies its body; passing a stringified
  body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
  to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
  Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md

Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00

179 lines
5.4 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
interface PasswordSetFormProps {
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
endpoint: string;
title: string;
description: string;
successTitle: string;
successDescription: string;
submitLabel: string;
}
const MIN_LENGTH = 9;
/**
* Shared form used by both the activation and password-reset flows. The
* activation token is read from the `?token=` query string. Empty / missing
* tokens land the user in an explicit error state instead of submitting a
* doomed request.
*/
export function PasswordSetForm({
endpoint,
title,
description,
successTitle,
successDescription,
submitLabel,
}: PasswordSetFormProps) {
const search = useSearchParams();
const token = search.get('token') ?? '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const tooShort = password.length > 0 && password.length < MIN_LENGTH;
const mismatch = confirm.length > 0 && password !== confirm;
const canSubmit = !!token && password.length >= MIN_LENGTH && password === confirm && !loading;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setLoading(true);
setError('');
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Something went wrong. Please try again.');
return;
}
setDone(true);
} catch {
setError('Unable to connect. Please try again.');
} finally {
setLoading(false);
}
}
if (!token) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3">
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
<p className="text-sm text-gray-500">
Please use the link from the email we sent you. If the link is broken, request a new
one.
</p>
<Link
href="/portal/forgot-password"
className="inline-block text-sm text-[#007bff] hover:underline"
>
Request a new link
</Link>
</div>
</BrandedAuthShell>
);
}
if (done) {
return (
<BrandedAuthShell>
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">{successTitle}</h1>
<p className="text-gray-500 text-sm">{successDescription}</p>
<Link
href="/portal/login"
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
>
Sign in
</Link>
</div>
</BrandedAuthShell>
);
}
return (
<BrandedAuthShell>
<div className="mb-6">
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
<p className="text-sm text-gray-500 mt-1">{description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
autoComplete="new-password"
minLength={MIN_LENGTH}
disabled={loading}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
{tooShort && (
<p className="text-xs text-red-600">
Password must be at least {MIN_LENGTH} characters.
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
disabled={loading}
/>
{mismatch && <p className="text-xs text-red-600">Passwords don&apos;t match.</p>}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={!canSubmit}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving
</>
) : (
submitLabel
)}
</Button>
</form>
</BrandedAuthShell>
);
}