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:
@@ -52,6 +52,11 @@ model Tenant {
|
|||||||
slug String @unique
|
slug String @unique
|
||||||
ownerEmail String
|
ownerEmail String
|
||||||
|
|
||||||
|
/// Physical address of the ISP business (optional)
|
||||||
|
businessAddress String?
|
||||||
|
/// Primary contact phone number (optional)
|
||||||
|
contactPhone String?
|
||||||
|
|
||||||
status TenantStatus @default(ACTIVE)
|
status TenantStatus @default(ACTIVE)
|
||||||
/// Timestamp when suspension was triggered (starts grace period clock)
|
/// Timestamp when suspension was triggered (starts grace period clock)
|
||||||
suspendedAt DateTime?
|
suspendedAt DateTime?
|
||||||
|
|||||||
138
src/app/(auth)/login/page.tsx
Normal file
138
src/app/(auth)/login/page.tsx
Normal 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't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/app/api/tenants/signup/route.ts
Normal file
120
src/app/api/tenants/signup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/lib/tenant.ts
Normal file
232
src/lib/tenant.ts
Normal file
@@ -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<string> {
|
||||||
|
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<CreateTenantResult> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user