From a11b75729a38bb55bca3ea8d7b8540aacbb01d13 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Sun, 12 Jul 2026 12:36:46 +0800 Subject: [PATCH] DONE: phone number verification, admin can add user employment; WIP: wire with onewaysms --- TODO.md | 4 +- be/.env.example | 12 + .../Auth/Actions/Fortify/CreateNewUser.php | 26 +- be/Modules/Auth/Contracts/SmsSender.php | 8 + .../Auth/Entities/PhoneVerificationOtp.php | 49 ++ be/Modules/Auth/Entities/User.php | 2 + .../PhoneVerificationController.php | 109 +++ .../Auth/Providers/AuthServiceProvider.php | 10 + be/Modules/Auth/Routes/api.php | 12 + be/Modules/Auth/Services/LogSmsSender.php | 17 + be/Modules/Auth/Services/OneWaySmsSender.php | 78 +++ .../Services/PhoneVerificationOtpService.php | 241 +++++++ be/Modules/Auth/Transformers/UserResource.php | 1 + .../User/Http/Controllers/UserController.php | 4 + .../Controllers/UserEmploymentController.php | 144 ++++ .../User/Repositories/UserRepository.php | 29 +- be/Modules/User/Routes/api.php | 4 + be/app/Providers/FortifyServiceProvider.php | 12 + be/config/auth.php | 13 + be/config/onewaysms.php | 13 + ...0_create_phone_verification_otps_table.php | 30 + ...1_add_phone_verified_at_to_users_table.php | 22 + fe/src/constants/employers.ts | 24 + fe/src/modules/auth/index.ts | 11 + fe/src/modules/auth/pages/Register.vue | 379 +++++++++-- fe/src/modules/auth/services/auth.service.ts | 53 ++ fe/src/modules/auth/types/auth.types.ts | 32 + .../components/PhoneVerificationSection.vue | 184 ++++++ .../modules/profile/pages/EmploymentTab.vue | 6 +- fe/src/modules/profile/pages/ProfileTab.vue | 11 +- .../modules/user/composables/useUserList.ts | 9 + fe/src/modules/user/pages/UserCreate.vue | 27 +- fe/src/modules/user/pages/UserEdit.vue | 618 +++++++++++++++++- fe/src/modules/user/pages/UserList.vue | 43 ++ .../user/services/userEmployment.service.ts | 47 ++ fe/src/modules/user/types/user.types.ts | 2 + fe/src/stores/auth.ts | 1 + 37 files changed, 2214 insertions(+), 73 deletions(-) create mode 100644 be/Modules/Auth/Contracts/SmsSender.php create mode 100644 be/Modules/Auth/Entities/PhoneVerificationOtp.php create mode 100644 be/Modules/Auth/Http/Controllers/PhoneVerificationController.php create mode 100644 be/Modules/Auth/Services/LogSmsSender.php create mode 100644 be/Modules/Auth/Services/OneWaySmsSender.php create mode 100644 be/Modules/Auth/Services/PhoneVerificationOtpService.php create mode 100644 be/Modules/User/Http/Controllers/UserEmploymentController.php create mode 100644 be/config/onewaysms.php create mode 100644 be/database/migrations/2026_07_12_100000_create_phone_verification_otps_table.php create mode 100644 be/database/migrations/2026_07_12_100001_add_phone_verified_at_to_users_table.php create mode 100644 fe/src/constants/employers.ts create mode 100644 fe/src/modules/profile/components/PhoneVerificationSection.vue create mode 100644 fe/src/modules/user/services/userEmployment.service.ts diff --git a/TODO.md b/TODO.md index 8b98b0f..ac60c42 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ [ ] sumbangan [x] wasi/penama [ ] pendaftaran anggota/meneruskan anggota/pencen -[ ] daftar lembaga (backdated) +[x] daftar lembaga (backdated) [x] boleh print semua borang [x] jana surat lepas lulus anggota @@ -29,4 +29,4 @@ [ ] syer maksima silap tukar jadi rm50.00 [x] letak sign digital [x] running no anggota dalam surat dan masa. -[ ] tukar logo di surat offer \ No newline at end of file +[x] tukar logo di surat offer \ No newline at end of file diff --git a/be/.env.example b/be/.env.example index 6b7e521..d052703 100644 --- a/be/.env.example +++ b/be/.env.example @@ -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= diff --git a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php index 543bf93..fb27230 100644 --- a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php +++ b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php @@ -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', ]); diff --git a/be/Modules/Auth/Contracts/SmsSender.php b/be/Modules/Auth/Contracts/SmsSender.php new file mode 100644 index 0000000..dbd69c6 --- /dev/null +++ b/be/Modules/Auth/Contracts/SmsSender.php @@ -0,0 +1,8 @@ + '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(); + } +} diff --git a/be/Modules/Auth/Entities/User.php b/be/Modules/Auth/Entities/User.php index 8819330..d77d143 100644 --- a/be/Modules/Auth/Entities/User.php +++ b/be/Modules/Auth/Entities/User.php @@ -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', diff --git a/be/Modules/Auth/Http/Controllers/PhoneVerificationController.php b/be/Modules/Auth/Http/Controllers/PhoneVerificationController.php new file mode 100644 index 0000000..01234ed --- /dev/null +++ b/be/Modules/Auth/Http/Controllers/PhoneVerificationController.php @@ -0,0 +1,109 @@ +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), + ]); + } +} diff --git a/be/Modules/Auth/Providers/AuthServiceProvider.php b/be/Modules/Auth/Providers/AuthServiceProvider.php index 7cce36f..8342818 100644 --- a/be/Modules/Auth/Providers/AuthServiceProvider.php +++ b/be/Modules/Auth/Providers/AuthServiceProvider.php @@ -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), + }; + }); } /** diff --git a/be/Modules/Auth/Routes/api.php b/be/Modules/Auth/Routes/api.php index 9a695f6..aed17e3 100644 --- a/be/Modules/Auth/Routes/api.php +++ b/be/Modules/Auth/Routes/api.php @@ -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'); }); \ No newline at end of file diff --git a/be/Modules/Auth/Services/LogSmsSender.php b/be/Modules/Auth/Services/LogSmsSender.php new file mode 100644 index 0000000..28f8e3e --- /dev/null +++ b/be/Modules/Auth/Services/LogSmsSender.php @@ -0,0 +1,17 @@ + $phoneNumber, + 'message' => $message, + ]); + } +} diff --git a/be/Modules/Auth/Services/OneWaySmsSender.php b/be/Modules/Auth/Services/OneWaySmsSender.php new file mode 100644 index 0000000..826fc6b --- /dev/null +++ b/be/Modules/Auth/Services/OneWaySmsSender.php @@ -0,0 +1,78 @@ +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; + } +} diff --git a/be/Modules/Auth/Services/PhoneVerificationOtpService.php b/be/Modules/Auth/Services/PhoneVerificationOtpService.php new file mode 100644 index 0000000..91a3b2a --- /dev/null +++ b/be/Modules/Auth/Services/PhoneVerificationOtpService.php @@ -0,0 +1,241 @@ +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); + } +} diff --git a/be/Modules/Auth/Transformers/UserResource.php b/be/Modules/Auth/Transformers/UserResource.php index 1876e62..1bf8195 100644 --- a/be/Modules/Auth/Transformers/UserResource.php +++ b/be/Modules/Auth/Transformers/UserResource.php @@ -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, diff --git a/be/Modules/User/Http/Controllers/UserController.php b/be/Modules/User/Http/Controllers/UserController.php index 366c9cb..af2aa0b 100644 --- a/be/Modules/User/Http/Controllers/UserController.php +++ b/be/Modules/User/Http/Controllers/UserController.php @@ -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( diff --git a/be/Modules/User/Http/Controllers/UserEmploymentController.php b/be/Modules/User/Http/Controllers/UserEmploymentController.php new file mode 100644 index 0000000..d326904 --- /dev/null +++ b/be/Modules/User/Http/Controllers/UserEmploymentController.php @@ -0,0 +1,144 @@ +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); + } + } +} diff --git a/be/Modules/User/Repositories/UserRepository.php b/be/Modules/User/Repositories/UserRepository.php index 5528484..a3215bd 100644 --- a/be/Modules/User/Repositories/UserRepository.php +++ b/be/Modules/User/Repositories/UserRepository.php @@ -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(); diff --git a/be/Modules/User/Routes/api.php b/be/Modules/User/Routes/api.php index 1461c8a..c90b3a3 100644 --- a/be/Modules/User/Routes/api.php +++ b/be/Modules/User/Routes/api.php @@ -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']); diff --git a/be/app/Providers/FortifyServiceProvider.php b/be/app/Providers/FortifyServiceProvider.php index 9f28f00..dd59b4c 100644 --- a/be/app/Providers/FortifyServiceProvider.php +++ b/be/app/Providers/FortifyServiceProvider.php @@ -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(); diff --git a/be/config/auth.php b/be/config/auth.php index ddf68b2..56c793d 100644 --- a/be/config/auth.php +++ b/be/config/auth.php @@ -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.', + ], + ]; diff --git a/be/config/onewaysms.php b/be/config/onewaysms.php new file mode 100644 index 0000000..b9892ef --- /dev/null +++ b/be/config/onewaysms.php @@ -0,0 +1,13 @@ + 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'), +]; diff --git a/be/database/migrations/2026_07_12_100000_create_phone_verification_otps_table.php b/be/database/migrations/2026_07_12_100000_create_phone_verification_otps_table.php new file mode 100644 index 0000000..b39cb1c --- /dev/null +++ b/be/database/migrations/2026_07_12_100000_create_phone_verification_otps_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/be/database/migrations/2026_07_12_100001_add_phone_verified_at_to_users_table.php b/be/database/migrations/2026_07_12_100001_add_phone_verified_at_to_users_table.php new file mode 100644 index 0000000..d6f8e5d --- /dev/null +++ b/be/database/migrations/2026_07_12_100001_add_phone_verified_at_to_users_table.php @@ -0,0 +1,22 @@ +timestamp('phone_verified_at')->nullable()->after('phone_number'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('phone_verified_at'); + }); + } +}; diff --git a/fe/src/constants/employers.ts b/fe/src/constants/employers.ts new file mode 100644 index 0000000..490b223 --- /dev/null +++ b/fe/src/constants/employers.ts @@ -0,0 +1,24 @@ +export const EMPLOYERS = [ + { + name: 'Infra Quest Sdn. Bhd. (IQSB)', + address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan', + }, + { + name: 'Permodalan Kelantan Berhad (PKB)', + address: + 'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan', + }, + { + name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)', + address: + 'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.', + }, + { + name: "An-Nisa'", + address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.', + }, + { + name: 'Kel Infra Sdn. Bhd.', + address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.', + }, +] as const diff --git a/fe/src/modules/auth/index.ts b/fe/src/modules/auth/index.ts index fab7ba7..acfc5a6 100644 --- a/fe/src/modules/auth/index.ts +++ b/fe/src/modules/auth/index.ts @@ -4,24 +4,35 @@ export { login, logout, register, + sendPhoneVerificationOtp, + verifyPhoneVerificationOtp, + sendAuthenticatedPhoneVerificationOtp, + verifyAuthenticatedPhoneVerificationOtp, sendVerificationEmail, requestForgotPassword, resetPassword, fetchCurrentUser, getAuthErrorMessage, getRegisterErrorMessage, + getPhoneVerificationErrorMessage, getForgotPasswordErrorMessage, getResetPasswordErrorMessage, resolvePostLoginRoute, resolvePostAuthRoute, isAccountPending, isEmailVerified, + isPhoneVerified, } from './services/auth.service' export type { LoginCredentials, LoginResponse, RegisterCredentials, RegisterResponse, + SendPhoneVerificationOtpPayload, + SendPhoneVerificationOtpResponse, + VerifyPhoneVerificationOtpPayload, + VerifyPhoneVerificationOtpResponse, + VerifyAuthenticatedPhoneVerificationOtpResponse, ResendVerificationResponse, ForgotPasswordPayload, ForgotPasswordResponse, diff --git a/fe/src/modules/auth/pages/Register.vue b/fe/src/modules/auth/pages/Register.vue index f7930ba..af6deba 100644 --- a/fe/src/modules/auth/pages/Register.vue +++ b/fe/src/modules/auth/pages/Register.vue @@ -1,5 +1,5 @@ diff --git a/fe/src/modules/user/services/userEmployment.service.ts b/fe/src/modules/user/services/userEmployment.service.ts new file mode 100644 index 0000000..38c8cce --- /dev/null +++ b/fe/src/modules/user/services/userEmployment.service.ts @@ -0,0 +1,47 @@ +import { api } from '@/core/services/api' +import type { EmploymentApiResponse, EmploymentPayload } from '@/modules/profile/types/employment.types' + +export async function createUserEmployment( + userId: string, + payload: EmploymentPayload, +): Promise { + const { data } = await api.post(`/v1/users/${userId}/employments`, payload) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal menambah pekerjaan.') + } + + return data +} + +export async function updateUserEmployment( + userId: string, + employmentId: string, + payload: EmploymentPayload, +): Promise { + const { data } = await api.put( + `/v1/users/${userId}/employments/${employmentId}`, + payload, + ) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.') + } + + return data +} + +export async function deleteUserEmployment( + userId: string, + employmentId: string, +): Promise { + const { data } = await api.delete( + `/v1/users/${userId}/employments/${employmentId}`, + ) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal memadam pekerjaan.') + } + + return data +} diff --git a/fe/src/modules/user/types/user.types.ts b/fe/src/modules/user/types/user.types.ts index 37ae0c1..46381ff 100644 --- a/fe/src/modules/user/types/user.types.ts +++ b/fe/src/modules/user/types/user.types.ts @@ -84,6 +84,7 @@ export interface CreateUserPayload { member_number: number member_type: string join_date: string + leave_date?: string | null birth_date: string birth_place: string } @@ -99,6 +100,7 @@ export interface ListUsersParams { join_date_to?: string leave_date_from?: string leave_date_to?: string + company_name?: string } export interface ListDeletedUsersParams { diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 2e27ff8..3f20581 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -37,6 +37,7 @@ export const useAuthStore = defineStore('auth', { isAccountPending: (state) => state.user?.status === 'pending', isAccountActive: (state) => state.user?.status === 'active', isEmailVerified: (state) => Boolean(state.user?.email_verified_at), + isPhoneVerified: (state) => Boolean(state.user?.phone_verified_at), roles: (state) => state.user?.roles ?? [], },