diff --git a/src/App.vue b/src/App.vue index 9d87945a..1bffc112 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,5 +3,13 @@ diff --git a/src/boot/api.ts b/src/boot/api.ts index 381407d3..48af9638 100644 --- a/src/boot/api.ts +++ b/src/boot/api.ts @@ -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({}); @@ -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; @@ -37,6 +45,17 @@ 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, @@ -44,7 +63,18 @@ export default boot(({ app }) => { breedingSitesApi, observationsApi, boundariesApi, + authApi, + userApi, }; }); -export { apiUrl, taxaApi, bitesApi, breedingSitesApi, observationsApi, boundariesApi }; +export { + apiUrl, + taxaApi, + bitesApi, + breedingSitesApi, + observationsApi, + boundariesApi, + authApi, + userApi, +}; diff --git a/src/components/layout/MainLeftDrawer.vue b/src/components/layout/MainLeftDrawer.vue index 367fc661..daa975c8 100644 --- a/src/components/layout/MainLeftDrawer.vue +++ b/src/components/layout/MainLeftDrawer.vue @@ -31,6 +31,8 @@ + + @@ -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() { diff --git a/src/components/layout/MainLeftDrawer/AuthButton.vue b/src/components/layout/MainLeftDrawer/AuthButton.vue new file mode 100644 index 00000000..c84d640a --- /dev/null +++ b/src/components/layout/MainLeftDrawer/AuthButton.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/layout/MainLeftDrawer/index.ts b/src/components/layout/MainLeftDrawer/index.ts index b64aacec..b93efca4 100644 --- a/src/components/layout/MainLeftDrawer/index.ts +++ b/src/components/layout/MainLeftDrawer/index.ts @@ -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'; diff --git a/src/components/reports/BaseReportDetailDrawer.vue b/src/components/reports/BaseReportDetailDrawer.vue index aaaca0bf..9153ca68 100644 --- a/src/components/reports/BaseReportDetailDrawer.vue +++ b/src/components/reports/BaseReportDetailDrawer.vue @@ -69,6 +69,15 @@ + + + + + + + Public + + diff --git a/src/components/reports/BaseReportVectorLayer.vue b/src/components/reports/BaseReportVectorLayer.vue index b1fa0038..cf2be3b2 100644 --- a/src/components/reports/BaseReportVectorLayer.vue +++ b/src/components/reports/BaseReportVectorLayer.vue @@ -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 }>(); @@ -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': [ diff --git a/src/i18n/ca/index.ts b/src/i18n/ca/index.ts index c2bb3d48..cbf5acb9 100644 --- a/src/i18n/ca/index.ts +++ b/src/i18n/ca/index.ts @@ -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', diff --git a/src/i18n/en-US/index.ts b/src/i18n/en-US/index.ts index 335a1d7a..fe7107bb 100644 --- a/src/i18n/en-US/index.ts +++ b/src/i18n/en-US/index.ts @@ -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', diff --git a/src/i18n/es/index.ts b/src/i18n/es/index.ts index 5b925076..afde6b6a 100644 --- a/src/i18n/es/index.ts +++ b/src/i18n/es/index.ts @@ -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', diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts new file mode 100644 index 00000000..281f68c9 --- /dev/null +++ b/src/stores/authStore.ts @@ -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, + }, +}); diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts new file mode 100644 index 00000000..f085479c --- /dev/null +++ b/src/stores/userStore.ts @@ -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; + }, + }, +}); diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts new file mode 100644 index 00000000..1f20a2fe --- /dev/null +++ b/src/utils/Utils.ts @@ -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(''); +}