diff --git a/src/actions/auth.ts b/src/actions/auth.ts index e1e5fddfe..8847c6b32 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -92,8 +92,8 @@ const createAppToken = () => const app = getState().auth.app; const params = { - client_id: app.client_id!, - client_secret: app.client_secret!, + client_id: app?.client_id, + client_secret: app?.client_secret, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'client_credentials', scope: getScopes(getState()), @@ -109,8 +109,8 @@ const createUserToken = (username: string, password: string) => const app = getState().auth.app; const params = { - client_id: app.client_id!, - client_secret: app.client_secret!, + client_id: app?.client_id, + client_secret: app?.client_secret, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'password', username: username, @@ -126,8 +126,8 @@ export const otpVerify = (code: string, mfa_token: string) => (dispatch: AppDispatch, getState: () => RootState) => { const app = getState().auth.app; return api(getState, 'app').post('/oauth/mfa/challenge', { - client_id: app.client_id, - client_secret: app.client_secret, + client_id: app?.client_id, + client_secret: app?.client_secret, mfa_token: mfa_token, code: code, challenge_type: 'totp', @@ -208,12 +208,12 @@ export const logOut = (refresh = true) => if (!account) return dispatch(noOp); 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, + client_id: state.auth.app?.client_id, + client_secret: state.auth.app?.client_secret, + token: state.auth.users[account.url]?.access_token, }; - return dispatch(revokeOAuthToken(params)) + return dispatch(revokeOAuthToken(params as Record)) .finally(() => { // Clear all stored cache from React Query queryClient.invalidateQueries(); @@ -246,7 +246,7 @@ export const switchAccount = (accountId: string, background = false) => export 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/src/actions/me.test.ts b/src/actions/me.test.ts deleted file mode 100644 index fe6ddd13a..000000000 --- a/src/actions/me.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { __stub } from 'soapbox/api'; -import { buildAccount } from 'soapbox/jest/factory'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; -import { AuthUserRecord, ReducerRecord } from 'soapbox/reducers/auth'; - -import { fetchMe, patchMe } from './me'; - -vi.mock('../../storage/kv-store', () => ({ - __esModule: true, - default: { - getItemOrError: vi.fn().mockReturnValue(Promise.resolve({})), - }, -})); - -let store: ReturnType; - -describe('fetchMe()', () => { - describe('without a token', () => { - beforeEach(() => { - const state = rootState; - store = mockStore(state); - }); - - it('dispatches the correct actions', async() => { - const expectedActions = [{ type: 'ME_FETCH_SKIP' }]; - await store.dispatch(fetchMe()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with a token', () => { - const accountUrl = 'accountUrl'; - const token = '123'; - - beforeEach(() => { - const state = { - ...rootState, - auth: ReducerRecord({ - me: accountUrl, - users: ImmutableMap({ - [accountUrl]: AuthUserRecord({ - 'access_token': token, - }), - }), - }), - entities: { - 'ACCOUNTS': { - store: { - [accountUrl]: buildAccount({ url: accountUrl }), - }, - lists: {}, - }, - }, - }; - - store = mockStore(state); - }); - - describe('with a successful API response', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet('/api/v1/accounts/verify_credentials').reply(200, {}); - }); - }); - - it('dispatches the correct actions', async() => { - const expectedActions = [ - { type: 'ME_FETCH_REQUEST' }, - { type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl }, - { type: 'ACCOUNTS_IMPORT', accounts: [] }, - { - type: 'AUTH_ACCOUNT_REMEMBER_SUCCESS', - account: {}, - accountUrl, - }, - { type: 'VERIFY_CREDENTIALS_REQUEST', token: '123' }, - { type: 'ACCOUNTS_IMPORT', accounts: [] }, - { type: 'VERIFY_CREDENTIALS_SUCCESS', token: '123', account: {} }, - ]; - await store.dispatch(fetchMe()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - }); -}); - -describe('patchMe()', () => { - beforeEach(() => { - const state = rootState; - store = mockStore(state); - }); - - describe('with a successful API response', () => { - beforeEach(() => { - __stub((mock) => { - mock.onPatch('/api/v1/accounts/update_credentials').reply(200, {}); - }); - }); - - it('dispatches the correct actions', async() => { - const expectedActions = [ - { type: 'ME_PATCH_REQUEST' }, - { type: 'ACCOUNTS_IMPORT', accounts: [] }, - { - type: 'ME_PATCH_SUCCESS', - me: {}, - }, - ]; - await store.dispatch(patchMe({})); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/src/actions/me.ts b/src/actions/me.ts index 7be77c52f..2933da913 100644 --- a/src/actions/me.ts +++ b/src/actions/me.ts @@ -33,11 +33,11 @@ const getMeUrl = (state: RootState) => { } }; -const getMeToken = (state: RootState) => { +function getMeToken(state: RootState): string | undefined { // 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; +} const fetchMe = () => (dispatch: AppDispatch, getState: () => RootState) => { diff --git a/src/components/sidebar-menu.tsx b/src/components/sidebar-menu.tsx index 67a838a28..f5ab9fcd3 100644 --- a/src/components/sidebar-menu.tsx +++ b/src/components/sidebar-menu.tsx @@ -15,8 +15,7 @@ import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbo import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications'; import { makeGetOtherAccounts } from 'soapbox/selectors'; -import type { List as ImmutableList } from 'immutable'; -import type { Account as AccountEntity } from 'soapbox/types/entities'; +import type { Account as AccountEntity } from 'soapbox/schemas/account'; const messages = defineMessages({ followers: { id: 'account.followers', defaultMessage: 'Followers' }, @@ -86,7 +85,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const features = useFeatures(); const me = useAppSelector((state) => state.me); const { account } = useAccount(me || undefined); - const otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); + const otherAccounts = useAppSelector((state) => getOtherAccounts(state)); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 0e5b1a753..c9a70ed70 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -26,7 +26,7 @@ export const NostrProvider: React.FC = ({ children }) => { const [isRelayOpen, setIsRelayOpen] = useState(false); const url = instance.nostr?.relay; - const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users.get(auth.me!)?.id); + const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users[auth.me!]?.id); const signer = useMemo( () => accountPubkey ? NKeys.get(accountPubkey) ?? window.nostr : undefined, diff --git a/src/features/auth-token-list/index.tsx b/src/features/auth-token-list/index.tsx index 40ca864c5..caaf6a66f 100644 --- a/src/features/auth-token-list/index.tsx +++ b/src/features/auth-token-list/index.tsx @@ -73,9 +73,9 @@ const AuthTokenList: React.FC = () => { const dispatch = useAppDispatch(); const intl = useIntl(); const tokens = useAppSelector(state => state.security.get('tokens').reverse()); - const currentTokenId = useAppSelector(state => { - const currentToken = state.auth.tokens.valueSeq().find((token) => token.me === state.auth.me); + const currentTokenId = useAppSelector(state => { + const currentToken = Object.values(state.auth.tokens).find((token) => token.me === state.auth.me); return currentToken?.id; }); @@ -86,7 +86,7 @@ const AuthTokenList: React.FC = () => { const body = tokens ? (
{tokens.map((token) => ( - + ))}
) : ; diff --git a/src/features/ui/components/profile-dropdown.tsx b/src/features/ui/components/profile-dropdown.tsx index 4ef3e5983..80961b5ac 100644 --- a/src/features/ui/components/profile-dropdown.tsx +++ b/src/features/ui/components/profile-dropdown.tsx @@ -1,7 +1,7 @@ import { useFloating } from '@floating-ui/react'; import clsx from 'clsx'; import throttle from 'lodash/throttle'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -9,11 +9,11 @@ import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth'; import Account from 'soapbox/components/account'; import { MenuDivider } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useClickOutside, useFeatures } from 'soapbox/hooks'; -import { makeGetAccount } from 'soapbox/selectors'; +import { makeGetOtherAccounts } from 'soapbox/selectors'; import ThemeToggle from './theme-toggle'; -import type { Account as AccountEntity } from 'soapbox/types/entities'; +import type { Account as AccountEntity } from 'soapbox/schemas'; const messages = defineMessages({ add: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' }, @@ -34,8 +34,6 @@ type IMenuItem = { action?: (event: React.MouseEvent) => void; } -const getAccount = makeGetAccount(); - const ProfileDropdown: React.FC = ({ account, children }) => { const dispatch = useAppDispatch(); const features = useFeatures(); @@ -43,8 +41,9 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const [visible, setVisible] = useState(false); const { x, y, strategy, refs } = useFloating({ placement: 'bottom-end' }); - const authUsers = useAppSelector((state) => state.auth.users); - const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!)); + + const getOtherAccounts = useCallback(makeGetOtherAccounts(), []); + const otherAccounts = useAppSelector((state) => getOtherAccounts(state)); const handleLogOut = () => { dispatch(logOut()); @@ -71,7 +70,7 @@ const ProfileDropdown: React.FC = ({ account, children }) => { menu.push({ text: renderAccount(account), to: `/@${account.acct}` }); - otherAccounts.forEach((otherAccount: AccountEntity) => { + otherAccounts.forEach((otherAccount) => { if (otherAccount && otherAccount.id !== account.id) { menu.push({ text: renderAccount(otherAccount), @@ -98,13 +97,13 @@ const ProfileDropdown: React.FC = ({ account, children }) => { }); return menu; - }, [account, authUsers, features]); + }, [account, otherAccounts, features]); const toggleVisible = () => setVisible(!visible); useEffect(() => { fetchOwnAccountThrottled(); - }, [account, authUsers]); + }, [account, otherAccounts]); useClickOutside(refs, () => { setVisible(false); diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index b8b7dbf28..5d0baaf1b 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -9,7 +9,7 @@ import { useOwnAccount } from './useOwnAccount'; export function useApi(): MastodonClient { const { account } = useOwnAccount(); const authUserUrl = useAppSelector((state) => state.auth.me); - const accessToken = useAppSelector((state) => account ? state.auth.users.get(account.url)?.access_token : undefined); + const accessToken = useAppSelector((state) => account ? state.auth.users[account.url]?.access_token : undefined); const baseUrl = new URL(BuildConfig.BACKEND_URL || account?.url || authUserUrl || location.origin).origin; return useMemo(() => { diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts deleted file mode 100644 index acfdcefd6..000000000 --- a/src/hooks/useAuth.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo } from 'react'; - -import { SoapboxAuth, soapboxAuthSchema, AuthUser } from 'soapbox/schemas/soapbox/soapbox-auth'; -import { Token } from 'soapbox/schemas/token'; - -import { useAppSelector } from './useAppSelector'; - -export function useAuth() { - const raw = useAppSelector((state) => state.auth); - - const data = useMemo(() => { - try { - return soapboxAuthSchema.parse(raw.toJS()); - } catch { - return { tokens: {}, users: {} }; - } - }, [raw]); - - const users = useMemo(() => Object.values(data.users), []); - const tokens = useMemo(() => Object.values(data.tokens), []); - - const user = data.me ? data.users[data.me] : undefined; - - return { - users, - tokens, - accountId: user?.id, - accountUrl: user?.url, - accessToken: user?.access_token, - }; -} \ No newline at end of file diff --git a/src/reducers/auth.test.ts b/src/reducers/auth.test.ts deleted file mode 100644 index fbee6005f..000000000 --- a/src/reducers/auth.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { - AUTH_APP_CREATED, - AUTH_LOGGED_IN, - AUTH_LOGGED_OUT, - VERIFY_CREDENTIALS_SUCCESS, - VERIFY_CREDENTIALS_FAIL, - SWITCH_ACCOUNT, -} from 'soapbox/actions/auth'; -import { ME_FETCH_SKIP } from 'soapbox/actions/me'; -import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload'; -import { buildAccount } from 'soapbox/jest/factory'; - -import reducer, { AuthAppRecord, AuthTokenRecord, AuthUserRecord, ReducerRecord } from './auth'; - -describe('auth reducer', () => { - it('should return the initial state', () => { - expect(reducer(undefined, {} as any).toJS()).toMatchObject({ - app: {}, - users: {}, - tokens: {}, - me: null, - }); - }); - - describe('AUTH_APP_CREATED', () => { - it('should copy in the app', () => { - const token = { token_type: 'Bearer', access_token: 'ABCDEFG' }; - const action = { type: AUTH_APP_CREATED, app: token }; - - const result = reducer(undefined, action); - const expected = AuthAppRecord(token); - - expect(result.app).toEqual(expected); - }); - }); - - describe('AUTH_LOGGED_IN', () => { - it('should import the token', () => { - const token = { token_type: 'Bearer', access_token: 'ABCDEFG' }; - const action = { type: AUTH_LOGGED_IN, token }; - - const result = reducer(undefined, action); - const expected = ImmutableMap({ 'ABCDEFG': AuthTokenRecord(token) }); - - expect(result.tokens).toEqual(expected); - }); - - it('should merge the token with existing state', () => { - const state = ReducerRecord({ - tokens: ImmutableMap({ 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }) }), - }); - - const expected = ImmutableMap({ - 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }), - 'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }), - }); - - const action = { - type: AUTH_LOGGED_IN, - token: { token_type: 'Bearer', access_token: 'HIJKLMN' }, - }; - - const result = reducer(state, action); - expect(result.tokens).toEqual(expected); - }); - }); - - describe('AUTH_LOGGED_OUT', () => { - it('deletes the user', () => { - const action = { - type: AUTH_LOGGED_OUT, - account: buildAccount({ url: 'https://gleasonator.com/users/alex' }), - }; - - const state = ReducerRecord({ - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }); - - const result = reducer(state, action); - expect(result.users).toEqual(expected); - }); - - it('sets `me` to the next available user', () => { - const state = ReducerRecord({ - me: 'https://gleasonator.com/users/alex', - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const action = { - type: AUTH_LOGGED_OUT, - account: buildAccount({ url: 'https://gleasonator.com/users/alex' }), - }; - - const result = reducer(state, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - }); - - describe('VERIFY_CREDENTIALS_SUCCESS', () => { - it('should import the user', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - }); - - const result = reducer(undefined, action); - expect(result.users).toEqual(expected); - }); - - it('should set the account in the token', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ - tokens: ImmutableMap({ 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }) }), - }); - - const expected = { - 'ABCDEFG': { - token_type: 'Bearer', - access_token: 'ABCDEFG', - account: '1234', - me: 'https://gleasonator.com/users/alex', - }, - }; - - const result = reducer(state, action); - expect(result.tokens.toJS()).toMatchObject(expected); - }); - - it('sets `me` to the account if unset', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const result = reducer(undefined, action); - expect(result.me).toEqual('https://gleasonator.com/users/alex'); - }); - - it('leaves `me` alone if already set', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ me: 'https://gleasonator.com/users/benis' }); - - const result = reducer(state, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - - it('deletes mismatched users', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ - users: ImmutableMap({ - 'https://gleasonator.com/users/mk': AuthUserRecord({ id: '4567', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/mk' }), - 'https://gleasonator.com/users/curtis': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/curtis' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }); - - const result = reducer(state, action); - expect(result.users).toEqual(expected); - }); - - it('upgrades from an ID to a URL', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ - me: '1234', - users: ImmutableMap({ - '1234': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG' }), - '5432': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN' }), - }), - tokens: ImmutableMap({ - 'ABCDEFG': AuthTokenRecord({ access_token: 'ABCDEFG', account: '1234' }), - }), - }); - - const expected = { - me: 'https://gleasonator.com/users/alex', - users: { - 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, - '5432': { id: '5432', access_token: 'HIJKLMN' }, - }, - tokens: { - 'ABCDEFG': { access_token: 'ABCDEFG', account: '1234', me: 'https://gleasonator.com/users/alex' }, - }, - }; - - const result = reducer(state, action); - expect(result.toJS()).toMatchObject(expected); - }); - }); - - describe('VERIFY_CREDENTIALS_FAIL', () => { - it('should delete the failed token if it 403\'d', () => { - const state = ReducerRecord({ - tokens: ImmutableMap({ - 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }), - 'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }), - }), - }); - - const expected = ImmutableMap({ - 'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }), - }); - - const action = { - type: VERIFY_CREDENTIALS_FAIL, - token: 'ABCDEFG', - error: { response: { status: 403 } }, - }; - - const result = reducer(state, action); - expect(result.tokens).toEqual(expected); - }); - - it('should delete any users associated with the failed token', () => { - const state = ReducerRecord({ - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }); - - const action = { - type: VERIFY_CREDENTIALS_FAIL, - token: 'ABCDEFG', - error: { response: { status: 403 } }, - }; - - const result = reducer(state, action); - expect(result.users).toEqual(expected); - }); - - it('should reassign `me` to the next in line', () => { - const state = ReducerRecord({ - me: 'https://gleasonator.com/users/alex', - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const action = { - type: VERIFY_CREDENTIALS_FAIL, - token: 'ABCDEFG', - error: { response: { status: 403 } }, - }; - - const result = reducer(state, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - }); - - describe('SWITCH_ACCOUNT', () => { - it('sets the value of `me`', () => { - const action = { - type: SWITCH_ACCOUNT, - account: { url: 'https://gleasonator.com/users/benis' }, - }; - - const result = reducer(undefined, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - }); - - describe('ME_FETCH_SKIP', () => { - it('sets `me` to null', () => { - const state = ReducerRecord({ me: 'https://gleasonator.com/users/alex' }); - const action = { type: ME_FETCH_SKIP }; - const result = reducer(state, action); - expect(result.me).toEqual(null); - }); - }); - - describe('MASTODON_PRELOAD_IMPORT', () => { - it('imports the user and token', async () => { - const data = await import('soapbox/__fixtures__/mastodon_initial_state.json'); - - const action = { - type: MASTODON_PRELOAD_IMPORT, - data, - }; - - const expected = { - me: 'https://mastodon.social/@benis911', - app: {}, - users: { - 'https://mastodon.social/@benis911': { - id: '106801667066418367', - access_token: 'Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q', - url: 'https://mastodon.social/@benis911', - }, - }, - tokens: { - 'Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q': { - access_token: 'Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q', - account: '106801667066418367', - me: 'https://mastodon.social/@benis911', - scope: 'read write follow push', - token_type: 'Bearer', - }, - }, - }; - - const result = reducer(undefined, action); - expect(result.toJS()).toMatchObject(expected); - }); - }); -}); diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index f066f3dbb..64934a665 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -1,409 +1,214 @@ -import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; -import trim from 'lodash/trim'; +import { AxiosError } from 'axios'; +import { produce } from 'immer'; +import { z } from 'zod'; -import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload'; -import * as BuildConfig from 'soapbox/build-config'; -import KVStore from 'soapbox/storage/kv-store'; -import { validId, isURL } from 'soapbox/utils/auth'; +import { Account, accountSchema } from 'soapbox/schemas'; +import { Application, applicationSchema } from 'soapbox/schemas/application'; +import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth'; +import { Token, tokenSchema } from 'soapbox/schemas/token'; +import { jsonSchema } from 'soapbox/schemas/utils'; import { AUTH_APP_CREATED, AUTH_LOGGED_IN, - AUTH_APP_AUTHORIZED, AUTH_LOGGED_OUT, SWITCH_ACCOUNT, VERIFY_CREDENTIALS_SUCCESS, VERIFY_CREDENTIALS_FAIL, + AUTH_APP_AUTHORIZED, } from '../actions/auth'; import { ME_FETCH_SKIP } from '../actions/me'; -import type { AxiosError } from 'axios'; -import type { AnyAction } from 'redux'; -import type { APIEntity, Account as AccountEntity } from 'soapbox/types/entities'; +import type { UnknownAction } from 'redux'; -export const AuthAppRecord = ImmutableRecord({ - access_token: null as string | null, - client_id: null as string | null, - client_secret: null as string | null, - id: null as string | null, - name: null as string | null, - redirect_uri: null as string | null, - token_type: null as string | null, - vapid_key: null as string | null, - website: null as string | null, -}); +const STORAGE_KEY = 'soapbox:auth'; +const SESSION_KEY = 'soapbox:auth:me'; -export const AuthTokenRecord = ImmutableRecord({ - access_token: '', - account: null as string | null, - created_at: 0, - expires_in: null as number | null, - id: null as number | null, - me: null as string | null, - refresh_token: null as string | null, - scope: '', - token_type: '', -}); - -export const AuthUserRecord = ImmutableRecord({ - access_token: '', - id: '', - url: '', -}); - -export const ReducerRecord = ImmutableRecord({ - app: AuthAppRecord(), - tokens: ImmutableMap(), - users: ImmutableMap(), - me: null as string | null, -}); - -type AuthToken = ReturnType; -type AuthUser = ReturnType; -type State = ReturnType; - -const buildKey = (parts: string[]) => parts.join(':'); - -// For subdirectory support -const NAMESPACE = trim(BuildConfig.FE_SUBDIRECTORY, '/') ? `soapbox@${BuildConfig.FE_SUBDIRECTORY}` : 'soapbox'; - -const STORAGE_KEY = buildKey([NAMESPACE, 'auth']); -const SESSION_KEY = buildKey([NAMESPACE, 'auth', 'me']); - -const getSessionUser = () => { - const id = sessionStorage.getItem(SESSION_KEY); - return validId(id) ? id : undefined; -}; - -const getLocalState = () => { - const state = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - - if (!state) return undefined; - - return ReducerRecord({ - app: AuthAppRecord(state.app), - tokens: ImmutableMap(Object.entries(state.tokens).map(([key, value]) => [key, AuthTokenRecord(value as any)])), - users: ImmutableMap(Object.entries(state.users).map(([key, value]) => [key, AuthUserRecord(value as any)])), - me: state.me, - }); -}; - -const sessionUser = getSessionUser(); -export const localState = getLocalState(); fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)!)); - -// Checks if the user has an ID and access token -const validUser = (user?: AuthUser) => { +/** Get current user's URL from session storage. */ +function getSessionUser(): string | undefined { + const value = sessionStorage.getItem(SESSION_KEY); try { - return !!(user && validId(user.id) && validId(user.access_token)); - } catch (e) { - return false; - } -}; - -// Finds the first valid user in the state -const firstValidUser = (state: State) => 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; + return z.string().url().parse(value); } catch { - return null; + return undefined; } -}; +} -// If `me` doesn't match an existing user, attempt to shift it. -const maybeShiftMe = (state: State) => { - const me = state.me!; - const user = state.users.get(me); +/** Retrieve state from browser storage. */ +function getLocalState(): SoapboxAuth | undefined { + const data = localStorage.getItem(STORAGE_KEY); + const result = jsonSchema.pipe(soapboxAuthSchema).safeParse(data); - if (!validUser(user)) { - const nextUser = firstValidUser(state); - return state.set('me', getUrlOrId(nextUser)); - } else { - return state; + if (!result.success) { + return undefined; } -}; -// 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); + return result.data; +} - return getUrlOrId(user); -}); +/** Serialize and save the auth into localStorage. */ +function persistAuth(auth: SoapboxAuth): void { + const value = JSON.stringify(auth); + localStorage.setItem(STORAGE_KEY, value); -// Upgrade the initial state -const migrateLegacy = (state: State) => { - if (localState) return state; - return state.withMutations(state => { - const app = AuthAppRecord(JSON.parse(localStorage.getItem('soapbox:auth:app')!)); - const user = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:user')!)) as ImmutableMap; - if (!user) return; - state.set('me', '_legacy'); // Placeholder account ID - state.set('app', app); - state.set('tokens', ImmutableMap({ - [user.get('access_token')]: AuthTokenRecord(user.set('account', '_legacy')), - })); - state.set('users', ImmutableMap({ - '_legacy': AuthUserRecord({ - id: '_legacy', - access_token: user.get('access_token'), - }), - })); - }); -}; - -const isUpgradingUrlId = (state: State) => { - const me = state.me; - const user = state.users.get(me!); - return validId(me) && user && !isURL(me); -}; - -// Checks the state and makes it valid -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.get('access_token') === id - )) - )); - }); -}; - -const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS())); - -const persistSession = (state: State) => { - const me = state.me; - if (me && typeof me === 'string') { - sessionStorage.setItem(SESSION_KEY, me); + if (auth.me) { + sessionStorage.setItem(SESSION_KEY, auth.me); } -}; +} -const persistState = (state: State) => { - persistAuth(state); - persistSession(state); -}; +/** Hydrate the initial state, or create a new state. */ +function initialize(): SoapboxAuth { + const auth = getLocalState() || { tokens: {}, users: {} }; + auth.me = getSessionUser() || auth.me; -const initialize = (state: State) => { - return state.withMutations(state => { - maybeShiftMe(state); - setSessionUser(state); - migrateLegacy(state); - sanitizeState(state); - persistState(state); + maybeShiftMe(auth); + persistAuth(auth); + + return auth; +} + +/** Initial state of the reducer. */ +const initialState = initialize(); + +/** Import a Token into the state. */ +function importToken(auth: SoapboxAuth, token: Token): SoapboxAuth { + return produce(auth, draft => { + draft.tokens[token.access_token] = token; }); -}; +} -const initialState = initialize(ReducerRecord().merge(localState as any)); - -const importToken = (state: State, token: APIEntity) => { - return state.setIn(['tokens', token.access_token], AuthTokenRecord(token)); -}; - -// Upgrade the `_legacy` placeholder ID with a real account -const upgradeLegacyId = (state: State, account: APIEntity) => { - if (localState) return state; - return state.withMutations(state => { - state.update('me', me => me === '_legacy' ? account.url : me); - state.deleteIn(['users', '_legacy']); +/** Import Application into the state. */ +function importApplication(auth: SoapboxAuth, app: Application): SoapboxAuth { + return produce(auth, draft => { + draft.app = app; }); - // TODO: Delete `soapbox:auth:app` and `soapbox:auth:user` localStorage? - // By this point it's probably safe, but we'll leave it just in case. -}; +} -// Users are now stored by their ActivityPub ID instead of their -// primary key to support auth against multiple hosts. -const upgradeNonUrlId = (state: State, account: APIEntity) => { - const me = state.me; - if (isURL(me)) return state; +/** If the user is not set, set it to the first available user. This mutates the object. */ +function maybeShiftMe(auth: SoapboxAuth): void { + if (!auth.me || !auth.users[auth.me]) { + auth.me = Object.keys(auth.users)[0]; + } +} - return state.withMutations(state => { - state.update('me', me => me === account.id ? account.url : me); - state.deleteIn(['users', account.id]); - }); -}; - -// Returns a predicate function for filtering a mismatched user/token -const userMismatch = (token: string, account: APIEntity) => { - return (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; - - return sameToken && (differentUrl || differentId); +/** Import an Account into the state as an auth user. */ +function importCredentials(auth: SoapboxAuth, accessToken: string, account: Account): SoapboxAuth { + const authUser: AuthUser = { + id: account.id, + access_token: accessToken, + url: account.url, }; -}; -const importCredentials = (state: State, token: string, account: APIEntity) => { - return 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('me', me => me || account.url); - upgradeLegacyId(state, account); - upgradeNonUrlId(state, account); + return produce(auth, draft => { + draft.users[account.url] = authUser; + maybeShiftMe(draft); }); -}; +} -const deleteToken = (state: State, token: string) => { - return state.withMutations(state => { - state.update('tokens', tokens => tokens.delete(token)); - state.update('users', users => users.filterNot(user => user.get('access_token') === token)); - maybeShiftMe(state); - }); -}; +function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth { + return produce(auth, draft => { + delete draft.tokens[accessToken]; -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.get('me') === accountUrl)); - maybeShiftMe(state); - }); -}; - -const importMastodonPreload = (state: State, data: ImmutableMap) => { - return 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; - - if (validId(accessToken) && validId(accountId) && isURL(accountUrl)) { - state.setIn(['tokens', accessToken], AuthTokenRecord({ - access_token: accessToken, - account: accountId, - me: accountUrl, - scope: 'read write follow push', - token_type: 'Bearer', - })); - - state.setIn(['users', accountUrl], AuthUserRecord({ - id: accountId, - access_token: accessToken, - url: accountUrl, - })); + for (const url in draft.users) { + if (draft.users[url].access_token === accessToken) { + delete draft.users[url]; + } } - maybeShiftMe(state); + maybeShiftMe(draft); }); -}; +} -const persistAuthAccount = (account: APIEntity) => { - if (account && account.url) { - const key = `authAccount:${account.url}`; - if (!account.pleroma) account.pleroma = {}; - KVStore.getItem(key).then((oldAccount: any) => { - const settings = oldAccount?.pleroma?.settings_store || {}; - if (!account.pleroma.settings_store) { - account.pleroma.settings_store = settings; - } - KVStore.setItem(key, account); - }) - .catch(console.error); - } -}; +function deleteUser(auth: SoapboxAuth, accountUrl: string): SoapboxAuth { + return produce(auth, draft => { + const accessToken = draft.users[accountUrl]?.access_token; -const deleteForbiddenToken = (state: State, error: AxiosError, token: string) => { + delete draft.tokens[accessToken]; + delete draft.users[accountUrl]; + + maybeShiftMe(draft); + }); +} + +function deleteForbiddenToken(auth: SoapboxAuth, error: AxiosError, token: string): SoapboxAuth { if ([401, 403].includes(error.response?.status!)) { - return deleteToken(state, token); + return deleteToken(auth, token); } else { - return state; + return auth; } -}; +} -const reducer = (state: State, action: AnyAction) => { +function reducer(state: SoapboxAuth, action: UnknownAction): SoapboxAuth { switch (action.type) { - case AUTH_APP_CREATED: - return state.set('app', AuthAppRecord(action.app)); - case AUTH_APP_AUTHORIZED: - return state.update('app', app => app.merge(action.token)); - case AUTH_LOGGED_IN: - return importToken(state, action.token); - case AUTH_LOGGED_OUT: - return deleteUser(state, action.account); - case VERIFY_CREDENTIALS_SUCCESS: - persistAuthAccount(action.account); - return importCredentials(state, action.token, action.account); - case VERIFY_CREDENTIALS_FAIL: - return deleteForbiddenToken(state, action.error, action.token); - case SWITCH_ACCOUNT: - return state.set('me', action.account.url); + case AUTH_APP_CREATED: { + const result = applicationSchema.safeParse(action.app); + return result.success ? importApplication(state, result.data) : state; + } + case AUTH_APP_AUTHORIZED: { + const result = tokenSchema.safeParse(action.token); + if (result.success) { + return produce(state, draft => { + if (draft.app) { + draft.app.access_token = result.data.access_token; + } + }); + } else { + return state; + } + } + case AUTH_LOGGED_IN: { + const result = tokenSchema.safeParse(action.token); + return result.success ? importToken(state, result.data) : state; + } + case AUTH_LOGGED_OUT: { + const result = accountSchema.safeParse(action.account); + return result.success ? deleteUser(state, result.data.url) : state; + } + case VERIFY_CREDENTIALS_SUCCESS: { + const result = accountSchema.safeParse(action.account); + if (result.success && typeof action.token === 'string') { + return importCredentials(state, action.token, result.data); + } else { + return state; + } + } + case VERIFY_CREDENTIALS_FAIL: { + if (action.error instanceof AxiosError && typeof action.token === 'string') { + return deleteForbiddenToken(state, action.error, action.token); + } else { + return state; + } + } + case SWITCH_ACCOUNT: { + const result = accountSchema.safeParse(action.account); + if (!result.success) { + return state; + } + // Middle-click to switch profiles updates the user in the new tab but leaves the current tab alone. + if (action.background === true) { + sessionStorage.setItem(SESSION_KEY, result.data.url); + return state; + } + return { ...state, me: result.data.url }; + } case ME_FETCH_SKIP: - return state.set('me', null); - case MASTODON_PRELOAD_IMPORT: - return importMastodonPreload(state, fromJS(action.data) as ImmutableMap); + return { ...state, me: undefined }; default: return state; } -}; +} -const reload = () => location.replace('/'); - -// `me` is a user ID string -const validMe = (state: State) => { - const me = state.me; - return typeof me === 'string' && me !== '_legacy'; -}; - -// `me` has changed from one valid ID to another -const userSwitched = (oldState: State, state: State) => { - const me = state.me; - const oldMe = oldState.me; - - const stillValid = validMe(oldState) && validMe(state); - const didChange = oldMe !== me; - const userUpgradedUrl = state.users.get(me!)?.id === oldMe; - - return stillValid && didChange && !userUpgradedUrl; -}; - -const maybeReload = (oldState: State, state: State, action: AnyAction) => { - const shouldRefresh = action.type === AUTH_LOGGED_OUT && action.refresh; - const switched = userSwitched(oldState, state); - - if (switched || shouldRefresh) { - reload(); - } -}; - -export default function auth(oldState: State = initialState, action: AnyAction) { +export default function auth(oldState: SoapboxAuth = initialState, action: UnknownAction): SoapboxAuth { const state = reducer(oldState, action); - if (!state.equals(oldState)) { - // Persist the state in localStorage + // Persist the state in localStorage when it changes. + if (state !== oldState) { persistAuth(state); + } - // When middle-clicking a profile, we want to save the - // user in localStorage, but not update the reducer - if (action.background === true) { - return oldState; - } - - // Persist the session - persistSession(state); - - // Reload the page under some conditions - maybeReload(oldState, state, action); + // Reload the page when the user logs out or switches accounts. + if (action.type === AUTH_LOGGED_OUT || (oldState.me && (oldState.me !== state.me))) { + location.replace('/'); } return state; diff --git a/src/schemas/application.ts b/src/schemas/application.ts new file mode 100644 index 000000000..7366789bf --- /dev/null +++ b/src/schemas/application.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const applicationSchema = z.object({ + name: z.string().catch(''), + website: z.string().url().nullable().catch(null), + scopes: z.string().array().catch([]), + redirect_uris: z.string().url().array().optional().catch(undefined), + redirect_uri: z.string().url().optional().catch(undefined), + client_id: z.string().optional().catch(undefined), + client_secret: z.string().optional().catch(undefined), + client_secret_expires_at: z.number().optional().catch(0), +}).transform((app) => { + const { redirect_uris, redirect_uri, ...rest } = app; + + return { + ...rest, + redirect_uris: redirect_uris || (redirect_uri ? [redirect_uri] : []), + }; +}); + +type Application = z.infer; + +export { applicationSchema, Application }; \ No newline at end of file diff --git a/src/schemas/soapbox/soapbox-auth.ts b/src/schemas/soapbox/soapbox-auth.ts index b80d71bba..e51a8f794 100644 --- a/src/schemas/soapbox/soapbox-auth.ts +++ b/src/schemas/soapbox/soapbox-auth.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { applicationSchema } from 'soapbox/schemas/application'; import { tokenSchema } from 'soapbox/schemas/token'; const authUserSchema = z.object({ @@ -8,10 +9,17 @@ const authUserSchema = z.object({ url: z.string().url(), }); +const authAppSchema = applicationSchema.and( + z.object({ + access_token: z.string().optional().catch(undefined), + }), +); + const soapboxAuthSchema = z.object({ + app: authAppSchema.optional(), tokens: z.record(z.string(), tokenSchema), users: z.record(z.string(), authUserSchema), - me: z.string().url().optional().catch(undefined), + me: z.string().url().optional(), }); type AuthUser = z.infer; diff --git a/src/schemas/token.ts b/src/schemas/token.ts index b32019a5a..2feaf30bd 100644 --- a/src/schemas/token.ts +++ b/src/schemas/token.ts @@ -5,6 +5,8 @@ const tokenSchema = z.object({ token_type: z.string(), scope: z.string(), created_at: z.number(), + id: z.coerce.string().optional().catch(undefined), // Pleroma (primary key) + me: z.string().url().optional().catch(undefined), // Pleroma (ActivityPub ID of user) }); type Token = z.infer; diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 6bd738e0e..a5d3076cc 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -11,7 +11,6 @@ import { getSettings } from 'soapbox/actions/settings'; import { Entities } from 'soapbox/entity-store/entities'; import { type MRFSimple } from 'soapbox/schemas/pleroma'; import { getDomain } from 'soapbox/utils/accounts'; -import { validId } from 'soapbox/utils/auth'; import ConfigDB from 'soapbox/utils/config-db'; import { getFeatures } from 'soapbox/utils/features'; import { shouldFilter } from 'soapbox/utils/timelines'; @@ -261,34 +260,27 @@ export const makeGetReport = () => { ); }; -const getAuthUserIds = createSelector([ - (state: RootState) => state.auth.users, -], authUsers => { - return authUsers.reduce((ids: ImmutableOrderedSet, authUser) => { - try { - const id = authUser.id; - return validId(id) ? ids.add(id) : ids; - } catch { - return ids; - } - }, ImmutableOrderedSet()); -}); - -export const makeGetOtherAccounts = () => { +export function makeGetOtherAccounts() { return createSelector([ (state: RootState) => state.entities[Entities.ACCOUNTS]?.store as EntityStore, - getAuthUserIds, + (state: RootState) => state.auth.users, (state: RootState) => state.me, ], - (accounts, authUserIds, me) => { - return authUserIds - .reduce((list: ImmutableList, id: string) => { - if (id === me) return list; - const account = accounts[id]; - return account ? list.push(account) : list; - }, ImmutableList()); + (store, authUsers, me): AccountSchema[] => { + const accountIds = Object.values(authUsers).map((authUser) => authUser.id); + + return accountIds.reduce((accounts, id: string) => { + if (id === me) return accounts; + + const account = store[id]; + if (account) { + accounts.push(account); + } + + return accounts; + }, []); }); -}; +} const getSimplePolicy = createSelector([ (state: RootState) => state.admin.configs, diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 73757fb80..34799cae5 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -30,13 +30,13 @@ export const isLoggedIn = (getState: () => RootState) => { return validId(getState().me); }; -export const getAppToken = (state: RootState) => state.auth.app.access_token as string; +export const getAppToken = (state: RootState) => state.auth.app?.access_token; export 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; }; export const getAccessToken = (state: RootState) => { @@ -48,7 +48,7 @@ export const getAuthUserId = (state: RootState) => { const me = state.auth.me; return ImmutableList([ - state.auth.users.get(me!)?.id, + state.auth.users[me!]?.id, me, ].filter(id => id)).find(validId); }; @@ -57,7 +57,7 @@ export const getAuthUserUrl = (state: RootState) => { const me = state.auth.me; return ImmutableList([ - state.auth.users.get(me!)?.url, + state.auth.users[me!]?.url, me, ].filter(url => url)).find(isURL); };