Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,13 @@
</template>

<script setup lang="ts">
//
import { onMounted } from 'vue';
import { useAuthStore } from 'src/stores/authStore';

const authStore = useAuthStore();

// Initialize auth state on app start
onMounted(async () => {
await authStore.initialize();
});
</script>
32 changes: 31 additions & 1 deletion src/boot/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ import {
ObservationsApi,
TaxaApi,
BoundariesApi,
AuthApi,
UsersApi,
} from 'mosquito-alert';
import { BASE_PATH } from 'mosquito-alert/base';
import { attachAuthInterceptor } from 'mosquito-alert/interceptors';
import { useAuthStore } from 'src/stores/authStore';

const apiConfig = new Configuration({
...(import.meta.env.VITE_API_BASE_URL ? { basePath: import.meta.env.VITE_API_BASE_URL } : {}),
// accessToken: localStorage.getItem('access_token') ?? undefined, // () => localStorage.getItem('access_token') || undefined
});

const apiUrl = apiConfig.basePath || BASE_PATH;

const axiosInstance = axios.create({});
Expand All @@ -23,6 +29,8 @@ const bitesApi = new BitesApi(apiConfig, undefined, axiosInstance);
const breedingSitesApi = new BreedingSitesApi(apiConfig, undefined, axiosInstance);
const observationsApi = new ObservationsApi(apiConfig, undefined, axiosInstance);
const boundariesApi = new BoundariesApi(apiConfig, undefined, axiosInstance);
const authApi = new AuthApi(apiConfig, undefined, axiosInstance);
const userApi = new UsersApi(apiConfig, undefined, axiosInstance);

export default boot(({ app }) => {
const i18n = app.config.globalProperties.$i18n;
Expand All @@ -37,14 +45,36 @@ export default boot(({ app }) => {
return config;
});

attachAuthInterceptor(axiosInstance, {
configuration: apiConfig,
refreshToken: () => localStorage.getItem('refresh_token') || '',
updateAccessToken: (newAccessToken) => {
localStorage.setItem('access_token', newAccessToken);
// Also update the store to maintain reactivity
const authStore = useAuthStore();
authStore.accessToken = newAccessToken;
},
});

// Make available in Options API components as this.$api...
app.config.globalProperties.$api = {
taxaApi,
bitesApi,
breedingSitesApi,
observationsApi,
boundariesApi,
authApi,
userApi,
};
});

export { apiUrl, taxaApi, bitesApi, breedingSitesApi, observationsApi, boundariesApi };
export {
apiUrl,
taxaApi,
bitesApi,
breedingSitesApi,
observationsApi,
boundariesApi,
authApi,
userApi,
};
7 changes: 5 additions & 2 deletions src/components/layout/MainLeftDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
<q-separator color="grey-4" />
<ShareButton />
<q-separator inset color="grey-4" />
<AuthButton />
<q-separator inset color="grey-4" />
<LanguageSwitcher />

</q-btn-group>
Expand All @@ -47,12 +49,13 @@
import { ref } from 'vue'
import { useQuasar } from 'quasar'

import { LanguageSwitcher, ShareButton } from './MainLeftDrawer'
import { LanguageSwitcher, ShareButton, AuthButton } from './MainLeftDrawer'

