forgot and reset password

This commit is contained in:
mohanmd 2026-06-19 14:33:40 +05:30
parent 121337eea7
commit ed7f2f8f4e
16 changed files with 6298 additions and 5381 deletions

View File

@ -279,4 +279,129 @@ public function uploadLicense(Request $request)
], 500);
}
}
// -------------------------------------------------------------------------
// Forgot / Reset Password
// -------------------------------------------------------------------------
/**
* POST /api/sponsors/forgot-password
* Send a 6-digit OTP to the sponsor's registered email.
* Accepts: email OR mobile
*/
public function forgotPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'nullable|email',
'mobile' => 'nullable|string',
]);
$validator->after(function ($v) use ($request) {
if (!$request->filled('email') && !$request->filled('mobile')) {
$v->errors()->add('email', 'Either email or mobile number is required.');
}
});
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors(),
], 422);
}
$sponsor = null;
if ($request->filled('email')) {
$sponsor = Sponsor::where('email', $request->email)->first();
} elseif ($request->filled('mobile')) {
$sponsor = Sponsor::where('mobile', $request->mobile)->first();
}
// Prevent email enumeration — always return success
if (!$sponsor || !$sponsor->email) {
return response()->json([
'success' => true,
'message' => 'If an account with that contact exists, a reset OTP has been sent.',
]);
}
$otp = (string) mt_rand(100000, 999999);
$cacheKey = 'sponsor_pwd_reset_' . md5($sponsor->email);
\Illuminate\Support\Facades\Cache::put($cacheKey, [
'otp' => Hash::make($otp),
'expires_at' => now()->addMinutes(10)->timestamp,
], now()->addMinutes(10));
try {
\Illuminate\Support\Facades\Mail::to($sponsor->email)->send(
new \App\Mail\ForgotPasswordOtpMail($otp, $sponsor->full_name, 'Sponsor')
);
} catch (\Exception $e) {
logger()->error('Sponsor forgot-password mail failed: ' . $e->getMessage());
}
return response()->json([
'success' => true,
'message' => 'If an account with that contact exists, a reset OTP has been sent.',
]);
}
/**
* POST /api/sponsors/reset-password
* Verify OTP and set new password.
*/
public function resetPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'otp' => 'required|string|size:6',
'password' => 'required|string|min:8|confirmed',
'password_confirmation' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors(),
], 422);
}
$cacheKey = 'sponsor_pwd_reset_' . md5($request->email);
$cached = \Illuminate\Support\Facades\Cache::get($cacheKey);
if (!$cached || $cached['expires_at'] < now()->timestamp) {
\Illuminate\Support\Facades\Cache::forget($cacheKey);
return response()->json([
'success' => false,
'message' => 'OTP has expired or is invalid. Please request a new one.',
], 422);
}
if (!Hash::check($request->otp, $cached['otp'])) {
return response()->json([
'success' => false,
'message' => 'Invalid OTP. Please check and try again.',
], 422);
}
$sponsor = Sponsor::where('email', $request->email)->first();
if (!$sponsor) {
return response()->json([
'success' => false,
'message' => 'Account not found.',
], 404);
}
$sponsor->update(['password' => Hash::make($request->password)]);
\Illuminate\Support\Facades\Cache::forget($cacheKey);
return response()->json([
'success' => true,
'message' => 'Password reset successfully. You can now log in with your new password.',
]);
}
}

View File

