Compare commits

...

10 Commits

Author SHA1 Message Date
ISMAIL MASSERAN e6e4ba7dbe Merge branch 'feature/update-saham-yuran-pelaburan' into 'main'
DONE: add flexible json update saham, yuran, and pelaburan ui

See merge request erahn/voting!21
2026-07-19 03:55:28 +00:00
ISMAIL MASSERAN cd1dc4b9d8 DONE: add flexible json update saham, yuran, and pelaburan ui 2026-07-19 11:54:13 +08:00
ISMAIL MASSERAN fa2eaef6c4 Merge branch 'feature/implement-sso' into 'main'
DONE: disable login and implement sso

See merge request erahn/voting!20
2026-07-14 07:02:11 +00:00
ISMAIL MASSERAN d5563d11c9 DONE: disable login and implement sso 2026-07-14 15:01:25 +08:00
ISMAIL MASSERAN 213ac73e07 Merge branch 'feature/standardize-ui-display' into 'main'
Feature/standardize ui display

See merge request erahn/voting!19
2026-06-23 07:44:42 +00:00
ISMAIL MASSERAN 378b00a1e6 Feature/standardize ui display 2026-06-23 07:44:42 +00:00
ISMAIL MASSERAN a969d4bbcf Merge branch 'feature/add-dividen-share-display' into 'main'
DONE: add dividen for yuran and saham by injecting json

See merge request erahn/voting!18
2026-06-23 05:13:19 +00:00
ISMAIL MASSERAN 11cbededf6 DONE: add dividen for yuran and saham by injecting json 2026-06-23 13:12:33 +08:00
ISMAIL MASSERAN 8bbd64a91f Merge branch 'feature/add-jumlah-div-terkumpul' into 'main'
DONE: add accumulated amount display

