forgot and reset password
This commit is contained in:
parent
121337eea7
commit
ed7f2f8f4e
@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
34
app/Http/Middleware/EmployerGuestMiddleware.php
Normal file
34
app/Http/Middleware/EmployerGuestMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Mail/ForgotPasswordOtpMail.php
Normal file
39
app/Mail/ForgotPasswordOtpMail.php
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,7 @@
|
||||
$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.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
|
||||
49
resources/views/emails/forgot-password-otp.blade.php
Normal file
49
resources/views/emails/forgot-password-otp.blade.php
Normal 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>
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
// 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::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::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::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');
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/**'],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user