From 431bbee17c99178e6c83f1a7b705ef5723d2bf93 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Sun, 12 Jul 2026 10:03:31 +0800 Subject: [PATCH 1/4] DONE: replace email verification by link instead of otp --- TODO.md | 8 +- be/.env.example | 4 + .../Auth/Actions/Fortify/CreateNewUser.php | 33 ---- .../Auth/Actions/Fortify/LoginResponse.php | 22 +-- .../Auth/Actions/Fortify/RegisterResponse.php | 23 ++- .../Auth/Emails/EmailVerificationOtpEmail.php | 38 ---- .../Auth/Entities/EmailVerificationOtp.php | 44 ----- be/Modules/Auth/Entities/User.php | 20 +-- .../EmailVerificationController.php | 76 ++++---- .../Notifications/VerifyEmailNotification.php | 51 ++++++ be/Modules/Auth/Routes/api.php | 11 +- .../Services/EmailVerificationOtpService.php | 98 ----------- .../views/emails/verification-otp.blade.php | 21 --- .../views/emails/verify-email.blade.php | 19 ++ .../Services/MembershipApplicationService.php | 3 - be/app/Providers/FortifyServiceProvider.php | 8 +- be/config/auth.php | 8 +- ...01_drop_email_verification_otps_table.php} | 18 +- .../components/EmailVerificationBanner.vue | 46 +++++ fe/src/modules/auth/index.ts | 9 +- fe/src/modules/auth/pages/AccountPending.vue | 19 +- fe/src/modules/auth/pages/Login.vue | 18 +- fe/src/modules/auth/pages/Register.vue | 14 +- fe/src/modules/auth/pages/VerifyEmail.vue | 164 ------------------ fe/src/modules/auth/routes.ts | 6 - fe/src/modules/auth/services/auth.service.ts | 29 +--- fe/src/modules/auth/types/auth.types.ts | 28 +-- fe/src/router/index.ts | 3 +- fe/src/stores/auth.ts | 1 + fe/src/themes/Enigma/SideMenu/SideMenu.vue | 14 ++ 30 files changed, 263 insertions(+), 593 deletions(-) delete mode 100644 be/Modules/Auth/Emails/EmailVerificationOtpEmail.php delete mode 100644 be/Modules/Auth/Entities/EmailVerificationOtp.php create mode 100644 be/Modules/Auth/Notifications/VerifyEmailNotification.php delete mode 100644 be/Modules/Auth/Services/EmailVerificationOtpService.php delete mode 100644 be/Modules/Auth/resources/views/emails/verification-otp.blade.php create mode 100644 be/Modules/Auth/resources/views/emails/verify-email.blade.php rename be/database/migrations/{2026_06_04_000001_create_email_verification_otps_table.php => 2026_07_12_000001_drop_email_verification_otps_table.php} (83%) create mode 100644 fe/src/modules/auth/components/EmailVerificationBanner.vue delete mode 100644 fe/src/modules/auth/pages/VerifyEmail.vue diff --git a/TODO.md b/TODO.md index 6014041..8b98b0f 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] boleh print semua borang +[x] jana surat lepas lulus anggota ## Present to Boss (2/7/2026) [x] discuss logo baru MyKOPKB diff --git a/be/.env.example b/be/.env.example index cc40028..6b7e521 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,6 @@ AUTH_COOKIE_EXPOSE_TOKEN=false ACTIVE_ROLE_PREFER_MEMBER=true ACTIVE_ROLE_MEMBER_REDIRECT=/profile ACTIVE_ROLE_ADMIN_REDIRECT=/profile + +EMAIL_VERIFICATION_EXPIRE_MINUTES=60 +EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2 diff --git a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php index 932ce8e..543bf93 100644 --- a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php +++ b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php @@ -12,7 +12,6 @@ use Laravel\Fortify\Contracts\CreatesNewUsers; use Modules\Auth\Entities\User; use Modules\Role\Entities\Role; use Modules\User\Notifications\UserActivationNotification; -use Modules\Auth\Services\EmailVerificationOtpService; use Exception; class CreateNewUser implements CreatesNewUsers @@ -54,38 +53,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); - } - return $user; } - - /** - * Notify admins 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); - - $sender = auth()->user() ?? $newUser; // Use current user as sender, or new user if no auth - - foreach ($adminUsers as $admin) { - try { - $admin->notify(new UserActivationNotification($newUser, $sender)); - } catch (Exception $e) { - Log::error('Failed to send user activation notification: '.$e->getMessage()); - } - } - } catch (Exception $e) { - 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/Emails/EmailVerificationOtpEmail.php b/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php deleted file mode 100644 index 2dd14a8..0000000 --- a/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php +++ /dev/null @@ -1,38 +0,0 @@ -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/EmailVerificationOtp.php deleted file mode 100644 index 62d4780..0000000 --- a/be/Modules/Auth/Entities/EmailVerificationOtp.php +++ /dev/null @@ -1,44 +0,0 @@ - 'datetime', - 'attempts' => 'integer', - ]; - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function isExpired(): bool - { - return $this->expires_at->isPast(); - } - - public function hasExceededMaxAttempts(): bool - { - return $this->attempts >= (int) config('auth.email_verification.max_attempts', 5); - } -} diff --git a/be/Modules/Auth/Entities/User.php b/be/Modules/Auth/Entities/User.php index f1b61c9..8819330 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'; @@ -301,9 +303,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 +317,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/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/Routes/api.php b/be/Modules/Auth/Routes/api.php index ea3eb18..9a695f6 100644 --- a/be/Modules/Auth/Routes/api.php +++ b/be/Modules/Auth/Routes/api.php @@ -11,10 +11,9 @@ use Laravel\Fortify\Http\Controllers\RegisteredUserController; 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 +29,8 @@ 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'); }); \ 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/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/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/app/Providers/FortifyServiceProvider.php b/be/app/Providers/FortifyServiceProvider.php index 0077392..9f28f00 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) { @@ -87,10 +87,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/config/auth.php b/be/config/auth.php index 59abb17..ddf68b2 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'), ], /* 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/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..fab7ba7 100644 --- a/fe/src/modules/auth/index.ts +++ b/fe/src/modules/auth/index.ts @@ -4,28 +4,25 @@ export { login, logout, register, - verifyEmail, - resendVerificationEmail, + sendVerificationEmail, requestForgotPassword, resetPassword, fetchCurrentUser, getAuthErrorMessage, getRegisterErrorMessage, - getVerifyEmailErrorMessage, getForgotPasswordErrorMessage, getResetPasswordErrorMessage, resolvePostLoginRoute, resolvePostAuthRoute, isAccountPending, - isLoginVerificationRequired, + isEmailVerified, } from './services/auth.service' export type { LoginCredentials, LoginResponse, RegisterCredentials, RegisterResponse, - VerifyEmailPayload, - VerifyEmailResponse, + 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 @@ - - diff --git a/fe/src/modules/auth/routes.ts b/fe/src/modules/auth/routes.ts index 31906f2..5387b1b 100644 --- a/fe/src/modules/auth/routes.ts +++ b/fe/src/modules/auth/routes.ts @@ -13,12 +13,6 @@ export const authPublicRoutes: RouteRecordRaw[] = [ component: () => import('./pages/Register.vue'), meta: { module: 'auth' }, }, - { - path: '/verify-email', - name: 'verify-email', - component: () => import('./pages/VerifyEmail.vue'), - meta: { module: 'auth' }, - }, { path: '/forgot-password', name: 'forgot-password', diff --git a/fe/src/modules/auth/services/auth.service.ts b/fe/src/modules/auth/services/auth.service.ts index 06391b2..3cccecb 100644 --- a/fe/src/modules/auth/services/auth.service.ts +++ b/fe/src/modules/auth/services/auth.service.ts @@ -5,16 +5,13 @@ import type { ForgotPasswordResponse, LoginCredentials, LoginResponse, - LoginVerificationRequiredData, RegisterCredentials, RegisterResponse, - ResendVerificationResponse, ResetPasswordPayload, ResetPasswordResponse, + ResendVerificationResponse, SessionResponse, SwitchRoleResponse, - VerifyEmailPayload, - VerifyEmailResponse, } from '../types/auth.types' export async function login(credentials: LoginCredentials): Promise { @@ -27,13 +24,8 @@ export async function register(credentials: RegisterCredentials): Promise { - const { data } = await api.post('/verify-email', payload) - return data -} - -export async function resendVerificationEmail(email: string): Promise { - const { data } = await api.post('/verify-email/resend', { email }) +export async function sendVerificationEmail(): Promise { + const { data } = await api.post('/v1/email/verification-notification') return data } @@ -73,10 +65,6 @@ export function getRegisterErrorMessage(error: unknown): string { return getApiErrorMessage(error, 'Registration failed. Please try again.') } -export function getVerifyEmailErrorMessage(error: unknown): string { - return getApiErrorMessage(error, 'Email verification failed. Please try again.') -} - export function getForgotPasswordErrorMessage(error: unknown): string { return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.') } @@ -85,15 +73,12 @@ export function getResetPasswordErrorMessage(error: unknown): string { return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.') } -export function isAccountPending(user: { status: string } | null | undefined): boolean { - return user?.status === 'pending' +export function isEmailVerified(user: { email_verified_at?: string | null } | null | undefined): boolean { + return Boolean(user?.email_verified_at) } -export function isLoginVerificationRequired( - response: LoginResponse, -): response is LoginResponse & { data: LoginVerificationRequiredData } { - return 'requires_email_verification' in response.data - && response.data.requires_email_verification === true +export function isAccountPending(user: { status: string } | null | undefined): boolean { + return user?.status === 'pending' } export function resolvePostAuthRoute( diff --git a/fe/src/modules/auth/types/auth.types.ts b/fe/src/modules/auth/types/auth.types.ts index 5a46f42..fad34db 100644 --- a/fe/src/modules/auth/types/auth.types.ts +++ b/fe/src/modules/auth/types/auth.types.ts @@ -45,6 +45,7 @@ export interface AuthUser { birth_date: string | null birth_place: string | null onboarding_completed_at: string | null + email_verified_at: string | null roles?: Array } @@ -54,20 +55,17 @@ export interface LoginSessionData { expires_at: string } -export interface LoginVerificationRequiredData { - email: string - requires_email_verification: true -} - -export interface LoginResponse { +export interface AuthSessionResponse { success: boolean message: string - data: LoginSessionData | LoginVerificationRequiredData + data: LoginSessionData active_role?: AuthRole | null can_switch_role?: boolean redirect_path?: string } +export type LoginResponse = AuthSessionResponse + export interface SwitchRoleResponse extends SessionResponse { message: string } @@ -80,21 +78,7 @@ export interface RegisterCredentials { password_confirmation: string } -export interface RegisterResponse { - success: boolean - message: string - data: { - email: string - requires_email_verification: boolean - } -} - -export interface VerifyEmailPayload { - email: string - otp: string -} - -export type VerifyEmailResponse = LoginResponse +export type RegisterResponse = AuthSessionResponse export interface ResendVerificationResponse { success: boolean diff --git a/fe/src/router/index.ts b/fe/src/router/index.ts index 639454f..c512b52 100644 --- a/fe/src/router/index.ts +++ b/fe/src/router/index.ts @@ -38,7 +38,7 @@ const router = createRouter({ ], }) -const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'verify-email', 'membership-application-apply']) +const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply']) router.beforeEach(async (to) => { const authStore = useAuthStore(pinia) @@ -66,7 +66,6 @@ router.beforeEach(async (to) => { return true } - // Redirect authenticated users away from login/register/verify-email. if ( authStore.isAuthenticated && PUBLIC_ROUTE_NAMES.has(routeName) && diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 5e241c4..2e27ff8 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -36,6 +36,7 @@ export const useAuthStore = defineStore('auth', { isAuthenticated: (state) => state.user !== null, isAccountPending: (state) => state.user?.status === 'pending', isAccountActive: (state) => state.user?.status === 'active', + isEmailVerified: (state) => Boolean(state.user?.email_verified_at), roles: (state) => state.user?.roles ?? [], }, diff --git a/fe/src/themes/Enigma/SideMenu/SideMenu.vue b/fe/src/themes/Enigma/SideMenu/SideMenu.vue index 21dd164..ab02773 100644 --- a/fe/src/themes/Enigma/SideMenu/SideMenu.vue +++ b/fe/src/themes/Enigma/SideMenu/SideMenu.vue @@ -1,4 +1,5 @@ diff --git a/fe/src/modules/user/services/userEmployment.service.ts b/fe/src/modules/user/services/userEmployment.service.ts new file mode 100644 index 0000000..38c8cce --- /dev/null +++ b/fe/src/modules/user/services/userEmployment.service.ts @@ -0,0 +1,47 @@ +import { api } from '@/core/services/api' +import type { EmploymentApiResponse, EmploymentPayload } from '@/modules/profile/types/employment.types' + +export async function createUserEmployment( + userId: string, + payload: EmploymentPayload, +): Promise { + const { data } = await api.post(`/v1/users/${userId}/employments`, payload) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal menambah pekerjaan.') + } + + return data +} + +export async function updateUserEmployment( + userId: string, + employmentId: string, + payload: EmploymentPayload, +): Promise { + const { data } = await api.put( + `/v1/users/${userId}/employments/${employmentId}`, + payload, + ) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.') + } + + return data +} + +export async function deleteUserEmployment( + userId: string, + employmentId: string, +): Promise { + const { data } = await api.delete( + `/v1/users/${userId}/employments/${employmentId}`, + ) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal memadam pekerjaan.') + } + + return data +} diff --git a/fe/src/modules/user/types/user.types.ts b/fe/src/modules/user/types/user.types.ts index 37ae0c1..46381ff 100644 --- a/fe/src/modules/user/types/user.types.ts +++ b/fe/src/modules/user/types/user.types.ts @@ -84,6 +84,7 @@ export interface CreateUserPayload { member_number: number member_type: string join_date: string + leave_date?: string | null birth_date: string birth_place: string } @@ -99,6 +100,7 @@ export interface ListUsersParams { join_date_to?: string leave_date_from?: string leave_date_to?: string + company_name?: string } export interface ListDeletedUsersParams { diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 2e27ff8..3f20581 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -37,6 +37,7 @@ export const useAuthStore = defineStore('auth', { isAccountPending: (state) => state.user?.status === 'pending', isAccountActive: (state) => state.user?.status === 'active', isEmailVerified: (state) => Boolean(state.user?.email_verified_at), + isPhoneVerified: (state) => Boolean(state.user?.phone_verified_at), roles: (state) => state.user?.roles ?? [], }, -- 2.52.0 From 324b7facf17c466b1ebf52293ee0308b0a8f6e51 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 13 Jul 2026 12:15:44 +0800 Subject: [PATCH 3/4] DONE: sso into e-vote; WIP: feedback modules --- be/.env.example | 2 + be/.env.production | 32 ++-- .../Auth/Transformers/SSOUserResource.php | 62 ------- be/Modules/ExternalSystem/Actions/.gitkeep | 0 be/Modules/ExternalSystem/Config/.gitkeep | 0 be/Modules/ExternalSystem/Config/config.php | 6 + be/Modules/ExternalSystem/Console/.gitkeep | 0 ...GenerateExternalSystemSsoSecretCommand.php | 65 ++++++++ .../Database/Factories/.gitkeep | 0 .../Database/Migrations/.gitkeep | 0 ...2_130000_create_external_systems_table.php | 45 +++++ .../ExternalSystem/Database/Seeders/.gitkeep | 0 .../Seeders/ExternalSystemDatabaseSeeder.php | 16 ++ be/Modules/ExternalSystem/Emails/.gitkeep | 0 be/Modules/ExternalSystem/Entities/.gitkeep | 0 .../Entities/ExternalSystem.php | 53 ++++++ .../ExternalSystemLaunchException.php | 16 ++ be/Modules/ExternalSystem/Helpers/.gitkeep | 0 .../ExternalSystem/Http/Controllers/.gitkeep | 0 .../Controllers/ExternalSystemController.php | 83 ++++++++++ .../ExternalSystem/Http/Requests/.gitkeep | 0 be/Modules/ExternalSystem/Jobs/.gitkeep | 0 .../ExternalSystem/Notifications/.gitkeep | 0 be/Modules/ExternalSystem/Policies/.gitkeep | 0 be/Modules/ExternalSystem/Providers/.gitkeep | 0 .../Providers/EventServiceProvider.php | 27 +++ .../ExternalSystemServiceProvider.php | 156 ++++++++++++++++++ .../Providers/RouteServiceProvider.php | 50 ++++++ .../ExternalSystem/Repositories/.gitkeep | 0 .../Repositories/Contracts/.gitkeep | 0 be/Modules/ExternalSystem/Routes/.gitkeep | 0 be/Modules/ExternalSystem/Routes/api.php | 13 ++ be/Modules/ExternalSystem/Routes/web.php | 8 + be/Modules/ExternalSystem/Services/.gitkeep | 0 .../Services/ExternalSystemLaunchService.php | 143 ++++++++++++++++ .../ExternalSystem/Support/JwtSigner.php | 33 ++++ .../ExternalSystem/Tests/Feature/.gitkeep | 0 be/Modules/ExternalSystem/Tests/Unit/.gitkeep | 0 .../ExternalSystem/Transformers/.gitkeep | 0 .../Transformers/ExternalSystemResource.php | 30 ++++ be/Modules/ExternalSystem/composer.json | 30 ++++ be/Modules/ExternalSystem/module.json | 11 ++ be/Modules/ExternalSystem/package.json | 15 ++ be/config/auth.php | 2 +- be/modules_statuses.json | 3 +- .../composables/useExternalSystemDetail.ts | 32 +++- .../composables/useExternalSystemLaunch.ts | 49 ++++++ .../composables/useExternalSystemList.ts | 33 +++- .../data/dummy-external-systems.ts | 38 ----- .../pages/ExternalSystemDetail.vue | 7 +- .../pages/ExternalSystemList.vue | 17 +- .../services/external-system.service.ts | 41 +++++ .../types/external-system.types.ts | 22 +++ 53 files changed, 1005 insertions(+), 135 deletions(-) delete mode 100644 be/Modules/Auth/Transformers/SSOUserResource.php create mode 100644 be/Modules/ExternalSystem/Actions/.gitkeep create mode 100644 be/Modules/ExternalSystem/Config/.gitkeep create mode 100644 be/Modules/ExternalSystem/Config/config.php create mode 100644 be/Modules/ExternalSystem/Console/.gitkeep create mode 100644 be/Modules/ExternalSystem/Console/GenerateExternalSystemSsoSecretCommand.php create mode 100644 be/Modules/ExternalSystem/Database/Factories/.gitkeep create mode 100644 be/Modules/ExternalSystem/Database/Migrations/.gitkeep create mode 100644 be/Modules/ExternalSystem/Database/Migrations/2026_07_12_130000_create_external_systems_table.php create mode 100644 be/Modules/ExternalSystem/Database/Seeders/.gitkeep create mode 100644 be/Modules/ExternalSystem/Database/Seeders/ExternalSystemDatabaseSeeder.php create mode 100644 be/Modules/ExternalSystem/Emails/.gitkeep create mode 100644 be/Modules/ExternalSystem/Entities/.gitkeep create mode 100644 be/Modules/ExternalSystem/Entities/ExternalSystem.php create mode 100644 be/Modules/ExternalSystem/Exceptions/ExternalSystemLaunchException.php create mode 100644 be/Modules/ExternalSystem/Helpers/.gitkeep create mode 100644 be/Modules/ExternalSystem/Http/Controllers/.gitkeep create mode 100644 be/Modules/ExternalSystem/Http/Controllers/ExternalSystemController.php create mode 100644 be/Modules/ExternalSystem/Http/Requests/.gitkeep create mode 100644 be/Modules/ExternalSystem/Jobs/.gitkeep create mode 100644 be/Modules/ExternalSystem/Notifications/.gitkeep create mode 100644 be/Modules/ExternalSystem/Policies/.gitkeep create mode 100644 be/Modules/ExternalSystem/Providers/.gitkeep create mode 100644 be/Modules/ExternalSystem/Providers/EventServiceProvider.php create mode 100644 be/Modules/ExternalSystem/Providers/ExternalSystemServiceProvider.php create mode 100644 be/Modules/ExternalSystem/Providers/RouteServiceProvider.php create mode 100644 be/Modules/ExternalSystem/Repositories/.gitkeep create mode 100644 be/Modules/ExternalSystem/Repositories/Contracts/.gitkeep create mode 100644 be/Modules/ExternalSystem/Routes/.gitkeep create mode 100644 be/Modules/ExternalSystem/Routes/api.php create mode 100644 be/Modules/ExternalSystem/Routes/web.php create mode 100644 be/Modules/ExternalSystem/Services/.gitkeep create mode 100644 be/Modules/ExternalSystem/Services/ExternalSystemLaunchService.php create mode 100644 be/Modules/ExternalSystem/Support/JwtSigner.php create mode 100644 be/Modules/ExternalSystem/Tests/Feature/.gitkeep create mode 100644 be/Modules/ExternalSystem/Tests/Unit/.gitkeep create mode 100644 be/Modules/ExternalSystem/Transformers/.gitkeep create mode 100644 be/Modules/ExternalSystem/Transformers/ExternalSystemResource.php create mode 100644 be/Modules/ExternalSystem/composer.json create mode 100644 be/Modules/ExternalSystem/module.json create mode 100644 be/Modules/ExternalSystem/package.json create mode 100644 fe/src/modules/external-system/composables/useExternalSystemLaunch.ts delete mode 100644 fe/src/modules/external-system/data/dummy-external-systems.ts create mode 100644 fe/src/modules/external-system/services/external-system.service.ts diff --git a/be/.env.example b/be/.env.example index d052703..1bca928 100644 --- a/be/.env.example +++ b/be/.env.example @@ -103,3 +103,5 @@ 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/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/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/config/auth.php b/be/config/auth.php index 56c793d..3418de8 100644 --- a/be/config/auth.php +++ b/be/config/auth.php @@ -143,7 +143,7 @@ return [ 'otp_expiry_minutes' => (int) env('PHONE_VERIFICATION_OTP_EXPIRY_MINUTES', 10), 'token_expiry_minutes' => (int) env('PHONE_VERIFICATION_TOKEN_EXPIRY_MINUTES', 30), 'max_attempts' => (int) env('PHONE_VERIFICATION_OTP_MAX_ATTEMPTS', 5), - 'message' => 'Kod OTP MyKOPKB anda: :otp. Kod ini tamat tempoh dalam :minutes minit.', + 'message' => 'KoPKB: Kod OTP MyKOPKB anda: :otp. Kod ini tamat tempoh dalam :minutes minit.', ], ]; diff --git a/be/modules_statuses.json b/be/modules_statuses.json index c835c83..ab74143 100644 --- a/be/modules_statuses.json +++ b/be/modules_statuses.json @@ -7,5 +7,6 @@ "ExternalAPI": true, "Notification": true, "MembershipApplication": true, - "Activity": true + "Activity": true, + "ExternalSystem": true } \ No newline at end of file diff --git a/fe/src/modules/external-system/composables/useExternalSystemDetail.ts b/fe/src/modules/external-system/composables/useExternalSystemDetail.ts index 9cc3a4e..4b78cce 100644 --- a/fe/src/modules/external-system/composables/useExternalSystemDetail.ts +++ b/fe/src/modules/external-system/composables/useExternalSystemDetail.ts @@ -1,32 +1,52 @@ import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' -import { useExternalSystemList } from './useExternalSystemList' +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' +import { getExternalSystem } from '../services/external-system.service' import { getExternalSystemStatus, isExternalSystemAccessible, } from '../utils/external-system.utils' +import type { ExternalSystem } from '../types/external-system.types' export function useExternalSystemDetail() { const route = useRoute() - const { getSystemById } = useExternalSystemList() const loading = ref(false) const error = ref(null) + const system = ref(null) const systemId = computed(() => String(route.params.id ?? '')) - const system = computed(() => getSystemById(systemId.value) ?? null) - const status = computed(() => (system.value ? getExternalSystemStatus(system.value) : null)) const isAccessible = computed(() => system.value ? isExternalSystemAccessible(system.value) : false, ) + async function fetchSystem(id: string) { + loading.value = true + error.value = null + + try { + system.value = await getExternalSystem(id) + } catch (err) { + system.value = null + error.value = getApiErrorMessage(err, 'Sistem luaran tidak dijumpai.') + } finally { + loading.value = false + } + } + watch( systemId, - () => { - error.value = system.value ? null : 'Sistem luaran tidak dijumpai.' + (id) => { + if (!id) { + system.value = null + error.value = 'Sistem luaran tidak dijumpai.' + return + } + + fetchSystem(id) }, { immediate: true }, ) diff --git a/fe/src/modules/external-system/composables/useExternalSystemLaunch.ts b/fe/src/modules/external-system/composables/useExternalSystemLaunch.ts new file mode 100644 index 0000000..61c4867 --- /dev/null +++ b/fe/src/modules/external-system/composables/useExternalSystemLaunch.ts @@ -0,0 +1,49 @@ +import { ref } from 'vue' +import Swal from 'sweetalert2' +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' +import { launchExternalSystemSso } from '../services/external-system.service' +import { isExternalSystemAccessible } from '../utils/external-system.utils' +import type { ExternalSystem } from '../types/external-system.types' + +export function useExternalSystemLaunch() { + const launching = ref(false) + + async function launchExternalSystem(system: ExternalSystem) { + if (!isExternalSystemAccessible(system) || launching.value) { + return + } + + launching.value = true + + try { + if (system.sso_enabled) { + const response = await launchExternalSystemSso(system.code) + window.open( + response.data.launch_url, + system.opens_in_new_tab ? '_blank' : '_self', + 'noopener,noreferrer', + ) + return + } + + window.open( + system.url, + system.opens_in_new_tab ? '_blank' : '_self', + 'noopener,noreferrer', + ) + } catch (error) { + await Swal.fire({ + icon: 'error', + title: 'Gagal membuka sistem', + text: getApiErrorMessage(error, 'Tidak dapat membuka sistem luaran.'), + }) + } finally { + launching.value = false + } + } + + return { + launching, + launchExternalSystem, + } +} diff --git a/fe/src/modules/external-system/composables/useExternalSystemList.ts b/fe/src/modules/external-system/composables/useExternalSystemList.ts index 0b2048f..0e18dfe 100644 --- a/fe/src/modules/external-system/composables/useExternalSystemList.ts +++ b/fe/src/modules/external-system/composables/useExternalSystemList.ts @@ -1,5 +1,6 @@ -import { computed, ref } from 'vue' -import { dummyExternalSystems } from '../data/dummy-external-systems' +import { computed, onMounted, ref } from 'vue' +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' +import { listExternalSystems } from '../services/external-system.service' import { externalSystemStatusLabel, getExternalSystemStatus, @@ -9,14 +10,16 @@ import type { ExternalSystem } from '../types/external-system.types' export function useExternalSystemList() { const search = ref('') const loading = ref(false) + const error = ref(null) + const allSystems = ref([]) const systems = computed(() => { const query = search.value.trim().toLowerCase() if (!query) { - return dummyExternalSystems + return allSystems.value } - return dummyExternalSystems.filter((system) => { + return allSystems.value.filter((system) => { const haystack = [ system.name, system.code, @@ -35,14 +38,34 @@ export function useExternalSystemList() { ) function getSystemById(id: string): ExternalSystem | undefined { - return dummyExternalSystems.find((system) => system.id === id) + return allSystems.value.find((system) => system.id === id) } + async function fetchSystems() { + loading.value = true + error.value = null + + try { + allSystems.value = await listExternalSystems() + } catch (err) { + error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai sistem luaran.') + allSystems.value = [] + } finally { + loading.value = false + } + } + + onMounted(() => { + fetchSystems() + }) + return { systems, search, loading, + error, availableCount, getSystemById, + fetchSystems, } } diff --git a/fe/src/modules/external-system/data/dummy-external-systems.ts b/fe/src/modules/external-system/data/dummy-external-systems.ts deleted file mode 100644 index f9ae832..0000000 --- a/fe/src/modules/external-system/data/dummy-external-systems.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ExternalSystem } from '../types/external-system.types' - -export const dummyExternalSystems: ExternalSystem[] = [ - { - id: 'ext-001', - code: 'portal-mykopkb', - name: 'Portal Ahli KOPKB', - description: - 'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.', - url: 'https://mykopkb.koppkb.com', - icon: 'Users', - is_active: true, - starts_at: '2026-01-01T00:00:00+08:00', - ends_at: null, - opens_in_new_tab: true, - contact_email: 'dev_kopkb@gmail.com', - notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.', - created_at: '2026-01-15T09:00:00+08:00', - updated_at: '2026-06-01T14:30:00+08:00', - }, - { - id: 'ext-002', - code: 'e-vote', - name: 'Sistem Pengundian AGM KOPKB', - description: - 'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.', - url: 'https://e-vote.erahn.com.my/login', - icon: 'Vote', - is_active: true, - starts_at: '2026-05-01T08:00:00+08:00', - ends_at: null, - opens_in_new_tab: true, - contact_email: 'dev_kopkb@gmail.com', - notes: 'Sila lengkapkan profil sebelum mengundi.', - created_at: '2026-05-20T10:00:00+08:00', - updated_at: '2026-06-28T11:15:00+08:00', - }, -] diff --git a/fe/src/modules/external-system/pages/ExternalSystemDetail.vue b/fe/src/modules/external-system/pages/ExternalSystemDetail.vue index 0b97907..1b99c14 100644 --- a/fe/src/modules/external-system/pages/ExternalSystemDetail.vue +++ b/fe/src/modules/external-system/pages/ExternalSystemDetail.vue @@ -6,15 +6,16 @@ import { Box } from '@/components/ui/box' import { Button } from '@/components/ui/button' import { Lucide } from '@/components/ui/lucide' import { useExternalSystemDetail } from '../composables/useExternalSystemDetail' +import { useExternalSystemLaunch } from '../composables/useExternalSystemLaunch' import { externalSystemStatusLabel, externalSystemStatusVariant, formatExternalSystemDateTime, - openExternalSystem, } from '../utils/external-system.utils' const router = useRouter() const { system, error, status, isAccessible } = useExternalSystemDetail() +const { launching, launchExternalSystem } = useExternalSystemLaunch() function goBack() { router.push({ name: 'list-external-systems' }) @@ -22,7 +23,7 @@ function goBack() { function handleOpen() { if (!system.value) return - openExternalSystem(system.value) + launchExternalSystem(system.value) } @@ -93,7 +94,7 @@ function handleOpen() { @@ -94,7 +101,7 @@ function handleOpen(system: ExternalSystem) { - +
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 +} -- 2.52.0 From 52ffd3393a3d5dbc2a411dbfe1f7a85dfd457633 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Tue, 14 Jul 2026 12:02:37 +0800 Subject: [PATCH 4/4] DONE: feedback module, use permission name for notification --- .../Auth/Actions/Fortify/CreateNewUser.php | 34 +- be/Modules/Feedback/Actions/.gitkeep | 0 be/Modules/Feedback/Config/.gitkeep | 0 be/Modules/Feedback/Config/config.php | 5 + be/Modules/Feedback/Console/.gitkeep | 0 .../Feedback/Database/Factories/.gitkeep | 0 .../Feedback/Database/Migrations/.gitkeep | 0 ...26_07_13_042006_create_feedbacks_table.php | 54 ++ be/Modules/Feedback/Database/Seeders/.gitkeep | 0 .../Seeders/FeedbackDatabaseSeeder.php | 16 + be/Modules/Feedback/Emails/.gitkeep | 0 be/Modules/Feedback/Entities/.gitkeep | 0 be/Modules/Feedback/Entities/Feedback.php | 119 +++++ be/Modules/Feedback/Helpers/.gitkeep | 0 be/Modules/Feedback/Http/Controllers/.gitkeep | 0 .../Http/Controllers/FeedbackController.php | 339 ++++++++++++ be/Modules/Feedback/Http/Requests/.gitkeep | 0 .../Http/Requests/FeedbackRequest.php | 83 +++ be/Modules/Feedback/Jobs/.gitkeep | 0 be/Modules/Feedback/Notifications/.gitkeep | 0 .../Notifications/FeedbackNotification.php | 66 +++ be/Modules/Feedback/Policies/.gitkeep | 0 .../Feedback/Policies/FeedbackPolicy.php | 59 +++ be/Modules/Feedback/Providers/.gitkeep | 0 .../Providers/EventServiceProvider.php | 27 + .../Providers/FeedbackServiceProvider.php | 160 ++++++ .../Providers/RouteServiceProvider.php | 39 ++ be/Modules/Feedback/Repositories/.gitkeep | 0 .../Feedback/Repositories/Contracts/.gitkeep | 0 .../Contracts/FeedbackRepositoryInterface.php | 44 ++ .../Repositories/FeedbackRepository.php | 149 ++++++ be/Modules/Feedback/Routes/.gitkeep | 0 be/Modules/Feedback/Routes/api.php | 20 + be/Modules/Feedback/Services/.gitkeep | 0 be/Modules/Feedback/Tests/Feature/.gitkeep | 0 be/Modules/Feedback/Tests/Unit/.gitkeep | 0 be/Modules/Feedback/Transformers/.gitkeep | 0 .../Transformers/FeedbackResource.php | 79 +++ be/Modules/Feedback/composer.json | 30 ++ be/Modules/Feedback/module.json | 12 + be/Modules/Feedback/package.json | 15 + be/Modules/User/Policies/UserPolicy.php | 22 +- be/app/Traits/NotifiesAdmins.php | 10 +- be/modules_statuses.json | 3 +- fe/src/main/side-menu.ts | 2 + fe/src/modules/auth/pages/Login.vue | 2 + fe/src/modules/auth/pages/Register.vue | 2 + .../feedback/components/HelpdeskFab.vue | 54 ++ .../feedback/composables/useFeedbackList.ts | 104 ++++ fe/src/modules/feedback/index.ts | 22 + fe/src/modules/feedback/menu.ts | 10 + .../modules/feedback/pages/FeedbackDetail.vue | 486 ++++++++++++++++++ .../modules/feedback/pages/FeedbackList.vue | 289 +++++++++++ .../modules/feedback/pages/FeedbackSubmit.vue | 347 +++++++++++++ fe/src/modules/feedback/routes.ts | 33 ++ .../feedback/services/feedback.service.ts | 170 ++++++ .../modules/feedback/types/feedback.types.ts | 180 +++++++ .../pages/MembershipApplication.vue | 2 + fe/src/router/index.ts | 8 +- fe/src/themes/Layout.vue | 13 +- 60 files changed, 3083 insertions(+), 26 deletions(-) create mode 100644 be/Modules/Feedback/Actions/.gitkeep create mode 100644 be/Modules/Feedback/Config/.gitkeep create mode 100644 be/Modules/Feedback/Config/config.php create mode 100644 be/Modules/Feedback/Console/.gitkeep create mode 100644 be/Modules/Feedback/Database/Factories/.gitkeep create mode 100644 be/Modules/Feedback/Database/Migrations/.gitkeep create mode 100644 be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php create mode 100644 be/Modules/Feedback/Database/Seeders/.gitkeep create mode 100644 be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php create mode 100644 be/Modules/Feedback/Emails/.gitkeep create mode 100644 be/Modules/Feedback/Entities/.gitkeep create mode 100644 be/Modules/Feedback/Entities/Feedback.php create mode 100644 be/Modules/Feedback/Helpers/.gitkeep create mode 100644 be/Modules/Feedback/Http/Controllers/.gitkeep create mode 100644 be/Modules/Feedback/Http/Controllers/FeedbackController.php create mode 100644 be/Modules/Feedback/Http/Requests/.gitkeep create mode 100644 be/Modules/Feedback/Http/Requests/FeedbackRequest.php create mode 100644 be/Modules/Feedback/Jobs/.gitkeep create mode 100644 be/Modules/Feedback/Notifications/.gitkeep create mode 100644 be/Modules/Feedback/Notifications/FeedbackNotification.php create mode 100644 be/Modules/Feedback/Policies/.gitkeep create mode 100644 be/Modules/Feedback/Policies/FeedbackPolicy.php create mode 100644 be/Modules/Feedback/Providers/.gitkeep create mode 100644 be/Modules/Feedback/Providers/EventServiceProvider.php create mode 100644 be/Modules/Feedback/Providers/FeedbackServiceProvider.php create mode 100644 be/Modules/Feedback/Providers/RouteServiceProvider.php create mode 100644 be/Modules/Feedback/Repositories/.gitkeep create mode 100644 be/Modules/Feedback/Repositories/Contracts/.gitkeep create mode 100644 be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php create mode 100644 be/Modules/Feedback/Repositories/FeedbackRepository.php create mode 100644 be/Modules/Feedback/Routes/.gitkeep create mode 100644 be/Modules/Feedback/Routes/api.php create mode 100644 be/Modules/Feedback/Services/.gitkeep create mode 100644 be/Modules/Feedback/Tests/Feature/.gitkeep create mode 100644 be/Modules/Feedback/Tests/Unit/.gitkeep create mode 100644 be/Modules/Feedback/Transformers/.gitkeep create mode 100644 be/Modules/Feedback/Transformers/FeedbackResource.php create mode 100644 be/Modules/Feedback/composer.json create mode 100644 be/Modules/Feedback/module.json create mode 100644 be/Modules/Feedback/package.json create mode 100644 fe/src/modules/feedback/components/HelpdeskFab.vue create mode 100644 fe/src/modules/feedback/composables/useFeedbackList.ts create mode 100644 fe/src/modules/feedback/index.ts create mode 100644 fe/src/modules/feedback/menu.ts create mode 100644 fe/src/modules/feedback/pages/FeedbackDetail.vue create mode 100644 fe/src/modules/feedback/pages/FeedbackList.vue create mode 100644 fe/src/modules/feedback/pages/FeedbackSubmit.vue create mode 100644 fe/src/modules/feedback/routes.ts create mode 100644 fe/src/modules/feedback/services/feedback.service.ts create mode 100644 fe/src/modules/feedback/types/feedback.types.ts diff --git a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php index fb27230..52ea091 100644 --- a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php +++ b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php @@ -13,6 +13,7 @@ use Modules\Auth\Entities\User; use Modules\Auth\Services\PhoneVerificationOtpService; use Modules\Role\Entities\Role; use Modules\User\Notifications\UserActivationNotification; +use Modules\User\Policies\UserPolicy; use Exception; class CreateNewUser implements CreatesNewUsers @@ -23,11 +24,6 @@ class CreateNewUser implements CreatesNewUsers protected PhoneVerificationOtpService $phoneVerificationOtpService, ) {} - /** - * Validate and create a newly registered user. - * - * @param array $input - */ public function create(array $input): User { $phoneNumber = $this->phoneVerificationOtpService->normalizePhoneNumber($input['phone_number'] ?? ''); @@ -77,6 +73,34 @@ class CreateNewUser implements CreatesNewUsers $user->assignRole($role); } + if ($user->status === 'pending') { + $this->notifyAdminsForActivation($user); + } + return $user; } + + /** + * Notify users who can kemaskini pengguna about new user requiring activation. + */ + private function notifyAdminsForActivation(User $newUser): void + { + try { + $recipients = $this->getUsersWithPermission(UserPolicy::PERMISSION_UPDATE) + ->where('id', '!=', $newUser->id); + + $sender = auth()->user() ?? $newUser; + + foreach ($recipients as $recipient) { + try { + $recipient->notify(new UserActivationNotification($newUser, $sender)); + } catch (Exception $e) { + Log::error('Failed to send user activation notification: '.$e->getMessage()); + } + } + } catch (Exception $e) { + Log::error('Failed to notify admins for user activation: '.$e->getMessage()); + } + } + } 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/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/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/modules_statuses.json b/be/modules_statuses.json index ab74143..ea6b6af 100644 --- a/be/modules_statuses.json +++ b/be/modules_statuses.json @@ -8,5 +8,6 @@ "Notification": true, "MembershipApplication": true, "Activity": true, - "ExternalSystem": true + "ExternalSystem": true, + "Feedback": true } \ No newline at end of file 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/pages/Login.vue b/fe/src/modules/auth/pages/Login.vue index 01ae6b8..9cd8a4b 100644 --- a/fe/src/modules/auth/pages/Login.vue +++ b/fe/src/modules/auth/pages/Login.vue @@ -12,6 +12,7 @@ import { login, resolvePostAuthRoute, } from '@/modules/auth' +import { HelpdeskFab } from '@/modules/feedback' import { useAuthStore } from '@/stores/auth' import illustrationUrl from '@/assets/images/logo.svg' @@ -70,6 +71,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION 'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]', 'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary', ]"> +
+
+import { Lucide } from '@/components/ui/lucide' +import { useRouter } from 'vue-router' + +const props = withDefaults( + defineProps<{ + /** Vertical offset from vertical center, in rem (matches Layout side tabs). */ + offsetRem?: number + /** Open feedback form in a new tab instead of navigating in place. */ + openInNewTab?: boolean + }>(), + { + offsetRem: 3.5, + openInNewTab: true, + }, +) + +const router = useRouter() + +function openHelpdesk(event: MouseEvent) { + event.preventDefault() + + const { href } = router.resolve({ name: 'feedback-submit' }) + + if (props.openInNewTab) { + window.open(href, '_blank', 'noopener,noreferrer') + return + } + + router.push({ name: 'feedback-submit' }) +} + + + 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 @@ + + +