Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions __tests__/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @vitest-environment node
import { POST } from '@/app/api/auth/logout/route';
import { describe, it, expect } from 'vitest';

describe('POST /api/auth/logout', () => {
it('should clear the auth cookie and return success', async () => {
const mockRequest = new Request('http://localhost:3000/api/auth/logout', {
method: 'POST',
headers: new Headers({
'cookie': 'auth_token=some-token-value'
}),
});

const res = await POST(mockRequest as any);
// Ensure response is JSON and success is true
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('success', true);

// Check that a Set-Cookie header was included (cookie cleared)
const setCookie = res.headers.get('set-cookie');
expect(typeof setCookie).toBe('string');
expect(setCookie).toContain('auth_token=');
});
});
1 change: 0 additions & 1 deletion app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { test } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { findUserByEmail, createUser, toPublicUser } from "@/lib/auth/users";
Expand Down
16 changes: 16 additions & 0 deletions app/api/auth/verify-email/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyEmailToken } from "@/lib/auth/users";

export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ success: false, error: "Missing verification token." }, { status: 400 });
}

const user = verifyEmailToken(token);
if (!user) {
return NextResponse.json({ success: false, error: "Invalid or expired verification token." }, { status: 400 });
}

return NextResponse.json({ success: true, message: "Email verified successfully." });
}
45 changes: 45 additions & 0 deletions app/escrow/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
import CreateEscrowForm from "@/components/escrow/CreateEscrowForm";
import { useEmailAuth } from "@/context/EmailAuthContext";
import Link from "next/link";

export default function CreateEscrowPage() {
const { user, isLoading } = useEmailAuth();

if (isLoading) {
return null;
}

if (!user) {
return (
<main aria-label="Create Escrow Agreement" className="relative min-h-screen px-6 py-20">
<div className="relative z-10 max-w-3xl mx-auto rounded-3xl glass p-8 text-center">
<h1 className="text-3xl font-bold text-dark-100">Sign in to create an escrow</h1>
<p className="mt-4 text-gray-600 dark:text-gray-300">
You need to be signed in to create escrow agreements.
</p>
<Link href="/login" className="mt-6 inline-flex rounded-xl bg-brand-500 px-5 py-3 text-white hover:bg-brand-400">
Go to Login
</Link>
</div>
</main>
);
}

if (!user.emailVerified) {
return (
<main aria-label="Create Escrow Agreement" className="relative min-h-screen px-6 py-20">
<div className="relative z-10 max-w-3xl mx-auto rounded-3xl glass p-8 text-center">
<h1 className="text-3xl font-bold text-dark-100">Verify your email to create an escrow</h1>
<p className="mt-4 text-gray-600 dark:text-gray-300">
Your email address must be verified before you can create a new escrow agreement.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
<Link href="/verify-email" className="inline-flex rounded-xl bg-brand-500 px-5 py-3 text-white hover:bg-brand-400">
Verify Email
</Link>
<Link href="/" className="inline-flex rounded-xl border border-gray-200 bg-transparent px-5 py-3 text-gray-900 hover:bg-gray-100 dark:border-gray-700 dark:text-white dark:hover:bg-gray-800">
Back to Home
</Link>
</div>
</div>
</main>
);
}

return (
<main aria-label="Create Escrow Agreement" className="relative min-h-screen px-6 py-20">
<div className="absolute inset-0 pointer-events-none">
Expand Down
67 changes: 67 additions & 0 deletions app/verify-email/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";

export default function VerifyEmailPage() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get("token");
const [status, setStatus] = useState<"pending" | "success" | "error">("pending");
const [message, setMessage] = useState<string>("Verifying your email...");

useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Verification token is missing.");
return;
}

const verify = async () => {
try {
const res = await fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`);
const data = await res.json();

if (!res.ok || !data.success) {
setStatus("error");
setMessage(data.error || "Verification failed. Please request a new link.");
return;
}

setStatus("success");
setMessage("Your email has been verified. You can now create escrows.");
} catch (error) {
setStatus("error");
setMessage("Unable to verify email. Please try again later.");
}
};

verify();
}, [token]);

return (
<main className="container mx-auto max-w-xl px-4 py-16">
<div className="rounded-3xl border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Email Verification</h1>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">{message}</p>

<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="button"
onClick={() => router.push("/")}
className="inline-flex items-center justify-center rounded-xl bg-brand-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-400"
>
Home
</button>
<button
type="button"
onClick={() => router.push("/escrow/create")}
className="inline-flex items-center justify-center rounded-xl border border-gray-200 bg-transparent px-4 py-3 text-sm font-semibold text-gray-900 transition hover:bg-gray-100 dark:border-gray-700 dark:text-white dark:hover:bg-gray-800"
>
Go to Create Escrow
</button>
</div>
</div>
</main>
);
}
1 change: 1 addition & 0 deletions context/EmailAuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface AuthUser {
id: string;
email: string;
name: string;
emailVerified: boolean;
}

interface EmailAuthContextValue {
Expand Down
48 changes: 47 additions & 1 deletion lib/auth/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ export interface StoredUser {
name: string;
passwordHash: string;
createdAt: string;
emailVerified: boolean;
verificationToken?: string;
verificationTokenExpiresAt?: string;
notificationPreferences?: NotificationPreferences; // Added to store preferences
}

export interface PublicUser {
id: string;
email: string;
name: string;
emailVerified: boolean;
}

const DEFAULT_NOTIFICATIONS: NotificationPreferences = {
Expand Down Expand Up @@ -55,27 +59,69 @@ export function findUserById(id: string): StoredUser | undefined {
return readUsers().find((u) => u.id === id);
}

export function findUserByVerificationToken(token: string): StoredUser | undefined {
return readUsers().find(
(u) =>
u.verificationToken === token &&
u.verificationTokenExpiresAt &&
new Date(u.verificationTokenExpiresAt) > new Date()
);
}

export function verifyEmailToken(token: string): StoredUser | null {
const users = readUsers();
const userIndex = users.findIndex(
(u) =>
u.verificationToken === token &&
u.verificationTokenExpiresAt &&
new Date(u.verificationTokenExpiresAt) > new Date()
);

if (userIndex === -1) {
return null;
}

users[userIndex].emailVerified = true;
delete users[userIndex].verificationToken;
delete users[userIndex].verificationTokenExpiresAt;
writeUsers(users);

return users[userIndex];
}

export function createUser(
email: string,
name: string,
passwordHash: string
): StoredUser {
const users = readUsers();
const verificationToken = randomUUID();
const tokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();

const user: StoredUser = {
id: randomUUID(),
email: email.toLowerCase().trim(),
name: name.trim(),
passwordHash,
createdAt: new Date().toISOString(),
emailVerified: false,
verificationToken,
verificationTokenExpiresAt: tokenExpiresAt,
notificationPreferences: DEFAULT_NOTIFICATIONS, // Apply defaults on creation
};

users.push(user);
writeUsers(users);
return user;
}

export function toPublicUser(user: StoredUser): PublicUser {
return { id: user.id, email: user.email, name: user.name };
return {
id: user.id,
email: user.email,
name: user.name,
emailVerified: user.emailVerified,
};
}

// ---------------------------------------------------------------------------
Expand Down
Loading