See merge request erahn/voting!17
2026-05-18 03:53:45 +00:00
ISMAIL MASSERAN fe9223cae1 DONE: add accumulated amount display 2026-05-18 11:52:32 +08:00
53 changed files with 11499 additions and 309 deletions
Vendored
BIN
View File
Binary file not shown.
+9 -1
View File
@@ -54,4 +54,12 @@ ONEWAY_SMS_LANG=1
PHYSICAL_ATTENDANCE_GATE_ENABLED=false
PHYSICAL_ATTENDANCE_GATE_SECRET=
PHYSICAL_ATTENDANCE_GATE_PERIOD=60
PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY=
PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY=
# API token idle lifetime in minutes (enforced server-side for Passport tokens)
VOTER_TOKEN_LIFETIME=120
ADMIN_TOKEN_LIFETIME=480
MYKOPKB_SSO_SECRET=
MYKOPKB_SSO_ISSUER="MYKOPKB 1.0"
MYKOPKB_SSO_AUDIENCE=e-vote
+1 -1
View File
@@ -12,7 +12,7 @@
[x] hardcode on daftar kehadiran
[x] buang flow pengesahan
[x] tambah siapa yang bagi duit (activity log)
[ ] there is certain part/files that is hardcoded
[x] there is certain part/files that is hardcoded
Penambahan Untuk Sistem Mengundi
[x] Pendaftaran kehadiran (fizikal/maya)
@@ -0,0 +1,171 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class UpdateVoterDividenFromJson extends Command
{
protected $signature = 'voter:update-dividen-from-json
{--file=database/seeds/inject/dividen_yuransaham.json : Relative path from project root}
{--election_id= : Override election_id (default: latest in voter table)}
{--dry-run : Show what would change without updating}';
protected $description = 'Update voter saham/yuran and populate dividen_saham/dividen_yuran from JSON for latest election only';
public function handle()
{
$fileOpt = (string) $this->option('file');
$filePath = $this->resolvePath($fileOpt);
if (!is_file($filePath)) {
$this->error("❌ JSON file not found: {$filePath}");
return 1;
}
$electionId = $this->option('election_id');
$electionId = $electionId !== null && $electionId !== '' ? (int) $electionId : (int) DB::table('voter')->max('election_id');
if ($electionId <= 0) {
$this->error("❌ Cannot determine latest election_id (got {$electionId}).");
return 1;
}
$json = file_get_contents($filePath);
$rows = json_decode($json, true);
if (!is_array($rows)) {
$this->error("❌ Invalid JSON: expected array of rows.");
return 1;
}
$map = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$noAnggota = isset($row['no_anggota']) ? trim((string) $row['no_anggota']) : '';
if ($noAnggota === '') {
continue;
}
$map[$noAnggota] = [
'saham' => $this->cleanNumber($row['saham'] ?? 0),
'yuran' => $this->cleanNumber($row['yuran'] ?? 0),
'dividen_saham' => $this->cleanNumber($row['dividen_saham'] ?? 0),
'dividen_yuran' => $this->cleanNumber($row['dividen_yuran'] ?? 0),
];
}
if (count($map) === 0) {
$this->error("❌ No usable rows found in JSON (missing no_anggota?).");
return 1;
}
$dryRun = (bool) $this->option('dry-run');
$this->info("Target election_id: {$electionId}");
$this->info("JSON members loaded: " . count($map));
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
$this->info("Voters in election: {$totalInElection}");
$updated = 0;
$matched = 0;
$missingInJson = 0;
$unchanged = 0;
$bar = $this->output->createProgressBar(max(1, $totalInElection));
$bar->start();
DB::table('voter')
->select(['id', 'no_anggota', 'saham', 'yuran', 'dividen_saham', 'dividen_yuran'])
->where('election_id', $electionId)
->orderBy('id')
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
foreach ($chunk as $voter) {
$noAnggota = $voter->no_anggota !== null ? trim((string) $voter->no_anggota) : '';
if ($noAnggota === '' || !array_key_exists($noAnggota, $map)) {
$missingInJson++;
$bar->advance();
continue;
}
$matched++;
$jsonRow = $map[$noAnggota];
$oldSaham = $voter->saham !== null ? (float) $voter->saham : 0.0;
$oldYuran = $voter->yuran !== null ? (float) $voter->yuran : 0.0;
$oldDividenSaham = $voter->dividen_saham !== null ? (float) $voter->dividen_saham : 0.0;
$oldDividenYuran = $voter->dividen_yuran !== null ? (float) $voter->dividen_yuran : 0.0;
if (
abs($oldSaham - $jsonRow['saham']) < 0.00001
&& abs($oldYuran - $jsonRow['yuran']) < 0.00001
&& abs($oldDividenSaham - $jsonRow['dividen_saham']) < 0.00001
&& abs($oldDividenYuran - $jsonRow['dividen_yuran']) < 0.00001
) {
$unchanged++;
$bar->advance();
continue;
}
if (!$dryRun) {
DB::table('voter')
->where('id', $voter->id)
->update([
'saham' => $jsonRow['saham'],
'yuran' => $jsonRow['yuran'],
'dividen_saham' => $jsonRow['dividen_saham'],
'dividen_yuran' => $jsonRow['dividen_yuran'],
'updated_at' => now(),
]);
}
$updated++;
$bar->advance();
}
}, 'id');
$bar->finish();
$this->line('');
$this->info("✅ Matched in JSON: {$matched}");
$this->info("✅ Updated: {$updated}" . ($dryRun ? " (would update)" : ""));
$this->info("✅ Unchanged: {$unchanged}");
$this->info("⚠️ Missing in JSON: {$missingInJson}");
return 0;
}
private function resolvePath(string $path): string
{
if (strpos($path, DIRECTORY_SEPARATOR) === 0) {
return $path;
}
return base_path($path);
}
private function cleanNumber($value): float
{
if ($value === null) {
return 0.0;
}
$value = (string) $value;
$value = str_replace(['RM', ',', ' '], '', $value);
if (preg_match('/^\((.*)\)$/', $value, $matches)) {
$value = $matches[1];
}
$value = preg_replace('/[^0-9\.\-]/', '', $value);
return is_numeric($value) ? (float) $value : 0.0;
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class UpdateVoterLatestYuranSahamFromJson extends Command
{
protected $signature = 'voter:update-latest-yuransaham-from-json
{--file=database/seeds/inject/latest_yuransaham.json : Relative path from project root}
{--election_id= : Override election_id (default: latest in voter table)}
{--dry-run : Show what would change without updating}';
protected $description = 'Update voter saham_terkini/yuran_terkini from JSON for latest election only';
public function handle()
{
$fileOpt = (string) $this->option('file');
$filePath = $this->resolvePath($fileOpt);
if (!is_file($filePath)) {
$this->error("❌ JSON file not found: {$filePath}");
return 1;
}
$electionId = $this->option('election_id');
$electionId = $electionId !== null && $electionId !== '' ? (int) $electionId : (int) DB::table('voter')->max('election_id');
if ($electionId <= 0) {
$this->error("❌ Cannot determine latest election_id (got {$electionId}).");
return 1;
}
$json = file_get_contents($filePath);
$rows = json_decode($json, true);
if (!is_array($rows)) {
$this->error("❌ Invalid JSON: expected array of rows.");
return 1;
}
$map = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$noAnggota = isset($row['no_anggota']) ? trim((string) $row['no_anggota']) : '';
if ($noAnggota === '') {
continue;
}
$map[$noAnggota] = [
'saham_terkini' => $this->cleanNumber($row['saham_terkini'] ?? 0),
'yuran_terkini' => $this->cleanNumber($row['yuran_terkini'] ?? 0),
];
}
if (count($map) === 0) {
$this->error("❌ No usable rows found in JSON (missing no_anggota?).");
return 1;
}
$dryRun = (bool) $this->option('dry-run');
$this->info("Target election_id: {$electionId}");
$this->info("JSON members loaded: " . count($map));
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
$this->info("Voters in election: {$totalInElection}");
$updated = 0;
$matched = 0;
$missingInJson = 0;
$unchanged = 0;
$bar = $this->output->createProgressBar(max(1, $totalInElection));
$bar->start();
DB::table('voter')
->select(['id', 'no_anggota', 'saham_terkini', 'yuran_terkini'])
->where('election_id', $electionId)
->orderBy('id')
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
foreach ($chunk as $voter) {
$noAnggota = $voter->no_anggota !== null ? trim((string) $voter->no_anggota) : '';
if ($noAnggota === '' || !array_key_exists($noAnggota, $map)) {
$missingInJson++;
$bar->advance();
continue;
}
$matched++;
$jsonRow = $map[$noAnggota];
$oldSahamTerkini = $voter->saham_terkini !== null ? (float) $voter->saham_terkini : 0.0;
$oldYuranTerkini = $voter->yuran_terkini !== null ? (float) $voter->yuran_terkini : 0.0;
if (
abs($oldSahamTerkini - $jsonRow['saham_terkini']) < 0.00001
&& abs($oldYuranTerkini - $jsonRow['yuran_terkini']) < 0.00001
) {
$unchanged++;
$bar->advance();
continue;
}
if (!$dryRun) {
DB::table('voter')
->where('id', $voter->id)
->update([
'saham_terkini' => $jsonRow['saham_terkini'],
'yuran_terkini' => $jsonRow['yuran_terkini'],
'updated_at' => now(),
]);
}
$updated++;
$bar->advance();
}
}, 'id');
$bar->finish();
$this->line('');
$this->info("✅ Matched in JSON: {$matched}");
$this->info("✅ Updated: {$updated}" . ($dryRun ? " (would update)" : ""));
$this->info("✅ Unchanged: {$unchanged}");
$this->info("⚠️ Missing in JSON: {$missingInJson}");
return 0;
}
private function resolvePath(string $path): string
{
if (strpos($path, DIRECTORY_SEPARATOR) === 0) {
return $path;
}
return base_path($path);
}
private function cleanNumber($value): float
{
if ($value === null) {
return 0.0;
}
$value = (string) $value;
$value = str_replace(['RM', ',', ' '], '', $value);
if (preg_match('/^\((.*)\)$/', $value, $matches)) {
$value = $matches[1];
}
$value = preg_replace('/[^0-9\.\-]/', '', $value);
return is_numeric($value) ? (float) $value : 0.0;
}
}
@@ -12,7 +12,7 @@ class UpdateVoterPelaburanFromJson extends Command
{--election_id= : Override election_id (default: latest in voter table)}
{--dry-run : Show what would change without updating}';
protected $description = 'Update voter pelaburan (dividen) from list-pelabur.json for latest election only';
protected $description = 'Update voter dividen_pelaburan (from JSON pelaburan) and accumulated_amount from list-pelabur.json for latest election only';
public function handle()
{
@@ -51,8 +51,11 @@ class UpdateVoterPelaburanFromJson extends Command
continue;
}
$pelaburanRaw = $row['pelaburan'] ?? 0;
$map[$noAnggota] = $this->cleanNumber($pelaburanRaw);
// JSON key "pelaburan" is the dividend amount → voter.dividen_pelaburan
$map[$noAnggota] = [
'dividen_pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 0),
'accumulated_amount' => $this->cleanNumber($row['accumulated_amount'] ?? 0),
];
}
if (count($map) === 0) {
@@ -65,6 +68,7 @@ class UpdateVoterPelaburanFromJson extends Command
$this->info("Target election_id: {$electionId}");
$this->info("JSON members loaded: " . count($map));
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
$this->info("Mapping: JSON.pelaburan → voter.dividen_pelaburan, JSON.accumulated_amount → voter.accumulated_amount");
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
$this->info("Voters in election: {$totalInElection}");
@@ -78,7 +82,7 @@ class UpdateVoterPelaburanFromJson extends Command
$bar->start();
DB::table('voter')
->select(['id', 'no_anggota', 'pelaburan'])
->select(['id', 'no_anggota', 'dividen_pelaburan', 'accumulated_amount'])
->where('election_id', $electionId)
->orderBy('id')
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
@@ -92,11 +96,15 @@ class UpdateVoterPelaburanFromJson extends Command
}
$matched++;
$jsonRow = $map[$noAnggota];
$newPelaburan = $map[$noAnggota];
$oldPelaburan = $voter->pelaburan !== null ? (float) $voter->pelaburan : 0.0;
$oldDividen = $voter->dividen_pelaburan !== null ? (float) $voter->dividen_pelaburan : 0.0;
$oldAccumulated = $voter->accumulated_amount !== null ? (float) $voter->accumulated_amount : 0.0;
if (abs($oldPelaburan - $newPelaburan) < 0.00001) {
if (
abs($oldDividen - $jsonRow['dividen_pelaburan']) < 0.00001
&& abs($oldAccumulated - $jsonRow['accumulated_amount']) < 0.00001
) {
$unchanged++;
$bar->advance();
continue;
@@ -106,7 +114,8 @@ class UpdateVoterPelaburanFromJson extends Command
DB::table('voter')
->where('id', $voter->id)
->update([
'pelaburan' => $newPelaburan,
'dividen_pelaburan' => $jsonRow['dividen_pelaburan'],
'accumulated_amount' => $jsonRow['accumulated_amount'],
'updated_at' => now(),
]);
}
@@ -129,7 +138,6 @@ class UpdateVoterPelaburanFromJson extends Command
private function resolvePath(string $path): string
{
// absolute path
if (strpos($path, DIRECTORY_SEPARATOR) === 0) {
return $path;
}
@@ -139,7 +147,7 @@ class UpdateVoterPelaburanFromJson extends Command
private function cleanNumber($value): float
{
if ($value === null) {
if ($value === null || $value === '') {
return 0.0;
}
@@ -155,4 +163,3 @@ class UpdateVoterPelaburanFromJson extends Command
return is_numeric($value) ? (float) $value : 0.0;
}
}
+2
View File
@@ -16,6 +16,8 @@ class Kernel extends ConsoleKernel
//
Commands\ImportCSV::class,
Commands\UpdateVoterPelaburanFromJson::class,
Commands\UpdateVoterDividenFromJson::class,
Commands\UpdateVoterLatestYuranSahamFromJson::class,
];
/**
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\Support\ApiTokenLifetime;
use App\User;
use Illuminate\Http\Request;
@@ -72,12 +73,12 @@ class LeaveController extends Controller
])
->log("admin leave impersonation: {$impersonator->email}");
return response()->json([
return response()->json(array_merge([
'status' => 'success',
'message' => 'Berjaya kembali ke akaun asal.',
'user' => $impersonator,
'token' => $accessToken,
]);
], ApiTokenLifetime::loginMeta('admin')));
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\Support\ApiTokenLifetime;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -70,13 +71,13 @@ class StartController extends Controller
])
->log("admin impersonate: {$impersonator->email} -> {$target->email}");
return response()->json([
return response()->json(array_merge([
'status' => 'success',
'message' => 'Impersonate berjaya.',
'user' => $target,
'impersonator' => $impersonator,
'token' => $accessToken,
]);
], ApiTokenLifetime::loginMeta('admin')));
}
}
@@ -6,6 +6,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Support\ApiTokenLifetime;
use App\User;
class LoginController extends Controller
@@ -21,11 +22,13 @@ class LoginController extends Controller
if ($this->checkLogin($request)) {
$user = Auth::user();
ApiTokenLifetime::revokeActiveTokens($user);
$result['status'] = 'success';
$result['message'] = 'Login Successfully';
$result['user'] = $user;
$result['election_status'] = Util::getElectionStatus();
$result['token'] = $user->createToken('My app', ['admin'])->accessToken;
$result = array_merge($result, ApiTokenLifetime::loginMeta('admin'));
activity()
->performedOn($user)
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Import keanggotaan JSON (match by no_anggota) into voter money columns for the current election.
* Expected row keys: no_anggota, pelaburan, pelaburan_2, saham, yuran. Null/empty → 0.
*/
class ImportKeanggotaanJsonController extends Controller
{
private const UPDATABLE_FIELDS = [
'pelaburan',
'pelaburan_2',
'saham',
'yuran',
];
public function __invoke(Request $request)
{
$this->validate($request, [
'file' => 'required|file|max:10240',
]);
$file = $request->file('file');
$extension = strtolower($file->getClientOriginalExtension() ?: '');
if ($extension !== 'json') {
return response()->json([
'status' => 'failed',
'message' => 'Fail mestilah format .json.',
], 422);
}
$raw = file_get_contents($file->getRealPath());
$rows = json_decode($raw, true);
if (!is_array($rows)) {
return response()->json([
'status' => 'failed',
'message' => 'JSON tidak sah. Jangkaan array objek.',
], 422);
}
$map = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$noAnggota = isset($row['no_anggota']) ? trim((string) $row['no_anggota']) : '';
if ($noAnggota === '') {
continue;
}
$payload = [];
foreach (self::UPDATABLE_FIELDS as $field) {
if (array_key_exists($field, $row)) {
$payload[$field] = $this->cleanNumber($row[$field]);
}
}
if (count($payload) === 0) {
continue;
}
$map[$noAnggota] = $payload;
}
if (count($map) === 0) {
return response()->json([
'status' => 'failed',
'message' => 'Tiada baris sah dalam JSON (perlu no_anggota dan sekurang-kurangnya satu medan).',
], 422);
}
$electionId = (int) Util::getCurrentElection();
if ($electionId <= 0) {
return response()->json([
'status' => 'failed',
'message' => 'Tidak dapat mengenal pasti election semasa.',
], 422);
}
$stats = [
'election_id' => $electionId,
'json_rows' => count($map),
'matched' => 0,
'updated' => 0,
'unchanged' => 0,
'missing_in_voter' => 0,
'missing_in_json' => 0,
];
$matchedKeys = [];
DB::table('voter')
->select(array_merge(['id', 'no_anggota'], self::UPDATABLE_FIELDS))
->where('election_id', $electionId)
->orderBy('id')
->chunkById(500, function ($chunk) use ($map, &$stats, &$matchedKeys) {
foreach ($chunk as $voter) {
$noAnggota = $voter->no_anggota !== null ? trim((string) $voter->no_anggota) : '';
if ($noAnggota === '' || !array_key_exists($noAnggota, $map)) {
$stats['missing_in_json']++;
continue;
}
$stats['matched']++;
$matchedKeys[$noAnggota] = true;
$jsonRow = $map[$noAnggota];
$update = [];
foreach ($jsonRow as $field => $newValue) {
$oldValue = $voter->{$field} !== null ? (float) $voter->{$field} : 0.0;
if (abs($oldValue - $newValue) >= 0.00001) {
$update[$field] = $newValue;
}
}
if (count($update) === 0) {
$stats['unchanged']++;
continue;
}
$update['updated_at'] = now();
DB::table('voter')->where('id', $voter->id)->update($update);
$stats['updated']++;
}
}, 'id');
foreach ($map as $noAnggota => $_payload) {
if (!isset($matchedKeys[$noAnggota])) {
$stats['missing_in_voter']++;
}
}
activity()
->withProperties([
'election_id' => $electionId,
'filename' => $file->getClientOriginalName(),
'stats' => $stats,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('import keanggotaan json');
return response()->json([
'status' => 'success',
'message' => 'Import keanggotaan berjaya.',
'data' => $stats,
]);
}
private function cleanNumber($value): float
{
if ($value === null || $value === '') {
return 0.0;
}
$value = (string) $value;
$value = str_replace(['RM', ',', ' '], '', $value);
if (preg_match('/^\((.*)\)$/', $value, $matches)) {
$value = $matches[1];
}
$value = preg_replace('/[^0-9\.\-]/', '', $value);
return is_numeric($value) ? (float) $value : 0.0;
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API\v1\Voter;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Support\ApiTokenLifetime;
use App\UserOTP;
use App\Voter;
use Carbon\Carbon;
@@ -219,6 +220,7 @@ class LoginController extends Controller
if (Auth::guard('voter')->attempt(['no_kp' => $request->no_kp, 'password' => 'admin', 'election_id' => Util::getCurrentElection()])) {
$user = Auth::guard('voter')->user();
ApiTokenLifetime::revokeActiveTokens($user);
activity()
->performedOn($user)
->withProperties([
@@ -231,13 +233,13 @@ class LoginController extends Controller
])
->log("voter login: {$user->name}");
return response()->json([
return response()->json(array_merge([
'status' => 'success',
'message' => 'Login successfully.',
'user' => Auth::guard('voter')->user(),
'election_status' => Util::getElectionStatus(),
'token' => Auth::guard('voter')->user()->createToken('My Token', ['vote'])->accessToken,
]);
], ApiTokenLifetime::loginMeta('voter')));
}
activity()
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Support\ApiTokenLifetime;
use App\Support\JwtVerifier;
use App\Voter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SsoLoginController extends Controller
{
public function login(Request $request)
{
$token = $request->query('token');
if (! $token) {
return $this->redirectToLogin('Token SSO tidak sah.', 'token_invalid');
}
$secret = config('services.mykopkb.sso_secret');
if (! $secret) {
return $this->redirectToLogin(
app()->environment('local', 'development')
? 'MYKOPKB_SSO_SECRET belum dikonfigurasi dalam .env.'
: 'Token SSO tamat tempoh atau tidak sah.',
'token_invalid'
);
}
$payload = JwtVerifier::verify($token, $secret, [
'issuer' => config('services.mykopkb.sso_issuer', 'mykopkb'),
'audience' => config('services.mykopkb.sso_audience', 'e-vote'),
]);
if (! $payload) {
return $this->redirectToLogin('Token SSO tamat tempoh atau tidak sah.', 'token_invalid');
}
$jti = $payload['jti'] ?? null;
if (! $jti || Cache::has("sso_jti:$jti")) {
return $this->redirectToLogin('Token SSO telah digunakan.', 'token_used');
}
$icNumber = preg_replace('/[\s-]+/', '', (string) ($payload['ic_number'] ?? ''));
$memberNumber = trim((string) ($payload['member_number'] ?? ''));
if ($icNumber === '' || $memberNumber === '') {
return $this->redirectToLogin('Token SSO tidak sah.', 'token_invalid');
}
$voter = Voter::query()
->where('no_kp', $icNumber)
->where('no_anggota', $memberNumber)
->where('election_id', Util::getCurrentElection())
->first();
if (! $voter) {
activity()
->withProperties([
'ic_number' => $icNumber,
'member_number' => $memberNumber,
'election_id' => Util::getCurrentElection(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('voter sso login rejected: not in current election');
return $this->redirectToLogin(
'Akaun pengundi tidak dijumpai untuk pilihan raya semasa. Sila daftar di kaunter IT atau log masuk secara manual.',
'voter_not_found'
);
}
Cache::put("sso_jti:$jti", true, now()->addMinutes(5));
ApiTokenLifetime::revokeActiveTokens($voter);
$passportToken = $voter->createToken('SSO Login', ['vote'])->accessToken;
$meta = ApiTokenLifetime::loginMeta('voter');
activity()
->performedOn($voter)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'no_kp' => $voter->no_kp,
'no_anggota' => $voter->no_anggota,
'sso_sub' => $payload['sub'] ?? null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("voter sso login: {$voter->name}");
$query = http_build_query([
'sso_token' => $passportToken,
'token_expires_at' => $meta['token_expires_at'],
]);
return redirect('/login?'.$query);
}
private function redirectToLogin(string $message, string $code = 'sso_error')
{
return redirect('/login?'.http_build_query([
'sso_error' => $message,
'sso_error_code' => $code,
]));
}
}
+1
View File
@@ -67,5 +67,6 @@ class Kernel extends HttpKernel
'has_voted' => \App\Http\Middleware\HasVotedMiddleware::class,
'physical_gate_display' => \App\Http\Middleware\PhysicalGateDisplayKeyMiddleware::class,
'fizikal_registration_verified' => \App\Http\Middleware\FizikalRegistrationVerifiedMiddleware::class,
'api_token_lifetime' => \App\Http\Middleware\EnforceApiTokenLifetimeMiddleware::class,
];
}
@@ -41,6 +41,8 @@ class ElectionMiddleware
&& !($request->is('api/v1/voter') && $request->isMethod('post'))
// Allow updating applicable voters list during election (add page uses POST /api/v1/voter/sync-applicable)
&& !($request->is('api/v1/voter/sync-applicable') && $request->isMethod('post'))
// Allow keanggotaan JSON import during election
&& !($request->is('api/v1/voter/import-keanggotaan-json') && $request->isMethod('post'))
&& !$request->is('api/v1/voter/*/verify-fizikal-registration')
// Pentadbir utama: urus akaun (tambah/kemaskini/padam) semasa mengundi
&& !$request->is('api/v1/admin')
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use App\Support\ApiTokenLifetime;
use Closure;
class EnforceApiTokenLifetimeMiddleware
{
/**
* Enforce application session lifetime for Passport personal access tokens.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string $portal voter|admin
* @return mixed
*/
public function handle($request, Closure $next, $portal = 'voter')
{
$user = $request->user();
if (! $user || ! ApiTokenLifetime::isExpired($user, $portal)) {
return $next($request);
}
try {
if (method_exists($user, 'token') && $user->token()) {
$user->token()->revoke();
}
} catch (\Throwable $e) {
// Continue with 401 even if revoke fails.
}
return response()->json([
'status' => 'failed',
'message' => 'Sesi tamat. Sila log masuk semula.',
], 401);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Support;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
class ApiTokenLifetime
{
public static function minutesForPortal(string $portal): int
{
$key = $portal . '_lifetime';
return (int) config('api_tokens.' . $key, config('session.lifetime', 120));
}
public static function expiresAtForPortal(string $portal): Carbon
{
return Carbon::now()->addMinutes(self::minutesForPortal($portal));
}
public static function loginMeta(string $portal): array
{
$expiresAt = self::expiresAtForPortal($portal);
return [
'token_expires_at' => $expiresAt->toIso8601String(),
'token_lifetime_minutes' => self::minutesForPortal($portal),
];
}
public static function isExpired(Authenticatable $user, string $portal): bool
{
if (! method_exists($user, 'token')) {
return false;
}
$token = $user->token();
if (! $token || ! $token->created_at) {
return false;
}
return Carbon::now()->greaterThanOrEqualTo(
Carbon::parse($token->created_at)->addMinutes(self::minutesForPortal($portal))
);
}
public static function revokeActiveTokens(Authenticatable $user): void
{
if (! method_exists($user, 'tokens')) {
return;
}
try {
$user->tokens()->where('revoked', false)->update(['revoked' => true]);
} catch (\Throwable $e) {
// Login should not fail if revocation fails.
}
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace App\Support;
class JwtVerifier
{
/**
* Verify an HS256 JWT and return the payload, or null if invalid.
*
* @param array{issuer?: string, audience?: string, leeway?: int} $options
*/
public static function verify(string $token, string $secret, array $options = []): ?array
{
$result = self::diagnose($token, $secret, $options);
return $result['valid'] ? $result['payload'] : null;
}
/**
* @param array{issuer?: string, audience?: string, leeway?: int} $options
* @return array{
* valid: bool,
* reason: string|null,
* payload: array|null,
* claims: array<string, mixed>|null
* }
*/
public static function diagnose(string $token, string $secret, array $options = []): array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return self::failure('malformed_token');
}
[$headerB64, $payloadB64, $signatureB64] = $parts;
$expected = self::base64UrlEncode(
hash_hmac('sha256', "$headerB64.$payloadB64", $secret, true)
);
if (! hash_equals($expected, $signatureB64)) {
return self::failure('invalid_signature', null, self::decodePayload($payloadB64));
}
$header = json_decode(self::base64UrlDecode($headerB64), true);
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
if (! is_array($header) || ! is_array($payload)) {
return self::failure('invalid_json');
}
if (($header['alg'] ?? '') !== 'HS256') {
return self::failure('unsupported_algorithm', $payload);
}
$issuer = $options['issuer'] ?? null;
if ($issuer !== null && ($payload['iss'] ?? '') !== $issuer) {
return self::failure('issuer_mismatch', $payload);
}
$audience = $options['audience'] ?? null;
if ($audience !== null && ($payload['aud'] ?? '') !== $audience) {
return self::failure('audience_mismatch', $payload);
}
$leeway = (int) ($options['leeway'] ?? 0);
$now = time();
if (($payload['exp'] ?? 0) < ($now - $leeway)) {
return self::failure('token_expired', $payload);
}
if (($payload['nbf'] ?? 0) > ($now + $leeway)) {
return self::failure('token_not_yet_valid', $payload);
}
return [
'valid' => true,
'reason' => null,
'payload' => $payload,
'claims' => self::summarizeClaims($payload),
];
}
public static function isLaravelEncryptedSecret(?string $secret): bool
{
if (! is_string($secret) || $secret === '') {
return false;
}
$decoded = base64_decode($secret, true);
if ($decoded === false) {
return false;
}
$json = json_decode($decoded, true);
return is_array($json)
&& array_key_exists('iv', $json)
&& array_key_exists('value', $json)
&& array_key_exists('mac', $json);
}
/**
* @return array<string, mixed>|null
*/
protected static function decodePayload(string $payloadB64): ?array
{
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
return is_array($payload) ? $payload : null;
}
/**
* @param array<string, mixed>|null $payload
* @return array{valid: false, reason: string, payload: null, claims: array<string, mixed>|null}
*/
protected static function failure(string $reason, ?array $payload = null, ?array $claims = null): array
{
return [
'valid' => false,
'reason' => $reason,
'payload' => null,
'claims' => $claims ?? ($payload ? self::summarizeClaims($payload) : null),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected static function summarizeClaims(array $payload): array
{
return [
'iss' => $payload['iss'] ?? null,
'aud' => $payload['aud'] ?? null,
'exp' => $payload['exp'] ?? null,
'nbf' => $payload['nbf'] ?? null,
'ic_number' => $payload['ic_number'] ?? null,
'member_number' => $payload['member_number'] ?? null,
];
}
protected static function base64UrlDecode(string $data): string
{
$remainder = strlen($data) % 4;
if ($remainder) {
$data .= str_repeat('=', 4 - $remainder);
}
return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
}
protected static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ class Voter extends Authenticatable
protected $table = 'voter';
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan','tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'dividen_saham', 'dividen_yuran', 'saham_terkini', 'yuran_terkini', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan', 'dividen_pelaburan', 'pelaburan_terkini', 'pelaburan_2', 'dividen_pelaburan_2', 'pelaburan_terkini_2', 'accumulated_amount', 'tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
protected $casts = [
'fizikal_registration_verified_at' => 'datetime',
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| API token idle lifetimes (minutes)
|--------------------------------------------------------------------------
|
| Passport 4 personal access tokens are issued with a one-year JWT TTL.
| These values are enforced in middleware based on oauth_access_tokens.created_at.
|
*/
'voter_lifetime' => (int) env('VOTER_TOKEN_LIFETIME', env('SESSION_LIFETIME', 120)),
'admin_lifetime' => (int) env('ADMIN_TOKEN_LIFETIME', 480),
];
+6
View File
@@ -35,4 +35,10 @@ return [
'secret' => env('STRIPE_SECRET'),
],
'mykopkb' => [
'sso_secret' => env('MYKOPKB_SSO_SECRET'),
'sso_issuer' => env('MYKOPKB_SSO_ISSUER'),
'sso_audience' => env('MYKOPKB_SSO_AUDIENCE', 'e-vote'),
],
];
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
*.sqlite
*.sqlite
@@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddAccumulatedAmountToVoterTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('voter', function (Blueprint $table) {
$table->decimal('accumulated_amount', 12, 2)->default(0)->after('pelaburan');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('voter', function (Blueprint $table) {
$table->dropColumn('accumulated_amount');
});
}
}
@@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddDividenSahamAndDividenYuranToVoterTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('voter', function (Blueprint $table) {
$table->decimal('dividen_saham', 12, 2)->default(0)->after('yuran');
$table->decimal('dividen_yuran', 12, 2)->default(0)->after('dividen_saham');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('voter', function (Blueprint $table) {
$table->dropColumn(['dividen_saham', 'dividen_yuran']);
});
}
}
@@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSahamTerkiniAndYuranTerkiniToVoterTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('voter', function (Blueprint $table) {
$table->decimal('saham_terkini', 12, 2)->default(0)->after('dividen_yuran');
$table->decimal('yuran_terkini', 12, 2)->default(0)->after('saham_terkini');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('voter', function (Blueprint $table) {
$table->dropColumn(['saham_terkini', 'yuran_terkini']);
});
}
}
@@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddDividenPelaburanAndPelaburanTerkiniToVoterTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('voter', function (Blueprint $table) {
$table->decimal('dividen_pelaburan', 12, 2)->default(0)->after('pelaburan');
$table->decimal('pelaburan_terkini', 12, 2)->default(0)->after('dividen_pelaburan');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('voter', function (Blueprint $table) {
$table->dropColumn(['dividen_pelaburan', 'pelaburan_terkini']);
});
}
}
@@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddPelaburan2ColumnsToVoterTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('voter', function (Blueprint $table) {
$table->decimal('pelaburan_2', 12, 2)->default(0)->after('pelaburan_terkini');
$table->decimal('dividen_pelaburan_2', 12, 2)->default(0)->after('pelaburan_2');
$table->decimal('pelaburan_terkini_2', 12, 2)->default(0)->after('dividen_pelaburan_2');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('voter', function (Blueprint $table) {
$table->dropColumn(['pelaburan_2', 'dividen_pelaburan_2', 'pelaburan_terkini_2']);
});
}
}
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -143,4 +143,4 @@ You can watch the System Demo [here](https://youtu.be/dsEoONiovdA).
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag=migrations
```
formula kiraan = ((Bulan masuk/12) * jumlah pelaburan) * 0.12
formula kiraan (purate) = ((Bulan masuk/12) * jumlah pelaburan) * 0.12
+30 -3
View File
@@ -19,16 +19,34 @@ const router = new VueRouter({
routes
});
var publicVoterRouteNames = {
'Voter Login': true,
'Voter Verify': true,
'Physical Gate Display': true,
'Roulette Wheel Demo': true,
'penyata': true
};
router.beforeEach(function (to, from, next) {
if (to.name === 'Voter Login' && utilMethods.isAdminPortalSession()) {
var dest = utilMethods.getAdminEntryRoute();
return next(Object.assign({}, dest, { replace: true }));
}
if (!to.path.startsWith('/admin') || to.name === 'Admin Login') {
var isAdminRoute = to.path.indexOf('/admin') === 0;
var isPublicVoterRoute = !!publicVoterRouteNames[to.name]
|| to.path.indexOf('/display/') === 0
|| to.path.indexOf('/demo/') === 0;
if (!isAdminRoute && !isPublicVoterRoute && !utilMethods.isLogin()) {
return next({ name: 'Voter Login', replace: true });
}
if (!isAdminRoute || to.name === 'Admin Login') {
return next();
}
if (!localStorage.getItem('Access Token')) {
return next();
if (!utilMethods.isLogin()) {
return next({ name: 'Admin Login', replace: true });
}
var role = localStorage.getItem('admin_role');
if (role === '3' && to.name !== 'Kehadiran Calon') {
@@ -37,6 +55,15 @@ router.beforeEach(function (to, from, next) {
next();
});
axios.interceptors.response.use(function (response) {
return response;
}, function (error) {
if (error.response && error.response.status === 401) {
utilMethods.handleUnauthorized(router);
}
return Promise.reject(error);
});
const app = new Vue({
router
}).$mount('#app');
@@ -139,15 +139,14 @@ export default {
// Swap admin token + user in local state.
var token = response.data.token;
if (token) {
localStorage['Access Token'] = 'Bearer ' + token;
vm.util.persistLoginSession(token, response.data.token_expires_at, {
adminSession: true,
adminRole: response.data.user && response.data.user.role
});
localStorage.setItem('is_impersonating', '1');
if (vm.util && vm.util.setAuthorization) vm.util.setAuthorization();
}
if (response.data.user) {
vm.data.user = response.data.user;
if (response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
}
vm.util.notify('Menyamar berjaya', 'success');
@@ -52,6 +52,9 @@
<router-link :to="{ name: 'Penyata Anggota' }" tag="li">
<a href="#">Penyata Anggota</a>
</router-link>
<router-link :to="{ name: 'Import Keanggotaan' }" tag="li">
<a href="#">Import Keanggotaan</a>
</router-link>
</ul>
</li>
@@ -146,9 +149,14 @@ export default {
})
.catch(error => {
$.notifyClose();
if (this.util.showResult(error) == 401) {
vm.logout();
var status = error.response ? error.response.status : 500;
if (status === 401) {
vm.util.clearSession();
vm.$router.replace({ name: 'Admin Login' });
return;
}
vm.util.showResult(error, 'error');
vm.loading = false;
})
} else {
this.$router.push({ name: 'Admin Login' });
@@ -186,15 +194,14 @@ export default {
return;
}
if (response.data.token) {
localStorage['Access Token'] = 'Bearer ' + response.data.token;
if (vm.util && vm.util.setAuthorization) vm.util.setAuthorization();
vm.util.persistLoginSession(response.data.token, response.data.token_expires_at, {
adminSession: true,
adminRole: response.data.user && response.data.user.role
});
}
localStorage.removeItem('is_impersonating');
if (response.data.user) {
vm.data.user = response.data.user;
if (response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
}
vm.util.notify('Berjaya kembali', 'success');
if (vm.$router) vm.$router.go(0);
@@ -206,9 +213,7 @@ export default {
},
logout: function () {
localStorage.removeItem('admin_role');
localStorage.removeItem('is_impersonating');
localStorage.clear();
this.util.clearSession();
this.$router.push({ name: 'Admin Login' });
}
}
@@ -84,12 +84,10 @@ export default {
.then(response => {
vm.stopLoading();
if (this.util.showResult(response, 'success')) {
localStorage['Access Token'] = `Bearer ${response.data.token}`;
localStorage.setItem('admin_session', '1');
if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
this.util.setAuthorization();
this.util.persistLoginSession(response.data.token, response.data.token_expires_at, {
adminSession: true,
adminRole: response.data.user && response.data.user.role
});
if (Number(response.data.user && response.data.user.role) === 3) {
vm.$router.push({ name: 'Kehadiran Calon' });
} else {
@@ -1,49 +1,60 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<h4><b>Kemaskini Maklumat</b></h4>
<form @submit.prevent="edit()" id="edit-form">
<div class="form-group">
<label for="name">Nama</label>
<input type="text" name="name" class="form-control" :value="data.voter.name" required/>
</div>
<div class="panel panel-default">
<div class="panel-body">
<h4><b>Kemaskini Maklumat</b></h4>
<form @submit.prevent="edit()" id="edit-form">
<div class="form-group">
<label for="name">Nama</label>
<input type="text" name="name" class="form-control" :value="data.voter.name" required />
</div>
<div class="form-group">
<label for="no_kp">No. Kad Pengenalan</label>
<input type="text" name="no_kp" class="form-control" :value="data.voter.no_kp" required/>
</div>
<div class="form-group">
<label for="no_kp">No. Kad Pengenalan</label>
<input type="text" name="no_kp" class="form-control" :value="data.voter.no_kp" required />
</div>
<div class="form-group">
<label for="telefon">No. Telefon</label>
<input type="text" name="telefon" class="form-control" :value="data.voter.telefon" required/>
</div>
<div class="form-group">
<label for="telefon">No. Telefon</label>
<input type="text" name="telefon" class="form-control" :value="data.voter.telefon" required />
</div>
<div class="form-group">
<label for="Unit">Unit</label>
<input type="text" name="unit" class="form-control" :value="data.voter.unit" required/>
</div>
<div class="form-group">
<label for="Unit">Unit</label>
<input type="text" name="unit" class="form-control" :value="data.voter.unit" required />
</div>
<div class="form-group">
<label for="saham">Saham</label>
<input type="text" name="saham" class="form-control" :value="data.voter.saham" required/>
</div>
<div class="form-group">
<label for="saham">Saham</label>
<input type="text" name="saham" class="form-control" :value="data.voter.saham" required />
</div>
<div class="form-group">
<label for="yuran">Yuran</label>
<input type="text" name="yuran" class="form-control" :value="data.voter.yuran" required/>
</div>
<div class="form-group">
<label for="yuran">Yuran</label>
<input type="text" name="yuran" class="form-control" :value="data.voter.yuran" required />
</div>
<div class="form-group">
<input type="submit" value="Hantar" class="btn btn-success"/>
<router-link :to="{name: 'Manage Voter'}" class="btn btn-default">Kembali</router-link>
</div>
</form>
<div class="form-group">
<label for="accumulated_amount">Jumlah Pelaburan (hingga Dis 2025)</label>
<input type="text" name="accumulated_amount" class="form-control"
:value="data.voter.accumulated_amount" required />
</div>
<div class="form-group">
<label for="pelaburan">Dividen Simpanan Khas (Tabung 1) Tahun 2025</label>
<input type="text" name="pelaburan" class="form-control" :value="data.voter.pelaburan" required />
</div>
<div class="form-group">
<input type="submit" value="Hantar" class="btn btn-success" />
<router-link :to="{ name: 'Manage Voter' }" class="btn btn-default">Kembali</router-link>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
export default{
export default {
data: function () {
return {
loading: false
@@ -52,7 +63,7 @@ export default{
created: function () {
if (!this.data.voter.id) {
this.$router.push({name:'Manage Voter'});
this.$router.push({ name: 'Manage Voter' });
}
},
@@ -62,15 +73,15 @@ export default{
this.loading = true;
var vm = this;
this.util.notify('Updating voter', 'loading');
axios.put(config.API+'voter/'+this.data.voter.id, $('#edit-form').serialize())
.then(response=>{
axios.put(config.API + 'voter/' + this.data.voter.id, $('#edit-form').serialize())
.then(response => {
vm.loading = false;
$.notifyClose();
if (vm.util.showResult(response, 'success')) {
vm.$router.push({name:'Manage Voter'});
vm.$router.push({ name: 'Manage Voter' });
}
})
.catch(error=>{
.catch(error => {
$.notifyClose();
vm.loading = false;
vm.util.showResult(error);
@@ -0,0 +1,313 @@
<template>
<div class="container col-md-10 col-md-offset-1">
<h4>Import Keanggotaan (JSON)</h4>
<div class="panel panel-default">
<div class="panel-body">
<p class="help-block">
Muat naik fail JSON dengan format yang sama seperti
<code>keanggotaan_jun-2026.json</code>. Data dikemas kini mengikut
<code>no_anggota</code> untuk pilihan raya semasa. Medan kosong/null diganti dengan
<code>0</code>.
</p>
<div class="panel panel-default" style="margin-bottom: 20px;">
<div class="panel-heading">Contoh format JSON</div>
<div class="panel-body">
<p class="help-block" style="margin-top: 0;">
Fail mestilah <strong>array objek</strong> dengan medan di bawah.
Nilai kosong (<code>""</code>) atau <code>null</code> akan diganti dengan <code>0</code>.
</p>
<div class="table-responsive" style="margin-bottom: 12px;">
<table class="table table-striped table-bordered table-condensed" style="margin-bottom: 0;">
<thead>
<tr>
<th v-for="col in sampleColumns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in sampleRows" :key="idx">
<td v-for="col in sampleColumns" :key="col">
{{ formatPreviewCell(row[col]) }}
</td>
</tr>
</tbody>
</table>
</div>
<pre
style="margin: 0; max-height: 220px; overflow: auto; background: #f5f5f5; padding: 10px; border-radius: 3px;">{{ sampleJson }}</pre>
</div>
</div>
<div class="form-group">
<label>Fail JSON</label>
<input ref="fileInput" type="file" class="form-control" accept=".json,application/json"
@change="onFileChange">
<small class="help-block">
Medan yang dikemas kini: <code>pelaburan</code>, <code>pelaburan_2</code>,
<code>saham</code>, <code>yuran</code>.
</small>
</div>
<div v-if="selectedFileName" class="alert alert-info" style="margin-bottom: 15px;">
Fail dipilih: <strong>{{ selectedFileName }}</strong>
<span v-if="previewLoading"> membaca fail...</span>
</div>
<div v-if="previewError" class="alert alert-danger">
{{ previewError }}
</div>
<div v-if="previewRows.length" class="panel panel-info" style="margin-bottom: 15px;">
<div class="panel-heading">
Pratonton JSON
<span class="pull-right">
{{ previewTotal }} baris
<span v-if="previewTotal > previewLimit">
(menunjukkan {{ previewLimit }} pertama)
</span>
</span>
</div>
<div class="panel-body" style="padding: 0;">
<div class="table-responsive" style="max-height: 360px; overflow: auto;">
<table class="table table-striped table-bordered table-condensed" style="margin-bottom: 0;">
<thead>
<tr>
<th>#</th>
<th v-for="col in previewColumns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in previewRows" :key="idx">
<td>{{ idx + 1 }}</td>
<td v-for="col in previewColumns" :key="col">
{{ formatPreviewCell(row[col]) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel-footer">
<a href="#" @click.prevent="showRaw = !showRaw">
{{ showRaw ? 'Sembunyikan' : 'Tunjuk' }} raw JSON
</a>
<pre v-if="showRaw"
style="margin: 10px 0 0; max-height: 280px; overflow: auto; background: #f5f5f5; padding: 10px; border-radius: 3px;">{{ previewRaw }}</pre>
</div>
</div>
<button class="btn btn-primary" :disabled="!canImport" @click="upload">
<i class="fa fa-upload"></i>
{{ uploading ? 'Mengimport...' : 'Import' }}
</button>
<button v-if="file" class="btn btn-default" style="margin-left: 8px;" :disabled="uploading" @click="clearFile">
Kosongkan
</button>
<div v-if="result" class="panel panel-success" style="margin-top: 20px;">
<div class="panel-heading">Hasil Import</div>
<div class="panel-body">
<ul class="list-unstyled" style="margin-bottom: 0;">
<li>Election ID: <strong>{{ result.election_id }}</strong></li>
<li>Baris JSON: <strong>{{ result.json_rows }}</strong></li>
<li>Padanan (matched): <strong>{{ result.matched }}</strong></li>
<li>Dikemas kini: <strong>{{ result.updated }}</strong></li>
<li>Tiada perubahan: <strong>{{ result.unchanged }}</strong></li>
<li>Dalam JSON tetapi tiada dalam voter: <strong>{{ result.missing_in_voter }}</strong></li>
<li>Dalam voter tetapi tiada dalam JSON: <strong>{{ result.missing_in_json }}</strong></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
const PREVIEW_LIMIT = 50;
const SAMPLE_ROWS = [
{
no_anggota: "7",
pelaburan: "473776.8",
pelaburan_2: "",
saham: "6600",
yuran: "4671.7"
},
{
no_anggota: "12",
pelaburan: "527090.7",
pelaburan_2: "",
saham: "10000",
yuran: "19463.9"
},
{
no_anggota: "22",
pelaburan: "",
pelaburan_2: "",
saham: "5000",
yuran: "5260"
}
];
export default {
data() {
return {
file: null,
selectedFileName: "",
uploading: false,
result: null,
previewLoading: false,
previewError: "",
previewRows: [],
previewColumns: [],
previewTotal: 0,
previewLimit: PREVIEW_LIMIT,
previewRaw: "",
showRaw: false,
sampleColumns: ["no_anggota", "pelaburan", "pelaburan_2", "saham", "yuran"],
sampleRows: SAMPLE_ROWS,
sampleJson: JSON.stringify(SAMPLE_ROWS, null, 2)
};
},
computed: {
canImport() {
return !!this.file && !this.uploading && !this.previewLoading && !this.previewError && this.previewRows.length > 0;
}
},
created() {
this.util.setTitle("MyKoPKB - Import Keanggotaan");
},
methods: {
onFileChange(e) {
const files = e && e.target ? e.target.files : null;
this.file = files && files.length ? files[0] : null;
this.selectedFileName = this.file ? this.file.name : "";
this.result = null;
this.resetPreview();
if (this.file) {
this.loadPreview(this.file);
}
},
resetPreview() {
this.previewLoading = false;
this.previewError = "";
this.previewRows = [];
this.previewColumns = [];
this.previewTotal = 0;
this.previewRaw = "";
this.showRaw = false;
},
loadPreview(file) {
this.previewLoading = true;
this.previewError = "";
const reader = new FileReader();
reader.onload = (event) => {
try {
const text = event.target.result;
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) {
throw new Error("JSON mestilah array objek.");
}
if (parsed.length === 0) {
throw new Error("JSON kosong (tiada baris).");
}
const columns = [];
parsed.forEach((row) => {
if (row && typeof row === "object" && !Array.isArray(row)) {
Object.keys(row).forEach((key) => {
if (columns.indexOf(key) === -1) {
columns.push(key);
}
});
}
});
if (columns.indexOf("no_anggota") === -1) {
throw new Error("JSON tiada medan no_anggota.");
}
this.previewColumns = columns;
this.previewTotal = parsed.length;
this.previewRows = parsed.slice(0, PREVIEW_LIMIT);
this.previewRaw = JSON.stringify(parsed.slice(0, PREVIEW_LIMIT), null, 2);
this.previewError = "";
} catch (err) {
this.resetPreview();
this.previewError = err && err.message
? "Gagal membaca JSON: " + err.message
: "Gagal membaca JSON.";
} finally {
this.previewLoading = false;
}
};
reader.onerror = () => {
this.resetPreview();
this.previewError = "Gagal membaca fail.";
this.previewLoading = false;
};
reader.readAsText(file);
},
formatPreviewCell(value) {
if (value === null || value === undefined || value === "") {
return "0";
}
return value;
},
clearFile() {
this.file = null;
this.selectedFileName = "";
this.result = null;
this.resetPreview();
if (this.$refs.fileInput) {
this.$refs.fileInput.value = "";
}
},
async upload() {
if (!this.canImport) return;
this.uploading = true;
this.result = null;
try {
this.util.setAuthorization();
const fd = new FormData();
fd.append("file", this.file);
const res = await axios.post(config.API + "voter/import-keanggotaan-json", fd, {
headers: { "Content-Type": "multipart/form-data" }
});
if (res && res.data && res.data.status === "success") {
this.util.notify(res.data.message || "Import berjaya.", "success");
this.result = res.data.data || null;
} else {
this.util.showResult(res, "error");
}
} catch (e) {
this.util.showResult(e, "error");
} finally {
this.uploading = false;
}
}
}
};
</script>
@@ -106,6 +106,8 @@ export default {
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Saham', key: 'saham', sortable: true },
{ title: 'Yuran', key: 'yuran', sortable: true },
{ title: 'Jumlah Pelaburan (hingga Dis 2025)', key: 'accumulated_amount', sortable: true },
{ title: 'Dividen Simpanan Khas (Tabung 1) Tahun 2025', key: 'pelaburan', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false }
]
}
@@ -53,11 +53,22 @@
</div>
<div class="member-info-card home-member-card home-member-card--in-section">
<div v-for="row in memberRows" :key="row.label" class="member-field">
<div v-for="row in memberRows" :key="row.key || row.label" class="member-field">
<span class="member-field-icon" aria-hidden="true"><i class="fa" :class="row.icon"></i></span>
<div class="member-field-body">
<span class="member-field-label">{{ row.label }}</span>
<span class="member-field-value">{{ row.value }}</span>
<div class="member-field-value-wrap">
<template v-if="row.entries">
<div v-for="(entry, entryIdx) in row.entries" :key="entryIdx" class="member-field-entry">
<span class="member-field-value">{{ entry.period }}: {{ entry.value }}</span>
<span v-if="entry.subvalue" class="member-field-subvalue">{{ entry.subvalue }}</span>
</div>
</template>
<template v-else>
<span class="member-field-value">{{ row.value }}</span>
<span v-if="row.subvalue" class="member-field-subvalue">{{ row.subvalue }}</span>
</template>
</div>
</div>
</div>
</div>
@@ -386,14 +397,59 @@ export default {
if (n === null || n === undefined || n === "") return "—";
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const dividendSub = (balance, dividend, percent) => {
if (balance === null || balance === undefined || balance === "") return null;
return "Dividen (" + percent + "%): RM " + fmt(dividend);
};
return [
{ icon: "fa-user", label: "Nama", value: u.name || "—" },
{ icon: "fa-credit-card", label: "No. Kad Pengenalan", value: u.no_kp || "—" },
{ icon: "fa-barcode", label: "No. Anggota", value: u.no_anggota || "—" },
{ icon: "fa-building", label: "Unit", value: u.unit || "—" },
{ icon: "fa-line-chart", label: "Saham", value: "RM " + fmt(u.saham) },
{ icon: "fa-money", label: "Yuran", value: "RM " + fmt(u.yuran) },
{ icon: "fa-percent", label: "Dividen Simpanan Khas (Tabung 1) Tahun 2025", value: "RM " + fmt(u.pelaburan) }
{
key: "saham",
icon: "fa-line-chart",
label: "Saham",
entries: [
{
period: "Sehingga Jun 2025",
value: "RM " + fmt(u.saham),
subvalue: dividendSub(u.saham, u.dividen_saham, 40)
},
{
period: "Terkumpul Sehingga Mei 2026",
value: "RM " + fmt(u.saham_terkini)
}
]
},
{
key: "yuran",
icon: "fa-money",
label: "Yuran",
entries: [
{
period: "Sehingga Jun 2025",
value: "RM " + fmt(u.yuran),
subvalue: dividendSub(u.yuran, u.dividen_yuran, 8)
},
{
period: "Terkumpul Sehingga Mei 2026",
value: "RM " + fmt(u.yuran_terkini)
}
]
},
{
key: "pelaburan",
icon: "fa-bank",
label: "Pelaburan Simpanan Khas (Tabung 1) - (Sehingga Dis 2025)",
value: "RM " + fmt(u.pelaburan),
subvalue: dividendSub(u.pelaburan, u.dividen_pelaburan, 12)
},
{
icon: "fa-bank",
label: "Pelaburan Simpanan Khas (Tabung 2) - (Sehingga " + this.formatMalayMonthYear() + ")",
value: "RM " + fmt(u.pelaburan_2),
}
];
}
},
@@ -410,6 +466,20 @@ export default {
},
methods: {
/**
* Malay short month + year for "Sehingga …" labels (defaults to previous month).
*/
formatMalayMonthYear(date) {
const d = date ? new Date(date) : new Date();
// End of previous calendar month
d.setDate(0);
const months = [
"Jan", "Feb", "Mac", "Apr", "Mei", "Jun",
"Jul", "Ogo", "Sep", "Okt", "Nov", "Dis"
];
return months[d.getMonth()] + " " + d.getFullYear();
},
async fetchPortalSettings() {
try {
this.util.setAuthorization();
@@ -1016,8 +1086,31 @@ export default {
line-height: 1.35;
}
.member-field-value {
.member-field-value-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.member-field-entry {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.member-field-entry+.member-field-entry {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
}
.member-field-value {
width: 100%;
text-align: right;
font-size: 1.45rem;
font-weight: 700;
@@ -1026,6 +1119,16 @@ export default {
word-break: break-word;
}
.member-field-subvalue {
width: 100%;
text-align: right;
font-size: 1.05rem;
font-weight: 600;
color: #2563eb;
line-height: 1.35;
word-break: break-word;
}
/* —— Claim —— */
.home-claim-card {
padding: 20px 22px;
@@ -1386,6 +1489,13 @@ export default {
line-height: 1.25;
}
.home-member-card--in-section .member-field-value-wrap {
flex: none;
width: 100%;
align-items: flex-start;
gap: 3px;
}
.home-member-card--in-section .member-field-value {
flex: none;
width: 100%;
@@ -1398,6 +1508,12 @@ export default {
overflow-wrap: anywhere;
}
.home-member-card--in-section .member-field-subvalue {
width: 100%;
text-align: left;
font-size: 1.1rem;
}
.home-claim-code {
font-size: 2.2rem;
letter-spacing: 0.14em;
@@ -47,6 +47,10 @@ export default {
}),
created: function () {
if (!this.util.isLogin()) {
this.$router.replace({ name: 'Voter Login' });
return;
}
this.refreshInfo();
},
@@ -81,7 +85,7 @@ export default {
axios.post(config.API + 'voter/logout')
.catch(function () { })
.finally(function () {
localStorage.clear();
vm.util.clearSession();
vm.$router.push({ name: 'Voter Login' });
});
},
@@ -105,10 +109,14 @@ export default {
vm.data.result = response.data.result;
})
.catch(error => {
vm.loading = false;
if (vm.util.showResult(error, 'error') == 401) {
vm.$router.push({ name: 'Voter Login' });
var status = error.response ? error.response.status : 500;
if (status === 401) {
vm.util.clearSession();
vm.$router.replace({ name: 'Voter Login' });
return;
}
vm.loading = false;
vm.util.showResult(error, 'error');
});
}
}
@@ -1,13 +1,58 @@
<template>
<div class="login-shell">
<div class="login-card">
<transition name="sso-overlay">
<div v-if="ssoSuccess.active" class="sso-overlay" role="status" aria-live="polite">
<div class="sso-overlay__card" :class="'sso-overlay__card--' + ssoSuccess.phase">
<div class="sso-overlay__icon" aria-hidden="true">
<div v-if="ssoSuccess.phase === 'loading'" class="sso-overlay__spinner"></div>
<svg v-else class="sso-overlay__check" viewBox="0 0 24 24" focusable="false">
<path
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-1.35 13.15-3.4-3.35a1 1 0 1 1 1.4-1.42l2.68 2.64 5.28-5.22a1 1 0 0 1 1.4 1.42l-6 5.93a1 1 0 0 1-1.36 0Z" />
</svg>
</div>
<p class="sso-overlay__title">{{ ssoSuccess.title }}</p>
<p class="sso-overlay__message">{{ ssoSuccess.message }}</p>
<div v-if="ssoSuccess.phase === 'loading'" class="sso-overlay__progress">
<div class="sso-overlay__progress-bar" :style="{ animationDuration: SSO_LOADING_MS + 'ms' }">
</div>
</div>
</div>
</div>
</transition>
<div class="login-card"
:class="{ 'login-card--shake': ssoAlert.shake, 'login-card--dimmed': ssoSuccess.active }">
<div class="login-header">
<img class="login-logo" src="/images/MyKoPKB-logo.png" alt="MyKoPKB" />
<h1 class="login-title">Selamat Datang</h1>
<p class="login-subtitle">Log masuk untuk meneruskan ke Portal MyKoPKB</p>
</div>
<form @submit.prevent="login" id="login_form" class="login-form" novalidate>
<transition name="sso-alert">
<div v-if="ssoAlert.visible" class="sso-alert" :class="'sso-alert--' + ssoAlert.code" role="alert"
aria-live="assertive">
<div class="sso-alert__icon" aria-hidden="true">
<svg v-if="ssoAlert.code === 'voter_not_found'" viewBox="0 0 24 24" focusable="false">
<path
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 5a1.25 1.25 0 1 1 0 2.5A1.25 1.25 0 0 1 12 7Zm-1 4.25a1 1 0 0 1 2 0V16a1 1 0 1 1-2 0v-4.75Z" />
</svg>
<svg v-else viewBox="0 0 24 24" focusable="false">
<path
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-.75 5.5a.75.75 0 0 1 1.5 0v6a.75.75 0 0 1-1.5 0v-6Zm.75 9.25a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" />
</svg>
</div>
<div class="sso-alert__body">
<p class="sso-alert__title">{{ ssoAlert.title }}</p>
<p class="sso-alert__message">{{ ssoAlert.message }}</p>
</div>
<button type="button" class="sso-alert__close" aria-label="Tutup" @click="dismissSsoAlert">
&times;
</button>
</div>
</transition>
<!-- Local / development: OTP login via IC number -->
<form v-if="isLocalDev" @submit.prevent="login" id="login_form" class="login-form" novalidate>
<div class="form-group mb-3">
<label class="login-label" for="no_kp">No. Kad Pengenalan</label>
<div class="login-field">
@@ -30,31 +75,87 @@
<span>{{ loading ? 'Sedang log masuk…' : 'Masuk' }}</span>
</button>
</form>
<!-- Production: SSO only -->
<div v-else class="sso-only-notice">
<div class="sso-only-notice__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path
d="M12 2a5 5 0 0 0-5 5v2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-1V7a5 5 0 0 0-5-5Zm-3 7V7a3 3 0 1 1 6 0v2H9Zm3 4a1.5 1.5 0 0 1 .75 2.8V18h-1.5v-2.2A1.5 1.5 0 0 1 12 13Z" />
</svg>
</div>
<div>
<p class="sso-only-notice__title">
Sila log masuk melalui
<a href="https://mykopkb.koppkb.com" class="sso-only-notice__link" target="_blank"
rel="noopener noreferrer">MyKoPKB</a>.
</p>
</div>
</div>
</div>
</div>
</template>
<script>
const SSO_LOADING_MS = 2500;
const SSO_SUCCESS_MS = 2500;
const LOCAL_ENVS = ['local', 'development'];
export default {
data() {
return {
SSO_LOADING_MS,
SSO_SUCCESS_MS,
loading: false,
no_kp: ''
no_kp: '',
ssoAlert: {
visible: false,
title: '',
message: '',
code: 'sso_error',
shake: false
},
ssoSuccess: {
active: false,
phase: 'loading',
title: 'Mengesahkan log masuk SSO',
message: 'Sila tunggu sebentar…'
},
ssoRedirectTimer: null
};
},
computed: {
isLocalDev() {
const env = (window.config && window.config.env) || '';
return LOCAL_ENVS.indexOf(String(env).toLowerCase()) !== -1;
}
},
beforeDestroy() {
if (this.ssoRedirectTimer) {
window.clearTimeout(this.ssoRedirectTimer);
}
},
created() {
if (this.util.isAdminPortalSession()) {
this.$router.replace(this.util.getAdminEntryRoute());
return;
}
this.handleSsoCallback();
},
methods: {
onNoKpInput() {
// keep input forgiving: strip dash/spaces and non-digits
this.no_kp = (this.no_kp || '').replace(/[\s-]+/g, '').replace(/[^\d]/g, '');
},
login() {
if (!this.isLocalDev) {
return;
}
this.startLoading();
const noKp = (this.no_kp || '').trim();
axios
@@ -68,9 +169,9 @@ export default {
this.$router.push({
name: 'Voter Verify',
params: {
'nokp': response.data.nokp,
'notel': response.data.notel,
'debug_otp': response.data.debug_otp || null
nokp: response.data.nokp,
notel: response.data.notel,
debug_otp: response.data.debug_otp || null
}
});
}
@@ -85,6 +186,87 @@ export default {
},
stopLoading() {
this.loading = false;
},
handleSsoCallback() {
const params = new URLSearchParams(window.location.search);
const ssoError = params.get('sso_error');
if (ssoError) {
const code = params.get('sso_error_code') || 'sso_error';
this.showSsoError(ssoError, code);
this.clearSsoQueryParams();
return;
}
const ssoToken = params.get('sso_token');
const tokenExpiresAt = params.get('token_expires_at');
if (!ssoToken) {
return;
}
this.clearSsoQueryParams();
this.completeSsoLogin(ssoToken, tokenExpiresAt);
},
completeSsoLogin(ssoToken, tokenExpiresAt) {
this.ssoSuccess = {
active: true,
phase: 'loading',
title: 'Mengesahkan log masuk SSO',
message: 'Sila tunggu sebentar…'
};
window.setTimeout(() => {
this.util.persistLoginSession(ssoToken, tokenExpiresAt, {
clearAdminSession: true
});
this.ssoSuccess = {
active: true,
phase: 'success',
title: 'Log masuk berjaya',
message: 'Anda akan dialihkan ke portal…'
};
this.util.notify('Log masuk SSO berjaya.', 'success');
this.ssoRedirectTimer = window.setTimeout(() => {
this.$router.replace({ name: 'Voter Home' });
}, SSO_SUCCESS_MS);
}, SSO_LOADING_MS);
},
clearSsoQueryParams() {
if (!window.location.search) {
return;
}
window.history.replaceState({}, document.title, window.location.pathname);
},
showSsoError(message, code) {
const titles = {
voter_not_found: 'Akaun pengundi tidak dijumpai',
token_invalid: 'Log masuk SSO gagal',
token_used: 'Token SSO telah digunakan',
sso_error: 'Log masuk SSO gagal'
};
this.ssoAlert = {
visible: true,
title: titles[code] || titles.sso_error,
message: message,
code: code,
shake: true
};
this.util.notify(message, 'error');
window.setTimeout(() => {
this.ssoAlert.shake = false;
}, 650);
},
dismissSsoAlert() {
this.ssoAlert.visible = false;
this.ssoAlert.shake = false;
}
}
};
@@ -107,8 +289,8 @@ export default {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
box-shadow:
0 18px 55px rgba(2, 6, 23, 0.12),
0 2px 10px rgba(2, 6, 23, 0.06);
0 18px 55px rgba(2, 6, 23, 0.12),
0 2px 10px rgba(2, 6, 23, 0.06);
padding: 28px 24px;
position: relative;
overflow: hidden;
@@ -150,6 +332,374 @@ export default {
line-height: 1.4;
}
.sso-only-notice {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 18px;
padding: 16px;
border: 1px solid rgba(37, 99, 235, 0.16);
border-radius: 14px;
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
color: #1e3a8a;
}
.sso-only-notice__icon {
flex: 0 0 24px;
width: 24px;
height: 24px;
}
.sso-only-notice__icon svg {
display: block;
width: 24px;
height: 24px;
fill: currentColor;
}
.sso-only-notice__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 800;
}
.sso-only-notice__link {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 160ms ease;
}
.sso-only-notice__link:hover,
.sso-only-notice__link:focus {
opacity: 0.8;
}
.sso-only-notice__message {
margin: 0;
font-size: 13px;
line-height: 1.5;
opacity: 0.9;
}
.sso-alert-enter-active,
.sso-alert-leave-active {
transition: opacity 280ms ease, transform 280ms ease, max-height 280ms ease;
overflow: hidden;
}
.sso-alert-enter,
.sso-alert-leave-to {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
.sso-alert-enter-to,
.sso-alert-leave {
max-height: 160px;
}
.sso-alert {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0 0 16px;
padding: 14px 14px 14px 16px;
border-radius: 14px;
border: 1px solid transparent;
position: relative;
animation: sso-alert-pulse 2.4s ease-in-out 1;
}
.sso-alert--voter_not_found {
background: linear-gradient(180deg, #fff7ed 0%, #ffedd5 100%);
border-color: rgba(234, 88, 12, 0.22);
color: #9a3412;
animation-name: sso-alert-pulse-warn;
}
.sso-alert--token_invalid,
.sso-alert--token_used,
.sso-alert--sso_error {
background: linear-gradient(180deg, #fef2f2 0%, #fee2e2 100%);
border-color: rgba(220, 38, 38, 0.2);
color: #991b1b;
}
.sso-alert__icon {
flex: 0 0 22px;
width: 22px;
height: 22px;
margin-top: 1px;
animation: sso-icon-pop 420ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.sso-alert__icon svg {
width: 22px;
height: 22px;
fill: currentColor;
display: block;
}
.sso-alert__body {
flex: 1;
min-width: 0;
}
.sso-alert__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 800;
line-height: 1.35;
}
.sso-alert__message {
margin: 0;
font-size: 13px;
line-height: 1.45;
opacity: 0.92;
}
.sso-alert__close {
flex: 0 0 auto;
border: 0;
background: transparent;
color: inherit;
font-size: 22px;
line-height: 1;
padding: 0 0 0 8px;
cursor: pointer;
opacity: 0.65;
transition: opacity 160ms ease, transform 160ms ease;
}
.sso-alert__close:hover {
opacity: 1;
transform: scale(1.08);
}
.login-card--shake {
animation: login-card-shake 560ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
.login-card--dimmed {
opacity: 0.35;
filter: blur(1px);
transform: scale(0.985);
transition: opacity 320ms ease, filter 320ms ease, transform 320ms ease;
pointer-events: none;
}
.sso-overlay-enter-active,
.sso-overlay-leave-active {
transition: opacity 280ms ease;
}
.sso-overlay-enter,
.sso-overlay-leave-to {
opacity: 0;
}
.sso-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(4px);
}
.sso-overlay__card {
width: 100%;
max-width: 360px;
background: #ffffff;
border-radius: 20px;
padding: 28px 24px 24px;
text-align: center;
box-shadow:
0 24px 60px rgba(2, 6, 23, 0.22),
0 4px 14px rgba(2, 6, 23, 0.08);
animation: sso-overlay-rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.sso-overlay__card--success {
animation: sso-overlay-rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both,
sso-overlay-success-pop 360ms ease 80ms both;
}
.sso-overlay__icon {
width: 64px;
height: 64px;
margin: 0 auto 14px;
display: flex;
align-items: center;
justify-content: center;
}
.sso-overlay__spinner {
width: 52px;
height: 52px;
border-radius: 50%;
border: 4px solid rgba(37, 99, 235, 0.16);
border-top-color: #2563eb;
animation: sso-spinner 760ms linear infinite;
}
.sso-overlay__check {
width: 64px;
height: 64px;
fill: #16a34a;
animation: sso-check-pop 520ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.sso-overlay__title {
margin: 0;
font-size: 20px;
font-weight: 900;
color: #0f172a;
letter-spacing: -0.02em;
}
.sso-overlay__message {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.45;
color: rgba(15, 23, 42, 0.72);
}
.sso-overlay__progress {
margin-top: 18px;
height: 4px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
overflow: hidden;
}
.sso-overlay__progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2563eb 0%, #3b82f6 100%);
animation: sso-progress ease-in-out forwards;
}
@keyframes sso-overlay-rise {
from {
opacity: 0;
transform: translateY(18px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes sso-overlay-success-pop {
from {
box-shadow: 0 24px 60px rgba(2, 6, 23, 0.22);
}
to {
box-shadow: 0 24px 60px rgba(22, 163, 74, 0.18);
}
}
@keyframes sso-spinner {
to {
transform: rotate(360deg);
}
}
@keyframes sso-check-pop {
0% {
transform: scale(0.35);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes sso-progress {
from {
width: 8%;
}
to {
width: 100%;
}
}
@keyframes login-card-shake {
0%,
100% {
transform: translateX(0);
}
15% {
transform: translateX(-8px);
}
30% {
transform: translateX(7px);
}
45% {
transform: translateX(-5px);
}
60% {
transform: translateX(4px);
}
75% {
transform: translateX(-2px);
}
}
@keyframes sso-alert-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
}
35% {
box-shadow: 0 0 0 6px rgba(220, 38, 38, 0.08);
}
}
@keyframes sso-alert-pulse-warn {
0%,
100% {
box-shadow: 0 0 0 0 rgba(234, 88, 12, 0);
}
35% {
box-shadow: 0 0 0 6px rgba(234, 88, 12, 0.1);
}
}
@keyframes sso-icon-pop {
0% {
transform: scale(0.4);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.login-form {
margin-top: 14px;
}
@@ -233,10 +783,10 @@ export default {
}
@media (max-width: 576px) {
.login-shell {
align-items: center;
padding: 20px 14px;
}
.login-shell {
align-items: center;
padding: 20px 14px;
}
.login-card {
max-width: none;
@@ -248,4 +798,4 @@ export default {
max-width: 200px;
}
}
</style>
</style>
@@ -157,10 +157,9 @@ export default {
.then(response => {
vm.stopLoading();
if (this.util.showResult(response, 'success')) {
localStorage.removeItem('admin_role');
localStorage.removeItem('admin_session');
localStorage['Access Token'] = `Bearer ${response.data.token}`;
this.util.setAuthorization();
this.util.persistLoginSession(response.data.token, response.data.token_expires_at, {
clearAdminSession: true
});
vm.$router.push({ name: 'Voter Home' });
}
})
+7
View File
@@ -43,6 +43,7 @@ import KehadiranCalon from "./components/demo/admin/voter/kehadiran.vue";
import KehadiranCalonFizikal from "./components/demo/admin/voter/fizikal.vue";
import KehadiranCalonMaya from "./components/demo/admin/voter/maya.vue";
import ViewKehadiranCalon from "./components/demo/admin/voter/viewkehadiran.vue";
import ImportKeanggotaan from "./components/demo/admin/voter/import-keanggotaan.vue";
import AllowanceSummary from "./components/demo/admin/allowance/summary.vue";
import ManageNominee from "./components/demo/admin/nominee/nominee.vue";
@@ -294,6 +295,12 @@ export default [
name: "Ringkasan Elaun",
},
{
path: "import-keanggotaan",
component: ImportKeanggotaan,
name: "Import Keanggotaan",
},
{
path: "nominee",
component: ManageNominee,
+72 -1
View File
@@ -14,7 +14,78 @@ const methods = {
* @return {Boolean} isLogin
*/
isLogin: function () {
return localStorage['Access Token'] ? true : false;
if (!localStorage['Access Token']) {
return false;
}
if (this.isSessionExpired()) {
this.clearSession();
return false;
}
return true;
},
isSessionExpired: function () {
var expiresAt = localStorage.getItem('token_expires_at');
if (!expiresAt) {
return false;
}
return Date.now() >= new Date(expiresAt).getTime();
},
clearSession: function () {
localStorage.removeItem('Access Token');
localStorage.removeItem('token_expires_at');
localStorage.removeItem('admin_role');
localStorage.removeItem('admin_session');
localStorage.removeItem('is_impersonating');
delete axios.defaults.headers.common['Authorization'];
},
persistLoginSession: function (token, expiresAt, options) {
options = options || {};
var bearer = token.indexOf('Bearer ') === 0 ? token : 'Bearer ' + token;
localStorage['Access Token'] = bearer;
if (expiresAt) {
localStorage.setItem('token_expires_at', expiresAt);
} else {
localStorage.removeItem('token_expires_at');
}
if (options.adminRole !== undefined && options.adminRole !== null) {
localStorage.setItem('admin_role', String(options.adminRole));
}
if (options.adminSession) {
localStorage.setItem('admin_session', '1');
}
if (options.clearAdminSession) {
localStorage.removeItem('admin_role');
localStorage.removeItem('admin_session');
}
this.setAuthorization();
},
handleUnauthorized: function (router) {
var isAdminPath = window.location.pathname.indexOf('/admin') === 0;
this.clearSession();
if (!router || !router.currentRoute) {
window.location.href = isAdminPath ? '/admin/login' : '/login';
return;
}
var routeName = router.currentRoute.name;
if (routeName === 'Voter Login' || routeName === 'Admin Login') {
return;
}
router.replace({ name: isAdminPath ? 'Admin Login' : 'Voter Login' });
},
/**
+2 -1
View File
@@ -32,7 +32,8 @@
"API": "/api/v1/", //URL OF YOUR API LOCATED
"baseURL": "/", //URL OF YOUR WEBSITE
"storageURL": "{{ config('app.cloudinary_enabled') ? '' : '/storage/' }}", //URL WHERE YOUR IMAGES and OTHER FILEs stored
"debug": {{ env('APP_DEBUG') }}
"debug": {{ env('APP_DEBUG') ? 'true' : 'false' }},
"env": @json(config('app.env'))
}
</script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
+3 -2
View File
@@ -31,7 +31,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::post('voter/login', 'API\v1\Voter\LoginController');
Route::post('voter/verify', 'API\v1\Voter\LoginController@verifyTAC');
Route::middleware(['auth:voterAPI', 'voter'])->group(function () {
Route::middleware(['auth:voterAPI', 'api_token_lifetime:voter', 'voter'])->group(function () {
Route::get('election/information', 'API\v1\Election\InformationController');
Route::post('election/vote', 'API\v1\Election\VoteController')->middleware(['fizikal_registration_verified', 'isvoted']);
Route::get('election/result', 'API\v1\Election\ResultController')->middleware('has_voted');
@@ -52,7 +52,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
//Admins API
Route::post('admin/login', 'API\v1\Admin\LoginController'); //Excluding Login for auth middleware
Route::middleware(['auth:api', 'admin', 'election', 'attendance_committee_role'])->group(function () {
Route::middleware(['auth:api', 'api_token_lifetime:admin', 'admin', 'election', 'attendance_committee_role'])->group(function () {
Route::prefix('admin')->group(function () { //Route /api/v1/admin
Route::get('information', 'API\v1\Admin\InformationController');
@@ -113,6 +113,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::get('', 'API\v1\Voter\GetController');
Route::get('member-catalog', 'API\v1\Voter\AllMembersCatalogController');
Route::post('sync-applicable', 'API\v1\Voter\SyncApplicableVotersController');
Route::post('import-keanggotaan-json', 'API\v1\Voter\ImportKeanggotaanJsonController');
Route::post('{id}/verify-fizikal-registration', 'API\v1\Voter\VerifyFizikalRegistrationController');
Route::delete('{id}', 'API\v1\Voter\DeleteController');
Route::put('{id}', 'API\v1\Voter\UpdateController');
+2
View File
@@ -43,6 +43,8 @@ Route::get('/fizikalpdf/{id}', [FizikalController::class, 'index']);
Route::get('/mayapdf/{id}', [MayaController::class, 'index']);
Route::get('/sso/login', 'API\v1\Voter\SsoLoginController@login');
Route::view('/{any}', 'index')->where('any', '.*');
Route::get('/penyata/{id}', [App\Http\Controllers\PenyataController::class, 'show']);