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