Cleaned up authentication logic.
This commit is contained in:
parent
4ec30b37f8
commit
cde09d1b50
|
@ -1,6 +1,5 @@
|
||||||
import { api } from 'src/api/main/index';
|
import { api } from 'src/api/main/index';
|
||||||
import {AuthStoreType} from 'stores/auth-store';
|
import {AuthStoreType} from 'stores/auth-store';
|
||||||
import Timeout = NodeJS.Timeout;
|
|
||||||
import { WeightUnit } from 'src/api/main/submission';
|
import { WeightUnit } from 'src/api/main/submission';
|
||||||
import {Page, PaginationOptions, toQueryParams} from 'src/api/main/models';
|
import {Page, PaginationOptions, toQueryParams} from 'src/api/main/models';
|
||||||
|
|
||||||
|
@ -67,41 +66,6 @@ export enum UserFollowResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthModule {
|
class AuthModule {
|
||||||
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
|
|
||||||
|
|
||||||
private tokenRefreshTimer?: Timeout;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to use the given credentials to obtain an access token for
|
|
||||||
* sending authenticated requests.
|
|
||||||
* @param authStore The auth store to use to update app state.
|
|
||||||
* @param credentials The credentials for logging in.
|
|
||||||
*/
|
|
||||||
public async login(authStore: AuthStoreType, credentials: TokenCredentials) {
|
|
||||||
authStore.token = await this.getNewToken(credentials);
|
|
||||||
authStore.user = await this.getMyUser(authStore);
|
|
||||||
// Load the user's attached data right away too.
|
|
||||||
const [personalDetails, preferences, roles] = await Promise.all([
|
|
||||||
this.getMyPersonalDetails(authStore),
|
|
||||||
this.getMyPreferences(authStore),
|
|
||||||
this.getMyRoles(authStore)
|
|
||||||
]);
|
|
||||||
authStore.user.personalDetails = personalDetails;
|
|
||||||
authStore.user.preferences = preferences;
|
|
||||||
authStore.roles = roles;
|
|
||||||
|
|
||||||
clearTimeout(this.tokenRefreshTimer);
|
|
||||||
this.tokenRefreshTimer = setInterval(
|
|
||||||
() => this.refreshToken(authStore),
|
|
||||||
AuthModule.TOKEN_REFRESH_INTERVAL_MS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public logout(authStore: AuthStoreType) {
|
|
||||||
authStore.logOut();
|
|
||||||
clearTimeout(this.tokenRefreshTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async register(payload: UserCreationPayload): Promise<User> {
|
public async register(payload: UserCreationPayload): Promise<User> {
|
||||||
const response = await api.post('/auth/register', payload);
|
const response = await api.post('/auth/register', payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
@ -117,18 +81,9 @@ class AuthModule {
|
||||||
return response.data.token;
|
return response.data.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshToken(authStore: AuthStoreType) {
|
public async refreshToken(authStore: AuthStoreType): Promise<string> {
|
||||||
try {
|
|
||||||
const response = await api.get('/auth/token', authStore.axiosConfig);
|
const response = await api.get('/auth/token', authStore.axiosConfig);
|
||||||
authStore.token = response.data.token;
|
return response.data.token;
|
||||||
} catch (error: any) {
|
|
||||||
authStore.logOut();
|
|
||||||
if (error.response) {
|
|
||||||
console.warn('Failed to refresh token: ', error.response);
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMyUser(authStore: AuthStoreType): Promise<User> {
|
public async getMyUser(authStore: AuthStoreType): Promise<User> {
|
||||||
|
|
|
@ -22,7 +22,7 @@ account-related actions.
|
||||||
<q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
|
<q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item clickable v-close-popup @click="api.auth.logout(authStore)">
|
<q-item clickable v-close-popup @click="authStore.logOut()">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ $t('accountMenuItem.logOut') }}</q-item-label>
|
<q-item-label>{{ $t('accountMenuItem.logOut') }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
@ -42,7 +42,6 @@ account-related actions.
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from 'stores/auth-store';
|
import { useAuthStore } from 'stores/auth-store';
|
||||||
import api from 'src/api/main';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { getUserRoute } from 'src/router/user-routing';
|
import { getUserRoute } from 'src/router/user-routing';
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import {onMounted, ref} from 'vue';
|
||||||
import AccountMenuItem from 'components/AccountMenuItem.vue';
|
import AccountMenuItem from 'components/AccountMenuItem.vue';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
|
|
||||||
|
@ -62,4 +62,8 @@ const leftDrawerOpen = ref(false);
|
||||||
function toggleLeftDrawer() {
|
function toggleLeftDrawer() {
|
||||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await authStore.tryLogInWithStoredToken();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
/**
|
||||||
|
* Module declaration for the "leaflet" library for showing openstreetmap content.
|
||||||
|
*/
|
||||||
declare module 'leaflet';
|
declare module 'leaflet';
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
|
@ -55,19 +55,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
import SlimForm from 'components/SlimForm.vue';
|
import SlimForm from 'components/SlimForm.vue';
|
||||||
import { ref } from 'vue';
|
import {ref} from 'vue';
|
||||||
import api from 'src/api/main';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import { useAuthStore } from 'stores/auth-store';
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import {useI18n} from 'vue-i18n';
|
||||||
import { useI18n } from 'vue-i18n';
|
import {resolveLocale} from 'src/i18n';
|
||||||
import { resolveLocale } from 'src/i18n';
|
import {showApiErrorToast, showWarningToast} from 'src/utils';
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const i18n = useI18n({ useScope: 'global' });
|
const i18n = useI18n({ useScope: 'global' });
|
||||||
const quasar = useQuasar();
|
|
||||||
|
|
||||||
const loginModel = ref({
|
const loginModel = ref({
|
||||||
email: '',
|
email: '',
|
||||||
|
@ -84,7 +82,7 @@ const passwordVisible = ref(false);
|
||||||
*/
|
*/
|
||||||
async function tryLogin() {
|
async function tryLogin() {
|
||||||
try {
|
try {
|
||||||
await api.auth.login(authStore, loginModel.value);
|
await authStore.logInWithCredentials(loginModel.value);
|
||||||
|
|
||||||
// Set the locale to the user's preferred locale.
|
// Set the locale to the user's preferred locale.
|
||||||
i18n.locale.value = resolveLocale(authStore.user?.preferences?.locale).value;
|
i18n.locale.value = resolveLocale(authStore.user?.preferences?.locale).value;
|
||||||
|
@ -96,18 +94,9 @@ async function tryLogin() {
|
||||||
await router.push(dest);
|
await router.push(dest);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response && error.response.status === 401) {
|
||||||
quasar.notify({
|
showWarningToast('loginPage.authFailed');
|
||||||
message: i18n.t('loginPage.authFailed'),
|
|
||||||
type: 'warning',
|
|
||||||
position: 'top',
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
quasar.notify({
|
showApiErrorToast(error);
|
||||||
message: i18n.t('generalErrors.apiError'),
|
|
||||||
type: 'negative',
|
|
||||||
position: 'top',
|
|
||||||
});
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,12 +117,8 @@ let initialPreferences: UserPreferences | null = null;
|
||||||
const newPassword = ref('');
|
const newPassword = ref('');
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Redirect away from the page if the user isn't viewing their own settings.
|
|
||||||
const userId = route.params.userId as string;
|
const userId = route.params.userId as string;
|
||||||
if (!authStore.user || authStore.user.id !== userId) {
|
if (authStore.user && authStore.user.id === userId) {
|
||||||
await router.replace(`/users/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
|
personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
|
||||||
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
|
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
|
||||||
|
|
||||||
|
@ -130,6 +126,10 @@ onMounted(async () => {
|
||||||
initialPreferences = structuredClone(toRaw(preferences.value));
|
initialPreferences = structuredClone(toRaw(preferences.value));
|
||||||
|
|
||||||
newPassword.value = '';
|
newPassword.value = '';
|
||||||
|
} else {
|
||||||
|
// Redirect away from the page if the user isn't viewing their own settings.
|
||||||
|
await router.replace(`/users/${userId}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const personalDetailsChanged = computed(() => {
|
const personalDetailsChanged = computed(() => {
|
||||||
|
|
|
@ -83,7 +83,12 @@ const isOwnUser = ref(false);
|
||||||
// re-render.
|
// re-render.
|
||||||
watch(route, async (updatedRoute) => {
|
watch(route, async (updatedRoute) => {
|
||||||
if (updatedRoute.params.userId && updatedRoute.params.userId.length > 0) {
|
if (updatedRoute.params.userId && updatedRoute.params.userId.length > 0) {
|
||||||
const userId = updatedRoute.params.userId[0];
|
let userId;
|
||||||
|
if (Array.isArray(updatedRoute.params.userId)) {
|
||||||
|
userId = updatedRoute.params.userId[0];
|
||||||
|
} else {
|
||||||
|
userId = updatedRoute.params.userId;
|
||||||
|
}
|
||||||
if (!profile.value || (profile.value.id !== userId)) {
|
if (!profile.value || (profile.value.id !== userId)) {
|
||||||
await loadUser(userId);
|
await loadUser(userId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { User } from 'src/api/main/auth';
|
import {TokenCredentials, User} from 'src/api/main/auth';
|
||||||
|
import api from 'src/api/main';
|
||||||
import {AxiosRequestConfig} from 'axios';
|
import {AxiosRequestConfig} from 'axios';
|
||||||
|
import Timeout = NodeJS.Timeout;
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
/**
|
/**
|
||||||
|
@ -20,18 +22,32 @@ interface AuthState {
|
||||||
*/
|
*/
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
|
||||||
|
tokenRefreshTimer: Timeout | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The list of roles that the currently authenticated user has.
|
* The list of roles that the currently authenticated user has.
|
||||||
*/
|
*/
|
||||||
roles: string[];
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOKEN_REFRESH_INTERVAL = 60 * 1000 * 25;
|
||||||
|
|
||||||
export const useAuthStore = defineStore('authStore', {
|
export const useAuthStore = defineStore('authStore', {
|
||||||
state: (): AuthState => {
|
state: (): AuthState => {
|
||||||
return { user: null, token: null, roles: [] };
|
return { user: null, token: null, tokenRefreshTimer: null, roles: [] };
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
|
/**
|
||||||
|
* Determines whether a user is currently logged in.
|
||||||
|
* @param state The store's state.
|
||||||
|
*/
|
||||||
loggedIn: (state) => state.user !== null && state.token !== null,
|
loggedIn: (state) => state.user !== null && state.token !== null,
|
||||||
|
/**
|
||||||
|
* Gets the axios config that can be applied to requests to authenticate
|
||||||
|
* them as the current user. This will always return a valid request config
|
||||||
|
* even when the user is not logged in.
|
||||||
|
* @param state The store's state.
|
||||||
|
*/
|
||||||
axiosConfig(state): AxiosRequestConfig {
|
axiosConfig(state): AxiosRequestConfig {
|
||||||
if (this.token !== null) {
|
if (this.token !== null) {
|
||||||
return {
|
return {
|
||||||
|
@ -41,19 +57,80 @@ export const useAuthStore = defineStore('authStore', {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Getter that returns true if a user is authenticated, and the user is
|
||||||
|
* an admin, meaning they have special access to additional data.
|
||||||
|
* @param state The store's state.
|
||||||
|
*/
|
||||||
isAdmin: state => state.roles.indexOf('admin') !== -1,
|
isAdmin: state => state.roles.indexOf('admin') !== -1,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
/**
|
/**
|
||||||
* Logs a user into the application.
|
* Attempts to log in with the given token credentials.
|
||||||
* @param user The user who was logged in.
|
* @param credentials The credentials to use.
|
||||||
* @param token The token that was obtained.
|
|
||||||
* @param roles The list of the user's roles.
|
|
||||||
*/
|
*/
|
||||||
logIn(user: User, token: string, roles: string[]) {
|
async logInWithCredentials(credentials: TokenCredentials) {
|
||||||
this.user = user;
|
const token = await api.auth.getNewToken(credentials);
|
||||||
this.token = token;
|
await this._logInWithToken(token);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Attempts to log in with a token that's stored in the browser's local
|
||||||
|
* storage.
|
||||||
|
*/
|
||||||
|
async tryLogInWithStoredToken() {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
if (token) {
|
||||||
|
this.token = token; // Temporarily set our token to the one that was stored, so we can use it to request a new one.
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
if (this.token) { // If we were able to refresh the token, we can now go through with the login.
|
||||||
|
await this._logInWithToken(token);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not log in with stored token: ', error);
|
||||||
|
this.logOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Initializes the auth state using a freshly-obtained token. This will
|
||||||
|
* populate all the necessary data to consider the user to be logged in,
|
||||||
|
* including fetching user data.
|
||||||
|
*
|
||||||
|
* Note: This method is intended to be used only internally.
|
||||||
|
* @param token The token to use.
|
||||||
|
*/
|
||||||
|
async _logInWithToken(token: string) {
|
||||||
|
this.updateToken(token);
|
||||||
|
this.user = await api.auth.getMyUser(this);
|
||||||
|
const [personalDetails, preferences, roles] = await Promise.all([
|
||||||
|
api.auth.getMyPersonalDetails(this),
|
||||||
|
api.auth.getMyPreferences(this),
|
||||||
|
api.auth.getMyRoles(this)
|
||||||
|
]);
|
||||||
|
this.user.personalDetails = personalDetails;
|
||||||
|
this.user.preferences = preferences;
|
||||||
this.roles = roles;
|
this.roles = roles;
|
||||||
|
if (this.tokenRefreshTimer) {
|
||||||
|
clearInterval(this.tokenRefreshTimer);
|
||||||
|
}
|
||||||
|
this.tokenRefreshTimer = setInterval(
|
||||||
|
() => this.refreshToken(),
|
||||||
|
TOKEN_REFRESH_INTERVAL
|
||||||
|
);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Refreshes the existing token used for authentication. If this fails,
|
||||||
|
* the auth state will reset to the nominal logged-out state.
|
||||||
|
*/
|
||||||
|
async refreshToken() {
|
||||||
|
try {
|
||||||
|
const newToken = await api.auth.refreshToken(this);
|
||||||
|
this.updateToken(newToken);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh token: ', error);
|
||||||
|
this.logOut();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Logs a user out of the application, resetting the auth state.
|
* Logs a user out of the application, resetting the auth state.
|
||||||
|
@ -61,7 +138,12 @@ export const useAuthStore = defineStore('authStore', {
|
||||||
logOut() {
|
logOut() {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.token = null;
|
this.token = null;
|
||||||
|
if (this.tokenRefreshTimer) {
|
||||||
|
clearInterval(this.tokenRefreshTimer);
|
||||||
|
}
|
||||||
|
this.tokenRefreshTimer = null;
|
||||||
this.roles = [];
|
this.roles = [];
|
||||||
|
localStorage.removeItem('auth-token');
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Updates the token that's stored for the currently authenticated user.
|
* Updates the token that's stored for the currently authenticated user.
|
||||||
|
@ -69,6 +151,7 @@ export const useAuthStore = defineStore('authStore', {
|
||||||
*/
|
*/
|
||||||
updateToken(token: string) {
|
updateToken(token: string) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
|
localStorage.setItem('auth-token', this.token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue