diff --git a/packages/pl-fe/src/actions/auth.ts b/packages/pl-fe/src/actions/auth.ts index 20ead9350..4c34b7fa7 100644 --- a/packages/pl-fe/src/actions/auth.ts +++ b/packages/pl-fe/src/actions/auth.ts @@ -275,7 +275,7 @@ const logOut = () => const params = { client_id: state.auth.app?.client_id!, client_secret: state.auth.app?.client_secret!, - token: state.auth.users.get(account.url)!.access_token, + token: state.auth.users[account.url]!.access_token, }; return dispatch(revokeOAuthToken(params)) @@ -312,7 +312,7 @@ const switchAccount = (accountId: string, background = false) => const fetchOwnAccounts = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - return state.auth.users.forEach((user) => { + return Object.values(state.auth.users).forEach((user) => { const account = selectAccount(state, user.id); if (!account) { dispatch(verifyCredentials(user.access_token, user.url)) diff --git a/packages/pl-fe/src/actions/me.ts b/packages/pl-fe/src/actions/me.ts index bbbf7549a..15d3dbe44 100644 --- a/packages/pl-fe/src/actions/me.ts +++ b/packages/pl-fe/src/actions/me.ts @@ -36,7 +36,7 @@ const getMeUrl = (state: RootState) => { const getMeToken = (state: RootState) => { // Fallback for upgrading IDs to URLs const accountUrl = getMeUrl(state) || state.auth.me; - return state.auth.users.get(accountUrl!)?.access_token; + return state.auth.users[accountUrl!]?.access_token; }; interface MeFetchSkipAction { diff --git a/packages/pl-fe/src/features/auth-token-list/index.tsx b/packages/pl-fe/src/features/auth-token-list/index.tsx index fab77ed6d..137b77392 100644 --- a/packages/pl-fe/src/features/auth-token-list/index.tsx +++ b/packages/pl-fe/src/features/auth-token-list/index.tsx @@ -83,7 +83,7 @@ const AuthTokenList: React.FC = () => { const intl = useIntl(); const tokens = useAppSelector(state => state.security.tokens.toReversed()); const currentTokenId = useAppSelector(state => { - const currentToken = state.auth.tokens.valueSeq().find((token) => token.me === state.auth.me); + const currentToken = Object.values(state.auth.tokens).find((token) => token.me === state.auth.me); return currentToken?.id; }); diff --git a/packages/pl-fe/src/features/ui/components/profile-dropdown.tsx b/packages/pl-fe/src/features/ui/components/profile-dropdown.tsx index dc29bd8b0..cc661fb64 100644 --- a/packages/pl-fe/src/features/ui/components/profile-dropdown.tsx +++ b/packages/pl-fe/src/features/ui/components/profile-dropdown.tsx @@ -40,7 +40,7 @@ type IMenuItem = { const getOtherAccounts = createSelector([ (state: RootState) => state.auth.users, (state: RootState) => state.entities[Entities.ACCOUNTS]?.store, -], (signedAccounts, accountEntities) => signedAccounts.toArray().map(([_, { id }]) => accountEntities?.[id] as AccountEntity).filter(account => account)); +], (signedAccounts, accountEntities) => Object.values(signedAccounts).map(({ id }) => accountEntities?.[id] as AccountEntity).filter(account => account)); const ProfileDropdown: React.FC = ({ account, children }) => { const dispatch = useAppDispatch(); diff --git a/packages/pl-fe/src/reducers/auth.ts b/packages/pl-fe/src/reducers/auth.ts index 9855bde59..b99b98798 100644 --- a/packages/pl-fe/src/reducers/auth.ts +++ b/packages/pl-fe/src/reducers/auth.ts @@ -1,5 +1,6 @@ -import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; +import { Map as ImmutableMap, fromJS } from 'immutable'; import trim from 'lodash/trim'; +import { create, Draft } from 'mutative'; import { applicationSchema, PlApiClient, tokenSchema, type CredentialAccount, type CredentialApplication, type Token } from 'pl-api'; import * as v from 'valibot'; @@ -26,22 +27,25 @@ import type { AnyAction } from 'redux'; const backendUrl = (isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : ''); -const AuthUserRecord = ImmutableRecord({ - access_token: '', - id: '', - url: '', +const authUserSchema = v.object({ + access_token: v.string(), + id: v.string(), + url: v.string(), }); -const ReducerRecord = ImmutableRecord({ - app: null as CredentialApplication | null, - tokens: ImmutableMap(), - users: ImmutableMap(), - me: null as string | null, - client: new PlApiClient(backendUrl), -}); +interface AuthUser { + access_token: string; + id: string; + url: string; +} -type AuthUser = ReturnType; -type State = ReturnType; +interface State { + app: CredentialApplication | null; + tokens: Record; + users: Record; + me: string | null; + client: InstanceType; +} const buildKey = (parts: string[]) => parts.join(':'); @@ -56,15 +60,15 @@ const getSessionUser = () => { return validId(id) ? id : undefined; }; -const getLocalState = () => { +const getLocalState = (): State | undefined => { const state = JSON.parse(localStorage.getItem(STORAGE_KEY)!); if (!state) return undefined; - return ReducerRecord({ + return ({ app: state.app && v.parse(applicationSchema, state.app), - tokens: ImmutableMap(Object.entries(state.tokens).map(([key, value]) => [key, v.parse(tokenSchema, value)])), - users: ImmutableMap(Object.entries(state.users).map(([key, value]) => [key, AuthUserRecord(value as any)])), + tokens: Object.fromEntries(Object.entries(state.tokens).map(([key, value]) => [key, v.parse(tokenSchema, value)])), + users: Object.fromEntries(Object.entries(state.users).map(([key, value]) => [key, v.parse(authUserSchema, value)])), me: state.me, client: new PlApiClient(parseBaseURL(state.me) || backendUrl, state.users[state.me]?.access_token), }); @@ -83,44 +87,45 @@ const validUser = (user?: AuthUser) => { }; // Finds the first valid user in the state -const firstValidUser = (state: State) => state.users.find(validUser); +const firstValidUser = (state: State | Draft) => Object.values(state.users).find(validUser); // For legacy purposes. IDs get upgraded to URLs further down. const getUrlOrId = (user?: AuthUser): string | null => { try { - const { id, url } = user!.toJS(); - return (url || id) as string; + if (!user) return null; + const { id, url } = user; + return (url || id); } catch { return null; } }; // If `me` doesn't match an existing user, attempt to shift it. -const maybeShiftMe = (state: State) => { +const maybeShiftMe = (state: State | Draft) => { const me = state.me!; - const user = state.users.get(me); + const user = state.users[me]; if (!validUser(user)) { const nextUser = firstValidUser(state); - return state.set('me', getUrlOrId(nextUser)); + state.me = getUrlOrId(nextUser); } else { return state; } }; // Set the user from the session or localStorage, whichever is valid first -const setSessionUser = (state: State) => state.update('me', me => { - const user = ImmutableList([ - state.users.get(sessionUser!)!, - state.users.get(me!)!, - ]).find(validUser); +const setSessionUser = (state: State) => { + const me = getUrlOrId([ + state.users[sessionUser!]!, + state.users[state.me!]!, + ].find(validUser)); - return getUrlOrId(user); -}); + state.me = me; +}; const isUpgradingUrlId = (state: State) => { const me = state.me; - const user = state.users.get(me!); + const user = state.users[me!]; return validId(me) && user && !isURL(me); }; @@ -129,24 +134,17 @@ const sanitizeState = (state: State) => { // Skip sanitation during ID to URL upgrade if (isUpgradingUrlId(state)) return state; - return state.withMutations(state => { - // Remove invalid users, ensure ID match - state.update('users', users => ( - users.filter((user, url) => ( - validUser(user) && user.get('url') === url - )) - )); - // Remove mismatched tokens - state.update('tokens', tokens => ( - tokens.filter((token, id) => ( - validId(id) && token.access_token === id - )) - )); - }); + state.users = Object.fromEntries(Object.entries(state.users).filter(([url, user]) => ( + validUser(user) && user.url === url + ))); + // Remove mismatched tokens + state.tokens = Object.fromEntries(Object.entries(state.tokens).filter(([id, token]) => ( + validId(id) && token.access_token === id + ))); }; const persistAuth = (state: State) => { - const { client, ...data } = state.toJS(); + const { client, ...data } = state; localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); }; @@ -162,103 +160,102 @@ const persistState = (state: State) => { persistSession(state); }; -const initialize = (state: State) => - state.withMutations(state => { - maybeShiftMe(state); - setSessionUser(state); - sanitizeState(state); - persistState(state); - }); +const initialize = (state: State) => { + maybeShiftMe(state); + setSessionUser(state); + sanitizeState(state); + persistState(state); -const initialState = initialize(ReducerRecord().merge(localState as any)); + return state; +}; -const importToken = (state: State, token: Token) => - state.setIn(['tokens', token.access_token], token); +const initialState: State = initialize({ + app: null, + tokens: {}, + users: {}, + me: null, + client: new PlApiClient(backendUrl), + ...localState, +}); + +const importToken = (state: State | Draft, token: Token) => { + state.tokens[token.access_token] = token; +}; // Users are now stored by their ActivityPub ID instead of their // primary key to support auth against multiple hosts. -const upgradeNonUrlId = (state: State, account: CredentialAccount) => { +const upgradeNonUrlId = (state: State | Draft, account: CredentialAccount) => { const me = state.me; if (isURL(me)) return state; - return state.withMutations(state => { - state.update('me', me => me === account.id ? account.url : me); - state.deleteIn(['users', account.id]); - }); + state.me = state.me === account.id ? account.url : state.me; + delete state.users[account.id]; }; // Returns a predicate function for filtering a mismatched user/token const userMismatch = (token: string, account: CredentialAccount) => (user: AuthUser, url: string) => { - const sameToken = user.get('access_token') === token; - const differentUrl = url !== account.url || user.get('url') !== account.url; - const differentId = user.get('id') !== account.id; + const sameToken = user.access_token === token; + const differentUrl = url !== account.url || user.url !== account.url; + const differentId = user.id !== account.id; return sameToken && (differentUrl || differentId); }; -const importCredentials = (state: State, token: string, account: CredentialAccount) => - state.withMutations(state => { - state.setIn(['users', account.url], AuthUserRecord({ - id: account.id, - access_token: token, - url: account.url, - })); - state.setIn(['tokens', token, 'account'], account.id); - state.setIn(['tokens', token, 'me'], account.url); - state.update('users', users => users.filterNot(userMismatch(token, account))); - state.update('client', client => - state.me - ? client - : client.baseURL === parseBaseURL(account.url) - ? (client.accessToken = token, client) - : new PlApiClient(parseBaseURL(account.url) || backendUrl, token), - ); - state.update('me', me => me || account.url); - upgradeNonUrlId(state, account); - }); - -const deleteToken = (state: State, token: string) => - state.withMutations(state => { - state.update('tokens', tokens => tokens.delete(token)); - state.update('users', users => users.filterNot(user => user.get('access_token') === token)); - maybeShiftMe(state); - }); - -const deleteUser = (state: State, account: Pick) => { - const accountUrl = account.url; - - return state.withMutations(state => { - state.update('users', users => users.delete(accountUrl)); - state.update('tokens', tokens => tokens.filterNot(token => token.me === accountUrl)); - maybeShiftMe(state); +const importCredentials = (state: State | Draft, token: string, account: CredentialAccount) => { + state.users[account.url] = v.parse(authUserSchema, { + id: account.id, + access_token: token, + url: account.url, }); + // state.tokens[token].account = account.id; + state.tokens[token].me = account.url; + state.users = Object.fromEntries(Object.entries(state.users).filter(([url, user]) => userMismatch(token, account)(user, url))); + if (!state.me) { + if (state.client.baseURL === parseBaseURL(account.url)) state.client.accessToken = token; + else state.client = new PlApiClient(parseBaseURL(account.url) || backendUrl, token); + } + state.me = state.me || account.url; + upgradeNonUrlId(state, account); }; -const importMastodonPreload = (state: State, data: ImmutableMap) => - state.withMutations(state => { - const accountId = data.getIn(['meta', 'me']) as string; - const accountUrl = data.getIn(['accounts', accountId, 'url']) as string; - const accessToken = data.getIn(['meta', 'access_token']) as string; +const deleteToken = (state: State | Draft, token: string) => { + delete state.tokens[token]; + state.users = Object.fromEntries(Object.entries(state.users).filter(([_, user]) => user.access_token !== token)); + maybeShiftMe(state); +}; - if (validId(accessToken) && validId(accountId) && isURL(accountUrl)) { - state.setIn(['tokens', accessToken], v.parse(tokenSchema, { - access_token: accessToken, - account: accountId, - me: accountUrl, - scope: 'read write follow push', - token_type: 'Bearer', - })); +const deleteUser = (state: State | Draft, account: Pick) => { + const accountUrl = account.url; - state.setIn(['users', accountUrl], AuthUserRecord({ - id: accountId, - access_token: accessToken, - url: accountUrl, - })); - } + delete state.users[accountUrl]; + state.tokens = Object.fromEntries(Object.entries(state.tokens).filter(([_, token]) => token.me !== accountUrl)); + maybeShiftMe(state); +}; - maybeShiftMe(state); - }); +const importMastodonPreload = (state: State | Draft, data: ImmutableMap) => { + const accountId = data.getIn(['meta', 'me']) as string; + const accountUrl = data.getIn(['accounts', accountId, 'url']) as string; + const accessToken = data.getIn(['meta', 'access_token']) as string; + + if (validId(accessToken) && validId(accountId) && isURL(accountUrl)) { + state.tokens[accessToken] = v.parse(tokenSchema, { + access_token: accessToken, + account: accountId, + me: accountUrl, + scope: 'read write follow push', + token_type: 'Bearer', + }); + + state.users[accountUrl] = v.parse(authUserSchema, { + id: accountId, + access_token: accessToken, + url: accountUrl, + }); + } + + maybeShiftMe(state); +}; const persistAuthAccount = (account: CredentialAccount) => { const persistedAccount = { ...account }; @@ -276,7 +273,7 @@ const persistAuthAccount = (account: CredentialAccount) => { return persistedAccount; }; -const deleteForbiddenToken = (state: State, error: { response: PlfeResponse }, token: string) => { +const deleteForbiddenToken = (state: State | Draft, error: { response: PlfeResponse }, token: string) => { if ([401, 403].includes(error.response?.status!)) { return deleteToken(state, token); } else { @@ -284,32 +281,49 @@ const deleteForbiddenToken = (state: State, error: { response: PlfeResponse }, t } }; -const reducer = (state: State, action: AnyAction | AuthAction | MeAction | PreloadAction) => { +const reducer = (state: State, action: AnyAction | AuthAction | MeAction | PreloadAction): State => { switch (action.type) { case AUTH_APP_CREATED: - return state.set('app', action.app); + return create(state, (draft) => { + draft.app = action.app; + }); case AUTH_APP_AUTHORIZED: - return state.update('app', app => ({ ...app, ...action.token })); + return create(state, (draft) => { + draft.app = ({ ...draft.app, ...action.token }); + }); case AUTH_LOGGED_IN: - return importToken(state, action.token); + return create(state, (draft) => { + importToken(draft, action.token); + }); case AUTH_LOGGED_OUT: - return deleteUser(state, action.account); + return create(state, (draft) => { + deleteUser(draft, action.account); + }); case VERIFY_CREDENTIALS_SUCCESS: - return importCredentials(state, action.token, persistAuthAccount(action.account)); + return create(state, (draft) => { + importCredentials(draft, action.token, persistAuthAccount(action.account)); + }); case VERIFY_CREDENTIALS_FAIL: - return deleteForbiddenToken(state, action.error, action.token); + return create(state, (draft) => { + deleteForbiddenToken(draft, action.error, action.token); + }); case SWITCH_ACCOUNT: - return state - .set('me', action.account.url) - .update('client', client => - client.baseURL === parseBaseURL(action.account.url) - ? (client.accessToken = action.account.access_token, client) - : new PlApiClient(parseBaseURL(action.account.url) || backendUrl, action.account.access_token), - ); + return create(state, (draft) => { + draft.me = action.account.url; + if (draft.client.baseURL === parseBaseURL(action.account.url)) { + draft.client.accessToken = action.account.access_token; + } else { + draft.client = new PlApiClient(parseBaseURL(action.account.url) || backendUrl, action.account.access_token); + } + }); case ME_FETCH_SKIP: - return state.set('me', null); + return create(state, (draft) => { + draft.me = null; + }); case MASTODON_PRELOAD_IMPORT: - return importMastodonPreload(state, fromJS(action.data) as ImmutableMap); + return create(state, (draft) => { + importMastodonPreload(draft, fromJS>(action.data)); + }); default: return state; } @@ -330,7 +344,7 @@ const userSwitched = (oldState: State, state: State) => { const stillValid = validMe(oldState) && validMe(state); const didChange = oldMe !== me; - const userUpgradedUrl = state.users.get(me!)?.id === oldMe; + const userUpgradedUrl = state.users[me!]?.id === oldMe; return stillValid && didChange && !userUpgradedUrl; }; @@ -344,10 +358,10 @@ const maybeReload = (oldState: State, state: State, action: AnyAction) => { } }; -const auth = (oldState: State = initialState, action: AnyAction) => { +const auth = (oldState: State = initialState, action: AnyAction): State => { const state = reducer(oldState, action); - if (!state.equals(oldState)) { + if (state !== oldState) { // Persist the state in localStorage persistAuth(state); @@ -367,8 +381,4 @@ const auth = (oldState: State = initialState, action: AnyAction) => { return state; }; -export { - AuthUserRecord, - ReducerRecord, - auth as default, -}; +export { auth as default }; diff --git a/packages/pl-fe/src/selectors/index.ts b/packages/pl-fe/src/selectors/index.ts index 67ce403e8..9ee606d65 100644 --- a/packages/pl-fe/src/selectors/index.ts +++ b/packages/pl-fe/src/selectors/index.ts @@ -261,7 +261,7 @@ const makeGetReport = () => { const getAuthUserIds = createSelector( [(state: RootState) => state.auth.users], - authUsers => authUsers.reduce((userIds: Array, authUser) => { + authUsers => Object.values(authUsers).reduce((userIds: Array, authUser) => { const userId = authUser?.id; if (validId(userId)) userIds.push(userId); return userIds; diff --git a/packages/pl-fe/src/utils/auth.ts b/packages/pl-fe/src/utils/auth.ts index d5b3d68f0..59cd8e545 100644 --- a/packages/pl-fe/src/utils/auth.ts +++ b/packages/pl-fe/src/utils/auth.ts @@ -30,7 +30,7 @@ const getUserToken = (state: RootState, accountId?: string | false | null) => { if (!accountId) return; const accountUrl = selectAccount(state, accountId)?.url; if (!accountUrl) return; - return state.auth.users.get(accountUrl)?.access_token; + return state.auth.users[accountUrl]?.access_token; }; const getAccessToken = (state: RootState) => { @@ -42,7 +42,7 @@ const getAuthUserId = (state: RootState) => { const me = state.auth.me; return [ - state.auth.users.get(me!)?.id, + state.auth.users[me!]?.id, me, ].filter(id => id).find(validId); }; @@ -51,7 +51,7 @@ const getAuthUserUrl = (state: RootState) => { const me = state.auth.me; return [ - state.auth.users.get(me!)?.url, + state.auth.users[me!]?.url, me, ].filter(url => url).find(isURL); };