From 324b7facf17c466b1ebf52293ee0308b0a8f6e51 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 13 Jul 2026 12:15:44 +0800 Subject: [PATCH] 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 +}