diff --git a/TODO.md b/TODO.md index 6014041..ac60c42 100644 --- a/TODO.md +++ b/TODO.md @@ -5,15 +5,15 @@ ## Improvement [ ] mesyuarat anggota tertinggi [ ] penyata anggota -[ ] daftar anggota +[x] daftar anggota [ ] baki pinjaman - transaction history: tarik dari ubs [ ] pembiayaan anggota [ ] sumbangan -[ ] wasi/penama +[x] wasi/penama [ ] pendaftaran anggota/meneruskan anggota/pencen -[ ] daftar lembaga (backdated) -[ ] boleh print semua borang -[ ] jana surat lepas lulus anggota +[x] daftar lembaga (backdated) +[x] boleh print semua borang +[x] jana surat lepas lulus anggota ## Present to Boss (2/7/2026) [x] discuss logo baru MyKOPKB @@ -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 cc40028..1bca928 100644 --- a/be/.env.example +++ b/be/.env.example @@ -3,6 +3,7 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost +FRONTEND_URL=http://localhost:5173 APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -87,3 +88,20 @@ AUTH_COOKIE_EXPOSE_TOKEN=false 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= + +EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb \ No newline at end of file diff --git a/be/.env.production b/be/.env.production index d0ac0a3..69139e2 100644 --- a/be/.env.production +++ b/be/.env.production @@ -54,7 +54,6 @@ REDIS_PORT=6379 REDIS_PASSWORD=mykopkb_redis@2025 REDIS_DB=0 -# only change this to smtp if is deployed to public server MAIL_MAILER=smtp MAIL_SCHEME=smtp MAIL_HOST=mail.koppkb.com @@ -80,18 +79,6 @@ VITE_APP_NAME="${APP_NAME}" EXTERNAL_API_TOKEN="" EXTERNAL_API_BASE_URL= -# SSO_SECRET="" -# SSO_VALIDATION_URL="https://saktitd.army.mil.my/api/sso-validate" -# SSO_MAX_ATTEMPTS=5 -# SSO_TOKEN_EXPIRATION=300 -# SSO_AUTO_ACTIVATE_USERS=true -# SSO_LOG_ACTIVITIES=true -# SSO_CONNECTION_TIMEOUT=10 -# SSO_RESPONSE_TIMEOUT=30 -# SSO_PROXY_URL= -# SSO_ENABLE_FALLBACK=true -# SSO_PROXY_ENABLED=true - SANCTUM_STATEFUL_DOMAINS=api.koppkb.com BLOCK_API_TOOLS_IN_PRODUCTION=true @@ -106,4 +93,21 @@ API_LOG_KEY_USAGE=true PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 FRONTEND_URL=https://mykopkb.koppkb.com -BROWSERSHOT_NO_SANDBOX=true \ No newline at end of file +BROWSERSHOT_NO_SANDBOX=true + +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=onewaysms +ONEWAYSMS_BASE_URL=http://gateway.onewaysms.com.my:10001/api.aspx +ONEWAYSMS_API_USERNAME=API7LO0ELJTAU +ONEWAYSMS_API_PASSWORD=API7LO0ELJTAU7LO0E +ONEWAYSMS_SENDER_ID=INFO + +EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb \ No newline at end of file diff --git a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php index 932ce8e..52ea091 100644 --- a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php +++ b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php @@ -10,23 +10,25 @@ 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 Modules\Auth\Services\EmailVerificationOtpService; +use Modules\User\Policies\UserPolicy; use Exception; class CreateNewUser implements CreatesNewUsers { use PasswordValidationRules, NotifiesAdmins; - /** - * Validate and create a newly registered user. - * - * @param array $input - */ + public function __construct( + protected PhoneVerificationOtpService $phoneVerificationOtpService, + ) {} + 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', @@ -36,15 +38,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', ]); @@ -54,9 +73,6 @@ class CreateNewUser implements CreatesNewUsers $user->assignRole($role); } - app(EmailVerificationOtpService::class)->send($user); - - // Send notification to admins if user requires activation if ($user->status === 'pending') { $this->notifyAdminsForActivation($user); } @@ -65,21 +81,19 @@ class CreateNewUser implements CreatesNewUsers } /** - * Notify admins about new user requiring activation + * Notify users who can kemaskini pengguna about new user requiring activation. */ private function notifyAdminsForActivation(User $newUser): void { try { - $adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT']; - - // Get users with specific roles plus admins (PENTADBIR and DEVELOPER) - $adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles); + $recipients = $this->getUsersWithPermission(UserPolicy::PERMISSION_UPDATE) + ->where('id', '!=', $newUser->id); - $sender = auth()->user() ?? $newUser; // Use current user as sender, or new user if no auth + $sender = auth()->user() ?? $newUser; - foreach ($adminUsers as $admin) { + foreach ($recipients as $recipient) { try { - $admin->notify(new UserActivationNotification($newUser, $sender)); + $recipient->notify(new UserActivationNotification($newUser, $sender)); } catch (Exception $e) { Log::error('Failed to send user activation notification: '.$e->getMessage()); } @@ -88,4 +102,5 @@ class CreateNewUser implements CreatesNewUsers Log::error('Failed to notify admins for user activation: '.$e->getMessage()); } } + } diff --git a/be/Modules/Auth/Actions/Fortify/LoginResponse.php b/be/Modules/Auth/Actions/Fortify/LoginResponse.php index 5226f5a..4851320 100644 --- a/be/Modules/Auth/Actions/Fortify/LoginResponse.php +++ b/be/Modules/Auth/Actions/Fortify/LoginResponse.php @@ -3,39 +3,19 @@ namespace Modules\Auth\Actions\Fortify; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Auth; use Laravel\Fortify\Contracts\LoginResponse as ContractsLoginResponse; use Modules\Auth\Services\AuthSessionService; -use Modules\Auth\Services\EmailVerificationOtpService; class LoginResponse implements ContractsLoginResponse { public function __construct( protected AuthSessionService $authSession, - protected EmailVerificationOtpService $otpService, ) {} public function toResponse($request): JsonResponse { - $user = $request->user(); - - if (! $user->hasVerifiedEmail()) { - $this->otpService->send($user); - - Auth::guard(config('fortify.guard'))->logout(); - - return response()->json([ - 'success' => true, - 'message' => 'Sila semak e-mel anda untuk kod pengesahan 6 digit.', - 'data' => [ - 'email' => $user->email, - 'requires_email_verification' => true, - ], - ]); - } - return $this->authSession->createAuthResponse( - $user, + $request->user(), 'Login successful' ); } diff --git a/be/Modules/Auth/Actions/Fortify/RegisterResponse.php b/be/Modules/Auth/Actions/Fortify/RegisterResponse.php index c3d2487..be1850b 100644 --- a/be/Modules/Auth/Actions/Fortify/RegisterResponse.php +++ b/be/Modules/Auth/Actions/Fortify/RegisterResponse.php @@ -3,24 +3,21 @@ namespace Modules\Auth\Actions\Fortify; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Auth; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; +use Modules\Auth\Services\AuthSessionService; class RegisterResponse implements RegisterResponseContract { + public function __construct( + protected AuthSessionService $authSession, + ) {} + public function toResponse($request): JsonResponse { - $email = $request->user()?->email; - - Auth::guard(config('fortify.guard'))->logout(); - - return response()->json([ - 'success' => true, - 'message' => 'Pendaftaran berjaya. Sila semak e-mel anda untuk kod pengesahan 6 digit.', - 'data' => [ - 'email' => $email, - 'requires_email_verification' => true, - ], - ], 201); + return $this->authSession->createAuthResponse( + $request->user(), + 'Pendaftaran berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.', + 201 + ); } } 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 @@ +mailMessage( - subject: 'Pengesahan E-mel - Kod OTP', - view: 'auth::emails.verification-otp', - data: [ - 'name' => $notifiable->name, - 'otp' => $this->otp, - 'minutes' => $minutes, - ], - ); - } -} diff --git a/be/Modules/Auth/Entities/EmailVerificationOtp.php b/be/Modules/Auth/Entities/PhoneVerificationOtp.php similarity index 52% rename from be/Modules/Auth/Entities/EmailVerificationOtp.php rename to be/Modules/Auth/Entities/PhoneVerificationOtp.php index 62d4780..9cab594 100644 --- a/be/Modules/Auth/Entities/EmailVerificationOtp.php +++ b/be/Modules/Auth/Entities/PhoneVerificationOtp.php @@ -4,19 +4,21 @@ namespace Modules\Auth\Entities; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -class EmailVerificationOtp extends Model +class PhoneVerificationOtp extends Model { use HasUuids; - protected $table = 'email_verification_otps'; + protected $table = 'phone_verification_otps'; protected $fillable = [ - 'user_id', + 'phone_number', 'code', 'expires_at', 'attempts', + 'verified_at', + 'verification_token', + 'verification_token_expires_at', ]; protected function casts(): array @@ -24,14 +26,11 @@ class EmailVerificationOtp extends Model return [ 'expires_at' => 'datetime', 'attempts' => 'integer', + 'verified_at' => 'datetime', + 'verification_token_expires_at' => 'datetime', ]; } - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - public function isExpired(): bool { return $this->expires_at->isPast(); @@ -39,6 +38,12 @@ class EmailVerificationOtp extends Model public function hasExceededMaxAttempts(): bool { - return $this->attempts >= (int) config('auth.email_verification.max_attempts', 5); + 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 f1b61c9..d77d143 100644 --- a/be/Modules/Auth/Entities/User.php +++ b/be/Modules/Auth/Entities/User.php @@ -2,7 +2,8 @@ namespace Modules\Auth\Entities; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -22,12 +23,13 @@ use Modules\Role\Entities\Role; use Modules\User\Entities\Address; use Modules\User\Entities\BankDetail; use Modules\User\Entities\Employment; +use Modules\Auth\Notifications\VerifyEmailNotification; use Modules\User\Entities\Heir; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes; + use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, MustVerifyEmailTrait, Notifiable, SoftDeletes; protected $table = 'users'; @@ -43,6 +45,7 @@ class User extends Authenticatable 'ic_number', 'position', 'phone_number', + 'phone_verified_at', 'image_url', 'status', 'two_factor_secret', @@ -129,6 +132,7 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'phone_verified_at' => 'datetime', 'password' => 'hashed', 'status' => 'string', 'gender' => 'string', @@ -301,9 +305,9 @@ class User extends Authenticatable return $currentUser->hasPermissionTo('menyamar pengguna'); } - public function hasVerifiedEmail(): bool + public function sendEmailVerificationNotification(): void { - return $this->email_verified_at !== null; + $this->notify(new VerifyEmailNotification); } /** @@ -315,15 +319,11 @@ class User extends Authenticatable } /** - * Whether credentials are valid for issuing a session (includes verified pending users). + * Whether credentials are valid for issuing a session (includes pending users awaiting admin activation). */ public function canAuthenticate(): bool { - if ($this->canLogin()) { - return true; - } - - return $this->hasVerifiedEmail() && $this->status === 'pending'; + return in_array($this->status, ['active', 'pending'], true); } /** diff --git a/be/Modules/Auth/Http/Controllers/EmailVerificationController.php b/be/Modules/Auth/Http/Controllers/EmailVerificationController.php index ead22a3..dadf6d2 100644 --- a/be/Modules/Auth/Http/Controllers/EmailVerificationController.php +++ b/be/Modules/Auth/Http/Controllers/EmailVerificationController.php @@ -3,60 +3,66 @@ namespace Modules\Auth\Http\Controllers; use App\Http\Controllers\Controller; +use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\URL; use Modules\Auth\Entities\User; -use Modules\Auth\Services\AuthSessionService; -use Modules\Auth\Services\EmailVerificationOtpService; class EmailVerificationController extends Controller { - public function __construct( - protected EmailVerificationOtpService $otpService, - protected AuthSessionService $authSession - ) {} - - public function verify(Request $request): JsonResponse + public function verify(Request $request, string $id, string $hash): RedirectResponse { - $validated = $request->validate([ - 'email' => ['required', 'string', 'email'], - 'otp' => ['required', 'string', 'digits:6'], - ]); - - $user = User::where('email', $validated['email'])->first(); - - if (! $user) { - return response()->json([ - 'success' => false, - 'message' => 'Kod OTP tidak sah.', - ], 422); + if (! URL::hasValidSignature($request)) { + return $this->redirectToFrontend('invalid'); } - $this->otpService->verify($user, $validated['otp']); + $user = User::find($id); - $user->refresh(); + if (! $user || ! hash_equals($hash, sha1($user->getEmailForVerification()))) { + return $this->redirectToFrontend('invalid'); + } - return $this->authSession->createAuthResponse( - $user, - 'E-mel anda telah berjaya disahkan. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.' - ); + if ($user->hasVerifiedEmail()) { + return $this->redirectToFrontend('already'); + } + + $user->markEmailAsVerified(); + + event(new Verified($user)); + + return $this->redirectToFrontend('success', $user); } - public function resend(Request $request): JsonResponse + public function send(Request $request): JsonResponse { - $validated = $request->validate([ - 'email' => ['required', 'string', 'email'], - ]); + $user = $request->user(); - $user = User::where('email', $validated['email'])->first(); - - if ($user && ! $user->hasVerifiedEmail()) { - $this->otpService->send($user); + if ($user->hasVerifiedEmail()) { + return response()->json([ + 'success' => true, + 'message' => 'E-mel anda telah disahkan.', + ]); } + $user->sendEmailVerificationNotification(); + return response()->json([ 'success' => true, - 'message' => 'Jika e-mel wujud dan belum disahkan, kod OTP baharu telah dihantar.', + 'message' => 'Pautan pengesahan e-mel telah dihantar.', ]); } + + protected function redirectToFrontend(string $status, ?User $user = null): RedirectResponse + { + $baseUrl = rtrim(config('user.frontend_url'), '/'); + $path = config('auth.verification.frontend_redirect_path', '/profile'); + + if ($user?->status === 'pending') { + $path = '/register?registered=success'; + } + + return redirect()->away("{$baseUrl}{$path}?verified={$status}"); + } } 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/Notifications/VerifyEmailNotification.php b/be/Modules/Auth/Notifications/VerifyEmailNotification.php new file mode 100644 index 0000000..1506daa --- /dev/null +++ b/be/Modules/Auth/Notifications/VerifyEmailNotification.php @@ -0,0 +1,51 @@ +mailMessage( + subject: 'Pengesahan E-mel - MyKOPKB', + view: 'auth::emails.verify-email', + data: [ + 'name' => $notifiable->name, + 'verificationUrl' => $this->verificationUrl($notifiable), + 'expireMinutes' => $expireMinutes, + ], + ); + } + + protected function verificationUrl(User $notifiable): string + { + $expireMinutes = (int) config('auth.verification.expire_minutes', 60); + + return URL::temporarySignedRoute( + 'verification.verify', + Carbon::now()->addMinutes($expireMinutes), + [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ] + ); + } +} 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 ea3eb18..aed17e3 100644 --- a/be/Modules/Auth/Routes/api.php +++ b/be/Modules/Auth/Routes/api.php @@ -3,18 +3,23 @@ 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'); -Route::post('/verify-email', [EmailVerificationController::class, 'verify']) - ->middleware('throttle:email-verification'); -Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend']) - ->middleware('throttle:email-verification-resend'); +Route::get('/email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify']) + ->middleware('throttle:email-verification') + ->name('verification.verify'); Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp']) ->middleware('throttle:password-reset-request'); @@ -30,4 +35,14 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () { // logout Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']); + + // 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/EmailVerificationOtpService.php b/be/Modules/Auth/Services/EmailVerificationOtpService.php deleted file mode 100644 index ed823d7..0000000 --- a/be/Modules/Auth/Services/EmailVerificationOtpService.php +++ /dev/null @@ -1,98 +0,0 @@ -hasVerifiedEmail()) { - return; - } - - $otp = $this->generateOtp(); - - EmailVerificationOtp::query() - ->where('user_id', $user->id) - ->delete(); - - EmailVerificationOtp::create([ - 'user_id' => $user->id, - 'code' => Hash::make($otp), - 'expires_at' => now()->addMinutes($this->expiryMinutes()), - 'attempts' => 0, - ]); - - $user->notify(new EmailVerificationOtpEmail($otp)); - } - - public function verify(User $user, string $otp): void - { - if ($user->hasVerifiedEmail()) { - throw ValidationException::withMessages([ - 'email' => ['E-mel anda telah disahkan.'], - ]); - } - - $record = EmailVerificationOtp::query() - ->where('user_id', $user->id) - ->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.'], - ]); - } - - $user->forceFill(['email_verified_at' => now()])->save(); - - EmailVerificationOtp::query() - ->where('user_id', $user->id) - ->delete(); - } - - protected function generateOtp(): string - { - return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); - } - - protected function expiryMinutes(): int - { - return (int) config('auth.email_verification.expiry_minutes', 10); - } -} 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/SSOUserResource.php b/be/Modules/Auth/Transformers/SSOUserResource.php deleted file mode 100644 index d26441c..0000000 --- a/be/Modules/Auth/Transformers/SSOUserResource.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'uuid' => $this->uuid, - 'name' => $this->name, - 'email' => $this->email, - 'army_number' => $this->army_number, - 'unit_id' => $this->unit_id, - 'rank_id' => $this->rank_id, - 'position_id' => $this->position_id, - 'phone_number' => $this->phone_number, - 'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null, - 'status' => $this->status, - 'token' => $this->when(isset($this->token), $this->token), - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, - 'roles' => $this->whenLoaded('roles', function () { - return $this->roles->map(function ($role) { - return [ - 'id' => $role->id, - 'name' => $role->name, - 'guard_name' => $role->guard_name, - 'permissions' => $role->permissions ? $role->permissions->map(function ($permission) { - return [ - 'id' => $permission->id, - 'name' => $permission->name, - 'guard_name' => $permission->guard_name, - 'route_name' => $permission->route_name ?? null, - 'created_at' => $permission->created_at, - 'updated_at' => $permission->updated_at, - ]; - }) : [], - 'created_at' => $role->created_at, - 'updated_at' => $role->updated_at, - ]; - }); - }), - 'unit' => new UnitResource($this->whenLoaded('unit')), - 'rank' => new RankResource($this->whenLoaded('rank')), - 'position' => new PositionResource($this->whenLoaded('position')), - ]; - } -} 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/Auth/resources/views/emails/verification-otp.blade.php b/be/Modules/Auth/resources/views/emails/verification-otp.blade.php deleted file mode 100644 index dcaf385..0000000 --- a/be/Modules/Auth/resources/views/emails/verification-otp.blade.php +++ /dev/null @@ -1,21 +0,0 @@ -@component('mail::message') -@include('emails.partials.header') - -# Pengesahan E-mel - -Assalamualaikum **{{ $name }}**, - -Terima kasih kerana mendaftar. Gunakan kod OTP di bawah untuk mengesahkan alamat e-mel anda. - -@component('mail::panel') -
- {{ $otp }} -
-@endcomponent - -Kod ini akan tamat tempoh dalam **{{ $minutes }} minit**. - -Jika anda tidak membuat pendaftaran ini, abaikan e-mel ini. - -@include('emails.partials.footer') -@endcomponent diff --git a/be/Modules/Auth/resources/views/emails/verify-email.blade.php b/be/Modules/Auth/resources/views/emails/verify-email.blade.php new file mode 100644 index 0000000..e48bedb --- /dev/null +++ b/be/Modules/Auth/resources/views/emails/verify-email.blade.php @@ -0,0 +1,19 @@ +@component('mail::message') +@include('emails.partials.header') + +# Pengesahan E-mel + +Assalamualaikum **{{ $name }}**, + +Terima kasih kerana mendaftar dengan MyKOPKB. Sila sahkan alamat e-mel anda dengan mengklik butang di bawah. + +@component('mail::button', ['url' => $verificationUrl]) +Sahkan E-mel +@endcomponent + +Pautan ini akan tamat tempoh dalam **{{ $expireMinutes }} minit**. + +Jika anda tidak membuat permintaan ini, abaikan e-mel ini. + +@include('emails.partials.footer') +@endcomponent diff --git a/be/Modules/ExternalSystem/Actions/.gitkeep b/be/Modules/ExternalSystem/Actions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Config/.gitkeep b/be/Modules/ExternalSystem/Config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Config/config.php b/be/Modules/ExternalSystem/Config/config.php new file mode 100644 index 0000000..125e47c --- /dev/null +++ b/be/Modules/ExternalSystem/Config/config.php @@ -0,0 +1,6 @@ + 'ExternalSystem', + 'issuer' => env('EXTERNAL_SYSTEM_SSO_ISSUER', 'mykopkb'), +]; diff --git a/be/Modules/ExternalSystem/Console/.gitkeep b/be/Modules/ExternalSystem/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Console/GenerateExternalSystemSsoSecretCommand.php b/be/Modules/ExternalSystem/Console/GenerateExternalSystemSsoSecretCommand.php new file mode 100644 index 0000000..6e32e29 --- /dev/null +++ b/be/Modules/ExternalSystem/Console/GenerateExternalSystemSsoSecretCommand.php @@ -0,0 +1,65 @@ +argument('code'); + $length = max(32, (int) $this->option('length')); + + $system = ExternalSystem::query()->where('code', $code)->first(); + + if (! $system) { + $this->components->error("External system [{$code}] not found."); + + return self::FAILURE; + } + + if (! $system->sso_enabled) { + $this->components->warn("External system [{$code}] does not have SSO enabled."); + } + + if (filled($system->sso_secret) && ! $this->option('force')) { + $this->components->error('An SSO secret already exists. Use --force to replace it.'); + + return self::FAILURE; + } + + if (filled($system->sso_secret) && $this->option('force')) { + if (! $this->confirm("Replace the existing SSO secret for [{$code}]?", false)) { + $this->components->info('Aborted.'); + + return self::SUCCESS; + } + } + + $secret = Str::password($length, symbols: true); + + $system->sso_secret = $secret; + $system->save(); + + $this->newLine(); + $this->components->info("SSO secret generated for [{$code}]."); + $this->newLine(); + $this->line('Copy this value into the external system environment:'); + $this->newLine(); + $this->line(" MYKOPKB_SSO_SECRET={$secret}"); + $this->newLine(); + $this->components->warn('This secret is shown once. Store it securely before closing this terminal.'); + + return self::SUCCESS; + } +} diff --git a/be/Modules/ExternalSystem/Database/Factories/.gitkeep b/be/Modules/ExternalSystem/Database/Factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Database/Migrations/.gitkeep b/be/Modules/ExternalSystem/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Database/Migrations/2026_07_12_130000_create_external_systems_table.php b/be/Modules/ExternalSystem/Database/Migrations/2026_07_12_130000_create_external_systems_table.php new file mode 100644 index 0000000..fdd5780 --- /dev/null +++ b/be/Modules/ExternalSystem/Database/Migrations/2026_07_12_130000_create_external_systems_table.php @@ -0,0 +1,45 @@ +uuid('id')->primary(); + $table->string('code')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('url'); + $table->string('icon')->default('ExternalLink'); + $table->boolean('is_active')->default(true); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->boolean('opens_in_new_tab')->default(true); + $table->boolean('sso_enabled')->default(false); + $table->string('sso_launch_path')->nullable(); + $table->text('sso_secret')->nullable(); + $table->string('sso_audience')->nullable(); + $table->unsignedSmallInteger('sso_token_ttl')->default(120); + $table->boolean('require_onboarding')->default(true); + $table->string('contact_email')->nullable(); + $table->text('notes')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('external_systems'); + } +}; diff --git a/be/Modules/ExternalSystem/Database/Seeders/.gitkeep b/be/Modules/ExternalSystem/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Database/Seeders/ExternalSystemDatabaseSeeder.php b/be/Modules/ExternalSystem/Database/Seeders/ExternalSystemDatabaseSeeder.php new file mode 100644 index 0000000..20eba6e --- /dev/null +++ b/be/Modules/ExternalSystem/Database/Seeders/ExternalSystemDatabaseSeeder.php @@ -0,0 +1,16 @@ +call(ExternalSystemSeeder::class); + } +} diff --git a/be/Modules/ExternalSystem/Emails/.gitkeep b/be/Modules/ExternalSystem/Emails/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Entities/.gitkeep b/be/Modules/ExternalSystem/Entities/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Entities/ExternalSystem.php b/be/Modules/ExternalSystem/Entities/ExternalSystem.php new file mode 100644 index 0000000..9d2b964 --- /dev/null +++ b/be/Modules/ExternalSystem/Entities/ExternalSystem.php @@ -0,0 +1,53 @@ + 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'opens_in_new_tab' => 'boolean', + 'sso_enabled' => 'boolean', + 'sso_secret' => 'encrypted', + 'sso_token_ttl' => 'integer', + 'require_onboarding' => 'boolean', + 'sort_order' => 'integer', + ]; + } +} diff --git a/be/Modules/ExternalSystem/Exceptions/ExternalSystemLaunchException.php b/be/Modules/ExternalSystem/Exceptions/ExternalSystemLaunchException.php new file mode 100644 index 0000000..16df13c --- /dev/null +++ b/be/Modules/ExternalSystem/Exceptions/ExternalSystemLaunchException.php @@ -0,0 +1,16 @@ +orderBy('sort_order') + ->orderBy('name') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => ExternalSystemResource::collection($systems), + ]); + } + + public function show(string $id): JsonResponse + { + $system = ExternalSystem::query()->find($id); + + if (! $system) { + return response()->json([ + 'success' => false, + 'message' => 'Sistem luaran tidak dijumpai.', + 'data' => null, + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => new ExternalSystemResource($system), + ]); + } + + public function launch(Request $request, string $code): JsonResponse + { + try { + $result = $this->launchService->launch($request->user(), $code); + + return response()->json([ + 'success' => true, + 'message' => 'SSO berjaya dijana.', + 'data' => $result, + ]); + } catch (ExternalSystemLaunchException $exception) { + return response()->json([ + 'success' => false, + 'message' => $exception->getMessage(), + 'code' => $exception->errorCode, + 'data' => null, + ], $exception->status); + } catch (\Throwable $exception) { + Log::error('Failed to launch external system SSO.', [ + 'code' => $code, + 'user_id' => $request->user()?->id, + 'message' => $exception->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Gagal membuka sistem luaran. Sila cuba lagi.', + 'code' => 'external_system_launch_failed', + 'data' => null, + ], 500); + } + } +} diff --git a/be/Modules/ExternalSystem/Http/Requests/.gitkeep b/be/Modules/ExternalSystem/Http/Requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Jobs/.gitkeep b/be/Modules/ExternalSystem/Jobs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Notifications/.gitkeep b/be/Modules/ExternalSystem/Notifications/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Policies/.gitkeep b/be/Modules/ExternalSystem/Policies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Providers/.gitkeep b/be/Modules/ExternalSystem/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Providers/EventServiceProvider.php b/be/Modules/ExternalSystem/Providers/EventServiceProvider.php new file mode 100644 index 0000000..d713565 --- /dev/null +++ b/be/Modules/ExternalSystem/Providers/EventServiceProvider.php @@ -0,0 +1,27 @@ +> + */ + protected $listen = []; + + /** + * Indicates if events should be discovered. + * + * @var bool + */ + protected static $shouldDiscoverEvents = true; + + /** + * Configure the proper event listeners for email verification. + */ + protected function configureEmailVerification(): void {} +} diff --git a/be/Modules/ExternalSystem/Providers/ExternalSystemServiceProvider.php b/be/Modules/ExternalSystem/Providers/ExternalSystemServiceProvider.php new file mode 100644 index 0000000..5475e39 --- /dev/null +++ b/be/Modules/ExternalSystem/Providers/ExternalSystemServiceProvider.php @@ -0,0 +1,156 @@ +registerCommands(); + $this->registerCommandSchedules(); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations')); + } + + /** + * Register the service provider. + */ + public function register(): void + { + $this->app->register(EventServiceProvider::class); + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register commands in the format of Command::class + */ + protected function registerCommands(): void + { + $this->commands([ + \Modules\ExternalSystem\Console\GenerateExternalSystemSsoSecretCommand::class, + ]); + } + + /** + * Register command Schedules. + */ + protected function registerCommandSchedules(): void + { + // $this->app->booted(function () { + // $schedule = $this->app->make(Schedule::class); + // $schedule->command('inspire')->hourly(); + // }); + } + + /** + * Register translations. + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/'.$this->nameLower); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->nameLower); + $this->loadJsonTranslationsFrom($langPath); + } else { + $this->loadTranslationsFrom(module_path($this->name, 'Lang'), $this->nameLower); + $this->loadJsonTranslationsFrom(module_path($this->name, 'Lang')); + } + } + + /** + * Register config. + */ + protected function registerConfig(): void + { + $configPath = module_path($this->name, config('modules.paths.generator.config.path')); + + if (is_dir($configPath)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname()); + $config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config); + $segments = explode('.', $this->nameLower.'.'.$config_key); + + // Remove duplicated adjacent segments + $normalized = []; + foreach ($segments as $segment) { + if (end($normalized) !== $segment) { + $normalized[] = $segment; + } + } + + $key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized); + + $this->publishes([$file->getPathname() => config_path($config)], 'config'); + $this->merge_config_from($file->getPathname(), $key); + } + } + } + } + + /** + * Merge config from the given path recursively. + */ + protected function merge_config_from(string $path, string $key): void + { + $existing = config($key, []); + $module_config = require $path; + + config([$key => array_replace_recursive($existing, $module_config)]); + } + + /** + * Register views. + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/'.$this->nameLower); + $sourcePath = module_path($this->name, 'Resources/Views'); + + $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']); + + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower); + + Blade::componentNamespace(config('modules.namespace').'\\' . $this->name . '\\View\\Components', $this->nameLower); + } + + /** + * Get the services provided by the provider. + */ + public function provides(): array + { + return []; + } + + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (config('view.paths') as $path) { + if (is_dir($path.'/modules/'.$this->nameLower)) { + $paths[] = $path.'/modules/'.$this->nameLower; + } + } + + return $paths; + } +} diff --git a/be/Modules/ExternalSystem/Providers/RouteServiceProvider.php b/be/Modules/ExternalSystem/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..ad71e45 --- /dev/null +++ b/be/Modules/ExternalSystem/Providers/RouteServiceProvider.php @@ -0,0 +1,50 @@ +mapApiRoutes(); + + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + */ + protected function mapWebRoutes(): void + { + Route::middleware('web')->group(module_path($this->name, '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + */ + protected function mapApiRoutes(): void + { + Route::middleware('api')->name('api.')->group(module_path($this->name, '/Routes/api.php')); + } +} diff --git a/be/Modules/ExternalSystem/Repositories/.gitkeep b/be/Modules/ExternalSystem/Repositories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Repositories/Contracts/.gitkeep b/be/Modules/ExternalSystem/Repositories/Contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Routes/.gitkeep b/be/Modules/ExternalSystem/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Routes/api.php b/be/Modules/ExternalSystem/Routes/api.php new file mode 100644 index 0000000..cce1a12 --- /dev/null +++ b/be/Modules/ExternalSystem/Routes/api.php @@ -0,0 +1,13 @@ +prefix('v1')->group(function () { + Route::get('external-systems', [ExternalSystemController::class, 'index']) + ->name('external-systems.index'); + Route::get('external-systems/{id}', [ExternalSystemController::class, 'show']) + ->name('external-systems.show'); + Route::post('external-systems/{code}/sso/launch', [ExternalSystemController::class, 'launch']) + ->name('external-systems.sso.launch'); +}); diff --git a/be/Modules/ExternalSystem/Routes/web.php b/be/Modules/ExternalSystem/Routes/web.php new file mode 100644 index 0000000..ae1d057 --- /dev/null +++ b/be/Modules/ExternalSystem/Routes/web.php @@ -0,0 +1,8 @@ +group(function () { + Route::resource('externalsystems', ExternalSystemController::class)->names('externalsystem'); +}); diff --git a/be/Modules/ExternalSystem/Services/.gitkeep b/be/Modules/ExternalSystem/Services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Services/ExternalSystemLaunchService.php b/be/Modules/ExternalSystem/Services/ExternalSystemLaunchService.php new file mode 100644 index 0000000..308e549 --- /dev/null +++ b/be/Modules/ExternalSystem/Services/ExternalSystemLaunchService.php @@ -0,0 +1,143 @@ +resolveSystem($code); + $this->assertSystemAvailable($system); + $this->assertUserEligible($user, $system); + + if (! $system->sso_enabled) { + throw new ExternalSystemLaunchException( + 'Sistem luaran tidak menyokong SSO.', + 'external_system_sso_disabled', + 422, + ); + } + + if (blank($system->sso_secret)) { + throw new ExternalSystemLaunchException( + 'Sistem luaran belum dikonfigurasi untuk SSO.', + 'external_system_sso_not_configured', + 503, + ); + } + + $ttl = $system->sso_token_ttl ?: 120; + $issuedAt = now(); + $expiresAt = $issuedAt->copy()->addSeconds($ttl); + + $payload = [ + 'iss' => config('externalsystem.issuer', config('app.name', 'mykopkb')), + 'aud' => $system->sso_audience ?: $system->code, + 'sub' => $user->id, + 'jti' => (string) Str::uuid(), + 'iat' => $issuedAt->timestamp, + 'nbf' => $issuedAt->timestamp, + 'exp' => $expiresAt->timestamp, + 'ic_number' => $user->ic_number, + 'member_number' => $user->member_number, + ]; + + $token = JwtSigner::sign($payload, $system->sso_secret); + $launchPath = $system->sso_launch_path ?: '/sso/login'; + $launchUrl = rtrim($system->url, '/').'/'.ltrim($launchPath, '/'); + $launchUrl .= '?token='.urlencode($token); + + return [ + 'launch_url' => $launchUrl, + 'expires_at' => $expiresAt->toIso8601String(), + ]; + } + + protected function resolveSystem(string $code): ExternalSystem + { + $system = ExternalSystem::query()->where('code', $code)->first(); + + if (! $system) { + throw new ExternalSystemLaunchException( + 'Sistem luaran tidak dijumpai.', + 'external_system_not_found', + 404, + ); + } + + return $system; + } + + protected function assertSystemAvailable(ExternalSystem $system): void + { + if (! $system->is_active) { + throw new ExternalSystemLaunchException( + 'Sistem luaran tidak aktif.', + 'external_system_inactive', + 403, + ); + } + + $now = now(); + + if ($system->starts_at?->isAfter($now)) { + throw new ExternalSystemLaunchException( + 'Sistem luaran belum tersedia.', + 'external_system_upcoming', + 403, + ); + } + + if ($system->ends_at?->isBefore($now)) { + throw new ExternalSystemLaunchException( + 'Tempoh akses sistem luaran telah tamat.', + 'external_system_ended', + 403, + ); + } + } + + protected function assertUserEligible(User $user, ExternalSystem $system): void + { + if ($user->status !== 'active') { + throw new ExternalSystemLaunchException( + 'Hanya anggota aktif boleh membuka sistem luaran.', + 'external_system_user_inactive', + 403, + ); + } + + if (blank($user->ic_number)) { + throw new ExternalSystemLaunchException( + 'Nombor kad pengenalan diperlukan sebelum membuka sistem luaran.', + 'external_system_ic_required', + 422, + ); + } + + if (blank($user->member_number)) { + throw new ExternalSystemLaunchException( + 'Nombor anggota diperlukan sebelum membuka sistem luaran.', + 'external_system_member_number_required', + 422, + ); + } + + if ($system->require_onboarding && blank($user->onboarding_completed_at)) { + throw new ExternalSystemLaunchException( + 'Sila lengkapkan profil anda sebelum membuka sistem luaran.', + 'external_system_profile_incomplete', + 422, + ); + } + } +} diff --git a/be/Modules/ExternalSystem/Support/JwtSigner.php b/be/Modules/ExternalSystem/Support/JwtSigner.php new file mode 100644 index 0000000..c444d03 --- /dev/null +++ b/be/Modules/ExternalSystem/Support/JwtSigner.php @@ -0,0 +1,33 @@ + $payload + */ + public static function sign(array $payload, string $secret): string + { + $header = [ + 'typ' => 'JWT', + 'alg' => 'HS256', + ]; + + $segments = [ + self::base64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)), + self::base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR)), + ]; + + $signingInput = implode('.', $segments); + $signature = hash_hmac('sha256', $signingInput, $secret, true); + $segments[] = self::base64UrlEncode($signature); + + return implode('.', $segments); + } + + protected static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/be/Modules/ExternalSystem/Tests/Feature/.gitkeep b/be/Modules/ExternalSystem/Tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Tests/Unit/.gitkeep b/be/Modules/ExternalSystem/Tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Transformers/.gitkeep b/be/Modules/ExternalSystem/Transformers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/ExternalSystem/Transformers/ExternalSystemResource.php b/be/Modules/ExternalSystem/Transformers/ExternalSystemResource.php new file mode 100644 index 0000000..ff7701a --- /dev/null +++ b/be/Modules/ExternalSystem/Transformers/ExternalSystemResource.php @@ -0,0 +1,30 @@ + $this->id, + 'code' => $this->code, + 'name' => $this->name, + 'description' => $this->description, + 'url' => $this->url, + 'icon' => $this->icon, + 'is_active' => $this->is_active, + 'starts_at' => $this->starts_at?->toIso8601String(), + 'ends_at' => $this->ends_at?->toIso8601String(), + 'opens_in_new_tab' => $this->opens_in_new_tab, + 'sso_enabled' => $this->sso_enabled, + 'contact_email' => $this->contact_email, + 'notes' => $this->notes, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/be/Modules/ExternalSystem/composer.json b/be/Modules/ExternalSystem/composer.json new file mode 100644 index 0000000..0947d8a --- /dev/null +++ b/be/Modules/ExternalSystem/composer.json @@ -0,0 +1,30 @@ +{ + "name": "nwidart/externalsystem", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\ExternalSystem\\": "App", + "Modules\\ExternalSystem\\Database\\Factories\\": "database/factories/", + "Modules\\ExternalSystem\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\ExternalSystem\\Tests\\": "tests/" + } + } +} diff --git a/be/Modules/ExternalSystem/module.json b/be/Modules/ExternalSystem/module.json new file mode 100644 index 0000000..e0b38bb --- /dev/null +++ b/be/Modules/ExternalSystem/module.json @@ -0,0 +1,11 @@ +{ + "name": "ExternalSystem", + "alias": "externalsystem", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\ExternalSystem\\Providers\\ExternalSystemServiceProvider" + ], + "files": [] +} diff --git a/be/Modules/ExternalSystem/package.json b/be/Modules/ExternalSystem/package.json new file mode 100644 index 0000000..d6fbfc8 --- /dev/null +++ b/be/Modules/ExternalSystem/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "axios": "^1.1.2", + "laravel-vite-plugin": "^0.7.5", + "sass": "^1.69.5", + "postcss": "^8.3.7", + "vite": "^4.0.0" + } +} diff --git a/be/Modules/Feedback/Actions/.gitkeep b/be/Modules/Feedback/Actions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Config/.gitkeep b/be/Modules/Feedback/Config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Config/config.php b/be/Modules/Feedback/Config/config.php new file mode 100644 index 0000000..cf0c8c1 --- /dev/null +++ b/be/Modules/Feedback/Config/config.php @@ -0,0 +1,5 @@ + 'Feedback', +]; diff --git a/be/Modules/Feedback/Console/.gitkeep b/be/Modules/Feedback/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Factories/.gitkeep b/be/Modules/Feedback/Database/Factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Migrations/.gitkeep b/be/Modules/Feedback/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php b/be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php new file mode 100644 index 0000000..e54961e --- /dev/null +++ b/be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php @@ -0,0 +1,54 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('title'); + $table->text('description'); + $table->string('page_url')->nullable(); // URL where the issue occurred + $table->json('browser_info')->nullable(); // Browser, OS, screen resolution etc. + $table->text('steps_to_reproduce')->nullable(); // Steps to reproduce the issue + $table->text('expected_behavior')->nullable(); // What should happen + $table->text('actual_behavior')->nullable(); // What actually happened + $table->text('additional_notes')->nullable(); // Any additional information + $table->foreignUuid('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->text('admin_notes')->nullable(); // Internal notes for admins + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + // Add ENUM columns + DB::statement("ALTER TABLE feedback ADD COLUMN type feedback_type_enum NOT NULL DEFAULT 'general_feedback'"); + DB::statement("ALTER TABLE feedback ADD COLUMN priority feedback_priority_enum NOT NULL DEFAULT 'medium'"); + DB::statement("ALTER TABLE feedback ADD COLUMN status feedback_status_enum NOT NULL DEFAULT 'open'"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('feedback'); + DB::statement("DROP TYPE IF EXISTS feedback_type_enum"); + DB::statement("DROP TYPE IF EXISTS feedback_priority_enum"); + DB::statement("DROP TYPE IF EXISTS feedback_status_enum"); + } +}; diff --git a/be/Modules/Feedback/Database/Seeders/.gitkeep b/be/Modules/Feedback/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php b/be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php new file mode 100644 index 0000000..47557c6 --- /dev/null +++ b/be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php @@ -0,0 +1,16 @@ +call([]); + } +} diff --git a/be/Modules/Feedback/Emails/.gitkeep b/be/Modules/Feedback/Emails/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Entities/.gitkeep b/be/Modules/Feedback/Entities/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Entities/Feedback.php b/be/Modules/Feedback/Entities/Feedback.php new file mode 100644 index 0000000..5323a6c --- /dev/null +++ b/be/Modules/Feedback/Entities/Feedback.php @@ -0,0 +1,119 @@ + 'array', + 'resolved_at' => 'datetime', + ]; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty(); + } + + /** + * Get the user who submitted the feedback + */ + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Get the admin assigned to handle this feedback + */ + public function assignedUser() + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function images(): MorphMany + { + return $this->documents()->where('type', self::IMAGE_DOCUMENT_TYPE); + } + + public function videos(): MorphMany + { + return $this->documents()->where('type', self::VIDEO_DOCUMENT_TYPE); + } + + /** + * Scope for filtering by type + */ + public function scopeOfType($query, $type) + { + return $query->where('type', $type); + } + + /** + * Scope for filtering by status + */ + public function scopeOfStatus($query, $status) + { + return $query->where('status', $status); + } + + /** + * Scope for filtering by priority + */ + public function scopeOfPriority($query, $priority) + { + return $query->where('priority', $priority); + } + + /** + * Scope for open feedback + */ + public function scopeOpen($query) + { + return $query->whereIn('status', ['open', 'in_progress']); + } + + /** + * Scope for resolved feedback + */ + public function scopeResolved($query) + { + return $query->whereIn('status', ['resolved', 'closed']); + } +} diff --git a/be/Modules/Feedback/Helpers/.gitkeep b/be/Modules/Feedback/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Http/Controllers/.gitkeep b/be/Modules/Feedback/Http/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Http/Controllers/FeedbackController.php b/be/Modules/Feedback/Http/Controllers/FeedbackController.php new file mode 100644 index 0000000..adfaa1f --- /dev/null +++ b/be/Modules/Feedback/Http/Controllers/FeedbackController.php @@ -0,0 +1,339 @@ + $request->get('type'), + 'status' => $request->get('status'), + 'priority' => $request->get('priority'), + ]; + + // Always try paginated methods first when perPage is specified + if ($perPage > 0) { + if (method_exists($this->repository, 'getAllWithRelationsPaginated')) { + return $this->repository->getAllWithRelationsPaginated($perPage, $search, $sortBy, $sortOrder, $filters); + } + + if (method_exists($this->repository, 'getAllPaginated')) { + return $this->repository->getAllPaginated($perPage, $search, $sortBy, $sortOrder, $filters); + } + } + + // Fallback to non-paginated methods + if (method_exists($this->repository, 'getAllWithRelations')) { + return $this->repository->getAllWithRelations($search, $sortBy, $sortOrder); + } + + return $this->repository->all($search, $sortBy, $sortOrder); + } + + /** + * Store a newly created resource in storage (public access) + */ + public function store(Request $request): JsonResponse + { + // Skip authorization for public feedback submission + try { + $validated = $this->validateRequest($request); + $data = $this->prepareStoreData($validated); + + $item = $this->repository->create($data); + + $this->uploadAttachments($item, $request); + + // Load relationships + $item->load(['user', 'documents']); + + // Send notification to admins + $this->notifyAdminsForNewFeedback($item); + + return response()->json([ + 'success' => true, + 'data' => new $this->resourceClass($item), + 'message' => 'Maklum balas berjaya dihantar. Terima kasih!', + ], 201); + + } catch (Exception $e) { + Log::error("Error creating {$this->resourceName}: ".$e->getMessage()); + + return $this->errorResponse($this->getErrorMessage('store').': '.$e->getMessage(), 500); + } + } + + /** + * Prepare data for store method + */ + protected function prepareStoreData(array $validated): array + { + $data = $validated; + + // Check if user is authenticated by looking for the Authorization header + $authHeader = request()->header('Authorization'); + if ($authHeader && str_starts_with($authHeader, 'Bearer ')) { + // Try to authenticate the user manually + $token = str_replace('Bearer ', '', $authHeader); + $personalAccessToken = PersonalAccessToken::findToken($token); + + if ($personalAccessToken) { + $data['user_id'] = $personalAccessToken->tokenable_id; + } else { + $data['user_id'] = null; + } + } else { + // User is not authenticated + $data['user_id'] = null; + } + + $data['browser_info'] = $this->getBrowserInfo(request()); + + unset($data['images'], $data['videos']); + + return $data; + } + + /** + * Upload image and video attachments via HasDocuments. + */ + protected function uploadAttachments(Feedback $feedback, Request $request): void + { + if ($request->hasFile('images')) { + foreach ($request->file('images') as $image) { + $feedback->uploadDocument($image, Feedback::IMAGE_DOCUMENT_TYPE); + } + } + + if ($request->hasFile('videos')) { + foreach ($request->file('videos') as $video) { + $feedback->uploadDocument($video, Feedback::VIDEO_DOCUMENT_TYPE); + } + } + } + + /** + * Prepare data for update method + */ + protected function prepareUpdateData(array $validated, $feedback): array + { + $data = $validated; + + unset($data['images'], $data['videos']); + + // Set resolved_at timestamp when status changes to resolved + if (isset($data['status']) && $data['status'] === 'resolved') { + $data['resolved_at'] = now(); + } elseif (isset($data['status']) && $data['status'] !== 'resolved') { + $data['resolved_at'] = null; + } + + return $data; + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, string $id): JsonResponse + { + $this->authorize('update', $this->modelClass); + + try { + $item = $this->repository->findById($id); + + if (! $item) { + return $this->errorResponse($this->getNotFoundMessage(), 404); + } + + $validated = $this->validateRequest($request); + $data = $this->prepareUpdateData($validated, $item); + + $item->update($data); + $item->load(['user', 'assignedUser', 'documents']); + + return response()->json([ + 'success' => true, + 'data' => new $this->resourceClass($item), + 'message' => $this->getSuccessMessage('update'), + ]); + } catch (Exception $e) { + Log::error("Error updating {$this->resourceName}: ".$e->getMessage()); + + return $this->errorResponse($this->getErrorMessage('update').': '.$e->getMessage(), 500); + } + } + + /** + * Load relations for show method + */ + protected function loadShowRelations($item) + { + return $item->load(['user', 'assignedUser', 'documents']); + } + + /** + * Check dependencies before deletion + */ + protected function checkDependencies($feedback): ?\Illuminate\Http\JsonResponse + { + foreach ($feedback->documents as $document) { + $feedback->deleteDocument($document->id); + } + + return null; + } + + /** + * Stream a feedback attachment inline (image/video preview). + */ + public function serveDocument(string $id, string $documentId): BinaryFileResponse + { + $this->authorize('view', $this->modelClass); + + $feedback = Feedback::findOrFail($id); + $document = $feedback->documents()->findOrFail($documentId); + + $disk = Storage::disk(Document::STORAGE_DISK); + + if (! $disk->exists($document->path)) { + abort(404, 'File not found'); + } + + return response()->file($disk->path($document->path), [ + 'Content-Type' => $document->mime_type ?? 'application/octet-stream', + 'Content-Disposition' => 'inline; filename="'.$document->name.'"', + ]); + } + + /** + * Get feedback statistics + */ + public function statistics(): JsonResponse + { + $stats = [ + 'total' => Feedback::count(), + 'open' => Feedback::open()->count(), + 'resolved' => Feedback::resolved()->count(), + 'by_type' => Feedback::selectRaw('type, COUNT(*) as count') + ->groupBy('type') + ->pluck('count', 'type'), + 'by_priority' => Feedback::selectRaw('priority, COUNT(*) as count') + ->groupBy('priority') + ->pluck('count', 'priority'), + 'by_status' => Feedback::selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status'), + ]; + + return response()->json([ + 'success' => true, + 'data' => $stats + ]); + } + + /** + * Get user's own feedback + */ + public function myFeedback(Request $request): JsonResponse + { + $query = Feedback::where('user_id', auth()->id()) + ->with(['assignedUser', 'documents']) + ->orderBy('created_at', 'desc'); + + // Apply filters + if ($request->has('status') && $request->status) { + $query->ofStatus($request->status); + } + + if ($request->has('type') && $request->type) { + $query->ofType($request->type); + } + + $perPage = $request->get('per_page', 15); + $feedback = $query->paginate($perPage); + + return response()->json([ + 'success' => true, + 'data' => FeedbackResource::collection($feedback->items()), + 'meta' => [ + 'current_page' => $feedback->currentPage(), + 'last_page' => $feedback->lastPage(), + 'per_page' => $feedback->perPage(), + 'total' => $feedback->total(), + ] + ]); + } + + /** + * Get browser information from request + */ + private function getBrowserInfo(Request $request): array + { + return [ + 'user_agent' => $request->userAgent(), + 'ip_address' => $request->ip(), + 'referer' => $request->header('referer'), + 'accept_language' => $request->header('accept-language'), + 'screen_resolution' => $request->input('screen_resolution'), + 'viewport_size' => $request->input('viewport_size'), + 'timezone' => $request->input('timezone'), + ]; + } + + /** + * Notify admins about new feedback submission + */ + private function notifyAdminsForNewFeedback(Feedback $feedback): void + { + try { + $adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT']; + + // Get users with specific roles plus admins (PENTADBIR and DEVELOPER) + $adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles); + + $sender = auth()->user() ?? $feedback->user; // Use current user as sender, or feedback submitter if no auth + + foreach ($adminUsers as $admin) { + try { + $admin->notify(new FeedbackNotification($feedback, $sender)); + } catch (Exception $e) { + Log::error('Failed to send feedback notification: '.$e->getMessage()); + } + } + } catch (Exception $e) { + Log::error('Failed to notify admins for new feedback: '.$e->getMessage()); + } + } +} \ No newline at end of file diff --git a/be/Modules/Feedback/Http/Requests/.gitkeep b/be/Modules/Feedback/Http/Requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Http/Requests/FeedbackRequest.php b/be/Modules/Feedback/Http/Requests/FeedbackRequest.php new file mode 100644 index 0000000..543092c --- /dev/null +++ b/be/Modules/Feedback/Http/Requests/FeedbackRequest.php @@ -0,0 +1,83 @@ +|string> + */ + public function rules(): array + { + $rules = [ + 'type' => 'required|in:bug,feature_request,general_feedback,ui_issue,performance_issue', + 'title' => 'required|string|max:255', + 'description' => 'required|string', + 'priority' => 'required|in:low,medium,high,critical', + 'page_url' => 'nullable|url', + 'steps_to_reproduce' => 'nullable|string', + 'expected_behavior' => 'nullable|string', + 'actual_behavior' => 'nullable|string', + 'additional_notes' => 'nullable|string', + 'images' => 'nullable|array', + 'images.*' => 'file|mimes:jpeg,png,jpg,gif,webp|max:10240', // 10MB max + 'videos' => 'nullable|array', + 'videos.*' => 'file|mimes:mp4,mov,webm|max:51200', // 50MB max + ]; + + // For update operations, add admin-specific fields + if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { + $rules = [ + 'type' => 'sometimes|in:bug,feature_request,general_feedback,ui_issue,performance_issue', + 'title' => 'sometimes|string|max:255', + 'description' => 'sometimes|string', + 'priority' => 'sometimes|in:low,medium,high,critical', + 'page_url' => 'nullable|url', + 'steps_to_reproduce' => 'nullable|string', + 'expected_behavior' => 'nullable|string', + 'actual_behavior' => 'nullable|string', + 'additional_notes' => 'nullable|string', + 'status' => 'sometimes|in:open,in_progress,resolved,closed,rejected', + 'assigned_to' => 'nullable|exists:users,id', + 'admin_notes' => 'nullable|string', + ]; + } + + return $rules; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'type.required' => 'Jenis maklum balas diperlukan.', + 'type.in' => 'Jenis maklum balas tidak sah.', + 'title.required' => 'Tajuk diperlukan.', + 'title.max' => 'Tajuk tidak boleh melebihi 255 aksara.', + 'description.required' => 'Penerangan diperlukan.', + 'priority.required' => 'Keutamaan diperlukan.', + 'priority.in' => 'Keutamaan tidak sah.', + 'page_url.url' => 'URL halaman tidak sah.', + 'images.*.mimes' => 'Format imej mestilah jpeg, png, jpg, gif atau webp.', + 'images.*.max' => 'Saiz imej tidak boleh melebihi 10MB.', + 'videos.*.mimes' => 'Format video mestilah mp4, mov atau webm.', + 'videos.*.max' => 'Saiz video tidak boleh melebihi 50MB.', + 'status.in' => 'Status tidak sah.', + 'assigned_to.exists' => 'Pengguna yang ditugaskan tidak wujud.', + ]; + } +} diff --git a/be/Modules/Feedback/Jobs/.gitkeep b/be/Modules/Feedback/Jobs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Notifications/.gitkeep b/be/Modules/Feedback/Notifications/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Notifications/FeedbackNotification.php b/be/Modules/Feedback/Notifications/FeedbackNotification.php new file mode 100644 index 0000000..bb083d2 --- /dev/null +++ b/be/Modules/Feedback/Notifications/FeedbackNotification.php @@ -0,0 +1,66 @@ +feedback = $feedback; + $this->sender = $sender; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + */ + public function toArray($notifiable): array + { + return [ + 'feedback_id' => $this->feedback->id, + 'sender_id' => $this->sender ? $this->sender->id : null, + 'type' => 'feedback_submitted', + 'message' => $this->getNotificationMessage(), + 'feedback_title' => $this->feedback->title ?? 'Unknown', + 'feedback_type' => $this->feedback->type ?? 'Unknown', + 'feedback_priority' => $this->feedback->priority ?? 'normal', + 'user_name' => $this->feedback->user ? $this->feedback->user->name : 'Anonymous', + 'user_email' => $this->feedback->user ? $this->feedback->user->email : null, + ]; + } + + /** + * Get notification message + */ + private function getNotificationMessage(): string + { + $userName = $this->feedback->user ? $this->feedback->user->name : 'Pengguna tanpa nama'; + $feedbackTitle = $this->feedback->title ?? 'Unknown'; + $feedbackType = $this->feedback->type ?? 'Unknown'; + $feedbackPriority = $this->feedback->priority ?? 'normal'; + $feedbackId = $this->feedback->id; + + return "Maklum balas baru telah diterima. ID: {$feedbackId}, Tajuk: {$feedbackTitle}, Jenis: {$feedbackType}, Keutamaan: {$feedbackPriority}, Pengguna: {$userName}"; + } +} + diff --git a/be/Modules/Feedback/Policies/.gitkeep b/be/Modules/Feedback/Policies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Policies/FeedbackPolicy.php b/be/Modules/Feedback/Policies/FeedbackPolicy.php new file mode 100644 index 0000000..f6fa555 --- /dev/null +++ b/be/Modules/Feedback/Policies/FeedbackPolicy.php @@ -0,0 +1,59 @@ +hasPermissionTo('lihat maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can view the model. + */ + public function view($user, ?Feedback $feedback = null): bool + { + return $user->hasPermissionTo('lihat maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can create models. + */ + public function create($user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update($user, ?Feedback $feedback = null): bool + { + return $user->hasPermissionTo('kemaskini maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can delete any model. + */ + public function deleteAny($user): bool + { + return $user->hasPermissionTo('padam maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can delete the model. + */ + public function delete($user, ?Feedback $feedback = null): bool + { + return $user->hasPermissionTo('padam maklum balas'); // Adjust based on your authorization logic + } +} diff --git a/be/Modules/Feedback/Providers/.gitkeep b/be/Modules/Feedback/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Providers/EventServiceProvider.php b/be/Modules/Feedback/Providers/EventServiceProvider.php new file mode 100644 index 0000000..52d943b --- /dev/null +++ b/be/Modules/Feedback/Providers/EventServiceProvider.php @@ -0,0 +1,27 @@ +> + */ + protected $listen = []; + + /** + * Indicates if events should be discovered. + * + * @var bool + */ + protected static $shouldDiscoverEvents = true; + + /** + * Configure the proper event listeners for email verification. + */ + protected function configureEmailVerification(): void {} +} diff --git a/be/Modules/Feedback/Providers/FeedbackServiceProvider.php b/be/Modules/Feedback/Providers/FeedbackServiceProvider.php new file mode 100644 index 0000000..f3373ba --- /dev/null +++ b/be/Modules/Feedback/Providers/FeedbackServiceProvider.php @@ -0,0 +1,160 @@ +registerCommands(); + $this->registerCommandSchedules(); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations')); + } + + /** + * Register the service provider. + */ + public function register(): void + { + $this->app->register(EventServiceProvider::class); + $this->app->register(RouteServiceProvider::class); + + // Register repository binding + $this->app->bind( + \Modules\Feedback\Repositories\Contracts\FeedbackRepositoryInterface::class, + \Modules\Feedback\Repositories\FeedbackRepository::class + ); + } + + /** + * Register commands in the format of Command::class + */ + protected function registerCommands(): void + { + // $this->commands([]); + } + + /** + * Register command Schedules. + */ + protected function registerCommandSchedules(): void + { + // $this->app->booted(function () { + // $schedule = $this->app->make(Schedule::class); + // $schedule->command('inspire')->hourly(); + // }); + } + + /** + * Register translations. + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/'.$this->nameLower); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->nameLower); + $this->loadJsonTranslationsFrom($langPath); + } else { + $this->loadTranslationsFrom(module_path($this->name, 'Lang'), $this->nameLower); + $this->loadJsonTranslationsFrom(module_path($this->name, 'Lang')); + } + } + + /** + * Register config. + */ + protected function registerConfig(): void + { + $configPath = module_path($this->name, config('modules.paths.generator.config.path')); + + if (is_dir($configPath)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname()); + $config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config); + $segments = explode('.', $this->nameLower.'.'.$config_key); + + // Remove duplicated adjacent segments + $normalized = []; + foreach ($segments as $segment) { + if (end($normalized) !== $segment) { + $normalized[] = $segment; + } + } + + $key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized); + + $this->publishes([$file->getPathname() => config_path($config)], 'config'); + $this->merge_config_from($file->getPathname(), $key); + } + } + } + } + + /** + * Merge config from the given path recursively. + */ + protected function merge_config_from(string $path, string $key): void + { + $existing = config($key, []); + $module_config = require $path; + + config([$key => array_replace_recursive($existing, $module_config)]); + } + + /** + * Register views. + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/'.$this->nameLower); + $sourcePath = module_path($this->name, 'Resources/Views'); + + $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']); + + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower); + + Blade::componentNamespace(config('modules.namespace').'\\' . $this->name . '\\View\\Components', $this->nameLower); + } + + /** + * Get the services provided by the provider. + */ + public function provides(): array + { + return []; + } + + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (config('view.paths') as $path) { + if (is_dir($path.'/modules/'.$this->nameLower)) { + $paths[] = $path.'/modules/'.$this->nameLower; + } + } + + return $paths; + } +} diff --git a/be/Modules/Feedback/Providers/RouteServiceProvider.php b/be/Modules/Feedback/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..7b588c1 --- /dev/null +++ b/be/Modules/Feedback/Providers/RouteServiceProvider.php @@ -0,0 +1,39 @@ +mapApiRoutes(); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + */ + protected function mapApiRoutes(): void + { + Route::middleware('api')->group(module_path($this->name, '/Routes/api.php')); + } +} \ No newline at end of file diff --git a/be/Modules/Feedback/Repositories/.gitkeep b/be/Modules/Feedback/Repositories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Repositories/Contracts/.gitkeep b/be/Modules/Feedback/Repositories/Contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php b/be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php new file mode 100644 index 0000000..2072938 --- /dev/null +++ b/be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php @@ -0,0 +1,44 @@ +orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + // Apply filters + if (!empty($filters['type'])) { + $query->where('type', $filters['type']); + } + + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (!empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + return $query->paginate($perPage); + } + + /** + * Get all feedback with their relationships and pagination with search + */ + public function getAllWithRelationsPaginated(int $perPage = 10, string $search = '', string $sortBy = 'id', string $sortOrder = 'asc', array $filters = []) + { + $query = Feedback::with(['user', 'assignedUser', 'documents'])->orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + // Apply filters + if (!empty($filters['type'])) { + $query->where('type', $filters['type']); + } + + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (!empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + return $query->paginate($perPage); + } + + /** + * Get all feedback with their relationships and search + */ + public function getAllWithRelations(string $search = '', string $sortBy = 'id', string $sortOrder = 'asc'): Collection + { + $query = Feedback::with(['user', 'assignedUser', 'documents'])->orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + return $query->get(); + } + + /** + * Create a new feedback + */ + public function create(array $data): Feedback + { + return Feedback::create($data); + } + + /** + * Find feedback by ID + */ + public function findById(string $id): ?Feedback + { + return Feedback::with(['user', 'assignedUser', 'documents'])->find($id); + } + + /** + * Delete feedback (soft delete) + */ + public function delete(string $id): bool + { + $feedback = Feedback::find($id); + if ($feedback) { + return $feedback->delete(); + } + + return false; + } + + /** + * Get all feedback + */ + public function all(string $search = '', string $sortBy = 'id', string $sortOrder = 'asc'): Collection + { + $query = Feedback::with(['user', 'assignedUser', 'documents'])->orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + return $query->get(); + } +} diff --git a/be/Modules/Feedback/Routes/.gitkeep b/be/Modules/Feedback/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Routes/api.php b/be/Modules/Feedback/Routes/api.php new file mode 100644 index 0000000..925128a --- /dev/null +++ b/be/Modules/Feedback/Routes/api.php @@ -0,0 +1,20 @@ +name('feedback.store'); + +// Authenticated feedback routes +Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () { + Route::get('/feedback', [FeedbackController::class, 'index'])->name('feedback.index'); + Route::get('/feedback/{id}', [FeedbackController::class, 'show'])->name('feedback.show'); + Route::put('/feedback/{id}', [FeedbackController::class, 'update'])->name('feedback.update'); + Route::patch('/feedback/{id}', [FeedbackController::class, 'update'])->name('feedback.patch'); + Route::delete('/feedback/{id}', [FeedbackController::class, 'destroy'])->name('feedback.destroy'); + Route::get('/feedback-statistics', [FeedbackController::class, 'statistics'])->name('feedback.statistics'); + Route::get('/my-feedback', [FeedbackController::class, 'myFeedback'])->name('feedback.my'); + Route::get('/feedback/{id}/documents/{documentId}/download', [FeedbackController::class, 'serveDocument']) + ->name('feedback.download-document'); +}); \ No newline at end of file diff --git a/be/Modules/Feedback/Services/.gitkeep b/be/Modules/Feedback/Services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Tests/Feature/.gitkeep b/be/Modules/Feedback/Tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Tests/Unit/.gitkeep b/be/Modules/Feedback/Tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Transformers/.gitkeep b/be/Modules/Feedback/Transformers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Transformers/FeedbackResource.php b/be/Modules/Feedback/Transformers/FeedbackResource.php new file mode 100644 index 0000000..5b1d87c --- /dev/null +++ b/be/Modules/Feedback/Transformers/FeedbackResource.php @@ -0,0 +1,79 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'title' => $this->title, + 'description' => $this->description, + 'priority' => $this->priority, + 'status' => $this->status, + 'page_url' => $this->page_url, + 'browser_info' => $this->browser_info, + 'images' => $this->whenLoaded('documents', function () { + return $this->documents + ->where('type', Feedback::IMAGE_DOCUMENT_TYPE) + ->values() + ->map(fn ($document) => $this->formatDocument($document)); + }), + 'videos' => $this->whenLoaded('documents', function () { + return $this->documents + ->where('type', Feedback::VIDEO_DOCUMENT_TYPE) + ->values() + ->map(fn ($document) => $this->formatDocument($document)); + }), + 'steps_to_reproduce' => $this->steps_to_reproduce, + 'expected_behavior' => $this->expected_behavior, + 'actual_behavior' => $this->actual_behavior, + 'additional_notes' => $this->additional_notes, + 'admin_notes' => $this->admin_notes, + 'resolved_at' => $this->resolved_at, + 'user' => $this->whenLoaded('user', function () { + return [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'email' => $this->user->email, + 'army_number' => $this->user->army_number, + ]; + }), + 'assigned_user' => $this->whenLoaded('assignedUser', function () { + return [ + 'id' => $this->assignedUser->id, + 'name' => $this->assignedUser->name, + 'email' => $this->assignedUser->email, + ]; + }), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } + + protected function formatDocument($document): array + { + return [ + 'id' => $document->id, + 'name' => $document->name, + 'mime_type' => $document->mime_type, + 'file_size' => $document->file_size, + 'type' => $document->type, + 'url' => url(route('feedback.download-document', [ + 'id' => $this->id, + 'documentId' => $document->id, + ])), + ]; + } +} diff --git a/be/Modules/Feedback/composer.json b/be/Modules/Feedback/composer.json new file mode 100644 index 0000000..4c03c5a --- /dev/null +++ b/be/Modules/Feedback/composer.json @@ -0,0 +1,30 @@ +{ + "name": "nwidart/feedback", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\Feedback\\": "App", + "Modules\\Feedback\\Database\\Factories\\": "database/factories/", + "Modules\\Feedback\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Feedback\\Tests\\": "tests/" + } + } +} diff --git a/be/Modules/Feedback/module.json b/be/Modules/Feedback/module.json new file mode 100644 index 0000000..a23bebd --- /dev/null +++ b/be/Modules/Feedback/module.json @@ -0,0 +1,12 @@ +{ + "name": "Feedback", + "alias": "feedback", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Feedback\\Providers\\FeedbackServiceProvider", + "Modules\\Feedback\\Providers\\RouteServiceProvider" + ], + "files": [] +} \ No newline at end of file diff --git a/be/Modules/Feedback/package.json b/be/Modules/Feedback/package.json new file mode 100644 index 0000000..d6fbfc8 --- /dev/null +++ b/be/Modules/Feedback/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "axios": "^1.1.2", + "laravel-vite-plugin": "^0.7.5", + "sass": "^1.69.5", + "postcss": "^8.3.7", + "vite": "^4.0.0" + } +} diff --git a/be/Modules/MembershipApplication/Services/MembershipApplicationService.php b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php index 29264a8..b44c3d8 100644 --- a/be/Modules/MembershipApplication/Services/MembershipApplicationService.php +++ b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php @@ -14,7 +14,6 @@ use Illuminate\Support\Facades\Notification; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Modules\Auth\Entities\User; -use Modules\Auth\Services\EmailVerificationOtpService; use Modules\MembershipApplication\Entities\MembershipApplication; use Modules\MembershipApplication\Enums\ApplicationStatus; use Modules\MembershipApplication\Enums\BoardDecision; @@ -513,8 +512,6 @@ class MembershipApplicationService ]); } - app(EmailVerificationOtpService::class)->send($user); - return [ 'user' => $user, 'plainPassword' => $plainPassword, 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/Policies/UserPolicy.php b/be/Modules/User/Policies/UserPolicy.php index 22d6212..5eeff2b 100644 --- a/be/Modules/User/Policies/UserPolicy.php +++ b/be/Modules/User/Policies/UserPolicy.php @@ -9,12 +9,20 @@ class UserPolicy { use HandlesAuthorization; + public const PERMISSION_VIEW = 'lihat pengguna'; + + public const PERMISSION_CREATE = 'daftar pengguna baru'; + + public const PERMISSION_UPDATE = 'kemaskini pengguna'; + + public const PERMISSION_DELETE = 'padam akaun pengguna'; + /** * Determine whether the user can view any models. */ public function viewAny($user): bool { - return $user->hasPermissionTo('lihat pengguna'); + return $user->hasPermissionTo(self::PERMISSION_VIEW); } /** @@ -22,7 +30,7 @@ class UserPolicy */ public function view($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('lihat pengguna'); + return $user->hasPermissionTo(self::PERMISSION_VIEW); } /** @@ -30,7 +38,7 @@ class UserPolicy */ public function create($user): bool { - return $user->hasPermissionTo('daftar pengguna baru'); + return $user->hasPermissionTo(self::PERMISSION_CREATE); } /** @@ -38,7 +46,7 @@ class UserPolicy */ public function update($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('kemaskini pengguna'); + return $user->hasPermissionTo(self::PERMISSION_UPDATE); } /** @@ -46,7 +54,7 @@ class UserPolicy */ public function deleteAny($user): bool { - return $user->hasPermissionTo('padam akaun pengguna'); + return $user->hasPermissionTo(self::PERMISSION_DELETE); } /** @@ -54,7 +62,7 @@ class UserPolicy */ public function delete($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('padam akaun pengguna'); + return $user->hasPermissionTo(self::PERMISSION_DELETE); } /** @@ -62,6 +70,6 @@ class UserPolicy */ public function restore($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('padam akaun pengguna'); + return $user->hasPermissionTo(self::PERMISSION_DELETE); } } 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 0077392..dd59b4c 100644 --- a/be/app/Providers/FortifyServiceProvider.php +++ b/be/app/Providers/FortifyServiceProvider.php @@ -57,9 +57,9 @@ class FortifyServiceProvider extends ServiceProvider }); RateLimiter::for('email-verification', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input('email', '')).'|'.$request->ip()); + $throttleKey = Str::transliterate($request->route('id', '').'|'.$request->ip()); - return Limit::perMinute(5)->by($throttleKey); + return Limit::perMinute(6)->by($throttleKey); }); RateLimiter::for('email-verification-resend', function (Request $request) { @@ -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(); @@ -87,10 +99,6 @@ class FortifyServiceProvider extends ServiceProvider $shouldBypassPassword = config('app.env', 'local'); if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) { - if (! $user->hasVerifiedEmail()) { - return $user; - } - if (! $user->canAuthenticate()) { throw ValidationException::withMessages([ 'email' => [$user->getLoginRestrictionMessage()], diff --git a/be/app/Traits/NotifiesAdmins.php b/be/app/Traits/NotifiesAdmins.php index 0b808c8..930969c 100644 --- a/be/app/Traits/NotifiesAdmins.php +++ b/be/app/Traits/NotifiesAdmins.php @@ -13,7 +13,7 @@ trait NotifiesAdmins protected function getAdminUsers(): Collection { return User::whereHas('roles', function ($query) { - $query->whereIn('name', ['PENTADBIR', 'DEVELOPER']); + $query->whereIn('name', ['IT', 'DEVELOPER']); })->get(); } @@ -26,6 +26,14 @@ trait NotifiesAdmins return $specificUsers->merge($adminUsers)->unique('id'); } + /** + * Get users who have a given permission (via role or direct assignment). + */ + protected function getUsersWithPermission(string $permission): Collection + { + return User::permission($permission)->get(); + } + /** * Get users with specific roles and merge with admins */ diff --git a/be/config/auth.php b/be/config/auth.php index 59abb17..3418de8 100644 --- a/be/config/auth.php +++ b/be/config/auth.php @@ -113,13 +113,13 @@ return [ /* |-------------------------------------------------------------------------- - | Email Verification OTP + | Email Verification Link |-------------------------------------------------------------------------- */ - 'email_verification' => [ - 'expiry_minutes' => (int) env('EMAIL_VERIFICATION_OTP_EXPIRY_MINUTES', 10), - 'max_attempts' => (int) env('EMAIL_VERIFICATION_OTP_MAX_ATTEMPTS', 5), + 'verification' => [ + 'expire_minutes' => (int) env('EMAIL_VERIFICATION_EXPIRE_MINUTES', 60), + 'frontend_redirect_path' => env('EMAIL_VERIFICATION_REDIRECT_PATH', '/profile'), ], /* @@ -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' => 'KoPKB: 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_06_04_000001_create_email_verification_otps_table.php b/be/database/migrations/2026_07_12_000001_drop_email_verification_otps_table.php similarity index 83% rename from be/database/migrations/2026_06_04_000001_create_email_verification_otps_table.php rename to be/database/migrations/2026_07_12_000001_drop_email_verification_otps_table.php index b514553..aaabf07 100644 --- a/be/database/migrations/2026_06_04_000001_create_email_verification_otps_table.php +++ b/be/database/migrations/2026_07_12_000001_drop_email_verification_otps_table.php @@ -6,10 +6,12 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void + { + Schema::dropIfExists('email_verification_otps'); + } + + public function down(): void { Schema::create('email_verification_otps', function (Blueprint $table) { $table->uuid('id')->primary(); @@ -18,16 +20,6 @@ return new class extends Migration $table->timestamp('expires_at'); $table->unsignedTinyInteger('attempts')->default(0); $table->timestamps(); - - $table->index(['user_id', 'expires_at']); }); } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('email_verification_otps'); - } }; 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/be/modules_statuses.json b/be/modules_statuses.json index c835c83..ea6b6af 100644 --- a/be/modules_statuses.json +++ b/be/modules_statuses.json @@ -7,5 +7,7 @@ "ExternalAPI": true, "Notification": true, "MembershipApplication": true, - "Activity": true + "Activity": true, + "ExternalSystem": true, + "Feedback": true } \ No newline at end of file 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/main/side-menu.ts b/fe/src/main/side-menu.ts index 9f4998c..8958ae5 100644 --- a/fe/src/main/side-menu.ts +++ b/fe/src/main/side-menu.ts @@ -6,6 +6,7 @@ import { activityMenu } from '@/modules/activity' import { dashboardMenu } from '@/modules/dashboard/menu' import { externalSystemMenu } from '@/modules/external-system/menu' import { activityLogMenu } from '@/modules/activity-log/menu' +import { feedbackMenu } from '@/modules/feedback' export type { Menu } @@ -17,6 +18,7 @@ const mainMenu: (string | Menu)[] = [ 'Teknologi Maklumat', ...roleMenu, ...activityLogMenu, + ...feedbackMenu, 'Pentadbiran', ...membershipApplicationMenu, ...userMenu, diff --git a/fe/src/modules/auth/components/EmailVerificationBanner.vue b/fe/src/modules/auth/components/EmailVerificationBanner.vue new file mode 100644 index 0000000..8e10740 --- /dev/null +++ b/fe/src/modules/auth/components/EmailVerificationBanner.vue @@ -0,0 +1,46 @@ + + + diff --git a/fe/src/modules/auth/index.ts b/fe/src/modules/auth/index.ts index 7174ddf..acfc5a6 100644 --- a/fe/src/modules/auth/index.ts +++ b/fe/src/modules/auth/index.ts @@ -4,28 +4,36 @@ export { login, logout, register, - verifyEmail, - resendVerificationEmail, + sendPhoneVerificationOtp, + verifyPhoneVerificationOtp, + sendAuthenticatedPhoneVerificationOtp, + verifyAuthenticatedPhoneVerificationOtp, + sendVerificationEmail, requestForgotPassword, resetPassword, fetchCurrentUser, getAuthErrorMessage, getRegisterErrorMessage, - getVerifyEmailErrorMessage, + getPhoneVerificationErrorMessage, getForgotPasswordErrorMessage, getResetPasswordErrorMessage, resolvePostLoginRoute, resolvePostAuthRoute, isAccountPending, - isLoginVerificationRequired, + isEmailVerified, + isPhoneVerified, } from './services/auth.service' export type { LoginCredentials, LoginResponse, RegisterCredentials, RegisterResponse, - VerifyEmailPayload, - VerifyEmailResponse, + SendPhoneVerificationOtpPayload, + SendPhoneVerificationOtpResponse, + VerifyPhoneVerificationOtpPayload, + VerifyPhoneVerificationOtpResponse, + VerifyAuthenticatedPhoneVerificationOtpResponse, + ResendVerificationResponse, ForgotPasswordPayload, ForgotPasswordResponse, ResetPasswordPayload, diff --git a/fe/src/modules/auth/pages/AccountPending.vue b/fe/src/modules/auth/pages/AccountPending.vue index 9d0cdaf..0919a65 100644 --- a/fe/src/modules/auth/pages/AccountPending.vue +++ b/fe/src/modules/auth/pages/AccountPending.vue @@ -1,6 +1,6 @@ - +
Tiada sistem dijumpai

