Migrate oauth store to pinia

This commit is contained in:
tusooa 2025-03-11 18:48:55 -04:00
commit 216d318bb5
No known key found for this signature in database
GPG key ID: 42AEC43D48433C51
12 changed files with 663 additions and 145 deletions

View file

@ -1,3 +1,4 @@
/* global process */
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
@ -16,6 +17,7 @@ import { applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import { useOAuthStore } from 'src/stores/oauth'
import { useI18nStore } from 'src/stores/i18n'
import { useInterfaceStore } from 'src/stores/interface'
import { useAnnouncementsStore } from 'src/stores/announcements'
@ -227,8 +229,9 @@ const getStickers = async ({ store }) => {
}
const getAppSecret = async ({ store }) => {
if (store.state.oauth.userToken) {
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
const oauth = useOAuthStore()
if (oauth.userToken) {
store.commit('setBackendInteractor', backendInteractorService(oauth.getToken))
}
}
@ -322,20 +325,65 @@ const setConfig = async ({ store }) => {
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
getAppSecret({ store })
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
if (store.getters.getUserToken()) {
return store.dispatch('loginUser', store.getters.getUserToken())
const oauth = useOAuthStore()
if (oauth.getUserToken) {
return store.dispatch('loginUser', oauth.getUserToken)
}
return Promise.resolve()
}
const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
const app = createApp(App)
// Must have app use pinia before we do anything that touches the store
// https://pinia.vuejs.org/core-concepts/plugins.html#Introduction
// "Plugins are only applied to stores created after the plugins themselves, and after pinia is passed to the app, otherwise they won't be applied."
app.use(pinia)
const waitForAllStoresToLoad = async () => {
// the stores that do not persist technically do not need to be awaited here,
// but that involves either hard-coding the stores in some place (prone to errors)
// or writing another vite plugin to analyze which stores needs persisting (++load time)
const allStores = import.meta.glob('../stores/*.js', { eager: true })
if (process.env.NODE_ENV === 'development') {
// do some checks to avoid common errors
if (!Object.keys(allStores).length) {
throw new Error('No stores are available. Check the code in src/boot/after_store.js')
}
}
await Promise.all(
Object.entries(allStores)
.map(async ([name, mod]) => {
const isStoreName = name => name.startsWith('use')
if (process.env.NODE_ENV === 'development') {
if (Object.keys(mod).filter(isStoreName).length !== 1) {
throw new Error('Each store file must export exactly one store as a named export. Check your code in src/stores/')
}
}
const storeFuncName = Object.keys(mod).find(isStoreName)
if (storeFuncName && typeof mod[storeFuncName] === 'function') {
const p = mod[storeFuncName]().$persistLoaded
if (!(p instanceof Promise)) {
throw new Error(`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`)
}
await p
} else {
throw new Error(`Store module ${name} does not export a 'use...' function`)
}
}))
}
try {
await waitForAllStoresToLoad()
} catch (e) {
console.error('Cannot load stores:', e)
storageError = e
}
if (storageError) {
useInterfaceStore().pushGlobalNotice({ messageKey: 'errors.storage_unavailable', level: 'error' })
}

View file

@ -1,5 +1,7 @@
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import oauthApi from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -17,11 +19,11 @@ const LoginForm = {
computed: {
isPasswordAuth () { return this.requiredPassword },
isTokenAuth () { return this.requiredToken },
...mapStores(useOAuthStore),
...mapState({
registrationOpen: state => state.instance.registrationOpen,
instance: state => state.instance,
loggingIn: state => state.users.loggingIn,
oauth: state => state.oauth
}),
...mapGetters(
'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
@ -41,37 +43,30 @@ const LoginForm = {
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.$store.dispatch('ensureAppToken')
this.oauthStore.ensureAppToken()
.then(() => {
const app = {
clientId: this.oauth.clientId,
clientSecret: this.oauth.clientSecret,
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.login({ ...app, ...data })
})
},
submitPassword () {
const { clientId } = this.oauth
const data = {
clientId,
oauth: this.oauth,
instance: this.instance.server,
commit: this.$store.commit
}
this.error = false
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.$store.dispatch('ensureAppToken').then(() => {
this.oauthStore.ensureAppToken().then(() => {
const app = {
clientId: this.oauth.clientId,
clientSecret: this.oauth.clientSecret,
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.getTokenWithCredentials(
{
...app,
instance: data.instance,
instance: this.instance.server,
username: this.user.username,
password: this.user.password
}

View file

@ -1,10 +1,12 @@
import oauth from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
const oac = {
props: ['code'],
mounted () {
if (this.code) {
const { clientId, clientSecret } = this.$store.state.oauth
const oauthStore = useOAuthStore()
const { clientId, clientSecret } = oauthStore
oauth.getToken({
clientId,
@ -12,7 +14,7 @@ const oac = {
instance: this.$store.state.instance.server,
code: this.code
}).then((result) => {
this.$store.commit('setToken', result.access_token)
oauthStore.setToken(result.access_token)
this.$store.dispatch('loginUser', result.access_token)
this.$router.push({ name: 'friends' })
})

View file

@ -94,3 +94,160 @@ export default function createPersistedState ({
}
})
}
/**
* This persists state for pinia, which falls back to read from the vuex state
* if pinia persisted state does not exist.
*
* When you migrate a module from vuex to pinia, you have to keep the original name.
* If the module was called `xxx`, the name of the store has to be `xxx` too.
*
* This adds one property to the store, $persistLoaded, which is a promise
* that resolves when the initial state is loaded. If the plugin is not enabled,
* $persistLoaded is a promise that resolves immediately.
* If we are not able to get the stored state because storage.getItem() throws or
* rejects, $persistLoaded will be a rejected promise with the thrown error.
*
* Call signature:
*
* defineStore(name, {
* ...,
* // setting the `persist` property enables this plugin
* // IMPORTANT: by default it is disabled, you have to set `persist` to at least an empty object
* persist: {
* // set to list of individual paths, or undefined/unset to persist everything
* paths: [],
* // function to call after loading initial state
* // if afterLoad is a function, it must return a state object that will be sent to `store.$patch`, or a promise to the state object
* // by default afterLoad is undefined
* afterLoad: (originalState) => {
* // ...
* return modifiedState
* },
* // if it exists, only persist state after these actions
* // if it doesn't exist or is undefined, persist state after every mutation of the state
* saveImmediatelyActions: [],
* // what to do after successfully saving the state
* onSaveSuccess: () => {},
* // what to do after there is an error saving the state
* onSaveError: () => {}
* }
* })
*
*/
export const piniaPersistPlugin = ({
vuexKey = 'vuex-lz',
keyFunction = (id) => `pinia-local-${id}`,
storage = defaultStorage,
reducer = defaultReducer
} = {}) => ({ store, options }) => {
if (!options.persist) {
return {
$persistLoaded: Promise.resolve()
}
}
let resolveLoaded
let rejectLoaded
const loadedPromise = new Promise((resolve, reject) => {
resolveLoaded = resolve
rejectLoaded = reject
})
const {
afterLoad,
paths = [],
saveImmediatelyActions,
onSaveSuccess = () => {},
onSaveError = () => {}
} = options.persist || {}
const loadedGuard = { loaded: false }
const key = keyFunction(store.$id)
const getState = async () => {
const id = store.$id
const value = await storage.getItem(key)
if (value) {
return value
}
const fallbackValue = await storage.getItem(vuexKey)
if (fallbackValue && fallbackValue[id]) {
console.info(`Migrating ${id} store data from vuex to pinia`)
const res = fallbackValue[id]
await storage.setItem(key, res)
return res
}
return {}
}
const setState = (state) => {
if (!loadedGuard.loaded) {
console.info('waiting for old state to be loaded...')
return Promise.reject()
} else {
return storage.setItem(key, state)
}
}
const getMaybeAugmentedState = async () => {
const savedRawState = await getState()
if (typeof afterLoad === 'function') {
try {
return await afterLoad(savedRawState)
} catch (e) {
console.error('Error running afterLoad:', e)
return savedRawState
}
} else {
return savedRawState
}
}
const persistCurrentState = async (state) => {
const stateClone = cloneDeep(state)
const stateToPersist = reducer(stateClone, paths)
try {
const res = await setState(stateToPersist)
onSaveSuccess(res)
} catch (e) {
console.error('Cannot persist state:', e)
onSaveError(e)
}
}
getMaybeAugmentedState()
.then(savedState => {
if (savedState) {
store.$patch(savedState)
}
loadedGuard.loaded = true
resolveLoaded()
// only subscribe after we have done setting the initial state
if (!saveImmediatelyActions) {
store.$subscribe(async (_mutation, state) => {
await persistCurrentState(state)
})
} else {
store.$onAction(({
name,
store,
after,
}) => {
if (saveImmediatelyActions.includes(name)) {
after(() => persistCurrentState(store.$state))
}
})
}
}, error => {
console.error('Cannot load storage:', error)
rejectLoaded(error)
})
return {
$persistLoaded: loadedPromise
}
}

View file

@ -16,7 +16,7 @@ import vuexModules from './modules/index.js'
import { createI18n } from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js'
import createPersistedState, { piniaPersistPlugin } from './lib/persisted_state.js'
import pushNotifications from './lib/push_notifications_plugin.js'
import messages from './i18n/messages.js'
@ -71,6 +71,8 @@ const persistedStateOptions = {
let storageError
const plugins = [pushNotifications]
const pinia = createPinia()
pinia.use(piniaPersistPlugin())
try {
const persistedState = await createPersistedState(persistedStateOptions)
plugins.push(persistedState)

View file

@ -1,3 +1,5 @@
import { useOAuthStore } from 'src/stores/oauth.js'
const PASSWORD_STRATEGY = 'password'
const TOKEN_STRATEGY = 'token'
@ -68,8 +70,8 @@ const mutations = {
// actions
const actions = {
async login ({ state, dispatch, commit }, { access_token: accessToken }) {
commit('setToken', accessToken, { root: true })
async login ({ state, dispatch }, { access_token: accessToken }) {
useOAuthStore().setToken(accessToken)
await dispatch('loginUser', accessToken, { root: true })
resetState(state)
}

View file

@ -7,7 +7,6 @@ import config from './config.js'
import profileConfig from './profileConfig.js'
import serverSideStorage from './serverSideStorage.js'
import adminSettings from './adminSettings.js'
import oauth from './oauth.js'
import authFlow from './auth_flow.js'
import oauthTokens from './oauth_tokens.js'
import drafts from './drafts.js'
@ -23,7 +22,6 @@ export default {
profileConfig,
serverSideStorage,
adminSettings,
oauth,
authFlow,
oauthTokens,
drafts,

View file

@ -5,6 +5,7 @@ import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import { registerPushNotifications, unregisterPushNotifications } from '../services/sw/sw.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
@ -526,17 +527,18 @@ const users = {
})
},
async signUp (store, userInfo) {
const oauthStore = useOAuthStore()
store.commit('signUpPending')
try {
const token = await store.dispatch('ensureAppToken')
const token = await oauthStore.ensureAppToken()
const data = await apiService.register(
{ credentials: token, params: { ...userInfo } }
)
if (data.access_token) {
store.commit('signUpSuccess')
store.commit('setToken', data.access_token)
oauthStore.setToken(data.access_token)
store.dispatch('loginUser', data.access_token)
return 'ok'
} else { // Request succeeded, but user cannot login yet.
@ -554,21 +556,16 @@ const users = {
},
logout (store) {
const { oauth, instance } = store.rootState
const data = {
...oauth,
commit: store.commit,
instance: instance.server
}
const oauth = useOAuthStore()
const { instance } = store.rootState
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return store.dispatch('ensureApp')
return oauth.ensureApp()
.then((app) => {
const params = {
app,
instance: data.instance,
instance: instance.server,
token: oauth.userToken
}
@ -577,9 +574,9 @@ const users = {
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.commit('clearToken')
oauth.clearToken()
store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.commit('setBackendInteractor', backendInteractorService(oauth.getToken))
store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingLists')
store.dispatch('stopFetchingBookmarkFolders')
@ -674,7 +671,7 @@ const users = {
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(response.status)) {
commit('clearToken')
useOAuthStore().clearToken()
}
if (response.status === 401) {

View file

@ -1,3 +1,4 @@
import { defineStore } from 'pinia'
import { createApp, getClientToken, verifyAppToken } from 'src/services/new_api/oauth.js'
// status codes about verifyAppToken (GET /api/v1/apps/verify_credentials)
@ -20,7 +21,7 @@ const isClientDataRejected = error => (
error.statusCode === 400
)
const oauth = {
export const useOAuthStore = defineStore('oauth', {
state: () => ({
clientId: false,
clientSecret: false,
@ -34,82 +35,77 @@ const oauth = {
*/
userToken: false
}),
mutations: {
setClientData (state, { clientId, clientSecret }) {
state.clientId = clientId
state.clientSecret = clientSecret
},
setAppToken (state, token) {
state.appToken = token
},
setToken (state, token) {
state.userToken = token
},
clearToken (state) {
state.userToken = false
// state.token is userToken with older name, coming from persistent state
// let's clear it as well, since it is being used as a fallback of state.userToken
delete state.token
}
},
getters: {
getToken: state => () => {
// state.token is userToken with older name, coming from persistent state
// added here for smoother transition, otherwise user will be logged out
return state.userToken || state.token || state.appToken
getToken () {
return this.userToken || this.appToken
},
getUserToken: state => () => {
// state.token is userToken with older name, coming from persistent state
// added here for smoother transition, otherwise user will be logged out
return state.userToken || state.token
getUserToken () {
return this.userToken
}
},
actions: {
async createApp ({ rootState, commit }) {
const instance = rootState.instance.server
setClientData ({ clientId, clientSecret }) {
this.clientId = clientId
this.clientSecret = clientSecret
},
setAppToken (token) {
this.appToken = token
},
setToken (token) {
this.userToken = token
},
clearToken () {
this.userToken = false
},
async createApp () {
const { state } = window.vuex
const instance = state.instance.server
const app = await createApp(instance)
commit('setClientData', app)
this.setClientData(app)
return app
},
/// Use this if you want to get the client id and secret but are not interested
/// in whether they are valid.
/// @return {{ clientId: string, clientSecret: string }} An object representing the app
ensureApp ({ state, dispatch }) {
if (state.clientId && state.clientSecret) {
async ensureApp () {
if (this.clientId && this.clientSecret) {
return {
clientId: state.clientId,
clientSecret: state.clientSecret
clientId: this.clientId,
clientSecret: this.clientSecret
}
} else {
return dispatch('createApp')
return this.createApp()
}
},
async getAppToken ({ state, rootState, commit }) {
async getAppToken () {
const { state } = window.vuex
const instance = state.instance.server
const res = await getClientToken({
clientId: state.clientId,
clientSecret: state.clientSecret,
instance: rootState.instance.server
clientId: this.clientId,
clientSecret: this.clientSecret,
instance
})
commit('setAppToken', res.access_token)
this.setAppToken(res.access_token)
return res.access_token
},
/// Use this if you want to ensure the app is still valid to use.
/// @return {string} The access token to the app (not attached to any user)
async ensureAppToken ({ state, rootState, dispatch, commit }) {
if (state.appToken) {
async ensureAppToken () {
const { state } = window.vuex
if (this.appToken) {
try {
await verifyAppToken({
instance: rootState.instance.server,
appToken: state.appToken
instance: state.instance.server,
appToken: this.appToken
})
return state.appToken
return this.appToken
} catch (e) {
if (!isAppTokenRejected(e)) {
// The server did not reject our token, but we encountered other problems. Maybe the server is down.
throw e
} else {
// The app token is rejected, so it is no longer useful.
commit('setAppToken', false)
this.setAppToken(false)
}
}
}
@ -117,7 +113,7 @@ const oauth = {
// appToken is not available, or is rejected: try to get a new one.
// First, make sure the client id and client secret are filled.
try {
await dispatch('ensureApp')
await this.ensureApp()
} catch (e) {
console.error('Cannot create app', e)
throw e
@ -126,7 +122,7 @@ const oauth = {
// Note that at this step, the client id and secret may be invalid
// (because the backend may have already deleted the app due to no user login)
try {
return await dispatch('getAppToken')
return await this.getAppToken()
} catch (e) {
if (!isClientDataRejected(e)) {
// Non-credentials problem, fail fast
@ -135,17 +131,27 @@ const oauth = {
} else {
// the client id and secret are invalid, so we should clear them
// and re-create our app
commit('setClientData', {
this.setClientData({
clientId: false,
clientSecret: false
})
await dispatch('createApp')
await this.createApp()
// try once again to get the token
return await dispatch('getAppToken')
return await this.getAppToken()
}
}
}
},
persist: {
afterLoad (state) {
// state.token is userToken with older name, coming from persistent state
if (state.token && !state.userToken) {
state.userToken = state.token
}
if ('token' in state) {
delete state.token
}
return state
}
}
}
export default oauth
})