export default {
components: {
LanguageSwitcher,
ShareButton
ShareButton,
AuthButton
},
setup() {

Expand Down
79 changes: 79 additions & 0 deletions src/components/layout/MainLeftDrawer/AuthButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<template>
<q-btn v-if="!userStore.user" icon="fa fat fa-user-circle" @click="showDialog = true" />
<q-btn v-else flat round>
<q-avatar color="primary" size="1.715em">
{{ getInitials(userStore.user?.full_name || '') }}
</q-avatar>

<q-menu anchor="bottom right" self="bottom left">
<q-list>
<q-item clickable v-close-popup @click="authStore.logout()">
<q-item-section>
{{ $t('logout') }}
</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>

<q-dialog v-model="showDialog">
<q-card style="min-width: 320px">
<q-card-section>
<div class="text-h6">{{ $t('login') }}</div>
</q-card-section>

<q-card-section>
<q-form @submit.prevent="onSubmit" class="q-gutter-md">
<q-input v-model="username" :label="$t('username')" filled autocomplete="username"
:rules="[val => !!val || $t('username_required')]" />

<q-input v-model="password" :label="$t('password')" type="password" filled autocomplete="current-password"
:rules="[val => !!val || $t('password_required')]" />

<div class="row justify-end q-gutter-sm">
<q-btn :label="$t('cancel')" flat v-close-popup />
<q-btn :label="$t('login')" type="submit" color="primary" />
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'

import { useAuthStore } from 'src/stores/authStore';
import { useUserStore } from 'src/stores/userStore';
import { getInitials } from 'src/utils/Utils';

const $q = useQuasar()
const { t } = useI18n()

const authStore = useAuthStore()
const userStore = useUserStore()

const showDialog = ref(false)

const username = ref('')
const password = ref('')

async function onSubmit() {
try {
await authStore.login(username.value, password.value)

showDialog.value = false
password.value = ''
} catch (err) {
console.error('Login failed', err)
$q.notify({
color: 'negative',
position: 'top-right',
message: t('login_failed'),
icon: 'report_problem'
})
}
}
</script>
1 change: 1 addition & 0 deletions src/components/layout/MainLeftDrawer/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as AuthButton } from './AuthButton.vue';
export { default as LanguageSwitcher } from './LanguageSwitcher.vue';
export { default as ShareButton } from './ShareButton.vue';
9 changes: 9 additions & 0 deletions src/components/reports/BaseReportDetailDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@
</div>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<q-icon color="primary" name="fa fa-light fa-share-nodes" />
</q-item-section>
<q-item-section class="col-auto">
<q-chip v-if="!report.published" icon="fa fa-lock" color="red" text-color="white" label="Not public" />
<span v-else>Public</span>
</q-item-section>
</q-item>
</q-list>
</q-tab-panel>

Expand Down
11 changes: 11 additions & 0 deletions src/components/reports/BaseReportVectorLayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import type { ReportType } from 'src/types/reportType';
import { getHistogramDateKey } from 'src/components/reports/analytics/utils';
import type VectorSource from 'ol/source/Vector';
import type { BiteGeoModel, BreedingSiteGeoModel, ObservationGeoModel } from 'mosquito-alert';
import { useAuthStore } from 'src/stores/authStore';

const authStore = useAuthStore();
const layerRef = ref<{ webglVectorLayer: Layer }>();
const sourceRef = ref<{ source: VectorSource }>();

Expand Down Expand Up @@ -77,6 +79,15 @@ watch(() => props.visible, (newValue) => {
layerRef.value.webglVectorLayer.setVisible(newValue === true)
})

// Refresh the layer when authentication state changes (e.g., after login)
// This ensures authenticated users see observations they have access to
watch(() => authStore.isAuthenticated, (newValue, oldValue) => {
// Refresh when authentication state changes (login or logout)
if (newValue !== oldValue) {
refresh();
}
})

const style = {
"circle-fill-color": ['get', 'color'],
'circle-radius': [
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/ca/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { BreedingSiteSiteType, BiteEventEnvironment, BiteEventMoment } from 'mos
import { ReportType } from 'src/types/reportType';

export default {
login: 'Iniciar sessió',
login_failed: 'Error en iniciar sessió',
logout: 'Tancar sessió',
username: "Nom d'usuari",
username_required: "Nom d'usuari requerit",
password_required: 'Contrasenya requerida',
password: 'Contrasenya',
cancel: 'Cancel·lar',
footer_collaborators_note: 'Aquest mapa interactiu ha estat finançat per',
url_copied: 'URL copiat al porta-retalls',
observations: 'Observacions',
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/en-US/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { BreedingSiteSiteType, BiteEventEnvironment, BiteEventMoment } from 'mos
import { ReportType } from 'src/types/reportType';

export default {
login: 'Login',
login_failed: 'Login failed',
logout: 'Logout',
username: 'Username',
username_required: 'Username required',
password_required: 'Password required',
password: 'Password',
cancel: 'Cancel',
footer_collaborators_note: 'This interactive map has been funded by',
url_copied: 'URL copied to clipboard',
observations: 'Citizen observations',
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/es/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { BreedingSiteSiteType, BiteEventEnvironment, BiteEventMoment } from 'mos
import { ReportType } from 'src/types/reportType';

export default {
login: 'Iniciar sesión',
login_failed: 'Error al iniciar sesión',
logout: 'Cerrar sesión',
username: 'Nombre de usuario',
username_required: 'Nombre de usuario requerido',
password_required: 'Contraseña requerida',
password: 'Contraseña',
cancel: 'Cancelar',
footer_collaborators_note: 'Este mapa interactivo ha sido financiado por',
url_copied: 'URL copiada al portapapeles',
observations: 'Observaciones',
Expand Down
73 changes: 73 additions & 0 deletions src/stores/authStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { defineStore } from 'pinia';

import { authApi } from 'src/boot/api';
import type { AuthApiObtainTokenRequest } from 'mosquito-alert';

import { useUserStore } from './userStore';

export const useAuthStore = defineStore('auth', {
state: () => ({
accessToken: null as string | null,
refreshToken: null as string | null,
}),
actions: {
async login(username: string, password: string) {
try {
const response = await authApi.obtainToken({
appUserTokenObtainPairRequest: {
username: username,
password: password,
},
} as AuthApiObtainTokenRequest);

// Update both state and localStorage
this.accessToken = response.data.access;
this.refreshToken = response.data.refresh;
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh);

// Load user info after login
const userStore = useUserStore();
await userStore.fetchCurrentUser();
} catch (error) {
console.error('Login failed:', error);
// Handle login error (e.g., show a notification)
throw error;
}
},
logout() {
// Clear both state and localStorage
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');

const userStore = useUserStore();
userStore.clearUser();
},
async initialize() {
// Check if tokens exist in localStorage on app start
const accessToken = localStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token');

if (accessToken && refreshToken) {
// Restore tokens to state
this.accessToken = accessToken;
this.refreshToken = refreshToken;

// Fetch user info
const userStore = useUserStore();
try {
await userStore.fetchCurrentUser();
} catch (error) {
// If fetching user fails, tokens might be invalid - clear them
console.error('Failed to fetch user on initialization:', error);
this.logout();
}
}
},
},
getters: {
isAuthenticated: (state) => !!state.accessToken,
},
});
26 changes: 26 additions & 0 deletions src/stores/userStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineStore } from 'pinia';
import type { RawAxiosRequestConfig } from 'axios';

import { userApi } from 'src/boot/api';
import type { User } from 'mosquito-alert';

export const useUserStore = defineStore('user', {
state: () => ({
user: null as User | null,
}),
actions: {
async fetchCurrentUser(options?: RawAxiosRequestConfig) {
try {
const response = await userApi.retrieveMine(options);
this.user = response.data;
} catch (error) {
console.error('Failed to fetch user:', error);
this.clearUser();
throw error;
}
},
clearUser() {
this.user = null;
},
},
});
8 changes: 8 additions & 0 deletions src/utils/Utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function getInitials(fullName: string): string {
return fullName
.split(' ')
.filter((word) => word.length > 0)
.map((word) => word[0]?.toUpperCase())
.slice(0, 2)
.join('');
}