DONE: phone number verification, admin can add user employment; WIP: wire with onewaysms

This commit is contained in:
ISMAIL MASSERAN
2026-07-12 12:36:46 +08:00
parent 431bbee17c
commit a11b75729a
37 changed files with 2214 additions and 73 deletions
+12
View File
@@ -89,5 +89,17 @@ ACTIVE_ROLE_PREFER_MEMBER=true
ACTIVE_ROLE_MEMBER_REDIRECT=/profile
ACTIVE_ROLE_ADMIN_REDIRECT=/profile
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
PHONE_VERIFICATION_OTP_EXPIRY_MINUTES=10
PHONE_VERIFICATION_TOKEN_EXPIRY_MINUTES=30
PHONE_VERIFICATION_OTP_MAX_ATTEMPTS=5
SMS_DRIVER=log # onewaysms
ONEWAYSMS_BASE_URL=http://gateway.onewaysms.com.my:10001/api.aspx
ONEWAYSMS_API_USERNAME=
ONEWAYSMS_API_PASSWORD=
ONEWAYSMS_SENDER_ID=
@@ -10,6 +10,7 @@ use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Modules\Auth\Entities\User;
use Modules\Auth\Services\PhoneVerificationOtpService;
use Modules\Role\Entities\Role;
use Modules\User\Notifications\UserActivationNotification;
use Exception;
@@ -18,6 +19,10 @@ class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, NotifiesAdmins;
public function __construct(
protected PhoneVerificationOtpService $phoneVerificationOtpService,
) {}
/**
* Validate and create a newly registered user.
*
@@ -25,7 +30,9 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
Validator::make($input, [
$phoneNumber = $this->phoneVerificationOtpService->normalizePhoneNumber($input['phone_number'] ?? '');
Validator::make(array_merge($input, ['phone_number' => $phoneNumber]), [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
@@ -35,15 +42,32 @@ class CreateNewUser implements CreatesNewUsers
Rule::unique(User::class),
],
'ic_number' => ['required', 'string', 'max:255'],
'phone_number' => [
...$this->phoneVerificationOtpService->phoneNumberRules(),
Rule::unique(User::class),
],
'phone_verification_token' => ['required', 'string', 'size:64'],
'password' => ['required', 'string', 'min:8'],
], [
'phone_number.required' => 'Nombor telefon diperlukan.',
'phone_number.regex' => 'Format nombor telefon tidak sah.',
'phone_number.unique' => 'Nombor telefon ini telah didaftarkan.',
'phone_verification_token.required' => 'Pengesahan nombor telefon diperlukan.',
])->validate();
$this->phoneVerificationOtpService->consumeRegistrationToken(
$phoneNumber,
$input['phone_verification_token'],
);
$user = User::create([
'name' => $input['name'],
'uuid' => Str::uuid(),
'email' => $input['email'],
'password' => Hash::make($input['password']),
'ic_number' => $input['ic_number'],
'phone_number' => $phoneNumber,
'phone_verified_at' => now(),
'status' => 'pending',
]);
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace Modules\Auth\Contracts;
interface SmsSender
{
public function send(string $phoneNumber, string $message): void;
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Auth\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class PhoneVerificationOtp extends Model
{
use HasUuids;
protected $table = 'phone_verification_otps';
protected $fillable = [
'phone_number',
'code',
'expires_at',
'attempts',
'verified_at',
'verification_token',
'verification_token_expires_at',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'attempts' => 'integer',
'verified_at' => 'datetime',
'verification_token_expires_at' => 'datetime',
];
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function hasExceededMaxAttempts(): bool
{
return $this->attempts >= (int) config('auth.phone_verification.max_attempts', 5);
}
public function isVerificationTokenExpired(): bool
{
return $this->verification_token_expires_at === null
|| $this->verification_token_expires_at->isPast();
}
}
+2
View File
@@ -45,6 +45,7 @@ class User extends Authenticatable implements MustVerifyEmail
'ic_number',
'position',
'phone_number',
'phone_verified_at',
'image_url',
'status',
'two_factor_secret',
@@ -131,6 +132,7 @@ class User extends Authenticatable implements MustVerifyEmail
{
return [
'email_verified_at' => 'datetime',
'phone_verified_at' => 'datetime',
'password' => 'hashed',
'status' => 'string',
'gender' => 'string',
@@ -0,0 +1,109 @@
<?php
namespace Modules\Auth\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Auth\Services\PhoneVerificationOtpService;
use Modules\Auth\Transformers\UserResource;
class PhoneVerificationController extends Controller
{
public function __construct(
protected PhoneVerificationOtpService $phoneVerificationOtpService,
) {}
public function send(Request $request): JsonResponse
{
$validated = $request->validate([
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
], [
'phone_number.required' => 'Nombor telefon diperlukan.',
'phone_number.regex' => 'Format nombor telefon tidak sah.',
]);
$this->phoneVerificationOtpService->send($validated['phone_number']);
return response()->json([
'success' => true,
'message' => 'Kod OTP telah dihantar ke nombor telefon anda.',
]);
}
public function verify(Request $request): JsonResponse
{
$validated = $request->validate([
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
'otp' => ['required', 'string', 'digits:6'],
], [
'phone_number.required' => 'Nombor telefon diperlukan.',
'phone_number.regex' => 'Format nombor telefon tidak sah.',
'otp.required' => 'Kod OTP diperlukan.',
'otp.digits' => 'Kod OTP mestilah 6 digit.',
]);
$result = $this->phoneVerificationOtpService->verify(
$validated['phone_number'],
$validated['otp'],
);
return response()->json([
'success' => true,
'message' => 'Nombor telefon berjaya disahkan.',
'data' => [
'phone_number' => $result['phone_number'],
'verification_token' => $result['verification_token'],
],
]);
}
public function sendForAuthenticatedUser(Request $request): JsonResponse
{
$user = $request->user();
$validated = $request->validate([
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
], [
'phone_number.required' => 'Nombor telefon diperlukan.',
'phone_number.regex' => 'Format nombor telefon tidak sah.',
]);
$this->phoneVerificationOtpService->sendForUser($user, $validated['phone_number']);
return response()->json([
'success' => true,
'message' => 'Kod OTP telah dihantar ke nombor telefon anda.',
]);
}
public function verifyForAuthenticatedUser(Request $request): JsonResponse
{
$user = $request->user();
$wasVerified = (bool) $user->phone_verified_at;
$validated = $request->validate([
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
'otp' => ['required', 'string', 'digits:6'],
], [
'phone_number.required' => 'Nombor telefon diperlukan.',
'phone_number.regex' => 'Format nombor telefon tidak sah.',
'otp.required' => 'Kod OTP diperlukan.',
'otp.digits' => 'Kod OTP mestilah 6 digit.',
]);
$verifiedUser = $this->phoneVerificationOtpService->verifyForUser(
$user,
$validated['phone_number'],
$validated['otp'],
);
return response()->json([
'success' => true,
'message' => $wasVerified
? 'Nombor telefon berjaya dikemas kini.'
: 'Nombor telefon berjaya disahkan.',
'data' => new UserResource($verifiedUser),
]);
}
}
@@ -9,6 +9,9 @@ use Modules\Auth\Actions\Fortify\CreateNewUser;
use Modules\Auth\Actions\Fortify\ResetUserPassword;
use Modules\Auth\Actions\Fortify\UpdateUserPassword;
use Modules\Auth\Actions\Fortify\UpdateUserProfileInformation;
use Modules\Auth\Contracts\SmsSender;
use Modules\Auth\Services\LogSmsSender;
use Modules\Auth\Services\OneWaySmsSender;
use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
@@ -42,6 +45,13 @@ class AuthServiceProvider extends ServiceProvider
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
$this->app->bind(SmsSender::class, function () {
return match (config('onewaysms.driver')) {
'log' => $this->app->make(LogSmsSender::class),
default => $this->app->make(OneWaySmsSender::class),
};
});
}
/**
+12
View File
@@ -3,11 +3,17 @@
use Illuminate\Support\Facades\Route;
use Modules\Auth\Http\Controllers\EmailVerificationController;
use Modules\Auth\Http\Controllers\PasswordResetController;
use Modules\Auth\Http\Controllers\PhoneVerificationController;
use Modules\Auth\Http\Controllers\SessionController;
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
// Auth
Route::post('/phone-verification/send', [PhoneVerificationController::class, 'send'])
->middleware('throttle:phone-verification-send');
Route::post('/phone-verification/verify', [PhoneVerificationController::class, 'verify'])
->middleware('throttle:phone-verification-verify');
Route::post('/register', [RegisteredUserController::class, 'store']);
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
@@ -33,4 +39,10 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// optional email verification (post-login)
Route::post('/email/verification-notification', [EmailVerificationController::class, 'send'])
->middleware('throttle:email-verification-resend');
// optional phone verification (post-login)
Route::post('/phone-verification/send', [PhoneVerificationController::class, 'sendForAuthenticatedUser'])
->middleware('throttle:phone-verification-send');
Route::post('/phone-verification/verify', [PhoneVerificationController::class, 'verifyForAuthenticatedUser'])
->middleware('throttle:phone-verification-verify');
});
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Modules\Auth\Services;
use Illuminate\Support\Facades\Log;
use Modules\Auth\Contracts\SmsSender;
class LogSmsSender implements SmsSender
{
public function send(string $phoneNumber, string $message): void
{
Log::info('SMS sent (log driver)', [
'phone_number' => $phoneNumber,
'message' => $message,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace Modules\Auth\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Modules\Auth\Contracts\SmsSender;
use RuntimeException;
class OneWaySmsSender implements SmsSender
{
public function send(string $phoneNumber, string $message): void
{
$baseUrl = (string) config('onewaysms.base_url');
$username = (string) config('onewaysms.api_username');
$password = (string) config('onewaysms.api_password');
$senderId = (string) config('onewaysms.sender_id');
if ($baseUrl === '' || $username === '' || $password === '' || $senderId === '') {
throw new RuntimeException('OneWaySMS credentials are not configured.');
}
$mobileNo = $this->toInternationalMobileNumber($phoneNumber);
$response = Http::timeout((int) config('http.timeout', 30))
->connectTimeout((int) config('http.connect_timeout', 10))
->get($baseUrl, [
'apiusername' => $username,
'apipassword' => $password,
'senderid' => $senderId,
'mobileno' => $mobileNo,
'message' => $message,
'languagetype' => 1,
]);
if (! $response->successful()) {
Log::error('OneWaySMS HTTP request failed', [
'status' => $response->status(),
'body' => $response->body(),
'phone_number' => $mobileNo,
]);
throw new RuntimeException('Failed to send SMS via OneWaySMS.');
}
$mtId = trim($response->body());
// Positive MT ID = success; zero/negative = gateway error codes.
if (! is_numeric($mtId) || (int) $mtId <= 0) {
Log::error('OneWaySMS gateway rejected SMS', [
'mt_id' => $mtId,
'phone_number' => $mobileNo,
]);
throw new RuntimeException('OneWaySMS gateway rejected the SMS request.');
}
Log::info('OneWaySMS sent successfully', [
'mt_id' => $mtId,
'phone_number' => $mobileNo,
]);
}
protected function toInternationalMobileNumber(string $phoneNumber): string
{
$phoneNumber = preg_replace('/[\s\-]/', '', trim($phoneNumber)) ?? '';
if (str_starts_with($phoneNumber, '+')) {
$phoneNumber = substr($phoneNumber, 1);
}
if (str_starts_with($phoneNumber, '0')) {
$phoneNumber = '60'.substr($phoneNumber, 1);
}
return $phoneNumber;
}
}
@@ -0,0 +1,241 @@
<?php
namespace Modules\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Contracts\SmsSender;
use Modules\Auth\Entities\PhoneVerificationOtp;
use Modules\Auth\Entities\User;
class PhoneVerificationOtpService
{
public function __construct(
protected SmsSender $smsSender,
) {}
public function send(string $phoneNumber): void
{
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
if ($this->phoneNumberBelongsToAnotherUser($phoneNumber)) {
throw ValidationException::withMessages([
'phone_number' => ['Nombor telefon ini telah didaftarkan.'],
]);
}
$this->dispatchOtp($phoneNumber);
}
public function sendForUser(User $user, string $phoneNumber): void
{
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
$this->assertPhoneNumberAvailableForUser($user, $phoneNumber);
if ($user->phone_verified_at && $user->phone_number === $phoneNumber) {
throw ValidationException::withMessages([
'phone_number' => ['Nombor telefon ini sama dengan nombor sedia ada.'],
]);
}
$this->dispatchOtp($phoneNumber);
}
public function verifyForUser(User $user, string $phoneNumber, string $otp): User
{
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
$this->assertPhoneNumberAvailableForUser($user, $phoneNumber);
$record = $this->validateOtpRecord($phoneNumber, $otp);
$record->delete();
$user->forceFill([
'phone_number' => $phoneNumber,
'phone_verified_at' => now(),
])->save();
return $user->fresh();
}
/**
* @return array{verification_token: string, phone_number: string}
*/
public function verify(string $phoneNumber, string $otp): array
{
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
$record = $this->validateOtpRecord($phoneNumber, $otp);
$verificationToken = Str::random(64);
$record->forceFill([
'verified_at' => now(),
'verification_token' => Hash::make($verificationToken),
'verification_token_expires_at' => now()->addMinutes($this->tokenExpiryMinutes()),
])->save();
return [
'verification_token' => $verificationToken,
'phone_number' => $phoneNumber,
];
}
public function consumeRegistrationToken(string $phoneNumber, string $verificationToken): void
{
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
$record = PhoneVerificationOtp::query()
->where('phone_number', $phoneNumber)
->whereNotNull('verified_at')
->latest()
->first();
if (! $record || $record->isVerificationTokenExpired()) {
throw ValidationException::withMessages([
'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'],
]);
}
if (! Hash::check($verificationToken, (string) $record->verification_token)) {
throw ValidationException::withMessages([
'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'],
]);
}
$record->delete();
}
protected function dispatchOtp(string $phoneNumber): void
{
$otp = $this->generateOtp();
PhoneVerificationOtp::query()
->where('phone_number', $phoneNumber)
->delete();
PhoneVerificationOtp::create([
'phone_number' => $phoneNumber,
'code' => Hash::make($otp),
'expires_at' => now()->addMinutes($this->otpExpiryMinutes()),
'attempts' => 0,
]);
$this->smsSender->send(
$phoneNumber,
$this->buildOtpMessage($otp),
);
}
protected function validateOtpRecord(string $phoneNumber, string $otp): PhoneVerificationOtp
{
$record = PhoneVerificationOtp::query()
->where('phone_number', $phoneNumber)
->latest()
->first();
if (! $record) {
throw ValidationException::withMessages([
'otp' => ['Kod OTP tidak dijumpai. Sila minta kod baharu.'],
]);
}
if ($record->isExpired()) {
$record->delete();
throw ValidationException::withMessages([
'otp' => ['Kod OTP telah tamat tempoh. Sila minta kod baharu.'],
]);
}
if ($record->hasExceededMaxAttempts()) {
$record->delete();
throw ValidationException::withMessages([
'otp' => ['Terlalu banyak percubaan. Sila minta kod baharu.'],
]);
}
if (! Hash::check($otp, $record->code)) {
$record->increment('attempts');
if ($record->fresh()->hasExceededMaxAttempts()) {
$record->delete();
}
throw ValidationException::withMessages([
'otp' => ['Kod OTP tidak sah.'],
]);
}
return $record;
}
protected function assertPhoneNumberAvailableForUser(User $user, string $phoneNumber): void
{
if ($this->phoneNumberBelongsToAnotherUser($phoneNumber, $user->id)) {
throw ValidationException::withMessages([
'phone_number' => ['Nombor telefon ini telah digunakan oleh akaun lain.'],
]);
}
}
protected function phoneNumberBelongsToAnotherUser(string $phoneNumber, ?string $exceptUserId = null): bool
{
return User::query()
->where('phone_number', $phoneNumber)
->when($exceptUserId, fn ($query) => $query->where('id', '!=', $exceptUserId))
->exists();
}
public function normalizePhoneNumber(string $phoneNumber): string
{
$phoneNumber = preg_replace('/[\s\-]/', '', trim($phoneNumber)) ?? '';
if (str_starts_with($phoneNumber, '+60')) {
$phoneNumber = '0'.substr($phoneNumber, 3);
} elseif (str_starts_with($phoneNumber, '60') && strlen($phoneNumber) > 10) {
$phoneNumber = '0'.substr($phoneNumber, 2);
}
return $phoneNumber;
}
public function phoneNumberRules(): array
{
return [
'required',
'string',
'max:20',
'regex:/^(\+?60|0)1[0-9]{8,9}$/',
];
}
protected function generateOtp(): string
{
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
protected function buildOtpMessage(string $otp): string
{
$template = (string) config('auth.phone_verification.message');
return str_replace(
[':otp', ':minutes'],
[$otp, (string) $this->otpExpiryMinutes()],
$template,
);
}
protected function otpExpiryMinutes(): int
{
return (int) config('auth.phone_verification.otp_expiry_minutes', 10);
}
protected function tokenExpiryMinutes(): int
{
return (int) config('auth.phone_verification.token_expiry_minutes', 30);
}
}
@@ -26,6 +26,7 @@ class UserResource extends JsonResource
'ic_number' => $this->ic_number,
'position' => $this->position,
'phone_number' => $this->phone_number,
'phone_verified_at' => $this->phone_verified_at,
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
'status' => $this->status,
'two_factor_secret' => $this->two_factor_secret,
@@ -195,6 +195,7 @@ class UserController extends BaseCrudController
'join_date_to' => 'nullable|date',
'leave_date_from' => 'nullable|date',
'leave_date_to' => 'nullable|date',
'company_name' => 'nullable|string|max:255',
]);
$perPage = min((int) $request->get('per_page', 10), 500);
@@ -203,6 +204,7 @@ class UserController extends BaseCrudController
'join_date_to' => $request->get('join_date_to'),
'leave_date_from' => $request->get('leave_date_from'),
'leave_date_to' => $request->get('leave_date_to'),
'company_name' => $request->get('company_name'),
]);
$items = $this->userService->getPaginatedList(
$perPage,
@@ -279,6 +281,7 @@ class UserController extends BaseCrudController
'join_date_to' => 'nullable|date',
'leave_date_from' => 'nullable|date',
'leave_date_to' => 'nullable|date',
'company_name' => 'nullable|string|max:255',
]);
$dateFilters = array_filter([
@@ -286,6 +289,7 @@ class UserController extends BaseCrudController
'join_date_to' => $request->get('join_date_to'),
'leave_date_from' => $request->get('leave_date_from'),
'leave_date_to' => $request->get('leave_date_to'),
'company_name' => $request->get('company_name'),
]);
$stats = $this->userService->getListStats(
@@ -0,0 +1,144 @@
<?php
namespace Modules\User\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Services\ActivityLogger;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Modules\Auth\Entities\User;
use Modules\User\Entities\Employment;
use Modules\User\Http\Requests\EmploymentRequest;
use Modules\User\Transformers\EmploymentResource;
class UserEmploymentController extends Controller
{
use AuthorizesRequests;
public function store(EmploymentRequest $request, string $user): JsonResponse
{
$this->authorize('update', User::class);
try {
$targetUser = User::query()->find($user);
if (! $targetUser) {
return response()->json([
'success' => false,
'message' => 'User not found.',
], 404);
}
$employment = $targetUser->employments()->create($request->validated());
ActivityLogger::log(
"Created employment for user {$targetUser->name}: {$employment->company_name}",
$employment
);
return response()->json([
'success' => true,
'data' => new EmploymentResource($employment),
'message' => 'Employment created successfully.',
], 201);
} catch (Exception $e) {
Log::error('Error creating user employment: '.$e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to create employment.',
], 500);
}
}
public function update(EmploymentRequest $request, string $user, string $employment): JsonResponse
{
$this->authorize('update', User::class);
try {
$targetUser = User::query()->find($user);
if (! $targetUser) {
return response()->json([
'success' => false,
'message' => 'User not found.',
], 404);
}
$employmentModel = $targetUser->employments()->find($employment);
if (! $employmentModel) {
return response()->json([
'success' => false,
'message' => 'Employment not found.',
], 404);
}
$employmentModel->update($request->validated());
ActivityLogger::log(
"Updated employment for user {$targetUser->name}: {$employmentModel->company_name}",
$employmentModel
);
return response()->json([
'success' => true,
'data' => new EmploymentResource($employmentModel->fresh()),
'message' => 'Employment updated successfully.',
]);
} catch (Exception $e) {
Log::error('Error updating user employment: '.$e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to update employment.',
], 500);
}
}
public function destroy(string $user, string $employment): JsonResponse
{
$this->authorize('update', User::class);
try {
$targetUser = User::query()->find($user);
if (! $targetUser) {
return response()->json([
'success' => false,
'message' => 'User not found.',
], 404);
}
$employmentModel = $targetUser->employments()->find($employment);
if (! $employmentModel) {
return response()->json([
'success' => false,
'message' => 'Employment not found.',
], 404);
}
$companyName = $employmentModel->company_name;
$employmentModel->delete();
ActivityLogger::log(
"Deleted employment for user {$targetUser->name}: {$companyName}",
$targetUser
);
return response()->json([
'success' => true,
'message' => 'Employment deleted successfully.',
]);
} catch (Exception $e) {
Log::error('Error deleting user employment: '.$e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to delete employment.',
], 500);
}
}
}
@@ -46,6 +46,31 @@ class UserRepository implements UserRepositoryInterface
}
}
private function applyCompanyNameFilter($query, string $companyName): void
{
if ($companyName === '') {
return;
}
$query->whereHas('employments', function ($employmentQuery) use ($companyName) {
$employmentQuery
->where('company_name', $companyName)
->whereRaw('employments.id = (
SELECT e2.id
FROM employments e2
WHERE e2.user_id = employments.user_id
ORDER BY e2.is_current DESC, e2.start_date DESC
LIMIT 1
)');
});
}
private function applyListFilters($query, array $filters): void
{
$this->applyDateRangeFilters($query, $filters);
$this->applyCompanyNameFilter($query, $filters['company_name'] ?? '');
}
/**
* Get all Users with pagination and search
*/
@@ -107,7 +132,7 @@ class UserRepository implements UserRepositoryInterface
$query->where('status', $status);
}
$this->applyDateRangeFilters($query, $dateFilters);
$this->applyListFilters($query, $dateFilters);
return $query->paginate($perPage);
}
@@ -177,7 +202,7 @@ class UserRepository implements UserRepositoryInterface
$baseQuery->where('status', $status);
}
$this->applyDateRangeFilters($baseQuery, $dateFilters);
$this->applyListFilters($baseQuery, $dateFilters);
$total = (int) (clone $baseQuery)->count();
+4
View File
@@ -5,6 +5,7 @@ use Modules\User\Http\Controllers\UserController;
use App\Http\Controllers\ImpersonateController;
use Modules\User\Http\Controllers\AddressController;
use Modules\User\Http\Controllers\EmploymentController;
use Modules\User\Http\Controllers\UserEmploymentController;
use Modules\User\Http\Controllers\BankController;
use Modules\User\Http\Controllers\BankDetailController;
use Modules\User\Http\Controllers\HeirController;
@@ -33,6 +34,9 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
Route::apiResource('heirs', HeirController::class)->names('heir');
// User role management routes
Route::post('users/{user}/roles', [UserController::class, 'assignRoles'])->name('users.roles.assign');
Route::post('users/{user}/employments', [UserEmploymentController::class, 'store'])->name('users.employments.store');
Route::put('users/{user}/employments/{employment}', [UserEmploymentController::class, 'update'])->name('users.employments.update');
Route::delete('users/{user}/employments/{employment}', [UserEmploymentController::class, 'destroy'])->name('users.employments.destroy');
// User profile management routes
Route::post('/profile', [UserController::class, 'updateProfile']);
@@ -80,6 +80,18 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('phone-verification-send', function (Request $request) {
$throttleKey = Str::transliterate($request->input('phone_number', '').'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('phone-verification-verify', function (Request $request) {
$throttleKey = Str::transliterate($request->input('phone_number', '').'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first();
+13
View File
@@ -133,4 +133,17 @@ return [
'max_attempts' => (int) env('PASSWORD_RESET_OTP_MAX_ATTEMPTS', 5),
],
/*
|--------------------------------------------------------------------------
| Phone Verification (Registration)
|--------------------------------------------------------------------------
*/
'phone_verification' => [
'otp_expiry_minutes' => (int) env('PHONE_VERIFICATION_OTP_EXPIRY_MINUTES', 10),
'token_expiry_minutes' => (int) env('PHONE_VERIFICATION_TOKEN_EXPIRY_MINUTES', 30),
'max_attempts' => (int) env('PHONE_VERIFICATION_OTP_MAX_ATTEMPTS', 5),
'message' => 'Kod OTP MyKOPKB anda: :otp. Kod ini tamat tempoh dalam :minutes minit.',
],
];
+13
View File
@@ -0,0 +1,13 @@
<?php
return [
/*
| Supported: "onewaysms", "log"
*/
'driver' => env('SMS_DRIVER', 'onewaysms'),
'base_url' => env('ONEWAYSMS_BASE_URL'),
'api_username' => env('ONEWAYSMS_API_USERNAME'),
'api_password' => env('ONEWAYSMS_API_PASSWORD'),
'sender_id' => env('ONEWAYSMS_SENDER_ID'),
];
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('phone_verification_otps', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('phone_number');
$table->string('code');
$table->timestamp('expires_at');
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamp('verified_at')->nullable();
$table->string('verification_token')->nullable();
$table->timestamp('verification_token_expires_at')->nullable();
$table->timestamps();
$table->index(['phone_number', 'expires_at']);
});
}
public function down(): void
{
Schema::dropIfExists('phone_verification_otps');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('phone_verified_at')->nullable()->after('phone_number');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('phone_verified_at');
});
}
};