Cuba istilah carian yang berbeza.

diff --git a/fe/src/modules/external-system/services/external-system.service.ts b/fe/src/modules/external-system/services/external-system.service.ts new file mode 100644 index 0000000..0e1b07a --- /dev/null +++ b/fe/src/modules/external-system/services/external-system.service.ts @@ -0,0 +1,41 @@ +import { api } from '@/core/services/api' +import type { + ExternalSystem, + ExternalSystemListResponse, + ExternalSystemResponse, + ExternalSystemSsoLaunchResponse, +} from '../types/external-system.types' + +export async function listExternalSystems(): Promise { + const { data } = await api.get('/v1/external-systems') + + if (!data.success) { + throw new Error(data.message ?? 'Gagal memuatkan senarai sistem luaran.') + } + + return data.data +} + +export async function getExternalSystem(id: string): Promise { + const { data } = await api.get(`/v1/external-systems/${id}`) + + if (!data.success) { + throw new Error(data.message ?? 'Sistem luaran tidak dijumpai.') + } + + return data.data +} + +export async function launchExternalSystemSso( + systemCode: string, +): Promise { + const { data } = await api.post( + `/v1/external-systems/${systemCode}/sso/launch`, + ) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal membuka sistem.') + } + + return data +} diff --git a/fe/src/modules/external-system/types/external-system.types.ts b/fe/src/modules/external-system/types/external-system.types.ts index 2db3a87..2e590f3 100644 --- a/fe/src/modules/external-system/types/external-system.types.ts +++ b/fe/src/modules/external-system/types/external-system.types.ts @@ -13,8 +13,30 @@ export type ExternalSystem = { starts_at: string | null ends_at: string | null opens_in_new_tab: boolean + sso_enabled: boolean contact_email: string | null notes: string | null created_at: string updated_at: string } + +export type ExternalSystemSsoLaunchResponse = { + success: boolean + message?: string + data: { + launch_url: string + expires_at: string + } +} + +export type ExternalSystemListResponse = { + success: boolean + message?: string + data: ExternalSystem[] +} + +export type ExternalSystemResponse = { + success: boolean + message?: string + data: ExternalSystem +} diff --git a/fe/src/modules/feedback/components/HelpdeskFab.vue b/fe/src/modules/feedback/components/HelpdeskFab.vue new file mode 100644 index 0000000..788028b --- /dev/null +++ b/fe/src/modules/feedback/components/HelpdeskFab.vue @@ -0,0 +1,54 @@ + + + diff --git a/fe/src/modules/feedback/composables/useFeedbackList.ts b/fe/src/modules/feedback/composables/useFeedbackList.ts new file mode 100644 index 0000000..62d9262 --- /dev/null +++ b/fe/src/modules/feedback/composables/useFeedbackList.ts @@ -0,0 +1,104 @@ +import { onMounted, ref, watch } from 'vue' +import debounce from 'lodash/debounce' +import type { SortConfig } from '@/components/ui/usage/DataTable.vue' +import { useApiPagination } from '@/composables/useApiPagination' +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' +import { listFeedback } from '../services/feedback.service' +import type { + FeedbackListItem, + FeedbackPriority, + FeedbackStatus, + FeedbackType, +} from '../types/feedback.types' + +export function useFeedbackList() { + const items = ref([]) + const loading = ref(false) + const error = ref(null) + const search = ref('') + const typeFilter = ref('') + const statusFilter = ref('') + const priorityFilter = ref('') + const sortBy = ref([{ key: 'created_at', order: 'desc' }]) + const page = ref(1) + const itemsPerPage = ref(10) + + const { pagination, applyPagination } = useApiPagination({ per_page: 10 }) + + async function fetchItems(requestPage = page.value) { + loading.value = true + error.value = null + + try { + const activeSort = sortBy.value[0] + const data = await listFeedback({ + page: requestPage, + per_page: itemsPerPage.value, + sort_by: activeSort?.key ?? 'created_at', + sort_order: activeSort?.order ?? 'desc', + search: search.value.trim() || undefined, + type: typeFilter.value || undefined, + status: statusFilter.value || undefined, + priority: priorityFilter.value || undefined, + }) + + items.value = data.data + applyPagination(data.pagination) + page.value = data.pagination.current_page + } catch (err) { + error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai maklum balas.') + items.value = [] + } finally { + loading.value = false + } + } + + function handleSortUpdate(value: SortConfig[]) { + sortBy.value = value + fetchItems(1) + } + + const debouncedSearch = debounce(() => { + fetchItems(1) + }, 400) + + watch(search, () => { + debouncedSearch() + }) + + watch([typeFilter, statusFilter, priorityFilter], () => { + fetchItems(1) + }) + + watch(page, (nextPage, previousPage) => { + if (nextPage !== previousPage) { + fetchItems(nextPage) + } + }) + + watch(itemsPerPage, (nextValue, previousValue) => { + if (nextValue !== previousValue) { + fetchItems(1) + } + }) + + onMounted(() => { + fetchItems(1) + }) + + return { + items, + loading, + error, + search, + typeFilter, + statusFilter, + priorityFilter, + sortBy, + page, + itemsPerPage, + pagination, + handleSortUpdate, + fetchItems, + } +} diff --git a/fe/src/modules/feedback/index.ts b/fe/src/modules/feedback/index.ts new file mode 100644 index 0000000..bfb8ce4 --- /dev/null +++ b/fe/src/modules/feedback/index.ts @@ -0,0 +1,22 @@ +export { feedbackPublicRoutes, feedbackLayoutRoutes } from './routes' +export { feedbackMenu } from './menu' +export { default as HelpdeskFab } from './components/HelpdeskFab.vue' +export { + submitFeedback, + listFeedback, + getFeedback, + updateFeedback, + deleteFeedback, + getMyFeedback, + getFeedbackStatistics, +} from './services/feedback.service' +export type { + Feedback, + FeedbackFormState, + FeedbackListItem, + FeedbackStatus, + FeedbackType, + FeedbackPriority, + SubmitFeedbackPayload, + UpdateFeedbackPayload, +} from './types/feedback.types' diff --git a/fe/src/modules/feedback/menu.ts b/fe/src/modules/feedback/menu.ts new file mode 100644 index 0000000..c5f288d --- /dev/null +++ b/fe/src/modules/feedback/menu.ts @@ -0,0 +1,10 @@ +import type { Menu } from '@/core/types/menu' + +export const feedbackMenu: Menu[] = [ + { + icon: 'MessageCircle', + route_name: 'list-feedback', + title: 'Maklum Balas', + permission: 'lihat maklum balas', + }, +] diff --git a/fe/src/modules/feedback/pages/FeedbackDetail.vue b/fe/src/modules/feedback/pages/FeedbackDetail.vue new file mode 100644 index 0000000..970914e --- /dev/null +++ b/fe/src/modules/feedback/pages/FeedbackDetail.vue @@ -0,0 +1,486 @@ + + +