| @@ -76,7 +76,7 @@ if (state.value.token) { | |||
| userData.value = jwtDecode<Claims>(state.value.token); | |||
| } | |||
| const hasToken = computed(() => !!store.getters.getToken); | |||
| const hasToken = computed(() => !!store.getters.token); | |||
| interface LogoElement { | |||
| letter: string; | |||
| @@ -2,6 +2,7 @@ import SignetRequestController from '@/api/requests'; | |||
| import { | |||
| AuthenticationRequest, | |||
| Bonus, | |||
| ChangePasswordRequest, | |||
| CloseRewardFundRequest, | |||
| ContributeRequest, | |||
| CreateQueueRequest, | |||
| @@ -9,6 +10,7 @@ import { | |||
| CreateRewardFundRequest, | |||
| DistributeRewardsRequest, | |||
| EditQueueRequest, | |||
| EscalatePrivilegesRequest, | |||
| GetBalanceRequest, | |||
| GetBalanceResponse, | |||
| GetContributionsRequest, | |||
| @@ -24,6 +26,7 @@ import { | |||
| LoginResponse, | |||
| NearlyCompleteFundsRequest, | |||
| NearlyCompleteFundsResponse, | |||
| Privileges, | |||
| QueueMember, | |||
| RewardDistributionInfo, | |||
| SubmitRewardFundRequest, | |||
| @@ -133,3 +136,13 @@ export const distributeRewardFund = (rewardFundID: number, payments: RewardDistr | |||
| }); | |||
| export const getUsers = () => controller.post<GetUsersResponse, null>('GetUsers', null); | |||
| export const changePrivileges = (userID: number, privileges: Privileges) => controller.post<SuccessResponse, EscalatePrivilegesRequest>('ChangePrivileges', { | |||
| userID, | |||
| privileges, | |||
| }); | |||
| export const changePassword = (userID: number, password: string) => controller.post<SuccessResponse, ChangePasswordRequest>('ChangePassword', { | |||
| userID, | |||
| password, | |||
| }); | |||
| @@ -24,7 +24,7 @@ class SignetRequestController { | |||
| method: 'GET', | |||
| headers: setHeaders( | |||
| undefined, | |||
| store.getters.getToken, | |||
| store.getters.token, | |||
| ), | |||
| }, | |||
| ); | |||
| @@ -42,7 +42,7 @@ class SignetRequestController { | |||
| body: JSON.stringify(payload), | |||
| headers: setHeaders( | |||
| { 'Content-Type': 'application/json' }, | |||
| store.getters.getToken, | |||
| store.getters.token, | |||
| ), | |||
| }, | |||
| ); | |||
| @@ -1,5 +1,6 @@ | |||
| // eslint-disable-next-line no-shadow | |||
| import Decimal from 'decimal.js'; | |||
| import { DateTime } from 'luxon'; | |||
| // eslint-disable-next-line no-shadow | |||
| export enum Privileges { | |||
| @@ -131,6 +132,7 @@ export interface AuthenticationRequest { | |||
| export interface LoginResponse { | |||
| token: string | null; | |||
| lastLogin: DateTime | null; | |||
| } | |||
| export interface GetQueueMembersRequest { | |||
| @@ -151,6 +153,7 @@ export interface GetRewardFundsResponse { | |||
| } | |||
| export interface Claims { | |||
| id: number; | |||
| username: string; | |||
| privileges: Privileges; | |||
| exp: number; | |||
| @@ -210,11 +213,22 @@ export interface DistributeRewardsRequest { | |||
| } | |||
| export interface User { | |||
| username: string, | |||
| password: string, | |||
| admin: number, | |||
| id: number; | |||
| username: string; | |||
| password: string; | |||
| admin: number; | |||
| } | |||
| export interface GetUsersResponse { | |||
| users: User[]; | |||
| } | |||
| export interface EscalatePrivilegesRequest { | |||
| userID: number; | |||
| privileges: Privileges; | |||
| } | |||
| export interface ChangePasswordRequest { | |||
| userID: number; | |||
| password: string; | |||
| } | |||
| @@ -8,10 +8,10 @@ const removeToken = () => { | |||
| store.commit('clearToken'); | |||
| }; | |||
| const hasPermission = (requiredRights: number) => { | |||
| const jwt = store.getters.getToken; | |||
| const jwt = store.getters.token; | |||
| if (jwt !== undefined && requiredRights !== undefined) { | |||
| try { | |||
| const decoded = jwtDecode<Claims>(store.getters.getToken); | |||
| const decoded = jwtDecode<Claims>(store.getters.token); | |||
| const expired = luxon.DateTime.now() | |||
| .toUnixInteger() > decoded.exp; | |||
| jwtDecode(jwt, { header: true }); | |||
| @@ -14,6 +14,8 @@ import ModifyQueueView from '@/views/ModifyQueueView.vue'; | |||
| import AdminDashboardView from '@/views/AdminDashboardView.vue'; | |||
| import ModifyUserView from '@/views/ModifyUserView.vue'; | |||
| import LogoutView from '@/views/LogoutView.vue'; | |||
| import ChangePasswordView from '@/views/ChangePasswordView.vue'; | |||
| import store from '@/store'; | |||
| const routes: Array<RouteRecordRaw> = [ | |||
| { | |||
| @@ -34,6 +36,12 @@ const routes: Array<RouteRecordRaw> = [ | |||
| component: LoginView, | |||
| meta: { title: 'Login' }, | |||
| }, | |||
| { | |||
| path: '/changepassword', | |||
| name: 'changepassword', | |||
| component: ChangePasswordView, | |||
| meta: { title: 'Change Password' }, | |||
| }, | |||
| { | |||
| path: '/logout', | |||
| name: 'logout', | |||
| @@ -129,7 +137,13 @@ const router = createRouter({ | |||
| router.beforeEach(async (to, from, next) => { | |||
| document.title = `Beignet - ${to.meta.title}`; | |||
| return next(); | |||
| if (!store.getters.passwordChangeRequired) { | |||
| return next(); | |||
| } | |||
| if (to.name !== 'changepassword') { | |||
| return next('changepassword'); | |||
| } | |||
| return next(undefined); | |||
| }); | |||
| export default router; | |||
| @@ -3,9 +3,11 @@ import { createStore } from 'vuex'; | |||
| export default createStore({ | |||
| state: { | |||
| token: undefined as string | undefined, | |||
| passwordChangeRequired: false, | |||
| }, | |||
| getters: { | |||
| getToken: (state) => state.token, | |||
| token: (state) => state.token, | |||
| passwordChangeRequired: (state) => state.passwordChangeRequired, | |||
| }, | |||
| mutations: { | |||
| setToken: (state, token) => { | |||
| @@ -14,6 +16,12 @@ export default createStore({ | |||
| clearToken: (state) => { | |||
| state.token = undefined; | |||
| }, | |||
| userNeedsToChangePassword: (state) => { | |||
| state.passwordChangeRequired = true; | |||
| }, | |||
| userHasChangedPassword: (state) => { | |||
| state.passwordChangeRequired = false; | |||
| }, | |||
| }, | |||
| actions: { | |||
| setToken: (context) => { | |||
| @@ -22,7 +30,12 @@ export default createStore({ | |||
| clearToken: (context) => { | |||
| context.commit('clearToken'); | |||
| }, | |||
| userNeedsToChangePassword: (context) => { | |||
| context.commit('userNeedsToChangePassword'); | |||
| }, | |||
| userHasChangedPassword: (context) => { | |||
| context.commit('userHasChangedPassword'); | |||
| }, | |||
| }, | |||
| modules: { | |||
| }, | |||
| modules: {}, | |||
| }); | |||
| @@ -0,0 +1,53 @@ | |||
| <template> | |||
| <section class="section is-medium"> | |||
| <div | |||
| class="is-flex is-flex-direction-column is-justify-content-space-around change-password-body"> | |||
| <div> | |||
| <span class="is-size-3-desktop is-size-4-mobile"> | |||
| Welcome, {{ user.username }} | |||
| </span> | |||
| <p>It's time to change your password.</p> | |||
| </div> | |||
| <div> | |||
| <input type="password" class="input is-medium" aria-label="Change Password" | |||
| v-model="password"/> | |||
| <div class="is-flex is-flex-direction-row is-justify-content-flex-end my-3"> | |||
| <button class="button is-success" @click="changeUserPassword">Submit</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import jwtDecode from 'jwt-decode'; | |||
| import { Claims } from '@/api/types'; | |||
| import store from '@/store'; | |||
| import { useRouter } from 'vue-router'; | |||
| import { ref } from 'vue'; | |||
| import { changePassword } from '@/api/composed'; | |||
| const router = useRouter(); | |||
| const password = ref<string>(); | |||
| const user = ref<Claims>(); | |||
| if (store.getters.token) { | |||
| user.value = jwtDecode<Claims>(store.getters.token); | |||
| } else { | |||
| await router.push('/'); | |||
| } | |||
| const changeUserPassword = async () => { | |||
| if (!user.value) throw new Error('There is no user!'); | |||
| if (!password.value) throw new Error('You need to type a password!'); | |||
| const resp = await changePassword(user.value?.id, password.value); | |||
| await store.dispatch('userHasChangedPassword'); | |||
| if (resp?.success) await router.push('/'); | |||
| }; | |||
| </script> | |||
| <style scoped lang="stylus"> | |||
| .change-password-body | |||
| height 45vh | |||
| </style> | |||
| @@ -122,7 +122,7 @@ | |||
| </label> | |||
| </div> | |||
| </div> | |||
| <p class="py-2" v-if="store.getters.getToken && hasPermission(Privileges.Admin)"> | |||
| <p class="py-2" v-if="store.getters.token && hasPermission(Privileges.Admin)"> | |||
| Enable contribution consolidation in order to select. | |||
| Click to select a row in order to mark a wallet to receive rewards. | |||
| </p> | |||
| @@ -154,8 +154,8 @@ | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <div class="my-4" v-if="store.getters.getToken && hasPermission(Privileges.Admin)"> | |||
| <ModifyWithConfirmation | |||
| <div class="my-4" v-if="store.getters.token && hasPermission(Privileges.Admin)"> | |||
| <ButtonWithConfirmation | |||
| :condition="selectedContributions.length === 0" | |||
| :inputs="{ button: { label: 'Distribute Rewards', style: 'is-success' }, | |||
| checkbox: { label: 'Allow Distribution' }}" | |||
| @@ -165,22 +165,22 @@ | |||
| </div> | |||
| </section> | |||
| <section class="section is-small px-0" | |||
| v-if="store.getters.getToken && hasPermission(Privileges.Admin)"> | |||
| v-if="store.getters.token && hasPermission(Privileges.Admin)"> | |||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||
| Submit Group Fund | |||
| </div> | |||
| <ModifyWithConfirmation | |||
| <ButtonWithConfirmation | |||
| :inputs="{ button: { label: 'Submit Fund', style: 'is-success' }, | |||
| checkbox: { label: 'Allow Submission' }}" | |||
| :modification="submitFund" | |||
| @confirmed="setAllowSubmit" | |||
| /> | |||
| </section> | |||
| <section v-if="store.getters.getToken && hasPermission(Privileges.AdminPlus)"> | |||
| <section v-if="store.getters.token && hasPermission(Privileges.AdminPlus)"> | |||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||
| Close Group Fund | |||
| </div> | |||
| <ModifyWithConfirmation | |||
| <ButtonWithConfirmation | |||
| :inputs="{ button: { label: 'Close Fund', style: 'is-danger' }, | |||
| checkbox: { label: 'Allow Closing' }}" | |||
| :modification="deleteFund" | |||
| @@ -230,7 +230,7 @@ import { | |||
| submitRewardFund, | |||
| } from '@/api/composed'; | |||
| import Decimal from 'decimal.js'; | |||
| import ModifyWithConfirmation from '@/components/ModifyWithConfirmation.vue'; | |||
| import ButtonWithConfirmation from '@/components/ButtonWithConfirmation.vue'; | |||
| const route = useRoute(); | |||
| const router = useRouter(); | |||
| @@ -389,7 +389,7 @@ const calculateReward = (bought: Decimal) => { | |||
| const selectedContributions = ref([] as Contribution[]); | |||
| const selectContribution = (contribution: SelectableContribution) => { | |||
| if (!store.getters.getToken || !hasPermission(Privileges.Admin)) return; | |||
| if (!store.getters.token || !hasPermission(Privileges.Admin)) return; | |||
| if (enableConsolidation.value) { | |||
| if (!contribution.selected) { | |||
| selectedContributions.value.push(contribution); | |||
| @@ -34,7 +34,12 @@ const submit = async () => { | |||
| if (resp.token !== null) { | |||
| sessionStorage.setItem('jwt', JSON.stringify({ token: resp.token })); | |||
| store.commit('setToken', resp.token); | |||
| await router.push('/'); | |||
| if (resp.lastLogin === null) { | |||
| await store.dispatch('userNeedsToChangePassword'); | |||
| await router.push('/changepassword'); | |||
| } else { | |||
| await router.push('/'); | |||
| } | |||
| } | |||
| }; | |||
| </script> | |||
| @@ -26,7 +26,9 @@ | |||
| </td> | |||
| <td class="p-2"> | |||
| <div class="select is-small"> | |||
| <select name="" id="" aria-label="User Privilege"> | |||
| <select name="" id="" | |||
| aria-label="User Privilege" | |||
| @change="setNewUserPermissions(user.id, $event)"> | |||
| <option :value="privilege" | |||
| :selected="getPrivilege(user.admin) === privilege" | |||
| v-for="(privilege, i) in Object.values(privileges)" :key="i" | |||
| @@ -52,7 +54,10 @@ import { | |||
| User, | |||
| } from '@/api/types'; | |||
| import { ref } from 'vue'; | |||
| import { getUsers } from '@/api/composed'; | |||
| import { | |||
| changePrivileges, | |||
| getUsers, | |||
| } from '@/api/composed'; | |||
| import jwtDecode from 'jwt-decode'; | |||
| import store from '@/store'; | |||
| @@ -64,12 +69,18 @@ try { | |||
| users.value = undefined; | |||
| } | |||
| const getSelectedPrivilege = (evt: Event) => { | |||
| const target = evt.target as HTMLSelectElement; | |||
| const privilege = target.options[target.selectedIndex].value as 'SuperUser' | 'AdminPlus' | 'Admin'; | |||
| return Privileges[privilege]; | |||
| }; | |||
| const userData = ref<Claims>({ | |||
| username: '', | |||
| privileges: -1, | |||
| exp: -1, | |||
| }); | |||
| userData.value = jwtDecode<Claims>(store.getters.getToken); | |||
| userData.value = jwtDecode<Claims>(store.getters.token); | |||
| const getPrivilege = (privilege: number) => Privileges[privilege]; | |||
| @@ -81,6 +92,11 @@ const getPrivileges = () => Object.fromEntries( | |||
| const privileges = getPrivileges(); | |||
| const setNewUserPermissions = (userID: number, evt: Event) => changePrivileges( | |||
| userID, | |||
| getSelectedPrivilege(evt), | |||
| ); | |||
| </script> | |||
| <style scoped lang="stylus"> | |||