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:
379
src/app/(auth)/signup/page.tsx
Normal file
379
src/app/(auth)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user