@ -11,6 +11,7 @@
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class WorkerAuthController extends Controller
{
@ -315,7 +316,17 @@ public function register(Request $request)
'preferred_location' => 'nullable|string|max:255',
'fcm_token' => 'nullable|string|max:255',
'passport' => 'nullable|array',
'passport.passport_number' => [
'nullable',
'string',
Rule::unique('worker_documents', 'number')->where(function ($query) {
return $query->where('type', 'passport');
}),
],
'visa' => 'nullable|array',
], [
'phone.unique' => 'This mobile number is already registered.',
'passport.passport_number.unique' => 'This passport number is already registered.',
]);
if ($validator->fails()) {
@ -970,4 +981,123 @@ private function normaliseDateForController(?string $raw): ?string
return $raw;
}
}
// -------------------------------------------------------------------------
// Forgot / Reset Password (via Mobile Number)
// -------------------------------------------------------------------------
/**
* POST /api/workers/forgot-password
* Send a 6-digit OTP to verify the worker's identity by phone number.
* Workers do not use email phone is the primary identifier.
*/
public function forgotPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'phone' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors(),
], 422);
}
$worker = Worker::where('phone', $request->phone)->first();
// Always return success to prevent phone enumeration
if (!$worker) {
return response()->json([
'success' => true,
'message' => 'If an account with that mobile number exists, a reset OTP has been sent.',
]);
}
$otp = (string) mt_rand(100000, 999999);
$cacheKey = 'worker_pwd_reset_' . md5($request->phone);
Cache::put($cacheKey, [
'otp' => Hash::make($otp),
'expires_at' => now()->addMinutes(10)->timestamp,
], now()->addMinutes(10));
// Send via email if available, otherwise log for dev
if ($worker->email) {
try {
Mail::to($worker->email)->send(
new \App\Mail\ForgotPasswordOtpMail($otp, $worker->name, 'Worker')
);
} catch (\Exception $e) {
logger()->error('Worker forgot-password mail failed: ' . $e->getMessage());
}
} else {
// Log OTP for debugging when no email configured
logger()->info("Worker password reset OTP for {$request->phone}: {$otp}");
}
return response()->json([
'success' => true,
'message' => 'If an account with that mobile number exists, a reset OTP has been sent.',
]);
}
/**
* POST /api/workers/reset-password
* Verify OTP sent to phone and set a new password.
*/
public function resetPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'phone' => 'required|string',
'otp' => 'required|string|size:6',
'password' => 'required|string|min:8|confirmed',
'password_confirmation' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors(),
], 422);
}
$cacheKey = 'worker_pwd_reset_' . md5($request->phone);
$cached = Cache::get($cacheKey);
if (!$cached || $cached['expires_at'] < now()->timestamp) {
Cache::forget($cacheKey);
return response()->json([
'success' => false,
'message' => 'OTP has expired or is invalid. Please request a new one.',
], 422);
}
if (!Hash::check($request->otp, $cached['otp'])) {
return response()->json([
'success' => false,
'message' => 'Invalid OTP. Please check and try again.',
], 422);
}
$worker = Worker::where('phone', $request->phone)->first();
if (!$worker) {
return response()->json([
'success' => false,
'message' => 'Account not found.',
], 404);
}
$worker->update(['password' => Hash::make($request->password)]);
Cache::forget($cacheKey);
return response()->json([
'success' => true,
'message' => 'Password reset successfully. You can now log in with your new password.',
]);
}
}

View File

@ -46,19 +46,20 @@ public function login(Request $request)
return back()->with('status', 'subscription_expired');
}
// Perform standard Laravel authentication
auth()->login($user);
// Perform standard Laravel authentication (remember=true keeps auth cookie alive)
auth()->login($user, true);
session(['user' => (object)[
// Regenerate BEFORE writing session data to avoid losing data on ID change
$request->session()->regenerate();
$request->session()->put('user', (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
'subscription_status' => $user->subscription_status ?? 'active',
'verification_status' => $verification_status,
]]);
$request->session()->regenerate();
]);
if ($request->source === 'mobile') {
return redirect('/mobile/employer/home');
@ -84,9 +85,11 @@ public function register(Request $request)
'company_name' => 'nullable|string|max:255',
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255',
'phone' => 'required|string|regex:/^[0-9]{7,15}$/',
'phone' => ['required', 'string', 'regex:/^\+?[0-9]{7,20}$/'],
'country_code' => 'nullable|string|max:10',
'address' => 'required|string|max:255',
], [
'phone.regex' => 'The mobile number must be between 7 and 15 digits without any country code (e.g. 501234567).',
'phone.regex' => 'The mobile number must contain only digits (7-20 characters).',
]);
// 2. Email uniqueness check (Check DB, return 409 if duplicate)
@ -118,6 +121,8 @@ public function register(Request $request)
'name' => $request->name,
'email' => $request->email,
'phone' => $request->phone,
'country_code' => $request->country_code,
'address' => $request->address,
],
'employer_otp' => [
'hash' => $hashedOtp,
@ -433,6 +438,7 @@ public function createPassword(Request $request)
'emirates_id_front' => null, // We did not store the file
'emirates_id_number' => $pending['emirates_id_number'] ?? null,
'emirates_id_expiry' => $pending['emirates_id_expiry'] ?? null,
'address' => $pending['address'] ?? null,
]);
// Create Sponsor
@ -452,6 +458,7 @@ public function createPassword(Request $request)
'status' => 'active',
'last_login_at' => now(),
'emirates_id_file' => null, // We did not store the file
'address' => $pending['address'] ?? null,
]);
// Create active subscription in database
@ -467,17 +474,20 @@ public function createPassword(Request $request)
'updated_at' => now(),
]);
// Auto-login (Laravel + Session)
auth()->login($user);
// Auto-login (Laravel + Session) with remember=true for persistence
auth()->login($user, true);
session(['user' => (object)[
// Regenerate session ID BEFORE writing data
request()->session()->regenerate();
request()->session()->put('user', (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => 'employer',
'subscription_status' => 'active',
'verification_status' => 'approved',
]]);
]);
// Flash first_login toast flag
session()->flash('first_login', true);

