feat(01-03): tenant signup API, service, and UI

- Add businessAddress and contactPhone fields to Tenant schema
- Create src/lib/tenant.ts with createTenant() function:
  - Validates input, slugifies business name, hashes password (bcrypt 12)
  - Prisma transaction creates Tenant + admin User atomically
  - Custom EmailAlreadyExistsError for 409 Conflict responses
- Create POST /api/tenants/signup route returning 201/400/409/500
- Create /signup page with full form (business name, owner info, password, optional fields)
  - Client-side validation: required fields, email format, password match
  - Redirects to /login?registered=true on success
- Update /login page to show success banner when ?registered=true
This commit is contained in:
kevin-asprec
2026-03-04 18:40:40 +08:00
parent 71a9277913
commit 43761d94ee
5 changed files with 874 additions and 0 deletions

View File

@@ -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<HTMLFormElement>) {
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 (
<div className="w-full max-w-md">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
{/* Logo / Brand */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">NetForge</h1>
<p className="mt-2 text-sm text-gray-600">Sign in to your account</p>
</div>
{/* Registration success banner */}
{justRegistered && (
<div
className="mb-5 rounded-lg bg-green-50 border border-green-200 p-3"
role="status"
>
<p className="text-sm text-green-700 font-medium">
Account created successfully. Please sign in.
</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => 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}
/>
</div>
{/* Password field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => 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}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
{/* Submit button */}
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
{/* Sign up link */}
<p className="mt-6 text-center text-sm text-gray-500">
Don&apos;t have an account?{" "}
<Link
href="/signup"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Sign up
</Link>
</p>
</div>
</div>
);
}

View File

@@ -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<FormData>(initialFormData);
const [error, setError] = useState("");
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const [isLoading, setIsLoading] = useState(false);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
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<Record<keyof FormData, string>> = {};
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<HTMLFormElement>) {
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 (
<div className="w-full max-w-lg">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
{/* Logo / Brand */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">NetForge</h1>
<p className="mt-2 text-sm text-gray-600">
Create your ISP management account
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{/* Business Name */}
<div>
<label
htmlFor="businessName"
className="block text-sm font-medium text-gray-700 mb-1"
>
Business Name <span className="text-red-500">*</span>
</label>
<input
id="businessName"
name="businessName"
type="text"
autoComplete="organization"
value={formData.businessName}
onChange={handleChange}
disabled={isLoading}
className={`w-full px-3 py-2 border 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 ${
fieldErrors.businessName ? "border-red-400" : "border-gray-300"
}`}
placeholder="Acme Internet Services"
/>
{fieldErrors.businessName && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.businessName}</p>
)}
</div>
{/* First Name / Last Name */}
<div className="grid grid-cols-2 gap-3">
<div>
<label
htmlFor="ownerFirstName"
className="block text-sm font-medium text-gray-700 mb-1"
>
First Name <span className="text-red-500">*</span>
</label>
<input
id="ownerFirstName"
name="ownerFirstName"
type="text"
autoComplete="given-name"
value={formData.ownerFirstName}
onChange={handleChange}
disabled={isLoading}
className={`w-full px-3 py-2 border 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 ${
fieldErrors.ownerFirstName ? "border-red-400" : "border-gray-300"
}`}
placeholder="Jane"
/>
{fieldErrors.ownerFirstName && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.ownerFirstName}</p>
)}
</div>
<div>
<label
htmlFor="ownerLastName"
className="block text-sm font-medium text-gray-700 mb-1"
>
Last Name <span className="text-red-500">*</span>
</label>
<input
id="ownerLastName"
name="ownerLastName"
type="text"
autoComplete="family-name"
value={formData.ownerLastName}
onChange={handleChange}
disabled={isLoading}
className={`w-full px-3 py-2 border 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 ${
fieldErrors.ownerLastName ? "border-red-400" : "border-gray-300"
}`}
placeholder="Smith"
/>
{fieldErrors.ownerLastName && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.ownerLastName}</p>
)}
</div>
</div>
{/* Email */}
<div>
<label
htmlFor="ownerEmail"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email Address <span className="text-red-500">*</span>
</label>
<input
id="ownerEmail"
name="ownerEmail"
type="email"
autoComplete="email"
value={formData.ownerEmail}
onChange={handleChange}
disabled={isLoading}
className={`w-full px-3 py-2 border 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 ${
fieldErrors.ownerEmail ? "border-red-400" : "border-gray-300"
}`}
placeholder="jane@acmeisp.com"
/>
{fieldErrors.ownerEmail && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.ownerEmail}</p>
)}
</div>
{/* Password */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password <span className="text-red-500">*</span>
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
value={formData.password}
onChange={handleChange}
disabled={isLoading}
className={`w-full px-3 py-2 border 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 ${
fieldErrors.password ? "border-red-400" : "border-gray-300"
}`}
placeholder="Min. 8 characters"
/>
{fieldErrors.password && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.password}</p>
)}
</div>
{/* Confirm Password */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
Confirm Password <span className="text-red-500">*</span>
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
value={formData.confirmPassword}
onChange={handleChange}
disabled={isLoading}
className={`w-full px-3 py-2 border 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 ${
fieldErrors.confirmPassword ? "border-red-400" : "border-gray-300"
}`}
placeholder="••••••••"
/>
{fieldErrors.confirmPassword && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.confirmPassword}</p>
)}
</div>
{/* Business Address (optional) */}
<div>
<label
htmlFor="businessAddress"
className="block text-sm font-medium text-gray-700 mb-1"
>
Business Address{" "}
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="businessAddress"
name="businessAddress"
type="text"
autoComplete="street-address"
value={formData.businessAddress}
onChange={handleChange}
disabled={isLoading}
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="123 Main St, City, State"
/>
</div>
{/* Contact Phone (optional) */}
<div>
<label
htmlFor="contactPhone"
className="block text-sm font-medium text-gray-700 mb-1"
>
Contact Phone{" "}
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="contactPhone"
name="contactPhone"
type="tel"
autoComplete="tel"
value={formData.contactPhone}
onChange={handleChange}
disabled={isLoading}
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="+1 (555) 000-0000"
/>
</div>
{/* Global error message */}
{error && (
<div
className="rounded-lg bg-red-50 border border-red-200 p-3"
role="alert"
>
<p className="text-sm text-red-700">{error}</p>
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{isLoading ? "Creating account..." : "Create account"}
</button>
</form>
{/* Sign in link */}
<p className="mt-6 text-center text-sm text-gray-500">
Already have an account?{" "}
<Link
href="/login"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -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<string, unknown>;
// 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 }
);
}
}