Commit fb62f636 by Samuel Taniel Mulyadi

add token expired

1 parent d7205995
...@@ -75,7 +75,7 @@ ...@@ -75,7 +75,7 @@
min-block-size: 100vh; /* Ensures the element covers the full viewport height */ min-block-size: 100vh; /* Ensures the element covers the full viewport height */
padding-block-start: 10px; padding-block-start: 10px;
// margin: 5% 5%;marginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmargin // margin: 5% 5%;marginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmargin
} }
.main-component-margin { .main-component-margin {
......
...@@ -13,6 +13,7 @@ const username = ref<string>('') ...@@ -13,6 +13,7 @@ const username = ref<string>('')
const civitas = ref<string>('') const civitas = ref<string>('')
const kodeIdentitas = ref<string>('') const kodeIdentitas = ref<string>('')
const accessToken = ref<string>('') const accessToken = ref<string>('')
const tokenParsedExp = ref<number>(0)
const refreshTokenExp = ref<number>(0) const refreshTokenExp = ref<number>(0)
const roles = ref<string[]>([]) const roles = ref<string[]>([])
const selectedRole = useStorage('selectedRole', 'admin') const selectedRole = useStorage('selectedRole', 'admin')
...@@ -28,6 +29,7 @@ const refresh = (): void => { ...@@ -28,6 +29,7 @@ const refresh = (): void => {
username.value = tokenParsed.preferred_username || '' username.value = tokenParsed.preferred_username || ''
civitas.value = tokenParsed.civitas || '' civitas.value = tokenParsed.civitas || ''
kodeIdentitas.value = tokenParsed.kodeIdentitas || '' kodeIdentitas.value = tokenParsed.kodeIdentitas || ''
tokenParsedExp.value = tokenParsed.exp || 0
accessToken.value = keycloakInstance.token || '' accessToken.value = keycloakInstance.token || ''
refreshTokenExp.value = refreshedTokenParsed.exp || 0 refreshTokenExp.value = refreshedTokenParsed.exp || 0
roles.value = keycloakInstance.resourceAccess?.vueplayground?.roles ?? [] roles.value = keycloakInstance.resourceAccess?.vueplayground?.roles ?? []
......
<script lang="ts" setup> <script lang="ts" setup>
import { HorizontalNavLayout } from '@layouts' import { HorizontalNavLayout } from '@layouts'
import navItems from '@/navigation/horizontal' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import keycloakInstance from '@/keycloak'
// Components
import Footer from '@/layouts/components/Footer.vue' import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue' import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import NavbarTokenExpiredTime from '@/layouts/components/NavbarTokenExpiredTime.vue'
import UserProfile from '@/layouts/components/UserProfile.vue' import UserProfile from '@/layouts/components/UserProfile.vue'
import navItems from '@/navigation/horizontal'
// Keycloak & state
const keycloakStore = useKeycloakStore()
const authenticated = computed(() => keycloakStore.authenticated)
const now = ref(Math.floor(Date.now() / 1000))
const tokenLifetime = ref(0)
let timer: ReturnType<typeof setInterval>
onMounted(() => {
// Set tokenLifetime only once on mount
if (keycloakInstance.tokenParsed?.exp && keycloakInstance.tokenParsed?.iat)
tokenLifetime.value = keycloakInstance.tokenParsed.exp - keycloakInstance.tokenParsed.iat
// Start timer
timer = setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
// Computed expiration values
const computedExpIn = computed(() => {
return authenticated.value && keycloakInstance.tokenParsed?.exp
? Math.max(keycloakInstance.tokenParsed?.exp - now.value, 0)
: 0
})
</script> </script>
<template> <template>
...@@ -39,10 +73,27 @@ import UserProfile from '@/layouts/components/UserProfile.vue' ...@@ -39,10 +73,27 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
:languages="themeConfig.app.i18n.langConfig" :languages="themeConfig.app.i18n.langConfig"
/> />
--> -->
<NavbarTokenExpiredTime />
<NavbarThemeSwitcher /> <NavbarThemeSwitcher />
<!-- <NavbarShortcuts /> --> <!-- <NavbarShortcuts /> -->
<!-- <NavBarNotifications class="me-2" /> --> <!-- <NavBarNotifications class="me-2" /> -->
<!--
<div class="pa-4">
<VAlert
v-if="authenticated"
type="info"
border="start"
variant="outlined"
class="mb-4"
>
<h3 class="text-h6 font-weight-bold">
Access token expires in {{ computedExpIn }} sec,
Refresh token in {{ computedRefExpIn }} sec,
Session: {{ computedSessionTimer }}
</h3>
</VAlert>
</div>
-->
<UserProfile /> <UserProfile />
</template> </template>
......
...@@ -5,6 +5,7 @@ import navItems from '@/navigation/vertical' ...@@ -5,6 +5,7 @@ import navItems from '@/navigation/vertical'
// Components // Components
import Footer from '@/layouts/components/Footer.vue' import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue' import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import NavbarTokenExpiredTime from '@/layouts/components/NavbarTokenExpiredTime.vue'
import UserProfile from '@/layouts/components/UserProfile.vue' import UserProfile from '@/layouts/components/UserProfile.vue'
// @layouts plugin // @layouts plugin
...@@ -41,6 +42,7 @@ import UserProfile from '@/layouts/components/UserProfile.vue' ...@@ -41,6 +42,7 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
:languages="themeConfig.app.i18n.langConfig" :languages="themeConfig.app.i18n.langConfig"
/> />
--> -->
<NavbarTokenExpiredTime />
<NavbarThemeSwitcher /> <NavbarThemeSwitcher />
<!-- <NavbarShortcuts /> --> <!-- <NavbarShortcuts /> -->
<!-- <NavBarNotifications class="me-2" /> --> <!-- <NavBarNotifications class="me-2" /> -->
......
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import keycloakInstance from '@/keycloak'
const keycloakStore = useKeycloakStore()
const authenticated = computed(() => keycloakStore.authenticated)
const now = ref(Math.floor(Date.now() / 1000))
const tokenLifetime = ref(0)
let timer: ReturnType<typeof setInterval>
onMounted(() => {
// Set tokenLifetime only once
if (keycloakInstance.tokenParsed?.exp && keycloakInstance.tokenParsed?.iat)
tokenLifetime.value = keycloakInstance.tokenParsed.exp - keycloakInstance.tokenParsed.iat
timer = setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
const computedExpIn = computed(() => {
return authenticated.value && keycloakInstance.tokenParsed?.exp
? Math.max(keycloakInstance.tokenParsed.exp - now.value, 0)
: 0
})
</script>
<template>
<div
v-if="authenticated"
class="me-4"
style="position: relative; block-size: 40px; inline-size: 40px;"
>
<VProgressCircular
:model-value="(computedExpIn / tokenLifetime) * 100"
:size="40"
:width="3"
color="primary"
/>
<div
class="text-subtitle-1 font-weight-bold"
style="position: absolute; inset-block-start: 50%; inset-inline-start: 50%; transform: translate(-50%, -50%);"
>
{{ computedExpIn }}
</div>
</div>
</template>
...@@ -15,6 +15,19 @@ export default defineNuxtPlugin(async nuxtApp => { ...@@ -15,6 +15,19 @@ export default defineNuxtPlugin(async nuxtApp => {
keycloakStore.refresh() keycloakStore.refresh()
console.log('User is authenticated') console.log('User is authenticated')
navigateTo('/profile') navigateTo('/profile')
setInterval(() => {
const now = Math.floor(Date.now() / 1000)
const tokenExp = keycloakInstance.tokenParsed?.exp ?? 0
if (tokenExp <= now) {
console.warn('Token expired. Logging out...')
keycloakInstance.logout({ redirectUri: `${window.location.origin}/login` })
}
else {
console.log('Token expires in:', tokenExp - now, 'seconds')
}
}, 10_000)
} }
else { else {
console.log('User is not authenticated') console.log('User is not authenticated')
......
<script lang="ts" setup> <script lang="ts" setup>
import { useKeycloakStore } from "@core/stores/keycloakStore"; import { useKeycloakStore } from '@core/stores/keycloakStore'
const keycloakStore = useKeycloakStore(); const keycloakStore = useKeycloakStore()
const isCurrentPasswordVisible = ref(false); const isCurrentPasswordVisible = ref(false)
const isNewPasswordVisible = ref(false); const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false); const isConfirmPasswordVisible = ref(false)
const currentPassword = ref(""); const currentPassword = ref('')
const newPassword = ref(""); const newPassword = ref('')
const newPasswordError = ref(""); const newPasswordError = ref('')
const confirmPassword = ref(""); const confirmPassword = ref('')
const isSubmitting = ref(false); const isSubmitting = ref(false)
watch(newPassword, (newValue) => { watch(newPassword, newValue => {
if (currentPassword.value && newValue === currentPassword.value) { if (currentPassword.value && newValue === currentPassword.value) {
newPasswordError.value = newPasswordError.value
"Kata sandi baru tidak boleh sama dengan kata sandi lama."; = 'Kata sandi baru tidak boleh sama dengan kata sandi lama.'
} else {
newPasswordError.value = "";
} }
}); else {
newPasswordError.value = ''
}
})
const passwordRequirements = [ const passwordRequirements = [
"Panjang minimal 8 karakter, maksimal 20 karakter", 'Panjang minimal 8 karakter, maksimal 20 karakter',
"Minimal satu karakter huruf besar", 'Minimal satu karakter huruf besar',
"Minimal satu angka", 'Minimal satu angka',
"Minimal satu simbol, atau karakter spasi", 'Minimal satu simbol, atau karakter spasi',
]; ]
// Aturan Validasi // Aturan Validasi
const oldPasswordRules = [ const oldPasswordRules = [
(v: string) => !!v || "Konfirmasi kata sandi diperlukan", (v: string) => !!v || 'Konfirmasi kata sandi diperlukan',
]; ]
const passwordRules = [ const passwordRules = [
(v: string) => !!v || "Kata sandi diperlukan", (v: string) => !!v || 'Kata sandi diperlukan',
(v: string) => v.length >= 8 || "Kata sandi minimal terdiri dari 8 karakter", (v: string) => v.length >= 8 || 'Kata sandi minimal terdiri dari 8 karakter',
(v: string) => (v: string) =>
/[a-z]/.test(v) || "Kata sandi setidaknya mengandung satu huruf kecil", /[a-z]/.test(v) || 'Kata sandi setidaknya mengandung satu huruf kecil',
(v: string) => (v: string) =>
/[A-Z]/.test(v) || "Kata sandi setidaknya mengandung satu huruf besar", /[A-Z]/.test(v) || 'Kata sandi setidaknya mengandung satu huruf besar',
(v: string) => /\d/.test(v) || "Kata sandi setidaknya berisi satu angka", (v: string) => /\d/.test(v) || 'Kata sandi setidaknya berisi satu angka',
(v: string) => (v: string) =>
/[ !"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]/.test(v) || /[ !"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]/.test(v)
"Kata sandi setidaknya mengandung satu simbol atau spasi", || 'Kata sandi setidaknya mengandung satu simbol atau spasi',
]; ]
const confirmPasswordRules = [ const confirmPasswordRules = [
(v: string) => !!v || "Konfirmasi kata sandi diperlukan", (v: string) => !!v || 'Konfirmasi kata sandi diperlukan',
(v: string) => v === newPassword.value || "Kata sandi tidak cocok", (v: string) => v === newPassword.value || 'Kata sandi tidak cocok',
]; ]
// Generate Password // Generate Password
function generatePassword(length: number = 10): string { function generatePassword(length: number = 10): string {
const lowercase = "abcdefghijklmnopqrstuvwxyz"; const lowercase = 'abcdefghijklmnopqrstuvwxyz'
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const numbers = "0123456789"; const numbers = '0123456789'
const symbols = ` !"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`; const symbols = ' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
// At least one character from each required category // At least one character from each required category
const mandatoryCharacters = [ const mandatoryCharacters = [
...@@ -64,113 +65,119 @@ function generatePassword(length: number = 10): string { ...@@ -64,113 +65,119 @@ function generatePassword(length: number = 10): string {
uppercase[Math.floor(Math.random() * uppercase.length)], uppercase[Math.floor(Math.random() * uppercase.length)],
numbers[Math.floor(Math.random() * numbers.length)], numbers[Math.floor(Math.random() * numbers.length)],
symbols[Math.floor(Math.random() * symbols.length)], symbols[Math.floor(Math.random() * symbols.length)],
]; ]
// Combine all character sets into one charset // Combine all character sets into one charset
const allCharacters = lowercase + uppercase + numbers + symbols; const allCharacters = lowercase + uppercase + numbers + symbols
const remainingLength = length - mandatoryCharacters.length; const remainingLength = length - mandatoryCharacters.length
let password = mandatoryCharacters; let password = mandatoryCharacters
// Fill the rest of the password with random characters from the combined charset // Fill the rest of the password with random characters from the combined charset
for (let i = 0; i < remainingLength; i++) { for (let i = 0; i < remainingLength; i++) {
const randomChar = const randomChar
allCharacters[Math.floor(Math.random() * allCharacters.length)]; = allCharacters[Math.floor(Math.random() * allCharacters.length)]
password.push(randomChar);
password.push(randomChar)
} }
// Shuffle the password to ensure randomness // Shuffle the password to ensure randomness
password = password.sort(() => Math.random() - 0.5); password = password.sort(() => Math.random() - 0.5)
// Return the password as a string // Return the password as a string
return password.join(""); return password.join('')
} }
// fungsi untuk encoding Base64 // fungsi untuk encoding Base64
function toBase64(str: string) { function toBase64(str: string) {
return btoa(unescape(encodeURIComponent(str))); return btoa(unescape(encodeURIComponent(str)))
} }
// Set Password // Set Password
async function setPassword() { async function setPassword() {
isSubmitting.value = true; isSubmitting.value = true
if (isEmpty(currentPassword.value)) { if (isEmpty(currentPassword.value)) {
alert("Kata Sandi Lama tidak boleh kosong"); alert('Kata Sandi Lama tidak boleh kosong')
return;
return
} }
// Buat objek data dengan password yang sudah di-encoding // Buat objek data dengan password yang sudah di-encoding
const requestData = { const requestData = {
oldPassword: toBase64(currentPassword.value), oldPassword: toBase64(currentPassword.value),
newPassword: toBase64(newPassword.value), newPassword: toBase64(newPassword.value),
}; }
if ( if (
newPassword.value.length < 8 || newPassword.value.length < 8
!/[a-z]/.test(newPassword.value) || || !/[a-z]/.test(newPassword.value)
!/\d/.test(newPassword.value) || || !/\d/.test(newPassword.value)
!/[ !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/.test(newPassword.value) || !/[ !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/.test(newPassword.value)
) { ) {
alert( alert(
"Kata Sandi harus memiliki minimal 8 karakter, setidaknya satu huruf besar, satu angka, dan satu karakter khusus." 'Kata Sandi harus memiliki minimal 8 karakter, setidaknya satu huruf besar, satu angka, dan satu karakter khusus.',
); )
return;
return
} }
if (newPassword.value === currentPassword.value) { if (newPassword.value === currentPassword.value) {
alert("Kata sandi baru tidak boleh sama dengan kata sandi lama."); alert('Kata sandi baru tidak boleh sama dengan kata sandi lama.')
return;
return
} }
if (newPassword.value !== confirmPassword.value) { if (newPassword.value !== confirmPassword.value) {
alert("Kata Sandi Baru dan Konfirmasi Kata Sandi tidak cocok."); alert('Kata Sandi Baru dan Konfirmasi Kata Sandi tidak cocok.')
return;
return
} }
try { try {
// Panggil API untuk mengganti password // Panggil API untuk mengganti password
const apiEndpoint = `https://api.ui.ac.id/my/pw`; const apiEndpoint = 'https://api.ui.ac.id/my/pw'
const response = await fetch(apiEndpoint, { const response = await fetch(apiEndpoint, {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
Authorization: `Bearer ${keycloakStore.accessToken}`, 'Authorization': `Bearer ${keycloakStore.accessToken}`,
}, },
body: JSON.stringify(requestData), body: JSON.stringify(requestData),
}); })
// Check for HTTP status code 401 which indicates old password is incorrect // Check for HTTP status code 401 which indicates old password is incorrect
if (response.status === 401) { if (response.status === 401)
throw new Error("Kata sandi lama tidak sesuai"); throw new Error('Kata sandi lama tidak sesuai')
}
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json()
throw new Error(errorData.message || "Gagal mengubah password"); throw new Error(errorData.message || 'Gagal mengubah password')
} }
// Jika berhasil, tampilkan pesan dan reset form // Jika berhasil, tampilkan pesan dan reset form
alert( alert(
"Kata sandi berhasil diubah. Silakan logout dan login kembali dengan kata sandi baru." 'Kata sandi berhasil diubah. Silakan logout dan login kembali dengan kata sandi baru.',
); )
currentPassword.value = ""; currentPassword.value = ''
newPassword.value = ""; newPassword.value = ''
confirmPassword.value = ""; confirmPassword.value = ''
} catch (error) { }
catch (error) {
// Handle error // Handle error
if (error instanceof Error) { if (error instanceof Error)
alert(error.message); alert(error.message)
} else { else
alert("Terjadi kesalahan saat mengubah kata sandi"); alert('Terjadi kesalahan saat mengubah kata sandi')
} }
} finally { finally {
isSubmitting.value = false; isSubmitting.value = false
} }
// Reset input // Reset input
currentPassword.value = ""; currentPassword.value = ''
newPassword.value = ""; newPassword.value = ''
confirmPassword.value = ""; confirmPassword.value = ''
} }
</script> </script>
...@@ -186,7 +193,10 @@ async function setPassword() { ...@@ -186,7 +193,10 @@ async function setPassword() {
<VCardText class="pt-0"> <VCardText class="pt-0">
<!-- 👉 Current Password --> <!-- 👉 Current Password -->
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<!-- 👉 current password --> <!-- 👉 current password -->
<VTextField <VTextField
v-model="currentPassword" v-model="currentPassword"
...@@ -197,18 +207,21 @@ async function setPassword() { ...@@ -197,18 +207,21 @@ async function setPassword() {
" "
autocomplete="on" autocomplete="on"
label="Kata Sandi Lama" label="Kata Sandi Lama"
:rules="oldPasswordRules"
clearable
@click:append-inner=" @click:append-inner="
isCurrentPasswordVisible = !isCurrentPasswordVisible isCurrentPasswordVisible = !isCurrentPasswordVisible
" "
:rules="oldPasswordRules"
clearable
/> />
</VCol> </VCol>
</VRow> </VRow>
<!-- 👉 New Password --> <!-- 👉 New Password -->
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<!-- 👉 new password --> <!-- 👉 new password -->
<VTextField <VTextField
v-model="newPassword" v-model="newPassword"
...@@ -219,16 +232,19 @@ async function setPassword() { ...@@ -219,16 +232,19 @@ async function setPassword() {
" "
label="Kata Sandi Baru" label="Kata Sandi Baru"
autocomplete="on" autocomplete="on"
@click:append-inner="
isNewPasswordVisible = !isNewPasswordVisible
"
:rules="passwordRules" :rules="passwordRules"
:error-messages="newPasswordError" :error-messages="newPasswordError"
clearable clearable
@click:append-inner="
isNewPasswordVisible = !isNewPasswordVisible
"
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<!-- 👉 confirm password --> <!-- 👉 confirm password -->
<VTextField <VTextField
v-model="confirmPassword" v-model="confirmPassword"
...@@ -239,11 +255,11 @@ async function setPassword() { ...@@ -239,11 +255,11 @@ async function setPassword() {
" "
autocomplete="on" autocomplete="on"
label="Konfirmasi Kata Sandi" label="Konfirmasi Kata Sandi"
:rules="confirmPasswordRules"
clearable
@click:append-inner=" @click:append-inner="
isConfirmPasswordVisible = !isConfirmPasswordVisible isConfirmPasswordVisible = !isConfirmPasswordVisible
" "
:rules="confirmPasswordRules"
clearable
/> />
</VCol> </VCol>
</VRow> </VRow>
...@@ -276,11 +292,20 @@ async function setPassword() { ...@@ -276,11 +292,20 @@ async function setPassword() {
<!-- 👉 Action Buttons --> <!-- 👉 Action Buttons -->
<div class="d-flex flex-wrap gap-4"> <div class="d-flex flex-wrap gap-4">
<VBtn @click="setPassword" :disabled="isSubmitting">{{ <VBtn
:disabled="isSubmitting"
@click="setPassword"
>
{{
isSubmitting ? "Sedang Memproses..." : "Simpan Perubahan" isSubmitting ? "Sedang Memproses..." : "Simpan Perubahan"
}}</VBtn> }}
</VBtn>
<VBtn type="reset" color="secondary" variant="outlined"> <VBtn
type="reset"
color="secondary"
variant="outlined"
>
Reset Reset
</VBtn> </VBtn>
<VBtn <VBtn
...@@ -291,8 +316,8 @@ async function setPassword() { ...@@ -291,8 +316,8 @@ async function setPassword() {
confirmPassword = newPassword; confirmPassword = newPassword;
" "
> >
Generate Kata Sandi</VBtn Generate Kata Sandi
> </VBtn>
</div> </div>
</VCardText> </VCardText>
</VForm> </VForm>
......
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!