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,85 +6,65 @@
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) {
// Payments are stored in the subscriptions table during registration
$subscriptions = DB::table('subscriptions')
->where('user_id', $user->id)
->latest('id')
->get();
$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' => $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),
'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();
// 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';
// 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,

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);
}
// 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

@ -21,8 +21,9 @@
$middleware->alias([
'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.employer' => \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
]);
})

View File

@ -1,4 +1,4 @@
{
{
"openapi": "3.0.0",
"info": {
"title": "Migrant Mobile API",
@ -13,7 +13,9 @@
],
"security": [
{
"bearerAuth": []
"bearerAuth": [
]
}
],
"paths": {
@ -23,8 +25,10 @@
"Sponsor/Auth"
],
"summary": "Register Sponsor Account",
"description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access \u2014 dashboard and charity events only. No payment required.",
"security": [],
"description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access — dashboard and charity events only. No payment required.",
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -51,7 +55,7 @@
"mobile": {
"type": "string",
"example": "+971501112233",
"description": "Mobile phone number — must be unique. (REQUIRED)"
"description": "Mobile phone number — must be unique. (REQUIRED)"
},
"password": {
"type": "string",
@ -212,7 +216,9 @@
],
"summary": "Upload Sponsor Trade License",
"description": "Uploads and processes the trade license document of a Sponsor using OCR.",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -301,8 +307,10 @@
"Sponsor/Auth"
],
"summary": "Authenticate Sponsor or Employer",
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user's role (employer or sponsor). Same behavior as /employers/login.",
"security": [],
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user\u0027s role (employer or sponsor). Same behavior as /employers/login.",
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -384,7 +392,7 @@
"Sponsor"
],
"summary": "Sponsor Dashboard",
"description": "Returns the sponsor profile summary, latest charity events, and total & active counts for employers and workers.",
"description": "Returns the sponsor profile summary, latest charity events, and total \u0026 active counts for employers and workers.",
"responses": {
"200": {
"description": "Dashboard data retrieved successfully.",
@ -480,7 +488,7 @@
"schema": {
"type": "string"
},
"description": "Optional event type filter (e.g. 'charity', 'update')."
"description": "Optional event type filter (e.g. \u0027charity\u0027, \u0027update\u0027)."
}
],
"responses": {
@ -600,8 +608,10 @@
"Worker/Auth"
],
"summary": "Register Worker Account (Step 1)",
"description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` \u2014 upload passport (OCR-processed)\n- `POST /workers/register/visa` \u2014 upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.",
"security": [],
"description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` — upload passport (OCR-processed)\n- `POST /workers/register/visa` — upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.",
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -646,12 +656,12 @@
1,
3
],
"description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. '1,3' or json '[1,3]' in multipart)."
"description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. \u00271,3\u0027 or json \u0027[1,3]\u0027 in multipart)."
},
"language": {
"type": "string",
"example": "English, Arabic",
"description": "Languages spoken by the worker (e.g. 'English, Arabic, Hindi')."
"description": "Languages spoken by the worker (e.g. \u0027English, Arabic, Hindi\u0027)."
},
"nationality": {
"type": "string",
@ -837,7 +847,7 @@
}
},
"422": {
"description": "Validation error \u2014 field validation failed or Passport OCR extraction failed.",
"description": "Validation error — field validation failed or Passport OCR extraction failed.",
"content": {
"application/json": {
"schema": {
@ -913,7 +923,9 @@
],
"summary": "Authenticate Worker",
"description": "Validates worker credentials (mobile phone number and password) and generates a secure, stateless Bearer token for api access.",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -1009,7 +1021,9 @@
],
"summary": "Extract Passport Data using Google Cloud Vision API",
"description": "Upload a passport image file to extract OCR data using the Google Cloud Vision API.",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -1100,7 +1114,9 @@
],
"summary": "Get Supported Languages and Config",
"description": "Returns a list of supported regional languages (Hindi, Swahili, Tagalog, Tamil) for helper onboarding.",
"security": [],
"security": [
],
"responses": {
"200": {
"description": "Config and supported languages retrieved successfully.",
@ -1124,7 +1140,7 @@
},
"name": {
"type": "string",
"example": "Hindi (\u0939\u093f\u0928\u094d\u0926\u0940)"
"example": "Hindi (हिन्दी)"
}
}
}
@ -1144,7 +1160,9 @@
],
"summary": "Get Master Skills List",
"description": "Returns a list of all available master skills for helper onboarding profile setup.",
"security": [],
"security": [
],
"responses": {
"200": {
"description": "Master skills list retrieved successfully.",
@ -1188,7 +1206,9 @@
],
"summary": "Get Nationalities List",
"description": "Returns a list of supported nationalities translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
"security": [],
"security": [
],
"parameters": [
{
"name": "lang",
@ -1197,7 +1217,7 @@
"type": "string",
"default": "en"
},
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)"
},
{
"name": "Accept-Language",
@ -1205,7 +1225,7 @@
"schema": {
"type": "string"
},
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)"
},
{
"name": "search",
@ -1303,7 +1323,9 @@
],
"summary": "Get Languages List",
"description": "Returns a list of supported languages translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
"security": [],
"security": [
],
"parameters": [
{
"name": "lang",
@ -1312,7 +1334,7 @@
"type": "string",
"default": "en"
},
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)"
},
{
"name": "Accept-Language",
@ -1320,7 +1342,7 @@
"schema": {
"type": "string"
},
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)"
},
{
"name": "search",
@ -1418,7 +1440,9 @@
],
"summary": "Get Localized Preferred Locations List",
"description": "Returns a list of preferred locations (Dubai, Abu Dhabi, Oman) translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
"security": [],
"security": [
],
"parameters": [
{
"name": "lang",
@ -1427,7 +1451,7 @@
"type": "string",
"default": "en"
},
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)"
},
{
"name": "Accept-Language",
@ -1435,7 +1459,7 @@
"schema": {
"type": "string"
},
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)"
},
{
"name": "search",
@ -1533,7 +1557,9 @@
],
"summary": "Get Localized Areas for a Preferred Location (Path Parameter)",
"description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using path routing.",
"security": [],
"security": [
],
"parameters": [
{
"name": "location",
@ -1551,7 +1577,7 @@
"type": "string",
"default": "en"
},
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)"
},
{
"name": "Accept-Language",
@ -1559,7 +1585,7 @@
"schema": {
"type": "string"
},
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)"
},
{
"name": "search",
@ -1661,7 +1687,9 @@
],
"summary": "Get Localized Areas for a Preferred Location (Query Parameter)",
"description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using query parameter routing.",
"security": [],
"security": [
],
"parameters": [
{
"name": "city",
@ -1688,7 +1716,7 @@
"type": "string",
"default": "en"
},
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)"
},
{
"name": "Accept-Language",
@ -1696,7 +1724,7 @@
"schema": {
"type": "string"
},
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)"
},
{
"name": "search",
@ -1797,8 +1825,10 @@
"Worker/Auth"
],
"summary": "Send Mobile OTP Verification Code",
"description": "Dispatches a mock 6-digit OTP verification code ('111111') to the worker's mobile phone number or email address.",
"security": [],
"description": "Dispatches a mock 6-digit OTP verification code (\u0027111111\u0027) to the worker\u0027s mobile phone number or email address.",
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -1854,7 +1884,9 @@
],
"summary": "Verify Mobile OTP Code",
"description": "Validates the received 6-digit OTP code against the cached record. Sets a 1-hour secure gate allowing profile setup.",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -1918,7 +1950,9 @@
],
"summary": "Complete Basic Profile Setup",
"description": "Saves basic onboarding data (Name, Nationality, Language, Skills) and generates the permanent worker account along with a secure bearer access token.",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -2041,7 +2075,7 @@
"Worker/Profile"
],
"summary": "Get Profile details",
"description": "Retrieves the authenticated worker's profile details including their selected job category, connected skills, and uploaded documents.",
"description": "Retrieves the authenticated worker\u0027s profile details including their selected job category, connected skills, and uploaded documents.",
"responses": {
"200": {
"description": "Worker profile details retrieved.",
@ -2174,7 +2208,7 @@
"Worker/Profile"
],
"summary": "Profile Go Live",
"description": "Sets the worker status to 'active' and availability to 'Immediate', making the profile searchable on the sponsor/employer marketplace.",
"description": "Sets the worker status to \u0027active\u0027 and availability to \u0027Immediate\u0027, making the profile searchable on the sponsor/employer marketplace.",
"responses": {
"200": {
"description": "Worker profile is now live and searchable.",
@ -2205,7 +2239,7 @@
"Worker/Profile"
],
"summary": "Toggle Profile Search Visibility",
"description": "Updates worker search visibility based on whether they are still actively seeking a job. 'still_looking' as true makes profile active, false hides it.",
"description": "Updates worker search visibility based on whether they are still actively seeking a job. \u0027still_looking\u0027 as true makes profile active, false hides it.",
"requestBody": {
"required": true,
"content": {
@ -2260,7 +2294,7 @@
"Worker/Profile"
],
"summary": "Mark Worker as Hired",
"description": "Changes the worker's status to 'Hired', which automatically removes them from active marketplace searches.",
"description": "Changes the worker\u0027s status to \u0027Hired\u0027, which automatically removes them from active marketplace searches.",
"responses": {
"200": {
"description": "Worker marked as Hired.",
@ -2329,7 +2363,7 @@
"Worker/Offers"
],
"summary": "Accept or Reject Job Offer",
"description": "Responds to a pending job offer. If accepted, the job offer status becomes 'accepted' and the worker's status is automatically changed to 'Hired', which reflects immediately in the Employer panel.",
"description": "Responds to a pending job offer. If accepted, the job offer status becomes \u0027accepted\u0027 and the worker\u0027s status is automatically changed to \u0027Hired\u0027, which reflects immediately in the Employer panel.",
"parameters": [
{
"name": "id",
@ -2529,7 +2563,7 @@
"others": {
"type": "string",
"example": "Spam and malicious links",
"description": "Custom reason string. Used only if reason is 'Others'."
"description": "Custom reason string. Used only if reason is \u0027Others\u0027."
},
"description": {
"type": "string",
@ -2711,7 +2745,7 @@
"tags": [
"Worker/Support"
],
"summary": "Get Support Ticket Details & Replies (Worker)",
"summary": "Get Support Ticket Details \u0026 Replies (Worker)",
"description": "Retrieves the details of a support ticket and its chronological reply history.",
"parameters": [
{
@ -2782,7 +2816,9 @@
],
"summary": "Register Sponsor (Alias)",
"description": "Legacy route matching the simplified sponsor registration (Name, Email, Phone) (Step 1).",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -2798,17 +2834,17 @@
"name": {
"type": "string",
"example": "Ahmad Bin Ahmed",
"description": "Sponsor's full name"
"description": "Sponsor\u0027s full name"
},
"email": {
"type": "string",
"example": "ahmad@example.com",
"description": "Sponsor's contact email address"
"description": "Sponsor\u0027s contact email address"
},
"phone": {
"type": "string",
"example": "+971509990001",
"description": "Sponsor's mobile phone number"
"description": "Sponsor\u0027s mobile phone number"
},
"emirates_id": {
"type": "object",
@ -2881,7 +2917,9 @@
],
"summary": "Verify Sponsor Email OTP",
"description": "Verifies the email address with the OTP verification code (Step 2).",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -2926,7 +2964,9 @@
],
"summary": "Sponsor Subscription Payment",
"description": "Submits a successful plan selection and PayTabs payment confirmation (Step 4).",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -2975,7 +3015,9 @@
],
"summary": "Create Sponsor Account Password",
"description": "Configures and finalizes the portal login password to complete registration (Step 5). Returns bearer token.",
"security": [],
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -3026,7 +3068,9 @@
],
"summary": "Get Available Subscription Plans",
"description": "Returns details (price, features) of subscription packages available to sponsors.",
"security": [],
"security": [
],
"responses": {
"200": {
"description": "Subscription plans list retrieved successfully."
@ -3040,8 +3084,10 @@
"Employer/Auth"
],
"summary": "Authenticate Employer or Sponsor",
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user's role (employer or sponsor).",
"security": [],
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user\u0027s role (employer or sponsor).",
"security": [
],
"requestBody": {
"required": true,
"content": {
@ -3117,95 +3163,6 @@
}
}
},
"/employers/forgot-password": {
"post": {
"tags": [
"Employer/Auth"
],
"summary": "Request Password Reset Code",
"description": "Validates employer email and sends a 6-digit password reset verification code (OTP).",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset verification code sent successfully."
},
"404": {
"description": "No employer account found with this email."
}
}
}
},
"/employers/reset-password": {
"post": {
"tags": [
"Employer/Auth"
],
"summary": "Reset Password with Verification Code",
"description": "Verifies the 6-digit OTP code and updates the password for both employer user and sponsor records.",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"otp",
"password",
"password_confirmation"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
},
"otp": {
"type": "string",
"example": "111111"
},
"password": {
"type": "string",
"example": "SecureNewPassword@123"
},
"password_confirmation": {
"type": "string",
"example": "SecureNewPassword@123"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset successfully."
},
"400": {
"description": "Invalid or expired verification code."
}
}
}
},
"/employers/conversations": {
"get": {
"tags": [
@ -3604,7 +3561,7 @@
"tags": [
"Employer/Profile"
],
"summary": "Get Employer Dashboard stats & recent events",
"summary": "Get Employer Dashboard stats \u0026 recent events",
"description": "Retrieves stats including contacted workers count, total hired workers, saved candidates, current plan details, and recent announcements or events.",
"responses": {
"200": {
@ -3619,7 +3576,7 @@
"Employer/Profile"
],
"summary": "Get Profile details (Employer)",
"description": "Retrieves the authenticated employer's profile details including their selected company name, phone, language preference, and notification settings.",
"description": "Retrieves the authenticated employer\u0027s profile details including their selected company name, phone, language preference, and notification settings.",
"responses": {
"200": {
"description": "Employer profile details retrieved successfully."
@ -3911,7 +3868,7 @@
},
"photo": {
"type": "string",
"example": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200"
"example": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format\u0026fit=crop\u0026q=80\u0026w=200"
},
"emirates_id_status": {
"type": "string",
@ -4032,7 +3989,7 @@
"Employer/Pipeline"
],
"summary": "Get Candidate Applications and Offers",
"description": "Returns all job applications and direct hiring offers connected to the authenticated sponsor account that have achieved 'Hired' status.",
"description": "Returns all job applications and direct hiring offers connected to the authenticated sponsor account that have achieved \u0027Hired\u0027 status.",
"parameters": [
{
"name": "search",
@ -4176,7 +4133,7 @@
"Employer/Pipeline"
],
"summary": "Mark Candidate as Hired",
"description": "Transitions the target candidate application, job offer, or helper status to 'Hired'.",
"description": "Transitions the target candidate application, job offer, or helper status to \u0027Hired\u0027.",
"parameters": [
{
"name": "id",
@ -4235,7 +4192,7 @@
"Employer/Pipeline"
],
"summary": "Toggle Worker Shortlist Status",
"description": "Toggles (adds or removes) a worker to/from the authenticated sponsor's shortlist.",
"description": "Toggles (adds or removes) a worker to/from the authenticated sponsor\u0027s shortlist.",
"requestBody": {
"required": true,
"content": {
@ -4414,7 +4371,7 @@
"profile_viewed": {
"type": "integer",
"example": 3,
"description": "Unique count of employers who viewed this worker's profile."
"description": "Unique count of employers who viewed this worker\u0027s profile."
},
"currently_working_sponsor": {
"type": "object",
@ -4701,7 +4658,7 @@
"others": {
"type": "string",
"example": "Spam and malicious links",
"description": "Custom reason string. Used only if reason is 'Others'."
"description": "Custom reason string. Used only if reason is \u0027Others\u0027."
},
"description": {
"type": "string",
@ -4883,7 +4840,7 @@
"tags": [
"Employer/Support"
],
"summary": "Get Support Ticket Details & Replies (Employer)",
"summary": "Get Support Ticket Details \u0026 Replies (Employer)",
"description": "Retrieves the details of a support ticket and its chronological reply history.",
"parameters": [
{
@ -4946,6 +4903,305 @@
}
}
}
},
"/workers/forgot-password": {
"post": {
"tags": [
"Workers - Auth"
],
"summary": "Forgot Password (Send OTP)",
"description": "Sends a 6-digit OTP to verify the worker\u0027s identity using their registered mobile number. Workers do not use email — mobile/phone is the primary identifier. OTP is valid for 10 minutes.",
"operationId": "workerForgotPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"phone"
],
"properties": {
"phone": {
"type": "string",
"example": "+971501234567",
"description": "Worker\u0027s registered mobile number"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OTP sent successfully (response is identical whether account exists or not, to prevent enumeration)",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "If an account with that mobile number exists, a reset OTP has been sent."
}
}
}
}
}
},
"422": {
"description": "Validation error — phone is required"
}
}
}
},
"/workers/reset-password": {
"post": {
"tags": [
"Workers - Auth"
],
"summary": "Reset Password (Verify OTP)",
"description": "Verifies the OTP sent to the worker\u0027s mobile number and sets a new password. The phone number must match the one used in the forgot-password step.",
"operationId": "workerResetPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"phone",
"otp",
"password",
"password_confirmation"
],
"properties": {
"phone": {
"type": "string",
"example": "+971501234567",
"description": "Worker\u0027s registered mobile number"
},
"otp": {
"type": "string",
"example": "482910",
"description": "6-digit OTP received"
},
"password": {
"type": "string",
"example": "newpassword123",
"description": "New password (min 8 chars)"
},
"password_confirmation": {
"type": "string",
"example": "newpassword123",
"description": "Must match password"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset successfully"
},
"422": {
"description": "Invalid OTP, expired OTP, or validation error"
},
"404": {
"description": "Account not found"
}
}
}
},
"/employers/forgot-password": {
"post": {
"tags": [
"Employers - Auth"
],
"summary": "Forgot Password (Send OTP)",
"description": "Sends a 6-digit OTP to the employer\u0027s registered email address. OTP expires in 10 minutes.",
"operationId": "employerForgotPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"format": "email",
"example": "employer@example.com"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OTP sent (response identical whether account exists or not)"
},
"422": {
"description": "Validation error"
}
}
}
},
"/employers/reset-password": {
"post": {
"tags": [
"Employers - Auth"
],
"summary": "Reset Password (Verify OTP)",
"description": "Verifies the OTP sent to the employer\u0027s email and sets a new password.",
"operationId": "employerResetPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"otp",
"password",
"password_confirmation"
],
"properties": {
"email": {
"type": "string",
"format": "email",
"example": "employer@example.com"
},
"otp": {
"type": "string",
"example": "482910"
},
"password": {
"type": "string",
"example": "newpassword123"
},
"password_confirmation": {
"type": "string",
"example": "newpassword123"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset successfully"
},
"422": {
"description": "Invalid or expired OTP"
}
}
}
},
"/sponsors/forgot-password": {
"post": {
"tags": [
"Sponsors - Auth"
],
"summary": "Forgot Password (Send OTP)",
"description": "Sends a 6-digit OTP to the sponsor\u0027s registered email. Accepts email or mobile number as identifier. OTP expires in 10 minutes.",
"operationId": "sponsorForgotPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"example": "sponsor@example.com",
"description": "Email address (either email or mobile required)"
},
"mobile": {
"type": "string",
"example": "+971501234567",
"description": "Mobile number (either email or mobile required)"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OTP sent (response identical whether account exists or not)"
},
"422": {
"description": "Validation error — email or mobile required"
}
}
}
},
"/sponsors/reset-password": {
"post": {
"tags": [
"Sponsors - Auth"
],
"summary": "Reset Password (Verify OTP)",
"description": "Verifies the OTP sent to the sponsor\u0027s email and sets a new password. Use the email field to identify the account.",
"operationId": "sponsorResetPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"otp",
"password",
"password_confirmation"
],
"properties": {
"email": {
"type": "string",
"format": "email",
"example": "sponsor@example.com"
},
"otp": {
"type": "string",
"example": "482910"
},
"password": {
"type": "string",
"example": "newpassword123"
},
"password_confirmation": {
"type": "string",
"example": "newpassword123"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset successfully"
},
"422": {
"description": "Invalid or expired OTP"
}
}
}
}
},
"components": {

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,17 +362,24 @@ 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>
<div className="flex">
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
hasError={!!errors.phone}
/>
<input
type="tel"
value={data.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="e.g. 501234567"
className={`w-full px-3.5 py-3 rounded-xl border text-sm focus:outline-none focus:ring-2 transition-all ${
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}
@ -257,6 +387,27 @@ export default function Register() {
)}
</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.address
? 'border-red-500 focus:ring-red-500/20 focus:border-red-500'
: 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]'
}`}
/>
{errors.address && (
<p className="mt-1.5 text-xs text-red-500 font-medium">
{Array.isArray(errors.address) ? errors.address[0] : errors.address}
</p>
)}
</div>
<button
type="submit"

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 () {
// 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,10 +257,17 @@
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');
// 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::get('/employer/register', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegister'])->name('employer.register');
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::post('/employer/verify-email', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'verifyEmail'])->name('employer.verify-email.submit')->middleware('throttle:5,1');
@ -251,9 +282,6 @@
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');

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/**'],