diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa972fb..da6b6b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,11 @@ model Tenant { slug String @unique ownerEmail String + /// Physical address of the ISP business (optional) + businessAddress String? + /// Primary contact phone number (optional) + contactPhone String? + status TenantStatus @default(ACTIVE) /// Timestamp when suspension was triggered (starts grace period clock) suspendedAt DateTime? diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..398c898 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const justRegistered = searchParams.get("registered") === "true"; + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setIsLoading(true); + + try { + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) { + setError("Invalid email or password"); + } else { + router.push("/dashboard"); + router.refresh(); + } + } catch { + setError("Something went wrong. Please try again."); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+ {/* Logo / Brand */} +
+

NetForge

+

Sign in to your account

+
+ + {/* Registration success banner */} + {justRegistered && ( +
+

+ Account created successfully. Please sign in. +

+
+ )} + +
+ {/* Email field */} +
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500" + placeholder="you@example.com" + disabled={isLoading} + /> +
+ + {/* Password field */} +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500" + placeholder="••••••••" + disabled={isLoading} + /> +
+ + {/* Error message */} + {error && ( +

+ {error} +

+ )} + + {/* Submit button */} + +
+ + {/* Sign up link */} +

+ Don't have an account?{" "} + + Sign up + +

+
+
+ ); +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..ff2a073 --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; + +// ============================================================================= +// Signup Page — /signup +// ============================================================================= +// New ISP tenant registration form. +// On successful submission, redirects to /login?registered=true. +// ============================================================================= + +interface FormData { + businessName: string; + ownerFirstName: string; + ownerLastName: string; + ownerEmail: string; + password: string; + confirmPassword: string; + businessAddress: string; + contactPhone: string; +} + +const initialFormData: FormData = { + businessName: "", + ownerFirstName: "", + ownerLastName: "", + ownerEmail: "", + password: "", + confirmPassword: "", + businessAddress: "", + contactPhone: "", +}; + +export default function SignupPage() { + const router = useRouter(); + const [formData, setFormData] = useState(initialFormData); + const [error, setError] = useState(""); + const [fieldErrors, setFieldErrors] = useState>>({}); + const [isLoading, setIsLoading] = useState(false); + + function handleChange(e: React.ChangeEvent) { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + // Clear field-level error on change + if (fieldErrors[name as keyof FormData]) { + setFieldErrors((prev) => ({ ...prev, [name]: undefined })); + } + if (error) setError(""); + } + + function validateForm(): boolean { + const errors: Partial> = {}; + + if (!formData.businessName.trim()) { + errors.businessName = "Business name is required"; + } + if (!formData.ownerFirstName.trim()) { + errors.ownerFirstName = "First name is required"; + } + if (!formData.ownerLastName.trim()) { + errors.ownerLastName = "Last name is required"; + } + if (!formData.ownerEmail.trim()) { + errors.ownerEmail = "Email address is required"; + } else { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.ownerEmail.trim())) { + errors.ownerEmail = "Please enter a valid email address"; + } + } + if (!formData.password) { + errors.password = "Password is required"; + } else if (formData.password.length < 8) { + errors.password = "Password must be at least 8 characters"; + } + if (!formData.confirmPassword) { + errors.confirmPassword = "Please confirm your password"; + } else if (formData.password !== formData.confirmPassword) { + errors.confirmPassword = "Passwords do not match"; + } + + setFieldErrors(errors); + return Object.keys(errors).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!validateForm()) return; + + setIsLoading(true); + + try { + const response = await fetch("/api/tenants/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + businessName: formData.businessName.trim(), + ownerFirstName: formData.ownerFirstName.trim(), + ownerLastName: formData.ownerLastName.trim(), + ownerEmail: formData.ownerEmail.trim().toLowerCase(), + password: formData.password, + businessAddress: formData.businessAddress.trim() || undefined, + contactPhone: formData.contactPhone.trim() || undefined, + }), + }); + + const data = await response.json(); + + if (response.status === 201) { + router.push("/login?registered=true"); + return; + } + + if (response.status === 409) { + setFieldErrors((prev) => ({ + ...prev, + ownerEmail: "An account with this email already exists", + })); + return; + } + + setError(data.error || "Something went wrong. Please try again."); + } catch { + setError("Unable to connect. Please check your connection and try again."); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+ {/* Logo / Brand */} +
+

NetForge

+

+ Create your ISP management account +

