Files
E-Vote/resources/assets/js/components/demo/voter/login.vue
T
2026-07-14 15:01:25 +08:00

726 lines
14 KiB
Vue

<template>
<div class="login-shell">
<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>
<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>
<div 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;
export default {
data() {
return {
SSO_LOADING_MS,
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() {
if (this.util.isAdminPortalSession()) {
this.$router.replace(this.util.getAdminEntryRoute());
return;
}
this.handleSsoCallback();
},
methods: {
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;
}
}
};
</script>
<style scoped>
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
background: #f6f7fb;
}
.login-card {
width: 100%;
max-width: 420px;
background: #ffffff;
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);
padding: 28px 24px;
position: relative;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 4px;
background: linear-gradient(90deg, #22c55e 0%, #3b82f6 55%, #a855f7 100%);
}
.login-header {
text-align: center;
margin-bottom: 16px;
padding-top: 6px;
}
.login-logo {
display: block;
margin: 0 auto 10px;
max-width: 240px;
height: auto;
}
.login-title {
margin: 0;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.02em;
color: #0f172a;
}
.login-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: rgba(15, 23, 42, 0.72);
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;
}
.login-label {
font-weight: 700;
font-size: 13px;
color: rgba(15, 23, 42, 0.85);
margin-bottom: 6px;
}
.login-field {
position: relative;
}
.login-field-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: rgba(15, 23, 42, 0.55);
pointer-events: none;
}
.login-field-icon svg {
width: 18px;
height: 18px;
fill: currentColor;
display: block;
}
.login-input {
border-radius: 14px;
padding: 12px 14px 12px 42px;
font-size: 16px;
border: 1px solid rgba(15, 23, 42, 0.14);
transition: box-shadow 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.login-input:focus {
border-color: rgba(59, 130, 246, 0.55);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
}
.login-help {
display: block;
margin-top: 8px;
color: rgba(15, 23, 42, 0.6);
}
.login-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
padding: 13px 14px;
font-weight: 900;
letter-spacing: 0.01em;
color: #ffffff;
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
border: 0;
box-shadow: 0 12px 24px rgba(29, 78, 216, 0.22);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.login-btn:hover {
filter: brightness(1.03);
transform: translateY(-1px);
box-shadow: 0 16px 34px rgba(29, 78, 216, 0.26);
}
.login-btn:disabled {
opacity: 0.72;
filter: saturate(0.9);
transform: none;
box-shadow: none;
cursor: not-allowed;
}
@media (max-width: 576px) {
.login-shell {
align-items: center;
padding: 20px 14px;
}
.login-card {
max-width: none;
border-radius: 20px;
padding: 22px 18px 18px;
}
.login-logo {
max-width: 200px;
}
}
</style>