import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { AppDispatch, RootState } from "../../store"; import { getRemoteHandle, parseLemmyJWT } from "../../helpers/lemmy"; import { resetPosts } from "../post/postSlice"; import { getClient } from "../../services/lemmy"; import { resetComments } from "../comment/commentSlice"; import { resetUsers } from "../user/userSlice"; import { resetInbox } from "../inbox/inboxSlice"; import { differenceWith, uniqBy } from "lodash"; import { resetCommunities } from "../community/communitySlice"; import { ApplicationContext } from "capacitor-application-context"; import { resetInstances } from "../instances/instancesSlice"; import { resetResolve } from "../resolve/resolveSlice"; import { resetMod } from "../moderation/modSlice"; import { getInstanceFromHandle, instanceSelector } from "./authSelectors"; import { receivedSite, resetSite } from "./siteSlice"; import { Register } from "lemmy-js-client"; import { setDefaultFeed } from "../settings/settingsSlice"; import { getDefaultServer } from "../../services/app"; const MULTI_ACCOUNT_STORAGE_NAME = "credentials"; /** * DO NOT CHANGE this type. It is persisted. */ export type Credential = { jwt?: string; /** * Can either be user handle or instance url. * * e.g. `aeharding@lemmy.world` or `lemmy.world` */ handle: string; }; /** * DO NOT CHANGE this type. It is persisted. */ type CredentialStoragePayload = { accounts: Credential[]; /** * Can either be user handle or instance url. * * e.g. `aeharding@lemmy.world` or `lemmy.world` */ activeHandle: string; }; interface AuthState { accountData: CredentialStoragePayload | undefined; connectedInstance: string; } const initialState: (connectedInstance?: string) => AuthState = ( connectedInstance = "", ) => ({ accountData: getCredentialsFromStorage(), connectedInstance, }); export const authSlice = createSlice({ name: "auth", initialState, reducers: { addAccount: (state, action: PayloadAction) => { state.accountData ??= { accounts: [action.payload], activeHandle: action.payload.handle, }; let cleanedPreviousAccounts; if ( state.accountData.accounts.length === 1 && !state.accountData.accounts[0]?.handle.includes("@") ) { // If only one account, and it's a guest, just switch it out cleanedPreviousAccounts = [action.payload]; } else { // Remove guest accounts for this instance when logging in cleanedPreviousAccounts = differenceWith( state.accountData.accounts, [getInstanceFromHandle(action.payload.handle)], (a, b) => a.handle === b, ); } const accounts = uniqBy( [action.payload, ...cleanedPreviousAccounts], (c) => c.handle, ); state.accountData = { accounts, activeHandle: action.payload.handle, }; updateCredentialsStorage(state.accountData); }, removeAccount: (state, action: PayloadAction) => { if (!state.accountData) return; const accounts = differenceWith( state.accountData.accounts, [action.payload], (a, b) => a.handle === b, ); const nextAccount = accounts[0]; if (!nextAccount) { state.accountData = undefined; updateCredentialsStorage(undefined); return; } state.accountData.accounts = accounts; if (state.accountData.activeHandle === action.payload) { state.accountData.activeHandle = nextAccount.handle; } updateCredentialsStorage(state.accountData); }, setPrimaryAccount: (state, action: PayloadAction) => { if (!state.accountData) return; state.accountData.activeHandle = action.payload; updateCredentialsStorage(state.accountData); }, setAccounts: (state, action: PayloadAction) => { if (!state.accountData) return; state.accountData.accounts = action.payload; updateCredentialsStorage(state.accountData); }, reset: (state) => { return initialState(state.connectedInstance); }, updateConnectedInstance(state, action: PayloadAction) { if (import.meta.env.VITE__TEST_MODE) { state.connectedInstance = getDefaultServer(); return; } state.connectedInstance = action.payload; }, }, }); // Action creators are generated for each case reducer function export const { addAccount, removeAccount, setPrimaryAccount, setAccounts, reset, updateConnectedInstance, } = authSlice.actions; export default authSlice.reducer; export const login = (baseUrl: string, username: string, password: string, totp?: string) => async (dispatch: AppDispatch) => { const client = getClient(baseUrl); const res = await client.login({ username_or_email: username, // lemmy-ui has maxlength of 60. If we don't clamp too users will complain password won't work password: password.slice(0, 60), totp_2fa_token: totp || undefined, }); if (!res.jwt) { // todo throw new Error("broke"); } await dispatch(addJwt(baseUrl, res.jwt)); }; export const register = (baseUrl: string, register: Register) => async (dispatch: AppDispatch) => { const client = getClient(baseUrl); const res = await client.register(register); if (!res.jwt) { return res; } await dispatch(addJwt(baseUrl, res.jwt)); return true; }; export const addGuestInstance = (url: string) => async (dispatch: AppDispatch) => { const client = getClient(url); const site = await client.getSite(); dispatch(resetAccountSpecificStoreData()); dispatch(receivedSite(site)); dispatch(addAccount({ handle: url })); dispatch(updateConnectedInstance(url)); }; const addJwt = (baseUrl: string, jwt: string) => async (dispatch: AppDispatch) => { const authenticatedClient = getClient(baseUrl, jwt); const site = await authenticatedClient.getSite(); const myUser = site.my_user?.local_user_view?.person; if (!myUser) throw new Error("broke"); dispatch(resetAccountSpecificStoreData()); dispatch(receivedSite(site)); dispatch(addAccount({ jwt, handle: getRemoteHandle(myUser) })); dispatch(updateConnectedInstance(parseLemmyJWT(jwt).iss)); }; const resetAccountSpecificStoreData = () => (dispatch: AppDispatch) => { dispatch(resetPosts()); dispatch(resetComments()); dispatch(resetUsers()); dispatch(resetInbox()); dispatch(resetCommunities()); dispatch(resetResolve()); dispatch(resetInstances()); dispatch(resetMod()); dispatch(resetSite()); dispatch(setDefaultFeed(undefined)); }; export const logoutEverything = () => (dispatch: AppDispatch) => { dispatch(reset()); dispatch(resetAccountSpecificStoreData()); }; export const changeAccount = (handle: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(resetAccountSpecificStoreData()); dispatch(setPrimaryAccount(handle)); const instanceUrl = instanceSelector(getState()); if (instanceUrl) dispatch(updateConnectedInstance(instanceUrl)); }; export const logoutAccount = (handle: string) => (dispatch: AppDispatch, getState: () => RootState) => { const accountData = getState().auth.accountData; const currentAccount = accountData?.accounts?.find( ({ handle: h }) => handle === h, ); // Going to need to change active accounts if (handle === accountData?.activeHandle) { dispatch(resetAccountSpecificStoreData()); } // revoke token if (currentAccount && currentAccount.jwt) getClient( parseLemmyJWT(currentAccount.jwt).iss, currentAccount.jwt, )?.logout(); dispatch(removeAccount(handle)); const instanceUrl = instanceSelector(getState()); if (instanceUrl) dispatch(updateConnectedInstance(instanceUrl)); }; function updateCredentialsStorage( accounts: CredentialStoragePayload | undefined, ) { updateApplicationContextIfNeeded(accounts); if (!accounts) { localStorage.removeItem(MULTI_ACCOUNT_STORAGE_NAME); return; } localStorage.setItem(MULTI_ACCOUNT_STORAGE_NAME, JSON.stringify(accounts)); } function getCredentialsFromStorage(): CredentialStoragePayload | undefined { const serializedCredentials = localStorage.getItem( MULTI_ACCOUNT_STORAGE_NAME, ); if (!serializedCredentials) return; return JSON.parse(serializedCredentials); } // Run once on app load to sync state if needed updateApplicationContextIfNeeded(getCredentialsFromStorage()); /** * This syncs application state for the Apple Watch App */ function updateApplicationContextIfNeeded( accounts: CredentialStoragePayload | undefined, ) { const DEFAULT_INSTANCE = "lemmy.world"; const connectedInstance = (() => { if (!accounts) return DEFAULT_INSTANCE; if (!accounts.activeHandle.includes("@")) return accounts.activeHandle; return accounts.activeHandle.slice( accounts.activeHandle.lastIndexOf("@") + 1, ); })(); ApplicationContext.updateApplicationContext({ connectedInstance, authToken: accounts ? (accounts.accounts.find((a) => a.handle === accounts.activeHandle) ?.jwt ?? "") : "", }); }