Some checks failed
continuous-integration/drone/push Build is failing
When the demo mode is enabled, people set the language to their own language - which is understandable. However, this is really confusing for other people when they log in and the language is something unexpected. This change overrides the configured language when saving it while Vikunja is in demo mode.
470 lines
12 KiB
TypeScript
470 lines
12 KiB
TypeScript
import {computed, readonly, ref} from 'vue'
|
|
import {acceptHMRUpdate, defineStore} from 'pinia'
|
|
|
|
import {AuthenticatedHTTPFactory, HTTPFactory} from '@/helpers/fetcher'
|
|
import {getBrowserLanguage, i18n, setLanguage} from '@/i18n'
|
|
import {objectToSnakeCase} from '@/helpers/case'
|
|
import UserModel, {getAvatarUrl, getDisplayName} from '@/models/user'
|
|
import UserSettingsService from '@/services/userSettings'
|
|
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
|
|
import {setModuleLoading} from '@/stores/helper'
|
|
import {success} from '@/message'
|
|
import {
|
|
getRedirectUrlFromCurrentFrontendPath,
|
|
redirectToProvider,
|
|
redirectToProviderOnLogout,
|
|
} from '@/helpers/redirectToProvider'
|
|
import {AUTH_TYPES, type IUser} from '@/modelTypes/IUser'
|
|
import type {IUserSettings} from '@/modelTypes/IUserSettings'
|
|
import router from '@/router'
|
|
import {useConfigStore} from '@/stores/config'
|
|
import UserSettingsModel from '@/models/userSettings'
|
|
import {MILLISECONDS_A_SECOND} from '@/constants/date'
|
|
import {PrefixMode} from '@/modules/parseTaskText'
|
|
import type {IProvider} from '@/types/IProvider'
|
|
|
|
function redirectToProviderIfNothingElseIsEnabled() {
|
|
const {auth} = useConfigStore()
|
|
if (
|
|
auth.local.enabled === false &&
|
|
auth.openidConnect.enabled &&
|
|
auth.openidConnect.providers?.length === 1 &&
|
|
(window.location.pathname.startsWith('/login') || window.location.pathname === '/') && // Kinda hacky, but prevents an endless loop.
|
|
window.location.search.includes('redirectToProvider=true')
|
|
) {
|
|
redirectToProvider(auth.openidConnect.providers[0])
|
|
}
|
|
}
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
const configStore = useConfigStore()
|
|
|
|
const authenticated = ref(false)
|
|
const isLinkShareAuth = ref(false)
|
|
const needsTotpPasscode = ref(false)
|
|
|
|
const info = ref<IUser | null>(null)
|
|
const avatarUrl = ref('')
|
|
const settings = ref<IUserSettings>(new UserSettingsModel())
|
|
|
|
const lastUserInfoRefresh = ref<Date | null>(null)
|
|
const isLoading = ref(false)
|
|
const isLoadingGeneralSettings = ref(false)
|
|
|
|
const authUser = computed(() => {
|
|
return authenticated.value && (
|
|
info.value &&
|
|
info.value.type === AUTH_TYPES.USER
|
|
)
|
|
})
|
|
|
|
const authLinkShare = computed(() => {
|
|
return authenticated.value && (
|
|
info.value &&
|
|
info.value.type === AUTH_TYPES.LINK_SHARE
|
|
)
|
|
})
|
|
|
|
const userDisplayName = computed(() => info.value ? getDisplayName(info.value) : undefined)
|
|
|
|
|
|
function setIsLoading(newIsLoading: boolean) {
|
|
isLoading.value = newIsLoading
|
|
}
|
|
|
|
function setIsLoadingGeneralSettings(isLoading: boolean) {
|
|
isLoadingGeneralSettings.value = isLoading
|
|
}
|
|
|
|
function setUser(newUser: IUser | null, saveSettings = true) {
|
|
info.value = newUser
|
|
if (newUser !== null) {
|
|
reloadAvatar()
|
|
|
|
if (saveSettings && newUser.settings) {
|
|
loadSettings(newUser.settings)
|
|
}
|
|
|
|
isLinkShareAuth.value = newUser.id < 0
|
|
}
|
|
}
|
|
|
|
function setUserSettings(newSettings: IUserSettings) {
|
|
loadSettings(newSettings)
|
|
info.value = new UserModel({
|
|
...info.value !== null ? info.value : {},
|
|
name: newSettings.name,
|
|
})
|
|
}
|
|
|
|
function loadSettings(newSettings: IUserSettings) {
|
|
settings.value = new UserSettingsModel({
|
|
...newSettings,
|
|
frontendSettings: {
|
|
// Need to set default settings here in case the user does not have any saved in the api already
|
|
playSoundWhenDone: true,
|
|
quickAddMagicMode: PrefixMode.Default,
|
|
colorSchema: 'auto',
|
|
...newSettings.frontendSettings,
|
|
},
|
|
})
|
|
// console.log('settings from auth store', {...settings.value.frontendSettings})
|
|
}
|
|
|
|
function setAuthenticated(newAuthenticated: boolean) {
|
|
authenticated.value = newAuthenticated
|
|
}
|
|
|
|
function setIsLinkShareAuth(newIsLinkShareAuth: boolean) {
|
|
isLinkShareAuth.value = newIsLinkShareAuth
|
|
}
|
|
|
|
function setNeedsTotpPasscode(newNeedsTotpPasscode: boolean) {
|
|
needsTotpPasscode.value = newNeedsTotpPasscode
|
|
}
|
|
|
|
function reloadAvatar() {
|
|
if (!info.value) return
|
|
avatarUrl.value = `${getAvatarUrl(info.value)}&=${new Date().valueOf()}`
|
|
}
|
|
|
|
function updateLastUserRefresh() {
|
|
lastUserInfoRefresh.value = new Date()
|
|
}
|
|
|
|
// Logs a user in with a set of credentials.
|
|
async function login(credentials) {
|
|
const HTTP = HTTPFactory()
|
|
setIsLoading(true)
|
|
|
|
// Delete an eventually preexisting old token
|
|
removeToken()
|
|
|
|
try {
|
|
const response = await HTTP.post('login', objectToSnakeCase(credentials))
|
|
// Save the token to local storage for later use
|
|
saveToken(response.data.token, true)
|
|
|
|
// Tell others the user is autheticated
|
|
await checkAuth()
|
|
} catch (e) {
|
|
if (
|
|
e.response &&
|
|
e.response.data.code === 1017 &&
|
|
!credentials.totpPasscode
|
|
) {
|
|
setNeedsTotpPasscode(true)
|
|
}
|
|
|
|
throw e
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registers a new user and logs them in.
|
|
* Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
|
|
*/
|
|
async function register(credentials) {
|
|
const HTTP = HTTPFactory()
|
|
setIsLoading(true)
|
|
try {
|
|
await HTTP.post('register', credentials)
|
|
return login(credentials)
|
|
} catch (e) {
|
|
if (e.response?.data?.message) {
|
|
throw e.response.data
|
|
}
|
|
|
|
throw e
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function openIdAuth({provider, code}) {
|
|
const HTTP = HTTPFactory()
|
|
setIsLoading(true)
|
|
|
|
const fullProvider: IProvider = configStore.auth.openidConnect.providers.find((p: IProvider) => p.key === provider)
|
|
|
|
const data = {
|
|
code: code,
|
|
redirect_url: getRedirectUrlFromCurrentFrontendPath(fullProvider),
|
|
}
|
|
|
|
// Delete an eventually preexisting old token
|
|
removeToken()
|
|
try {
|
|
const response = await HTTP.post(`/auth/openid/${provider}/callback`, data)
|
|
// Save the token to local storage for later use
|
|
saveToken(response.data.token, true)
|
|
|
|
// Tell others the user is autheticated
|
|
await checkAuth()
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function linkShareAuth({hash, password}) {
|
|
const HTTP = HTTPFactory()
|
|
const response = await HTTP.post('/shares/' + hash + '/auth', {
|
|
password: password,
|
|
})
|
|
saveToken(response.data.token, false)
|
|
await checkAuth()
|
|
return response.data
|
|
}
|
|
|
|
/**
|
|
* Populates user information from jwt token saved in local storage in store
|
|
*/
|
|
async function checkAuth() {
|
|
const now = new Date()
|
|
const inOneMinute = new Date(new Date().setMinutes(now.getMinutes() + 1))
|
|
// This function can be called from multiple places at the same time and shortly after one another.
|
|
// To prevent hitting the api too frequently or race conditions, we check at most once per minute.
|
|
if (
|
|
lastUserInfoRefresh.value !== null &&
|
|
lastUserInfoRefresh.value > inOneMinute
|
|
) {
|
|
return
|
|
}
|
|
|
|
const jwt = getToken()
|
|
let isAuthenticated = false
|
|
if (jwt) {
|
|
try {
|
|
const base64 = jwt
|
|
.split('.')[1]
|
|
.replace('-', '+')
|
|
.replace('_', '/')
|
|
const info = new UserModel(JSON.parse(atob(base64)))
|
|
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
|
|
|
|
isAuthenticated = info.exp >= ts
|
|
// Settings should only be loaded from the api request, not via the jwt
|
|
setUser(info, false)
|
|
} catch (e) {
|
|
logout()
|
|
}
|
|
|
|
if (isAuthenticated) {
|
|
await refreshUserInfo()
|
|
}
|
|
}
|
|
|
|
setAuthenticated(isAuthenticated)
|
|
if (!isAuthenticated) {
|
|
setUser(null)
|
|
redirectToProviderIfNothingElseIsEnabled()
|
|
}
|
|
|
|
return Promise.resolve(authenticated)
|
|
}
|
|
|
|
async function refreshUserInfo() {
|
|
const jwt = getToken()
|
|
if (!jwt) {
|
|
return
|
|
}
|
|
|
|
const HTTP = AuthenticatedHTTPFactory()
|
|
try {
|
|
const response = await HTTP.get('user')
|
|
const newUser = new UserModel({
|
|
...response.data,
|
|
...(info.value?.type && {type: info.value?.type}),
|
|
...(info.value?.email && {email: info.value?.email}),
|
|
...(info.value?.exp && {exp: info.value?.exp}),
|
|
})
|
|
|
|
if (newUser.settings.language) {
|
|
await setLanguage(newUser.settings.language)
|
|
}
|
|
|
|
setUser(newUser)
|
|
updateLastUserRefresh()
|
|
|
|
if (
|
|
newUser.type === AUTH_TYPES.USER &&
|
|
(
|
|
typeof newUser.settings.language === 'undefined' ||
|
|
newUser.settings.language === ''
|
|
)
|
|
) {
|
|
// save current language
|
|
await saveUserSettings({
|
|
settings: {
|
|
...settings.value,
|
|
language: settings.value.language ? settings.value.language : getBrowserLanguage(),
|
|
},
|
|
showMessage: false,
|
|
})
|
|
}
|
|
|
|
return newUser
|
|
} catch (e) {
|
|
if(e?.response?.status === 401 ||
|
|
e?.response?.data?.message === 'missing, malformed, expired or otherwise invalid token provided') {
|
|
await logout()
|
|
return
|
|
}
|
|
|
|
console.log('continuerd')
|
|
|
|
const cause = {e}
|
|
|
|
if (typeof e?.response?.data?.message !== 'undefined') {
|
|
cause.message = e.response.data.message
|
|
}
|
|
|
|
console.error('Error refreshing user info:', e)
|
|
|
|
throw new Error('Error while refreshing user info:', {cause})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to verify the email
|
|
*/
|
|
async function verifyEmail(): Promise<boolean> {
|
|
const emailVerifyToken = localStorage.getItem('emailConfirmToken')
|
|
if (emailVerifyToken) {
|
|
const stopLoading = setModuleLoading(setIsLoading)
|
|
try {
|
|
await HTTPFactory().post('user/confirm', {token: emailVerifyToken})
|
|
return true
|
|
} catch(e) {
|
|
throw new Error(e.response.data.message)
|
|
} finally {
|
|
localStorage.removeItem('emailConfirmToken')
|
|
stopLoading()
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function saveUserSettings({
|
|
settings,
|
|
showMessage = true,
|
|
}: {
|
|
settings: IUserSettings
|
|
showMessage : boolean
|
|
}) {
|
|
const userSettingsService = new UserSettingsService()
|
|
|
|
const cancel = setModuleLoading(setIsLoadingGeneralSettings)
|
|
try {
|
|
let settingsUpdate = {...settings}
|
|
if (configStore.demoModeEnabled) {
|
|
settingsUpdate = {
|
|
...settingsUpdate,
|
|
language: null,
|
|
}
|
|
}
|
|
const updateSettingsPromise = userSettingsService.update(settingsUpdate)
|
|
setUserSettings(settingsUpdate)
|
|
await setLanguage(settings.language)
|
|
await updateSettingsPromise
|
|
if (showMessage) {
|
|
success({message: i18n.global.t('user.settings.general.savedSuccess')})
|
|
}
|
|
} catch (e) {
|
|
throw new Error('Error while saving user settings:', {cause: e})
|
|
} finally {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renews the api token and saves it to local storage
|
|
*/
|
|
function renewToken() {
|
|
// FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a
|
|
// link share in another tab. Without the timeout both the token renew and link share auth are executed at
|
|
// the same time and one might win over the other.
|
|
setTimeout(async () => {
|
|
if (!authenticated.value) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await refreshToken(!isLinkShareAuth.value)
|
|
await checkAuth()
|
|
} catch (e) {
|
|
// Don't logout on network errors as the user would then get logged out if they don't have
|
|
// internet for a short period of time - such as when the laptop is still reconnecting
|
|
if (e?.request?.status) {
|
|
await logout()
|
|
}
|
|
}
|
|
}, 5000)
|
|
}
|
|
|
|
async function logout() {
|
|
removeToken()
|
|
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
|
|
await router.push({name: 'user.login'})
|
|
await checkAuth()
|
|
|
|
// if configured, redirect to OIDC Provider on logout
|
|
if (
|
|
configStore.auth.local.enabled === false &&
|
|
configStore.auth.openidConnect.enabled &&
|
|
configStore.auth.openidConnect.providers?.length === 1)
|
|
{
|
|
redirectToProviderOnLogout(configStore.auth.openidConnect.providers[0])
|
|
}
|
|
}
|
|
|
|
return {
|
|
// state
|
|
authenticated: readonly(authenticated),
|
|
isLinkShareAuth: readonly(isLinkShareAuth),
|
|
needsTotpPasscode: readonly(needsTotpPasscode),
|
|
|
|
info: readonly(info),
|
|
avatarUrl: readonly(avatarUrl),
|
|
settings: readonly(settings),
|
|
|
|
lastUserInfoRefresh: readonly(lastUserInfoRefresh),
|
|
|
|
authUser,
|
|
authLinkShare,
|
|
userDisplayName,
|
|
|
|
isLoading: readonly(isLoading),
|
|
setIsLoading,
|
|
|
|
isLoadingGeneralSettings: readonly(isLoadingGeneralSettings),
|
|
setIsLoadingGeneralSettings,
|
|
|
|
setUser,
|
|
setUserSettings,
|
|
setAuthenticated,
|
|
setIsLinkShareAuth,
|
|
setNeedsTotpPasscode,
|
|
|
|
reloadAvatar,
|
|
updateLastUserRefresh,
|
|
|
|
login,
|
|
register,
|
|
openIdAuth,
|
|
linkShareAuth,
|
|
checkAuth,
|
|
refreshUserInfo,
|
|
verifyEmail,
|
|
saveUserSettings,
|
|
renewToken,
|
|
logout,
|
|
}
|
|
})
|
|
|
|
// support hot reloading
|
|
if (import.meta.hot) {
|
|
import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot))
|
|
} |