Added auth management, and AccountMenuItem.vue
This commit is contained in:
parent
4293ddb157
commit
c56f8f72c2
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
);
|
||||
|
|
|
@ -3,11 +3,11 @@ 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 {
|
||||
|
@ -27,14 +27,16 @@ interface RequestParams {
|
|||
}
|
||||
|
||||
class LeaderboardsModule {
|
||||
public async getLeaderboard(params: LeaderboardParams): Promise<Array<ExerciseSubmission>> {
|
||||
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))
|
||||
.map((gym) => getGymCompoundId(gym))
|
||||
.join(',');
|
||||
}
|
||||
if (params.timeframe) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
@ -24,7 +32,7 @@ import {ExerciseSubmission} from 'src/api/main/submission';
|
|||
import api from 'src/api/main';
|
||||
|
||||
interface Props {
|
||||
submission: ExerciseSubmission
|
||||
submission: ExerciseSubmission;
|
||||
}
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
|
|
@ -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>
|
|
@ -12,7 +12,7 @@ export default {
|
|||
leaderboard: 'Leaderboard',
|
||||
homePage: {
|
||||
overview: 'Overview of this gym:',
|
||||
recentLifts: 'Recent Lifts'
|
||||
recentLifts: 'Recent Lifts',
|
||||
},
|
||||
submitPage: {
|
||||
name: 'Your Name',
|
||||
|
|
|
@ -12,7 +12,7 @@ export default {
|
|||
leaderboard: 'Scorebord',
|
||||
homePage: {
|
||||
overview: 'Overzicht van dit sportschool:',
|
||||
recentLifts: 'Recente liften'
|
||||
recentLifts: 'Recente liften',
|
||||
},
|
||||
submitPage: {
|
||||
name: 'Jouw naam',
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,7 +30,11 @@
|
|||
<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>
|
||||
|
@ -39,7 +54,8 @@ 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 = '© <a href="https://www.openstreetmap.org/copyright">OSM</a>';
|
||||
const ATTRIBUTION =
|
||||
'© <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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>;
|
Loading…
Reference in New Issue