+
+ +
+ {/* Business Name */} +
+ + + {fieldErrors.businessName && ( +

{fieldErrors.businessName}

+ )} +
+ + {/* First Name / Last Name */} +
+
+ + + {fieldErrors.ownerFirstName && ( +

{fieldErrors.ownerFirstName}

+ )} +
+
+ + + {fieldErrors.ownerLastName && ( +

{fieldErrors.ownerLastName}

+ )} +
+
+ + {/* Email */} +
+ + + {fieldErrors.ownerEmail && ( +

{fieldErrors.ownerEmail}

+ )} +
+ + {/* Password */} +
+ + + {fieldErrors.password && ( +

{fieldErrors.password}

+ )} +
+ + {/* Confirm Password */} +
+ + + {fieldErrors.confirmPassword && ( +

{fieldErrors.confirmPassword}

+ )} +
+ + {/* Business Address (optional) */} +
+ + +
+ + {/* Contact Phone (optional) */} +
+ + +
+ + {/* Global error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Submit button */} + +
+ + {/* Sign in link */} +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/src/app/api/tenants/signup/route.ts b/src/app/api/tenants/signup/route.ts new file mode 100644 index 0000000..9d96e1e --- /dev/null +++ b/src/app/api/tenants/signup/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createTenant, EmailAlreadyExistsError } from "@/lib/tenant"; + +// ============================================================================= +// POST /api/tenants/signup +// ============================================================================= +// Creates a new ISP tenant and its admin user. +// +// Request body (JSON): +// businessName: string (required) +// ownerFirstName: string (required) +// ownerLastName: string (required) +// ownerEmail: string (required) +// password: string (required, min 8 chars) +// businessAddress: string (optional) +// contactPhone: string (optional) +// +// Responses: +// 201 Created — { tenant: { id, name, slug }, user: { id, email } } +// 400 Bad Request — { error: string } — validation failure +// 409 Conflict — { error: string } — email already registered +// 500 Internal — { error: string } — unexpected server error +// ============================================================================= + +export async function POST(request: NextRequest) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 } + ); + } + + if (!body || typeof body !== "object") { + return NextResponse.json( + { error: "Request body must be a JSON object" }, + { status: 400 } + ); + } + + const { + businessName, + ownerFirstName, + ownerLastName, + ownerEmail, + password, + businessAddress, + contactPhone, + } = body as Record; + + // Basic presence checks before passing to service layer + if (!businessName || typeof businessName !== "string") { + return NextResponse.json({ error: "businessName is required" }, { status: 400 }); + } + if (!ownerFirstName || typeof ownerFirstName !== "string") { + return NextResponse.json({ error: "ownerFirstName is required" }, { status: 400 }); + } + if (!ownerLastName || typeof ownerLastName !== "string") { + return NextResponse.json({ error: "ownerLastName is required" }, { status: 400 }); + } + if (!ownerEmail || typeof ownerEmail !== "string") { + return NextResponse.json({ error: "ownerEmail is required" }, { status: 400 }); + } + if (!password || typeof password !== "string") { + return NextResponse.json({ error: "password is required" }, { status: 400 }); + } + + try { + const { tenant, user } = await createTenant({ + businessName, + ownerFirstName, + ownerLastName, + ownerEmail, + password, + businessAddress: typeof businessAddress === "string" ? businessAddress : undefined, + contactPhone: typeof contactPhone === "string" ? contactPhone : undefined, + }); + + return NextResponse.json( + { + tenant: { + id: tenant.id, + name: tenant.name, + slug: tenant.slug, + }, + user: { + id: user.id, + email: user.email, + }, + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof EmailAlreadyExistsError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + + if (error instanceof Error) { + // Validation errors thrown by createTenant are descriptive user-facing messages + const isValidationError = + error.message.includes("required") || + error.message.includes("at least") || + error.message.includes("Invalid") || + error.message.includes("alphanumeric"); + + if (isValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + } + + console.error("[POST /api/tenants/signup] Unexpected error:", error); + return NextResponse.json( + { error: "An unexpected error occurred. Please try again." }, + { status: 500 } + ); + } +} diff --git a/src/lib/tenant.ts b/src/lib/tenant.ts new file mode 100644 index 0000000..1e5eefa --- /dev/null +++ b/src/lib/tenant.ts @@ -0,0 +1,232 @@ +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { TenantStatus } from "@prisma/client"; + +// ============================================================================= +// Tenant Service +// ============================================================================= +// Handles tenant provisioning (creating new ISP accounts). +// Each createTenant() call produces one Tenant + one admin User in a transaction. +// ============================================================================= + +export interface CreateTenantInput { + businessName: string; + ownerFirstName: string; + ownerLastName: string; + ownerEmail: string; + password: string; + businessAddress?: string; + contactPhone?: string; +} + +export interface CreateTenantResult { + tenant: { + id: string; + name: string; + slug: string; + ownerEmail: string; + businessAddress: string | null; + contactPhone: string | null; + status: TenantStatus; + createdAt: Date; + }; + user: { + id: string; + email: string; + firstName: string; + lastName: string; + tenantId: string | null; + roles: string[]; + isActive: boolean; + createdAt: Date; + }; +} + +/** + * Slugify a business name to a URL-friendly identifier. + * Example: "My ISP Co." -> "my-isp-co" + */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Generates a unique slug by appending a numeric suffix if the base slug is taken. + */ +async function generateUniqueSlug(businessName: string): Promise { + const baseSlug = slugify(businessName); + + if (!baseSlug) { + throw new Error("Business name must contain at least one alphanumeric character"); + } + + // Check if slug is available + const existing = await prisma.tenant.findUnique({ + where: { slug: baseSlug }, + }); + + if (!existing) { + return baseSlug; + } + + // Try appending incrementing numbers + for (let i = 2; i <= 99; i++) { + const candidateSlug = `${baseSlug}-${i}`; + const taken = await prisma.tenant.findUnique({ where: { slug: candidateSlug } }); + if (!taken) { + return candidateSlug; + } + } + + // Fallback: append timestamp + return `${baseSlug}-${Date.now()}`; +} + +/** + * Creates a new ISP tenant along with its initial admin user. + * + * Validates inputs, then runs a single Prisma transaction that: + * 1. Creates the Tenant record + * 2. Creates the admin User record linked to the tenant + * + * Returns both records without sensitive fields (no passwordHash). + * + * Throws descriptive errors for: + * - Empty business name + * - Password too short (< 8 chars) + * - Duplicate email within the new tenant context + */ +export async function createTenant(input: CreateTenantInput): Promise { + const { + businessName, + ownerFirstName, + ownerLastName, + ownerEmail, + password, + businessAddress, + contactPhone, + } = input; + + // --- Validation --- + if (!businessName || !businessName.trim()) { + throw new Error("Business name is required"); + } + + if (!ownerEmail || !ownerEmail.trim()) { + throw new Error("Owner email is required"); + } + + // Basic email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(ownerEmail.trim())) { + throw new Error("Invalid email address"); + } + + if (!password || password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } + + if (!ownerFirstName || !ownerFirstName.trim()) { + throw new Error("First name is required"); + } + + if (!ownerLastName || !ownerLastName.trim()) { + throw new Error("Last name is required"); + } + + const normalizedEmail = ownerEmail.trim().toLowerCase(); + + // Check if this email is already used as an ownerEmail for any tenant + // (The database uniqueness is per [email, tenantId], but we also want + // to prevent someone signing up twice with the same email globally + // at the tenant owner level.) + // + // Note: Same email CAN exist in different tenants for non-owner users, + // but when creating a new tenant the owner email should be fresh. + const emailInUse = await prisma.user.findFirst({ + where: { + email: normalizedEmail, + isSuperAdmin: false, + }, + }); + + if (emailInUse) { + throw new EmailAlreadyExistsError("An account with this email address already exists"); + } + + // Generate a unique slug for the tenant + const slug = await generateUniqueSlug(businessName.trim()); + + // Hash password with cost factor 12 + const passwordHash = await bcrypt.hash(password, 12); + + // --- Transaction: create Tenant + User atomically --- + const result = await prisma.$transaction(async (tx) => { + const tenant = await tx.tenant.create({ + data: { + name: businessName.trim(), + slug, + ownerEmail: normalizedEmail, + businessAddress: businessAddress?.trim() || null, + contactPhone: contactPhone?.trim() || null, + status: TenantStatus.ACTIVE, + }, + }); + + const user = await tx.user.create({ + data: { + email: normalizedEmail, + passwordHash, + firstName: ownerFirstName.trim(), + lastName: ownerLastName.trim(), + tenantId: tenant.id, + roles: ["ADMIN"], + isActive: true, + isSuperAdmin: false, + }, + }); + + return { tenant, user }; + }); + + // Return without passwordHash + return { + tenant: { + id: result.tenant.id, + name: result.tenant.name, + slug: result.tenant.slug, + ownerEmail: result.tenant.ownerEmail, + businessAddress: result.tenant.businessAddress, + contactPhone: result.tenant.contactPhone, + status: result.tenant.status, + createdAt: result.tenant.createdAt, + }, + user: { + id: result.user.id, + email: result.user.email, + firstName: result.user.firstName, + lastName: result.user.lastName, + tenantId: result.user.tenantId, + roles: result.user.roles, + isActive: result.user.isActive, + createdAt: result.user.createdAt, + }, + }; +} + +/** + * Custom error class for duplicate email during tenant signup. + * API route uses this to return 409 Conflict. + */ +export class EmailAlreadyExistsError extends Error { + constructor(message: string) { + super(message); + this.name = "EmailAlreadyExistsError"; + } +}