View File

@ -6,90 +6,70 @@
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Models\User;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
class PaymentController extends Controller
{
private function resolveCurrentUser()
private function resolveCurrentUser(): ?User
{
// Prefer Laravel's built-in auth (most reliable)
if (auth()->check()) {
return auth()->user();
}
// Fallback: session-based user (registration auto-login flow)
$sess = session('user');
$sessId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null);
if (!$sessId) {
$user = User::where('role', 'employer')->first();
if ($user) {
session(['user' => (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => 'employer',
'subscription_status' => $user->subscription_status ?? 'active',
]]);
return $user;
}
} else {
return User::find($sessId);
}
return null;
return $sessId ? User::find($sessId) : null;
}
public function index(Request $request)
{
$user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2;
// Auto-seed payments if none exist for a richer UX
$paymentsCount = Payment::where('user_id', $employerId)->count();
if ($paymentsCount === 0) {
$subscription = DB::table('subscriptions')->where('user_id', $employerId)->first();
$planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription';
$planAmount = $subscription ? $subscription->amount_aed : 199.00;
for ($i = 0; $i < 8; $i++) {
Payment::create([
'user_id' => $employerId,
'amount' => $planAmount,
'currency' => 'AED',
'description' => $planName,
'status' => 'success',
'created_at' => now()->subMonths($i)->subDays(rand(1, 5)),
'updated_at' => now()->subMonths($i)->subDays(rand(1, 5)),
]);
}
if (!$user) {
return redirect()->route('employer.login');
}
$payments = Payment::where('user_id', $employerId)
->where(function ($q) {
$q->where('description', 'like', '%Subscription%')
->orWhere('description', 'like', '%Pass%');
})
->latest()
->get()
->map(function ($p) {
return [
'id' => $p->id,
'amount' => (float)$p->amount,
'currency' => $p->currency,
'description' => $p->description,
'status' => $p->status,
'date' => $p->created_at->format('M d, Y'),
'time' => $p->created_at->format('h:i A'),
'invoice_no' => 'INV-' . str_pad($p->id, 6, '0', STR_PAD_LEFT),
];
})->toArray();
// Payments are stored in the subscriptions table during registration
$subscriptions = DB::table('subscriptions')
->where('user_id', $user->id)
->latest('id')
->get();
// Retrieve subscription details for billing and renewal analytics
$sub = DB::table('subscriptions')->where('user_id', $employerId)->where('status', 'active')->latest('id')->first();
$expiresAt = $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Dec 31, 2026';
$currentPlan = $sub ? (ucfirst($sub->plan_id) . ' Sponsor Pass') : 'Premium Sponsor Pass';
$subStatus = $user && $user->subscription_status ? ucfirst($user->subscription_status) : 'Active';
$payments = $subscriptions->map(function ($sub) {
$planLabel = ucfirst($sub->plan_id) . ' Sponsor Pass Subscription';
$date = \Carbon\Carbon::parse($sub->starts_at ?? $sub->created_at);
return [
'id' => $sub->id,
'amount' => (float) $sub->amount_aed,
'currency' => 'AED',
'description' => $planLabel,
'status' => $sub->status === 'active' ? 'success' : $sub->status,
'date' => $date->format('M d, Y'),
'time' => $date->format('h:i A'),
'invoice_no' => 'INV-' . str_pad($sub->id, 6, '0', STR_PAD_LEFT),
'transaction_id' => $sub->paytabs_transaction_id,
'expires_at' => $sub->expires_at ? \Carbon\Carbon::parse($sub->expires_at)->format('M d, Y') : null,
];
})->toArray();
// Latest active subscription for the summary cards
$activeSub = $subscriptions->where('status', 'active')->first();
$expiresAt = $activeSub && $activeSub->expires_at
? \Carbon\Carbon::parse($activeSub->expires_at)->format('M d, Y')
: null;
$currentPlan = $activeSub
? ucfirst($activeSub->plan_id) . ' Sponsor Pass'
: null;
$subStatus = ucfirst($user->subscription_status ?? 'none');
return Inertia::render('Employer/PaymentHistory', [
'payments' => $payments,
'currentPlan' => $currentPlan,
'expiresAt' => $expiresAt,
'payments' => $payments,
'currentPlan' => $currentPlan,
'expiresAt' => $expiresAt,
'subscriptionStatus' => $subStatus,
]);
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EmployerGuestMiddleware
{
/**
* If the employer is already authenticated, redirect them to the dashboard.
* Prevents logged-in users from seeing login/register pages.
*/
public function handle(Request $request, Closure $next): Response
{
// Check Laravel's built-in auth first (most reliable — survives refresh)
if (auth()->check() && auth()->user()->role === 'employer') {
return redirect()->route('employer.dashboard');
}
// Also check the custom session key used during registration auto-login
$sessionUser = session('user');
$role = is_array($sessionUser)
? ($sessionUser['role'] ?? null)
: ($sessionUser->role ?? null);
if ($sessionUser && $role === 'employer') {
return redirect()->route('employer.dashboard');
}
return $next($request);
}
}

View File

@ -13,12 +13,21 @@ class EmployerMiddleware
*/
public function handle(Request $request, Closure $next): Response
{
$user = auth()->check() ? auth()->user() : session('user');
$role = is_array($user) ? ($user['role'] ?? null) : ($user->role ?? null);
if (!$user || $role !== 'employer') {
return redirect()->route('employer.login');
// Prefer Laravel's built-in auth (survives page refresh reliably via remember cookie)
if (auth()->check() && auth()->user()->role === 'employer') {
return $next($request);
}
return $next($request);
// Fallback: check custom session key (used during registration auto-login)
$sessionUser = session('user');
$role = is_array($sessionUser)
? ($sessionUser['role'] ?? null)
: ($sessionUser->role ?? null);
if ($sessionUser && $role === 'employer') {
return $next($request);
}
return redirect()->route('employer.login');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ForgotPasswordOtpMail extends Mailable
{
use Queueable, SerializesModels;
public string $otp;
public string $name;
public string $userType; // 'Worker', 'Employer', 'Sponsor'
public function __construct(string $otp, string $name, string $userType = 'User')
{
$this->otp = $otp;
$this->name = $name;
$this->userType = $userType;
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Password Reset OTP ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.forgot-password-otp',
);
}
}

View File

@ -19,11 +19,12 @@
\App\Http\Middleware\HandleInertiaRequests::class,
]);
$middleware->alias([
'admin' => \App\Http\Middleware\AdminMiddleware::class,
'employer' => \App\Http\Middleware\EmployerMiddleware::class,
'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class,
'auth.employer'=> \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
'admin' => \App\Http\Middleware\AdminMiddleware::class,
'employer' => \App\Http\Middleware\EmployerMiddleware::class,
'employer.guest' => \App\Http\Middleware\EmployerGuestMiddleware::class,
'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class,
'auth.employer' => \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Head, Link, router } from '@inertiajs/react';
import axios from 'axios';
import { toast } from 'sonner';
@ -6,14 +6,131 @@ import {
Loader2,
Users,
DollarSign,
MessageSquare
MessageSquare,
ChevronDown,
Search
} from 'lucide-react';
// Common country dial codes with flag emojis
const COUNTRY_CODES = [
{ code: 'AE', dial: '+971', flag: '🇦🇪', name: 'United Arab Emirates' },
{ code: 'SA', dial: '+966', flag: '🇸🇦', name: 'Saudi Arabia' },
{ code: 'IN', dial: '+91', flag: '🇮🇳', name: 'India' },
{ code: 'PK', dial: '+92', flag: '🇵🇰', name: 'Pakistan' },
{ code: 'PH', dial: '+63', flag: '🇵🇭', name: 'Philippines' },
{ code: 'BD', dial: '+880', flag: '🇧🇩', name: 'Bangladesh' },
{ code: 'LK', dial: '+94', flag: '🇱🇰', name: 'Sri Lanka' },
{ code: 'NP', dial: '+977', flag: '🇳🇵', name: 'Nepal' },
{ code: 'EG', dial: '+20', flag: '🇪🇬', name: 'Egypt' },
{ code: 'JO', dial: '+962', flag: '🇯🇴', name: 'Jordan' },
{ code: 'LB', dial: '+961', flag: '🇱🇧', name: 'Lebanon' },
{ code: 'IQ', dial: '+964', flag: '🇮🇶', name: 'Iraq' },
{ code: 'KW', dial: '+965', flag: '🇰🇼', name: 'Kuwait' },
{ code: 'QA', dial: '+974', flag: '🇶🇦', name: 'Qatar' },
{ code: 'BH', dial: '+973', flag: '🇧🇭', name: 'Bahrain' },
{ code: 'OM', dial: '+968', flag: '🇴🇲', name: 'Oman' },
{ code: 'ET', dial: '+251', flag: '🇪🇹', name: 'Ethiopia' },
{ code: 'KE', dial: '+254', flag: '🇰🇪', name: 'Kenya' },
{ code: 'UG', dial: '+256', flag: '🇺🇬', name: 'Uganda' },
{ code: 'GH', dial: '+233', flag: '🇬🇭', name: 'Ghana' },
{ code: 'NG', dial: '+234', flag: '🇳🇬', name: 'Nigeria' },
{ code: 'ID', dial: '+62', flag: '🇮🇩', name: 'Indonesia' },
{ code: 'MY', dial: '+60', flag: '🇲🇾', name: 'Malaysia' },
{ code: 'GB', dial: '+44', flag: '🇬🇧', name: 'United Kingdom' },
{ code: 'US', dial: '+1', flag: '🇺🇸', name: 'United States' },
];
function CountryCodeSelector({ value, onChange, hasError }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const ref = useRef(null);
const searchRef = useRef(null);
const selected = COUNTRY_CODES.find(c => c.dial === value) || COUNTRY_CODES[0];
const filtered = COUNTRY_CODES.filter(c =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.dial.includes(search)
);
useEffect(() => {
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
useEffect(() => {
if (open && searchRef.current) searchRef.current.focus();
}, [open]);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => { setOpen(o => !o); setSearch(''); }}
className={`flex items-center gap-1.5 px-3 py-3 h-full rounded-l-xl border-r-0 border text-sm font-medium whitespace-nowrap transition-all focus:outline-none focus:ring-2 ${
hasError
? 'border-red-500 focus:ring-red-500/20 bg-red-50'
: 'border-slate-300 focus:ring-[#185FA5]/20 bg-slate-50 hover:bg-slate-100'
}`}
>
<span className="text-base leading-none">{selected.flag}</span>
<span className="text-slate-700 text-xs">{selected.dial}</span>
<ChevronDown className={`w-3 h-3 text-slate-500 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-72 bg-white border border-slate-200 rounded-xl shadow-xl overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-slate-100">
<div className="flex items-center gap-2 px-2.5 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
<Search className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<input
ref={searchRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search country..."
className="flex-1 bg-transparent text-xs outline-none text-slate-700 placeholder-slate-400"
/>
</div>
</div>
{/* List */}
<ul className="max-h-52 overflow-y-auto">
{filtered.length === 0 ? (
<li className="px-4 py-3 text-xs text-slate-400 text-center">No results found</li>
) : filtered.map(c => (
<li key={c.code}>
<button
type="button"
onClick={() => { onChange(c.dial); setOpen(false); }}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-slate-50 transition-colors ${
c.dial === value ? 'bg-blue-50 text-[#185FA5] font-semibold' : 'text-slate-700'
}`}
>
<span className="text-base">{c.flag}</span>
<span className="flex-1 text-left text-xs">{c.name}</span>
<span className="text-xs text-slate-500 font-mono">{c.dial}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
export default function Register() {
const [countryCode, setCountryCode] = useState('+971');
const [data, setData] = useState({
name: '',
email: '',
phone: '',
address: '',
});
const [loading, setLoading] = useState(false);
@ -41,8 +158,12 @@ export default function Register() {
if (!data.phone.trim()) {
newErrors.phone = 'Mobile number is required.';
} else if (!/^[0-9]{7,15}$/.test(data.phone)) {
newErrors.phone = 'Please enter a valid mobile number (7-15 digits without country code).';
} else if (!/^[0-9]{4,14}$/.test(data.phone.replace(/^0+/, ''))) {
newErrors.phone = 'Please enter a valid mobile number (digits only, without country code).';
}
if (!data.address.trim()) {
newErrors.address = 'Address is required.';
}
setErrors(newErrors);
@ -63,7 +184,9 @@ export default function Register() {
const formData = new FormData();
formData.append('name', data.name);
formData.append('email', data.email);
formData.append('phone', data.phone);
formData.append('phone', countryCode + data.phone.replace(/^0+/, ''));
formData.append('country_code', countryCode);
formData.append('address', data.address);
try {
await axios.post('/employer/register', formData, {
@ -239,20 +362,48 @@ export default function Register() {
{/* Mobile Number */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wider">Mobile Number</label>
<input
type="tel"
value={data.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="e.g. 501234567"
<div className="flex">
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
hasError={!!errors.phone}
/>
<input
type="tel"
value={data.phone}
onChange={(e) => handleInputChange('phone', e.target.value.replace(/[^0-9]/g, ''))}
placeholder="501234567"
className={`flex-1 px-3.5 py-3 rounded-r-xl border text-sm focus:outline-none focus:ring-2 transition-all ${
errors.phone
? 'border-red-500 focus:ring-red-500/20 focus:border-red-500'
: 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]'
}`}
/>
</div>
{errors.phone && (
<p className="mt-1.5 text-xs text-red-500 font-medium">
{Array.isArray(errors.phone) ? errors.phone[0] : errors.phone}
</p>
)}
</div>
{/* Address */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wider">Address</label>
<textarea
value={data.address}
onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="e.g. Villa 14, Al Safa, Dubai, UAE"
rows="2"
className={`w-full px-3.5 py-3 rounded-xl border text-sm focus:outline-none focus:ring-2 transition-all ${
errors.phone
errors.address
? 'border-red-500 focus:ring-red-500/20 focus:border-red-500'
: 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]'
}`}
/>
{errors.phone && (
{errors.address && (
<p className="mt-1.5 text-xs text-red-500 font-medium">
{Array.isArray(errors.phone) ? errors.phone[0] : errors.phone}
{Array.isArray(errors.address) ? errors.address[0] : errors.address}
</p>
)}
</div>

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Reset OTP</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f1f5f9; margin: 0; padding: 0; }
.wrapper { max-width: 520px; margin: 40px auto; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.header { background: #185FA5; padding: 32px 40px; text-align: center; }
.header h1 { color: #fff; font-size: 22px; font-weight: 800; margin: 0; letter-spacing: -0.5px; }
.header p { color: rgba(255,255,255,0.75); font-size: 13px; margin: 6px 0 0; }
.body { padding: 36px 40px; }
.greeting { font-size: 15px; color: #1e293b; font-weight: 600; margin-bottom: 12px; }
.message { font-size: 13.5px; color: #475569; line-height: 1.7; margin-bottom: 28px; }
.otp-box { background: #f0f7ff; border: 2px dashed #185FA5; border-radius: 12px; text-align: center; padding: 20px; margin-bottom: 28px; }
.otp-box .otp { font-size: 38px; font-weight: 900; letter-spacing: 10px; color: #185FA5; font-family: monospace; }
.otp-box .expiry { font-size: 11px; color: #64748b; margin-top: 8px; font-weight: 600; }
.warning { font-size: 12px; color: #94a3b8; line-height: 1.6; border-top: 1px solid #e2e8f0; padding-top: 20px; margin-top: 8px; }
.footer { background: #f8fafc; padding: 18px 40px; text-align: center; font-size: 11px; color: #94a3b8; }
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
<h1>{{ config('app.name') }}</h1>
<p>Password Reset Request</p>
</div>
<div class="body">
<p class="greeting">Hello, {{ $name }}</p>
<p class="message">
We received a request to reset your <strong>{{ $userType }}</strong> account password.
Use the OTP below to proceed. This code is valid for <strong>10 minutes</strong>.
</p>
<div class="otp-box">
<div class="otp">{{ $otp }}</div>
<div class="expiry"> Expires in 10 minutes</div>
</div>
<p class="warning">
If you did not request a password reset, please ignore this email your account is safe.
Never share this code with anyone.
</p>
</div>
<div class="footer">
© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.
</div>
</div>
</body>
</html>

View File

@ -43,6 +43,8 @@
Route::post('/workers/setup-profile', [WorkerAuthController::class, 'setupProfile']);
Route::post('/workers/register', [WorkerAuthController::class, 'register']);
Route::post('/workers/login', [WorkerAuthController::class, 'login']);
Route::post('/workers/forgot-password', [WorkerAuthController::class, 'forgotPassword']);
Route::post('/workers/reset-password', [WorkerAuthController::class, 'resetPassword']);
Route::post('/workers/ocr/passport-vision', [GoogleVisionOcrController::class, 'extractPassport']);
// Unprotected Employer Mobile Auth Endpoints
@ -59,6 +61,8 @@
Route::post('/sponsors/register', [SponsorAuthController::class, 'register']);
Route::post('/sponsors/register/license', [SponsorAuthController::class, 'uploadLicense']);
Route::post('/sponsors/login', [EmployerAuthController::class, 'login']);
Route::post('/sponsors/forgot-password', [SponsorAuthController::class, 'forgotPassword']);
Route::post('/sponsors/reset-password', [SponsorAuthController::class, 'resetPassword']);
// Protected Worker Mobile Endpoints (Token Authenticated via Bearer Token)

View File

@ -5,9 +5,33 @@
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
// Redirect root to admin dashboard (which guards via AdminMiddleware or redirects to login)
// Smart root redirect — send users to the right dashboard based on their role
Route::get('/', function () {
return redirect()->route('admin.dashboard');
// Check Laravel's built-in auth (survives refresh via remember cookie)
if (auth()->check()) {
$role = auth()->user()->role;
if ($role === 'employer') {
return redirect()->route('employer.dashboard');
}
if ($role === 'admin') {
return redirect()->route('admin.dashboard');
}
}
// Fallback: check custom session key (registration flow / legacy)
$sessionUser = session('user');
if ($sessionUser) {
$role = is_array($sessionUser) ? ($sessionUser['role'] ?? null) : ($sessionUser->role ?? null);
if ($role === 'employer') {
return redirect()->route('employer.dashboard');
}
if ($role === 'admin') {
return redirect()->route('admin.dashboard');
}
}
// Not logged in — send to admin login as default
return redirect()->route('admin.login');
});
// Admin Auth Routes
@ -233,29 +257,33 @@
Route::post('/tickets/{id}/generate-dev-response', [\App\Http\Controllers\Admin\SupportTicketController::class, 'generateDevResponse'])->name('admin.tickets.generate-dev-response');
});
// Employer Auth Routes
Route::get('/employer/login', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showLogin'])->name('employer.login');
Route::post('/employer/login', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'login'])->name('employer.login.submit');
Route::get('/employer/register', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegister'])->name('employer.register');
// Employer Auth Routes — guest-only pages redirect logged-in employers to dashboard
Route::middleware('employer.guest')->group(function () {
Route::get('/employer/login', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showLogin'])->name('employer.login');
Route::get('/employer/register', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegister'])->name('employer.register');
Route::get('/employer/forgot-password', function () {
return Inertia::render('Employer/Auth/ForgotPassword');
})->name('employer.forgot-password');
});
// Employer Auth POST/flow routes (no guest guard — mid-registration steps allowed)
Route::post('/employer/login', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'login'])->name('employer.login.submit');
Route::post('/employer/register', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'register'])->name('employer.register.submit');
Route::get('/employer/verify-email', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showVerifyEmail'])->name('employer.verify-email');
Route::get('/employer/verify-email', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showVerifyEmail'])->name('employer.verify-email');
Route::post('/employer/verify-email', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'verifyEmail'])->name('employer.verify-email.submit')->middleware('throttle:5,1');
Route::post('/employer/resend-otp', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resendOtp'])->name('employer.resend-otp')->middleware('throttle:3,1');
Route::get('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showUploadEmiratesId'])->name('employer.upload-emirates-id');
Route::post('/employer/resend-otp', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resendOtp'])->name('employer.resend-otp')->middleware('throttle:3,1');
Route::get('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showUploadEmiratesId'])->name('employer.upload-emirates-id');
Route::post('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'uploadEmiratesId'])->name('employer.upload-emirates-id.submit');
Route::get('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegisterPayment'])->name('employer.register-payment');
Route::get('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegisterPayment'])->name('employer.register-payment');
Route::post('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'storeRegisterPayment'])->name('employer.register-payment.submit');
Route::get('/employer/create-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showCreatePassword'])->name('employer.create-password');
Route::get('/employer/create-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showCreatePassword'])->name('employer.create-password');
Route::post('/employer/create-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'createPassword'])->name('employer.create-password.submit');
Route::post('/employer/logout', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'logout'])->name('employer.logout');
Route::get('/employer/pending-verification', function () {
return Inertia::render('Employer/Auth/PendingVerification');
})->name('employer.pending-verification');
Route::get('/employer/forgot-password', function () {
return Inertia::render('Employer/Auth/ForgotPassword');
})->name('employer.forgot-password');
Route::post('/employer/forgot-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'forgotPassword'])->name('employer.forgot-password.submit');
Route::post('/employer/reset-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resetPassword'])->name('employer.reset-password.submit');
Route::post('/employer/reset-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resetPassword'])->name('employer.reset-password.submit');
// Employer Protected Routes
Route::prefix('employer')->middleware(['employer'])->group(function () {

View File

@ -28,6 +28,7 @@ public function test_employer_web_registration_with_emirates_id()
'name' => 'Abdullah Ahmed',
'email' => 'abdullah@example.com',
'phone' => '501234567',
'address' => 'Villa 14, Al Safa, Dubai',
]);
$responseStep1->assertRedirect(route('employer.verify-email'));
@ -85,6 +86,7 @@ public function test_employer_web_registration_with_emirates_id()
'phone' => '501234567',
'company_name' => 'Abdullah Ahmed Household',
'emirates_id_front' => null, // File is not stored
'address' => 'Villa 14, Al Safa, Dubai',
]);
$profile = EmployerProfile::where('phone', '501234567')->first();
@ -95,6 +97,7 @@ public function test_employer_web_registration_with_emirates_id()
'email' => 'abdullah@example.com',
'mobile' => '501234567',
'emirates_id_file' => null, // File is not stored
'address' => 'Villa 14, Al Safa, Dubai',
]);
}
@ -113,6 +116,7 @@ public function test_employer_web_registration_with_two_sided_emirates_id()
'name' => 'Fatima Al-Mansoori',
'email' => 'fatima@example.com',
'phone' => '509876543',
'address' => 'Apartment 402, Al Nahda, Sharjah',
]);
$responseStep1->assertRedirect(route('employer.verify-email'));
@ -170,5 +174,6 @@ public function test_employer_web_registration_with_two_sided_emirates_id()
$this->assertNotNull($profile);
$this->assertEquals('784-1988-5310327-2', $profile->emirates_id_number);
$this->assertEquals('2028-04-11', $profile->emirates_id_expiry);
$this->assertEquals('Apartment 402, Al Nahda, Sharjah', $profile->address);
}
}

View File

@ -1074,4 +1074,100 @@ public function test_get_preferred_location_areas_localized()
'success' => false,
]);
}
/**
* Test worker registration fails validation if the phone number already exists.
*/
public function test_register_validation_fails_if_phone_number_already_exists()
{
// 1. Create a worker with a specific phone number
Worker::create([
'name' => 'Existing Worker',
'email' => 'existing@example.com',
'phone' => '+971501234567',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'status' => 'active',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Not Specified',
'bio' => 'Test worker',
]);
// 2. Attempt to register another worker with the same phone number
$response = $this->postJson('/api/workers/register', [
'name' => 'New Worker',
'phone' => '+971501234567',
'salary' => 2000,
'password' => 'secret123',
'language' => 'HI',
]);
$response->assertStatus(422)
->assertJson([
'success' => false,
'message' => 'Validation error.',
])
->assertJsonValidationErrors(['phone']);
$this->assertEquals(
'This mobile number is already registered.',
$response->json('errors.phone.0')
);
}
/**
* Test worker registration fails validation if the passport number already exists.
*/
public function test_register_validation_fails_if_passport_number_already_exists()
{
// 1. Create a worker and their passport document
$worker = Worker::create([
'name' => 'First Worker',
'email' => 'first@example.com',
'phone' => '+971501111111',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'status' => 'active',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Not Specified',
'bio' => 'Test worker',
]);
$worker->documents()->create([
'type' => 'passport',
'number' => 'SQ1234567',
]);
// 2. Attempt to register another worker with the same passport number
$response = $this->postJson('/api/workers/register', [
'name' => 'Second Worker',
'phone' => '+971502222222',
'salary' => 2000,
'password' => 'secret123',
'language' => 'HI',
'passport' => [
'passport_number' => 'SQ1234567',
]
]);
$response->assertStatus(422)
->assertJson([
'success' => false,
'message' => 'Validation error.',
])
->assertJsonValidationErrors(['passport.passport_number']);
$this->assertEquals(
'This passport number is already registered.',
$response->json('errors')['passport.passport_number'][0]
);
}
}

View File

@ -23,7 +23,7 @@ export default defineConfig({
port: 5173,
cors: true,
hmr: {
host: '192.168.0.245',
host: '192.168.29.192',
},
watch: {
ignored: ['**/storage/framework/views/**'],