diff --git a/.gitignore b/.gitignore
index c39d7e2..72acff5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,5 @@ tsconfig.tsbuildinfo
.playwright-mcp/
docker-compose.override.yml
.remember/
+.DS_Store
+eoi/
diff --git a/assets/eoi-template.pdf b/assets/eoi-template.pdf
new file mode 100644
index 0000000..bb74b3c
Binary files /dev/null and b/assets/eoi-template.pdf differ
diff --git a/src/app/(portal)/portal/activate/page.tsx b/src/app/(portal)/portal/activate/page.tsx
new file mode 100644
index 0000000..d488ab2
--- /dev/null
+++ b/src/app/(portal)/portal/activate/page.tsx
@@ -0,0 +1,24 @@
+import { Suspense } from 'react';
+
+import { PasswordSetForm } from '@/components/portal/password-set-form';
+
+export default function PortalActivatePage() {
+ return (
+
+ Loading…
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/app/(portal)/portal/forgot-password/page.tsx b/src/app/(portal)/portal/forgot-password/page.tsx
new file mode 100644
index 0000000..56fa322
--- /dev/null
+++ b/src/app/(portal)/portal/forgot-password/page.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import Link from 'next/link';
+import { useState } from 'react';
+import { Loader2, Mail } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+export default function PortalForgotPasswordPage() {
+ const [email, setEmail] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ try {
+ // Always returns 200 — caller never sees whether email exists.
+ await fetch('/api/portal/auth/forgot-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+ } finally {
+ setSubmitted(true);
+ setLoading(false);
+ }
+ }
+
+ if (submitted) {
+ return (
+
+
+
+
+
+
Check your email
+
+ If {email} matches a portal account, we've sent a reset link. The
+ link expires in 30 minutes.
+
+
+ Back to sign in
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Reset your password
+
+ Enter your email and we'll send you a reset link.
+
+
+
+
+
+
+ Back to sign in
+
+
+
+
+ );
+}
diff --git a/src/app/(portal)/portal/login/page.tsx b/src/app/(portal)/portal/login/page.tsx
index bcea286..95c0f25 100644
--- a/src/app/(portal)/portal/login/page.tsx
+++ b/src/app/(portal)/portal/login/page.tsx
@@ -1,15 +1,22 @@
'use client';
+import Link from 'next/link';
+import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
-import { Mail, Loader2 } from 'lucide-react';
+import { Loader2 } from 'lucide-react';
+
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function PortalLoginPage() {
+ const router = useRouter();
+ const search = useSearchParams();
+ const next = search.get('next') ?? '/portal/dashboard';
+
const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
- const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
@@ -18,59 +25,35 @@ export default function PortalLoginPage() {
setLoading(true);
try {
- const res = await fetch('/api/portal/auth/request', {
+ const res = await fetch('/api/portal/auth/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email }),
+ body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- setError((data as { error?: string }).error ?? 'Something went wrong. Please try again.');
+ setError((data as { error?: string }).error ?? 'Invalid email or password');
return;
}
- setSubmitted(true);
+ // typedRoutes: `next` is a runtime string we can't statically check.
+ router.replace(next as never);
+ router.refresh();
} catch {
- setError('Unable to connect. Please check your connection and try again.');
+ setError('Unable to connect. Please try again.');
} finally {
setLoading(false);
}
}
- if (submitted) {
- return (
-
-
-
-
-
-
Check your email
-
- If {email} is associated with a client account, you will receive a
- sign-in link shortly. The link expires in 24 hours.
-
-
-
-
- );
- }
-
return (
Client Portal
-
- Enter your email to receive a sign-in link
-
+
Sign in to your account
- {error && (
-
{error}
- )}
+
+
+
+
+ Forgot password?
+
+
+
setPassword(e.target.value)}
+ required
+ autoComplete="current-password"
+ disabled={loading}
+ />
+
+
+ {error &&
{error}
}
diff --git a/src/app/(portal)/portal/reset-password/page.tsx b/src/app/(portal)/portal/reset-password/page.tsx
new file mode 100644
index 0000000..3c49417
--- /dev/null
+++ b/src/app/(portal)/portal/reset-password/page.tsx
@@ -0,0 +1,24 @@
+import { Suspense } from 'react';
+
+import { PasswordSetForm } from '@/components/portal/password-set-form';
+
+export default function PortalResetPasswordPage() {
+ return (
+
+ Loading…
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/app/(portal)/portal/verify/page.tsx b/src/app/(portal)/portal/verify/page.tsx
deleted file mode 100644
index c32d4a9..0000000
--- a/src/app/(portal)/portal/verify/page.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-'use client';
-
-import { Suspense, useEffect, useRef } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { Loader2 } from 'lucide-react';
-
-function PortalVerifyInner() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const calledRef = useRef(false);
-
- useEffect(() => {
- if (calledRef.current) return;
- calledRef.current = true;
-
- const token = searchParams.get('token');
-
- if (!token) {
- router.replace('/portal/login?error=missing_token');
- return;
- }
-
- // Redirect to the verify API route which will set the cookie and redirect
- window.location.href = `/api/portal/auth/verify?token=${encodeURIComponent(token)}`;
- }, [searchParams, router]);
-
- return (
-
-
-
-
Verifying your access...
-
-
- );
-}
-
-export default function PortalVerifyPage() {
- return (
-
-
-
- }
- >
-
-
- );
-}
diff --git a/src/app/api/portal/auth/activate/route.ts b/src/app/api/portal/auth/activate/route.ts
new file mode 100644
index 0000000..f674058
--- /dev/null
+++ b/src/app/api/portal/auth/activate/route.ts
@@ -0,0 +1,34 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { errorResponse } from '@/lib/errors';
+import { activateAccount } from '@/lib/services/portal-auth.service';
+
+const bodySchema = z.object({
+ token: z.string().min(1),
+ password: z.string().min(12),
+});
+
+export async function POST(req: NextRequest): Promise {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+ }
+
+ const parsed = bodySchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.errors[0]?.message ?? 'Invalid input' },
+ { status: 400 },
+ );
+ }
+
+ try {
+ await activateAccount(parsed.data.token, parsed.data.password);
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ return errorResponse(err);
+ }
+}
diff --git a/src/app/api/portal/auth/forgot-password/route.ts b/src/app/api/portal/auth/forgot-password/route.ts
new file mode 100644
index 0000000..983ba24
--- /dev/null
+++ b/src/app/api/portal/auth/forgot-password/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { logger } from '@/lib/logger';
+import { requestPasswordReset } from '@/lib/services/portal-auth.service';
+
+const bodySchema = z.object({ email: z.string().email() });
+
+export async function POST(req: NextRequest): Promise {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+ }
+
+ const parsed = bodySchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
+ }
+
+ // Always return 200 to prevent account-enumeration. Errors are logged
+ // server-side, never surfaced to the client.
+ try {
+ await requestPasswordReset(parsed.data.email);
+ } catch (err) {
+ logger.error({ err }, 'Portal forgot-password failed (swallowed)');
+ }
+ return NextResponse.json({ success: true });
+}
diff --git a/src/app/api/portal/auth/request/route.ts b/src/app/api/portal/auth/request/route.ts
deleted file mode 100644
index ba89020..0000000
--- a/src/app/api/portal/auth/request/route.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { z } from 'zod';
-
-import { requestMagicLink } from '@/lib/services/portal.service';
-import { logger } from '@/lib/logger';
-
-const bodySchema = z.object({
- email: z.string().email(),
-});
-
-export async function POST(req: NextRequest): Promise {
- try {
- const body = await req.json();
- const parsed = bodySchema.safeParse(body);
-
- if (!parsed.success) {
- return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
- }
-
- await requestMagicLink(parsed.data.email);
-
- // Always return success to prevent email enumeration
- return NextResponse.json({ success: true });
- } catch (error) {
- logger.error({ error }, 'Portal magic link request failed');
- return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
- }
-}
diff --git a/src/app/api/portal/auth/reset-password/route.ts b/src/app/api/portal/auth/reset-password/route.ts
new file mode 100644
index 0000000..71deef7
--- /dev/null
+++ b/src/app/api/portal/auth/reset-password/route.ts
@@ -0,0 +1,34 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { errorResponse } from '@/lib/errors';
+import { resetPassword } from '@/lib/services/portal-auth.service';
+
+const bodySchema = z.object({
+ token: z.string().min(1),
+ password: z.string().min(12),
+});
+
+export async function POST(req: NextRequest): Promise {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+ }
+
+ const parsed = bodySchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.errors[0]?.message ?? 'Invalid input' },
+ { status: 400 },
+ );
+ }
+
+ try {
+ await resetPassword(parsed.data.token, parsed.data.password);
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ return errorResponse(err);
+ }
+}
diff --git a/src/app/api/portal/auth/sign-in/route.ts b/src/app/api/portal/auth/sign-in/route.ts
new file mode 100644
index 0000000..cee97ae
--- /dev/null
+++ b/src/app/api/portal/auth/sign-in/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { errorResponse } from '@/lib/errors';
+import { PORTAL_COOKIE } from '@/lib/portal/auth';
+import { signIn } from '@/lib/services/portal-auth.service';
+
+const bodySchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(1),
+});
+
+const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; // 24h, matches createPortalToken
+
+export async function POST(req: NextRequest): Promise {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+ }
+
+ const parsed = bodySchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: 'Invalid email or password' }, { status: 400 });
+ }
+
+ try {
+ const result = await signIn(parsed.data);
+ const res = NextResponse.json({ success: true });
+ res.cookies.set(PORTAL_COOKIE, result.token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ maxAge: SESSION_MAX_AGE_SECONDS,
+ });
+ return res;
+ } catch (err) {
+ return errorResponse(err);
+ }
+}
diff --git a/src/app/api/portal/auth/verify/route.ts b/src/app/api/portal/auth/verify/route.ts
deleted file mode 100644
index 60718b2..0000000
--- a/src/app/api/portal/auth/verify/route.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-
-import { verifyPortalToken, PORTAL_COOKIE } from '@/lib/portal/auth';
-import { env } from '@/lib/env';
-import { logger } from '@/lib/logger';
-
-export async function GET(req: NextRequest): Promise {
- try {
- const token = req.nextUrl.searchParams.get('token');
-
- if (!token) {
- return NextResponse.redirect(new URL('/portal/login?error=missing_token', env.APP_URL));
- }
-
- const session = await verifyPortalToken(token);
-
- if (!session) {
- return NextResponse.redirect(new URL('/portal/login?error=invalid_token', env.APP_URL));
- }
-
- const response = NextResponse.redirect(new URL('/portal/dashboard', env.APP_URL));
-
- response.cookies.set(PORTAL_COOKIE, token, {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'lax',
- path: '/',
- maxAge: 60 * 60 * 24, // 24 hours
- });
-
- logger.info({ clientId: session.clientId }, 'Portal session created');
-
- return response;
- } catch (error) {
- logger.error({ error }, 'Portal token verification failed');
- return NextResponse.redirect(new URL('/portal/login?error=server_error', env.APP_URL));
- }
-}
diff --git a/src/app/api/v1/clients/[id]/portal-user/route.ts b/src/app/api/v1/clients/[id]/portal-user/route.ts
new file mode 100644
index 0000000..cf37f87
--- /dev/null
+++ b/src/app/api/v1/clients/[id]/portal-user/route.ts
@@ -0,0 +1,59 @@
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseBody } from '@/lib/api/route-helpers';
+import { errorResponse } from '@/lib/errors';
+import { createPortalUser, resendActivation } from '@/lib/services/portal-auth.service';
+import { db } from '@/lib/db';
+import { eq } from 'drizzle-orm';
+import { portalUsers } from '@/lib/db/schema/portal';
+
+const inviteSchema = z.object({
+ email: z.string().email(),
+ name: z.string().min(1).max(200).optional(),
+});
+
+/**
+ * POST /api/v1/clients/:id/portal-user
+ *
+ * Admin creates a portal account for a client and triggers the activation
+ * email. Idempotent in spirit: if a portal user already exists for the
+ * email, returns 409 — the admin can resend the activation via
+ * ?action=resend.
+ */
+export const POST = withAuth(
+ withPermission('clients', 'edit', async (req, ctx, params) => {
+ try {
+ const url = new URL(req.url);
+ const action = url.searchParams.get('action');
+
+ if (action === 'resend') {
+ // Body is optional in resend mode; the portal user id is the path id
+ // in this case (not the client id). Looking up by client+email so
+ // admins don't have to track portal-user ids.
+ const body = await parseBody(req, inviteSchema);
+ const existing = await db.query.portalUsers.findFirst({
+ where: eq(portalUsers.email, body.email.toLowerCase().trim()),
+ });
+ if (!existing) {
+ return NextResponse.json({ error: 'Portal user not found' }, { status: 404 });
+ }
+ await resendActivation(existing.id, ctx.portId);
+ return NextResponse.json({ success: true });
+ }
+
+ const body = await parseBody(req, inviteSchema);
+ const result = await createPortalUser({
+ clientId: params.id!,
+ portId: ctx.portId,
+ email: body.email,
+ name: body.name,
+ createdBy: ctx.userId,
+ });
+ return NextResponse.json({ data: result }, { status: 201 });
+ } catch (err) {
+ return errorResponse(err);
+ }
+ }),
+);
diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx
index 81279b8..76ad818 100644
--- a/src/components/clients/client-detail-header.tsx
+++ b/src/components/clients/client-detail-header.tsx
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { ClientForm } from '@/components/clients/client-form';
+import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { apiFetch } from '@/lib/api/client';
interface ClientDetailHeaderProps {
@@ -127,6 +128,13 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
{/* Actions */}
+ {!isArchived && (
+
+ )}