Added auth management, and AccountMenuItem.vue

This commit is contained in:
Andrew Lalis 2023-01-30 15:57:11 +01:00
parent 4293ddb157
commit c56f8f72c2
17 changed files with 266 additions and 133 deletions

View File

@ -1 +1 @@
{}
{}

View File

@ -1,4 +1,6 @@
import { api } from 'src/api/main/index';
import { AuthStoreType } from 'stores/auth-store';
import Timeout = NodeJS.Timeout;
export interface User {
id: string;
@ -13,16 +15,38 @@ export interface TokenCredentials {
}
class AuthModule {
public async getToken(credentials: TokenCredentials): Promise<string> {
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
private tokenRefreshTimer?: Timeout;
public async login(authStore: AuthStoreType, credentials: TokenCredentials) {
authStore.token = await this.fetchNewToken(credentials);
authStore.user = await this.fetchMyUser(authStore);
clearTimeout(this.tokenRefreshTimer);
this.tokenRefreshTimer = setTimeout(
() => this.refreshToken(authStore),
AuthModule.TOKEN_REFRESH_INTERVAL_MS
);
}
public logout(authStore: AuthStoreType) {
authStore.$reset();
clearTimeout(this.tokenRefreshTimer);
}
private async fetchNewToken(credentials: TokenCredentials): Promise<string> {
const response = await api.post('/auth/token', credentials);
return response.data.token;
}
public async getMyUser(token: string): Promise<User> {
const response = await api.get('/auth/me', {
headers: {
'Authorization': 'Bearer ' + token
}
});
private async refreshToken(authStore: AuthStoreType) {
const response = await api.get('/auth/token', authStore.axiosConfig);
authStore.token = response.data.token;
}
private async fetchMyUser(authStore: AuthStoreType): Promise<User> {
const response = await api.get('/auth/me', authStore.axiosConfig);
return response.data;
}
}

View File

@ -1,7 +1,7 @@
import { GeoPoint } from 'src/api/main/models';
import SubmissionsModule, {ExerciseSubmission} from 'src/api/main/submission';
import SubmissionsModule, { ExerciseSubmission } from 'src/api/main/submission';
import { api } from 'src/api/main/index';
import {GymRoutable} from 'src/router/gym-routing';
import { GymRoutable } from 'src/router/gym-routing';
export interface Gym {
countryCode: string;
@ -49,7 +49,9 @@ class GymsModule {
};
}
public async getRecentSubmissions(gym: GymRoutable): Promise<Array<ExerciseSubmission>> {
public async getRecentSubmissions(
gym: GymRoutable
): Promise<Array<ExerciseSubmission>> {
const response = await api.get(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions`
);

View File

@ -3,49 +3,51 @@ import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
import { api } from 'src/api/main/index';
export enum LeaderboardTimeframe {
DAY = "DAY",
WEEK = "WEEK",
MONTH = "MONTH",
YEAR = "YEAR",
ALL = "ALL"
DAY = 'DAY',
WEEK = 'WEEK',
MONTH = 'MONTH',
YEAR = 'YEAR',
ALL = 'ALL',
}
export interface LeaderboardParams {
exerciseShortName?: string;
gyms?: Array<GymRoutable>;
timeframe?: LeaderboardTimeframe;
page?: number;
size?: number;
exerciseShortName?: string;
gyms?: Array<GymRoutable>;
timeframe?: LeaderboardTimeframe;
page?: number;
size?: number;
}
interface RequestParams {
exercise?: string;
gyms?: string;
t?: string;
page?: number;
size?: number;
exercise?: string;
gyms?: string;
t?: string;
page?: number;
size?: number;
}
class LeaderboardsModule {
public async getLeaderboard(params: LeaderboardParams): Promise<Array<ExerciseSubmission>> {
const requestParams: RequestParams = {};
if (params.exerciseShortName) {
requestParams.exercise = params.exerciseShortName;
}
if (params.gyms) {
requestParams.gyms = params.gyms
.map(gym => getGymCompoundId(gym))
.join(',');
}
if (params.timeframe) {
requestParams.t = params.timeframe;
}
if (params.page) requestParams.page = params.page;
if (params.size) requestParams.size = params.size;
const response = await api.get('/leaderboards', { params: requestParams });
return response.data.content;
public async getLeaderboard(
params: LeaderboardParams
): Promise<Array<ExerciseSubmission>> {
const requestParams: RequestParams = {};
if (params.exerciseShortName) {
requestParams.exercise = params.exerciseShortName;
}
if (params.gyms) {
requestParams.gyms = params.gyms
.map((gym) => getGymCompoundId(gym))
.join(',');
}
if (params.timeframe) {
requestParams.t = params.timeframe;
}
if (params.page) requestParams.page = params.page;
if (params.size) requestParams.size = params.size;
const response = await api.get('/leaderboards', { params: requestParams });
return response.data.content;
}
}
export default LeaderboardsModule;

View File

@ -1,6 +1,6 @@
import { SimpleGym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises';
import {api, BASE_URL} from 'src/api/main/index';
import { api, BASE_URL } from 'src/api/main/index';
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
import { sleep } from 'src/utils';
@ -38,10 +38,10 @@ export enum ExerciseSubmissionStatus {
}
class SubmissionsModule {
public async getSubmission(submissionId: string): Promise<ExerciseSubmission> {
const response = await api.get(
`/submissions/${submissionId}`
);
public async getSubmission(
submissionId: string
): Promise<ExerciseSubmission> {
const response = await api.get(`/submissions/${submissionId}`);
return response.data;
}
@ -52,7 +52,7 @@ class SubmissionsModule {
) {
return null;
}
return BASE_URL + `/submissions/${submission.id}/video`
return BASE_URL + `/submissions/${submission.id}/video`;
}
public async createSubmission(
@ -60,10 +60,7 @@ class SubmissionsModule {
payload: ExerciseSubmissionPayload
): Promise<ExerciseSubmission> {
const gymId = getGymCompoundId(gym);
const response = await api.post(
`/gyms/${gymId}/submissions`,
payload
);
const response = await api.post(`/gyms/${gymId}/submissions`, payload);
return response.data;
}
@ -85,7 +82,9 @@ class SubmissionsModule {
* Asynchronous method that waits until a submission is done processing.
* @param submissionId The submission's id.
*/
public async waitUntilSubmissionProcessed(submissionId: string): Promise<ExerciseSubmission> {
public async waitUntilSubmissionProcessed(
submissionId: string
): Promise<ExerciseSubmission> {
let failureCount = 0;
let attemptCount = 0;
while (failureCount < 5 && attemptCount < 60) {

View File

@ -0,0 +1,36 @@
<template>
<div class="q-mx-sm">
<q-btn-dropdown
color="primary"
:label="authStore.user?.name"
v-if="authStore.loggedIn"
no-caps
icon="person"
>
<q-list>
<q-item clickable v-close-popup @click="api.auth.logout(authStore)">
<q-item-section>
<q-item-label>Log out</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
color="primary"
:label="$t('Login')"
v-if="!authStore.loggedIn"
no-caps
icon="person"
to="/login"
/>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from 'stores/auth-store';
import api from 'src/api/main';
const authStore = useAuthStore();
</script>
<style scoped></style>

View File

@ -1,7 +1,15 @@
<template>
<q-expansion-item
expand-separator
:label="submission.rawWeight + ' ' + submission.weightUnit + ' x' + submission.reps + ' ' + submission.exercise.displayName"
:label="
submission.rawWeight +
' ' +
submission.weightUnit +
' x' +
submission.reps +
' ' +
submission.exercise.displayName
"
:caption="submission.submitterName"
>
<q-card>
@ -20,11 +28,11 @@
</template>
<script setup lang="ts">
import {ExerciseSubmission} from 'src/api/main/submission';
import { ExerciseSubmission } from 'src/api/main/submission';
import api from 'src/api/main';
interface Props {
submission: ExerciseSubmission
submission: ExerciseSubmission;
}
defineProps<Props>();
</script>

View File

@ -0,0 +1,31 @@
<template>
<q-select
v-model="i18n.locale.value"
:options="localeOptions"
:label="$t('mainLayout.language')"
dense
borderless
emit-value
map-options
options-dense
filled
hide-bottom-space
dark
options-dark
label-color="white"
options-selected-class="text-grey"
style="min-width: 150px"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const i18n = useI18n({ useScope: 'global' });
const localeOptions = [
{ value: 'en-US', label: 'English' },
{ value: 'nl-NL', label: 'Nederlands' },
];
</script>
<style scoped></style>

View File

@ -12,7 +12,7 @@ export default {
leaderboard: 'Leaderboard',
homePage: {
overview: 'Overview of this gym:',
recentLifts: 'Recent Lifts'
recentLifts: 'Recent Lifts',
},
submitPage: {
name: 'Your Name',

View File

@ -12,7 +12,7 @@ export default {
leaderboard: 'Scorebord',
homePage: {
overview: 'Overzicht van dit sportschool:',
recentLifts: 'Recente liften'
recentLifts: 'Recente liften',
},
submitPage: {
name: 'Jouw naam',

View File

@ -16,23 +16,8 @@
>Gymboard</router-link
>
</q-toolbar-title>
<q-select
v-model="i18n.locale.value"
:options="localeOptions"
:label="$t('mainLayout.language')"
dense
borderless
emit-value
map-options
options-dense
filled
hide-bottom-space
dark
options-dark
label-color="white"
options-selected-class="text-grey"
style="min-width: 150px"
/>
<AccountMenuItem />
<LocaleSelect />
</q-toolbar>
</q-header>
@ -55,13 +40,8 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const i18n = useI18n({ useScope: 'global' });
const localeOptions = [
{ value: 'en-US', label: 'English' },
{ value: 'nl-NL', label: 'Nederlands' },
];
import LocaleSelect from 'components/LocaleSelect.vue';
import AccountMenuItem from 'components/AccountMenuItem.vue';
const leftDrawerOpen = ref(false);

View File

@ -2,16 +2,13 @@
<StandardCenteredPage>
<h3>Testing Page</h3>
<p>
Use this page to test new functionality, before adding it to the main
app. This page should be hidden on production.
Use this page to test new functionality, before adding it to the main app.
This page should be hidden on production.
</p>
<div style="border: 3px solid red">
<h4>Auth Test</h4>
<q-btn
label="Do auth"
@click="doAuth()"
/>
<p>{{ authTestMessage }}</p>
<q-btn label="Do auth" @click="doAuth()" />
<q-btn label="Logout" @click="api.auth.logout(authStore)" />
</div>
</StandardCenteredPage>
</template>
@ -19,20 +16,16 @@
<script setup lang="ts">
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
import api from 'src/api/main';
import {ref} from 'vue';
import {sleep} from "src/utils";
import { useAuthStore } from 'stores/auth-store';
const authTestMessage = ref('');
const authStore = useAuthStore();
async function doAuth() {
const token = await api.auth.getToken({email: 'andrew.lalis@example.com', password: 'testpass'});
authTestMessage.value = 'Token: ' + token;
await sleep(2000);
const user = await api.auth.getMyUser(token);
authTestMessage.value = 'User: ' + JSON.stringify(user);
await api.auth.login(authStore, {
email: 'andrew.lalis@example.com',
password: 'testpass',
});
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@ -4,11 +4,22 @@
<div class="col-xs-12 col-md-6 q-pt-md">
<p>{{ $t('gymPage.homePage.overview') }}</p>
<ul>
<li v-if="gym.websiteUrl">Website: <a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a></li>
<li>Address: <em>{{ gym.streetAddress }}</em></li>
<li>City: <em>{{ gym.cityName }}</em></li>
<li>Country: <em>{{ gym.countryName }}</em></li>
<li>Registered at: <em>{{ gym.createdAt }}</em></li>
<li v-if="gym.websiteUrl">
Website:
<a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a>
</li>
<li>
Address: <em>{{ gym.streetAddress }}</em>
</li>
<li>
City: <em>{{ gym.cityName }}</em>
</li>
<li>
Country: <em>{{ gym.countryName }}</em>
</li>
<li>
Registered at: <em>{{ gym.createdAt }}</em>
</li>
</ul>
</div>
<div class="col-xs-12 col-md-6">
@ -19,27 +30,32 @@
<div v-if="recentSubmissions.length > 0">
<h4 class="text-center">{{ $t('gymPage.homePage.recentLifts') }}</h4>
<q-list>
<ExerciseSubmissionListItem v-for="sub in recentSubmissions" :submission="sub" :key="sub.id"/>
<ExerciseSubmissionListItem
v-for="sub in recentSubmissions"
:submission="sub"
:key="sub.id"
/>
</q-list>
</div>
</q-page>
</template>
<script setup lang="ts">
import {nextTick, onMounted, ref, Ref} from 'vue';
import {ExerciseSubmission} from 'src/api/main/submission';
import { nextTick, onMounted, ref, Ref } from 'vue';
import { ExerciseSubmission } from 'src/api/main/submission';
import api from 'src/api/main';
import {getGymFromRoute} from 'src/router/gym-routing';
import { getGymFromRoute } from 'src/router/gym-routing';
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
import {Gym} from 'src/api/main/gyms';
import { Gym } from 'src/api/main/gyms';
import 'leaflet/dist/leaflet.css';
import {Map, Marker, TileLayer} from 'leaflet';
import { Map, Marker, TileLayer } from 'leaflet';
const recentSubmissions: Ref<Array<ExerciseSubmission>> = ref([]);
const gym: Ref<Gym | undefined> = ref();
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>';
const ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>';
const map: Ref<Map | undefined> = ref();
const mapContainer = ref();
@ -55,13 +71,18 @@ function initMap() {
const g: Gym = gym.value;
console.log(mapContainer);
const tiles = new TileLayer(TILE_URL, { attribution: ATTRIBUTION, maxZoom: 19 });
const tiles = new TileLayer(TILE_URL, {
attribution: ATTRIBUTION,
maxZoom: 19,
});
const marker = new Marker([g.location.latitude, g.location.longitude], {
title: g.displayName,
alt: g.displayName
alt: g.displayName,
});
map.value = new Map(mapContainer.value, {})
.setView([g.location.latitude, g.location.longitude], 16);
map.value = new Map(mapContainer.value, {}).setView(
[g.location.latitude, g.location.longitude],
16
);
tiles.addTo(map.value);
marker.addTo(map.value);

View File

@ -1,11 +1,7 @@
<template>
<q-page>
<div class="q-ma-md row justify-end q-gutter-sm">
<q-spinner
color="primary"
size="3em"
v-if="loadingIndicatorActive"
/>
<q-spinner color="primary" size="3em" v-if="loadingIndicatorActive" />
<q-select
v-model="selectedExercise"
:options="exerciseOptions"
@ -22,7 +18,11 @@
/>
</div>
<q-list>
<ExerciseSubmissionListItem v-for="sub in submissions" :submission="sub" :key="sub.id"/>
<ExerciseSubmissionListItem
v-for="sub in submissions"
:submission="sub"
:key="sub.id"
/>
</q-list>
</q-page>
</template>
@ -43,10 +43,10 @@ const gym: Ref<Gym | undefined> = ref();
const exercises: Ref<Array<Exercise>> = ref([]);
const exerciseOptions = computed(() => {
let options = exercises.value.map(exercise => {
let options = exercises.value.map((exercise) => {
return {
value: exercise.shortName,
label: exercise.displayName
label: exercise.displayName,
};
});
options.push({ value: '', label: 'Any' });
@ -61,7 +61,9 @@ const timeframeOptions = [
{ value: LeaderboardTimeframe.YEAR, label: 'Year' },
{ value: LeaderboardTimeframe.ALL, label: 'All' },
];
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(LeaderboardTimeframe.DAY);
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(
LeaderboardTimeframe.DAY
);
const loadingIndicatorActive = ref(false);
@ -79,7 +81,7 @@ async function doSearch() {
submissions.value = await api.leaderboards.getLeaderboard({
timeframe: selectedTimeframe.value,
gyms: [gym.value],
exerciseShortName: selectedExercise.value
exerciseShortName: selectedExercise.value,
});
loadingIndicatorActive.value = false;
}

View File

@ -93,12 +93,12 @@ A high-level overview of the submission process is as follows:
<script setup lang="ts">
import { onMounted, ref, Ref } from 'vue';
import {getGymFromRoute, getGymRoute} from 'src/router/gym-routing';
import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
import SlimForm from 'components/SlimForm.vue';
import api from 'src/api/main';
import { Gym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises';
import {useRouter} from 'vue-router';
import { useRouter } from 'vue-router';
import { sleep } from 'src/utils';
interface Option {
@ -144,7 +144,9 @@ onMounted(async () => {
});
function submitButtonEnabled() {
return selectedVideoFile.value !== undefined && !submitting.value && validateForm();
return (
selectedVideoFile.value !== undefined && !submitting.value && validateForm()
);
}
function validateForm() {
@ -176,7 +178,6 @@ async function onSubmitted() {
} finally {
submitting.value = false;
}
}
</script>

View File

@ -38,4 +38,4 @@ export async function getGymFromRoute(): Promise<Gym> {
*/
export function getGymCompoundId(gym: GymRoutable): string {
return `${gym.countryCode}_${gym.cityShortName}_${gym.shortName}`;
}
}

View File

@ -0,0 +1,34 @@
/**
* This store keeps track of the authentication state of the web app, which
* is just keeping the current user and their token.
*
* See src/api/main/auth.ts for mutators of this store.
*/
import { defineStore } from 'pinia';
import { User } from 'src/api/main/auth';
interface AuthState {
user: User | null;
token: string | null;
}
export const useAuthStore = defineStore('authStore', {
state: (): AuthState => {
return { user: null, token: null };
},
getters: {
loggedIn: (state) => state.user !== null && state.token !== null,
axiosConfig(state) {
if (this.token !== null) {
return {
headers: { Authorization: 'Bearer ' + state.token },
};
} else {
return {};
}
},
},
});
export type AuthStoreType = ReturnType<typeof useAuthStore>;