DONE: disable login and implement sso
This commit is contained in:
+5
-1
@@ -58,4 +58,8 @@ PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY=
|
|||||||
|
|
||||||
# API token idle lifetime in minutes (enforced server-side for Passport tokens)
|
# API token idle lifetime in minutes (enforced server-side for Passport tokens)
|
||||||
VOTER_TOKEN_LIFETIME=120
|
VOTER_TOKEN_LIFETIME=120
|
||||||
ADMIN_TOKEN_LIFETIME=480
|
ADMIN_TOKEN_LIFETIME=480
|
||||||
|
|
||||||
|
MYKOPKB_SSO_SECRET=
|
||||||
|
MYKOPKB_SSO_ISSUER="MYKOPKB 1.0"
|
||||||
|
MYKOPKB_SSO_AUDIENCE=e-vote
|
||||||
@@ -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,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,10 @@ return [
|
|||||||
'secret' => env('STRIPE_SECRET'),
|
'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'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,90 +1,197 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-shell">
|
<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">
|
<div class="login-header">
|
||||||
<img class="login-logo" src="/images/MyKoPKB-logo.png" alt="MyKoPKB" />
|
<img class="login-logo" src="/images/MyKoPKB-logo.png" alt="MyKoPKB" />
|
||||||
<h1 class="login-title">Selamat Datang</h1>
|
<h1 class="login-title">Selamat Datang</h1>
|
||||||
<p class="login-subtitle">Log masuk untuk meneruskan ke Portal MyKoPKB</p>
|
<p class="login-subtitle">Log masuk untuk meneruskan ke Portal MyKoPKB</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="login" id="login_form" class="login-form" novalidate>
|
<transition name="sso-alert">
|
||||||
<div class="form-group mb-3">
|
<div v-if="ssoAlert.visible" class="sso-alert" :class="'sso-alert--' + ssoAlert.code" role="alert"
|
||||||
<label class="login-label" for="no_kp">No. Kad Pengenalan</label>
|
aria-live="assertive">
|
||||||
<div class="login-field">
|
<div class="sso-alert__icon" aria-hidden="true">
|
||||||
<span class="login-field-icon" aria-hidden="true">
|
<svg v-if="ssoAlert.code === 'voter_not_found'" viewBox="0 0 24 24" focusable="false">
|
||||||
<svg viewBox="0 0 24 24" focusable="false">
|
<path
|
||||||
<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" />
|
||||||
d="M4 7.5A3.5 3.5 0 0 1 7.5 4h9A3.5 3.5 0 0 1 20 7.5v9A3.5 3.5 0 0 1 16.5 20h-9A3.5 3.5 0 0 1 4 16.5v-9Zm3.5-1.5A1.5 1.5 0 0 0 6 7.5v9A1.5 1.5 0 0 0 7.5 18h9a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 16.5 6h-9Zm1.5 3h6v2H9V9Zm0 4h10v2H9v-2Z" />
|
</svg>
|
||||||
</svg>
|
<svg v-else viewBox="0 0 24 24" focusable="false">
|
||||||
</span>
|
<path
|
||||||
<input v-model.trim="no_kp" @input="onNoKpInput" :disabled="loading" type="text" id="no_kp"
|
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" />
|
||||||
name="no_kp" class="form-control login-input" placeholder="Contoh: 901010101010"
|
</svg>
|
||||||
autocomplete="off" inputmode="numeric" enterkeyhint="go" required />
|
|
||||||
</div>
|
</div>
|
||||||
<small class="login-help">Masukkan tanpa dash (-) atau jarak.</small>
|
<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">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<button type="submit" class="btn login-btn" :disabled="loading || !no_kp">
|
<div class="sso-only-notice">
|
||||||
<span v-if="loading" class="spinner-border spinner-border-sm mr-2" role="status"
|
<div class="sso-only-notice__icon" aria-hidden="true">
|
||||||
aria-hidden="true"></span>
|
<svg viewBox="0 0 24 24" focusable="false">
|
||||||
<span>{{ loading ? 'Sedang log masuk…' : 'Masuk' }}</span>
|
<path
|
||||||
</button>
|
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" />
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const SSO_LOADING_MS = 2500;
|
||||||
|
const SSO_SUCCESS_MS = 2500;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
SSO_LOADING_MS,
|
||||||
no_kp: ''
|
SSO_SUCCESS_MS,
|
||||||
|
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
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.ssoRedirectTimer) {
|
||||||
|
window.clearTimeout(this.ssoRedirectTimer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
if (this.util.isAdminPortalSession()) {
|
if (this.util.isAdminPortalSession()) {
|
||||||
this.$router.replace(this.util.getAdminEntryRoute());
|
this.$router.replace(this.util.getAdminEntryRoute());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.handleSsoCallback();
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
onNoKpInput() {
|
handleSsoCallback() {
|
||||||
// keep input forgiving: strip dash/spaces and non-digits
|
const params = new URLSearchParams(window.location.search);
|
||||||
this.no_kp = (this.no_kp || '').replace(/[\s-]+/g, '').replace(/[^\d]/g, '');
|
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);
|
||||||
},
|
},
|
||||||
login() {
|
completeSsoLogin(ssoToken, tokenExpiresAt) {
|
||||||
this.startLoading();
|
this.ssoSuccess = {
|
||||||
const noKp = (this.no_kp || '').trim();
|
active: true,
|
||||||
axios
|
phase: 'loading',
|
||||||
.post(config.API + 'voter/login', {
|
title: 'Mengesahkan log masuk SSO',
|
||||||
no_kp: noKp,
|
message: 'Sila tunggu sebentar…'
|
||||||
send_otp: false
|
};
|
||||||
})
|
|
||||||
.then(response => {
|
window.setTimeout(() => {
|
||||||
this.stopLoading();
|
this.util.persistLoginSession(ssoToken, tokenExpiresAt, {
|
||||||
if (this.util.showResult(response, 'success')) {
|
clearAdminSession: true
|
||||||
this.$router.push({
|
|
||||||
name: 'Voter Verify',
|
|
||||||
params: {
|
|
||||||
'nokp': response.data.nokp,
|
|
||||||
'notel': response.data.notel,
|
|
||||||
'debug_otp': response.data.debug_otp || null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
this.stopLoading();
|
|
||||||
this.util.showResult(error, 'error');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
startLoading() {
|
clearSsoQueryParams() {
|
||||||
this.loading = true;
|
if (!window.location.search) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
},
|
},
|
||||||
stopLoading() {
|
showSsoError(message, code) {
|
||||||
this.loading = false;
|
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 +214,8 @@ export default {
|
|||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 18px 55px rgba(2, 6, 23, 0.12),
|
0 18px 55px rgba(2, 6, 23, 0.12),
|
||||||
0 2px 10px rgba(2, 6, 23, 0.06);
|
0 2px 10px rgba(2, 6, 23, 0.06);
|
||||||
padding: 28px 24px;
|
padding: 28px 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -150,6 +257,374 @@ export default {
|
|||||||
line-height: 1.4;
|
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 {
|
.login-form {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
@@ -233,10 +708,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
.login-shell {
|
.login-shell {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20px 14px;
|
padding: 20px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Route::get('/fizikalpdf/{id}', [FizikalController::class, 'index']);
|
|||||||
|
|
||||||
Route::get('/mayapdf/{id}', [MayaController::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::view('/{any}', 'index')->where('any', '.*');
|
||||||
|
|
||||||
Route::get('/penyata/{id}', [App\Http\Controllers\PenyataController::class, 'show']);
|
Route::get('/penyata/{id}', [App\Http\Controllers\PenyataController::class, 'show']);
|
||||||
|
|||||||
Reference in New Issue
Block a user