diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml
index 4c5780f8b29..f15c9816d39 100644
--- a/.maestro/tests/assorted/deeplink.yaml
+++ b/.maestro/tests/assorted/deeplink.yaml
@@ -310,3 +310,80 @@ tags:
visible:
id: 'new-server-view'
timeout: 60000
+
+# should show a security confirmation prompt on deep link login and abort when cancelled.
+- runFlow:
+ file: '../../helpers/launch-app.yaml'
+- stopApp: ${APP_ID}
+- runFlow:
+ file: '../../helpers/open-deeplink.yaml'
+ env:
+ link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&forceLoginPrompt=true')}
+- extendedWaitUntil:
+ visible:
+ text: '.*Sign in to this workspace.*'
+ timeout: 60000
+- assertVisible:
+ text: '.*A link is asking to sign you in.*'
+# decline the prompt — the app must NOT sign in or navigate to the room
+- runFlow:
+ when:
+ platform: android
+ commands:
+ - tapOn:
+ id: 'android:id/button2'
+- runFlow:
+ when:
+ platform: ios
+ commands:
+ - tapOn:
+ text: 'Cancel'
+- extendedWaitUntil:
+ visible:
+ id: 'workspace-view'
+ timeout: 60000
+- assertNotVisible:
+ id: 'room-view-title-${output.room.name}'
+
+# should show a security confirmation prompt on deep link login and sign in when confirmed
+- runFlow:
+ file: '../../helpers/launch-app.yaml'
+- stopApp: ${APP_ID}
+- runFlow:
+ file: '../../helpers/open-deeplink.yaml'
+ env:
+ link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&forceLoginPrompt=true')}
+- extendedWaitUntil:
+ visible:
+ text: '.*Sign in to this workspace.*'
+ timeout: 60000
+# confirm the prompt — the app must sign in and navigate to the room.
+# android:id/button1 is the positive (confirm) button, disambiguating it from the "Login" button
+# rendered on the workspace screen behind the alert.
+- runFlow:
+ when:
+ platform: android
+ commands:
+ - extendedWaitUntil:
+ visible:
+ id: 'android:id/button1'
+ timeout: 60000
+ - tapOn:
+ id: 'android:id/button1'
+- runFlow:
+ when:
+ platform: ios
+ commands:
+ - tapOn:
+ text: 'Login'
+ rightOf:
+ text: 'Cancel'
+- extendedWaitUntil:
+ visible:
+ id: 'room-view-title-${output.room.name}'
+ timeout: 60000
+- runFlow: '../../helpers/go-back.yaml'
+- extendedWaitUntil:
+ visible:
+ id: 'rooms-list-view'
+ timeout: 60000
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 18337835f5e..0e9fdce9400 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -105,11 +105,11 @@
+ android:exported="false" />
+ android:exported="false" >
`${TOKEN_KEY}-${server}`;
+export const getLegacyUserTokenKey = (userId: string): string => `${TOKEN_KEY}-${userId}`;
+export const getUserTokenKey = (server: string, userId: string): string => `${TOKEN_KEY}-${server}-${userId}`;
+export const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED';
export const CURRENT_SERVER = 'currentServer';
export const CERTIFICATE_KEY = 'RC_CERTIFICATE_KEY';
diff --git a/app/lib/hooks/useShortnameToUnicode/index.tsx b/app/lib/hooks/useShortnameToUnicode/index.tsx
index d6f6d78a75c..890c8a71f1b 100644
--- a/app/lib/hooks/useShortnameToUnicode/index.tsx
+++ b/app/lib/hooks/useShortnameToUnicode/index.tsx
@@ -35,7 +35,7 @@ const useShortnameToUnicode = (isEmojiPicker?: boolean) => {
const replaceShortnameWithUnicode = (shortname: string) => {
const name = shortname.replace(/:/g, '');
-
+
// a custom emoji sharing a built-in shortcode/alias must win
if (customEmojis(name)) {
return shortname;
diff --git a/app/lib/methods/loggedInServer.ts b/app/lib/methods/loggedInServer.ts
index 6b4ef2ddb84..ba3f068062d 100644
--- a/app/lib/methods/loggedInServer.ts
+++ b/app/lib/methods/loggedInServer.ts
@@ -1,10 +1,10 @@
import { type TServerModel } from '../../definitions';
-import { TOKEN_KEY } from '../constants/keys';
+import { getServerUserIdKey } from '../constants/keys';
import { getAllServers } from '../database/services/Server';
import UserPreferences from './userPreferences';
export const isLoggedInServer = (serverId?: string | null): boolean =>
- !!serverId && !!UserPreferences.getString(`${TOKEN_KEY}-${serverId}`);
+ !!serverId && !!UserPreferences.getString(getServerUserIdKey(serverId));
export const findLoggedInServer = async (): Promise =>
(await getAllServers()).find(({ id }) => isLoggedInServer(id));
diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts
index f650be4b3d7..705ecf24516 100644
--- a/app/lib/methods/logout.ts
+++ b/app/lib/methods/logout.ts
@@ -8,16 +8,26 @@ import database, { getDatabase } from '../database';
import log from './helpers/log';
import { disconnect } from '../services/connect';
import sdk from '../services/sdk';
-import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys';
+import {
+ CURRENT_SERVER,
+ E2E_PRIVATE_KEY,
+ E2E_PUBLIC_KEY,
+ E2E_RANDOM_PASSWORD_KEY,
+ getLegacyUserTokenKey,
+ getServerUserIdKey,
+ getUserTokenKey
+} from '../constants/keys';
import UserPreferences from './userPreferences';
import { removePushToken } from '../services/restApi';
import { roomsSubscription } from './subscriptions/rooms';
import { _activeUsersSubTimeout } from './getUsersPresence';
function removeServerKeys({ server, userId }: { server: string; userId?: string | null }) {
- UserPreferences.removeItem(`${TOKEN_KEY}-${server}`);
+ UserPreferences.removeItem(getServerUserIdKey(server));
if (userId) {
- UserPreferences.removeItem(`${TOKEN_KEY}-${userId}`);
+ UserPreferences.removeItem(getUserTokenKey(server, userId));
+ // A logout before the migration ran leaves a token the native fallbacks would still read.
+ UserPreferences.removeItem(getLegacyUserTokenKey(userId));
}
UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`);
UserPreferences.removeItem(`${server}-${E2E_PUBLIC_KEY}`);
@@ -29,7 +39,7 @@ export async function removeServerData({ server }: { server: string }): Promise<
try {
const batch: Model[] = [];
const serversDB = database.servers;
- const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
+ const userId = UserPreferences.getString(getServerUserIdKey(server));
const usersCollection = serversDB.get('users');
if (userId) {
@@ -78,9 +88,9 @@ async function logoutFromServer(server: string, resume: string): Promise {
export async function removeServer({ server }: { server: string }): Promise {
try {
- const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
+ const userId = UserPreferences.getString(getServerUserIdKey(server));
if (userId) {
- const resume = UserPreferences.getString(`${TOKEN_KEY}-${userId}`);
+ const resume = UserPreferences.getString(getUserTokenKey(server, userId));
if (resume) {
await logoutFromServer(server, resume);
diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts
new file mode 100644
index 00000000000..52016697a09
--- /dev/null
+++ b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts
@@ -0,0 +1,189 @@
+import { migrateTokenKeysToServerScoped } from './migrateTokenKeysToServerScoped';
+import UserPreferences from './userPreferences';
+import database from '../database';
+import log from './helpers/log';
+import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getServerUserIdKey, getUserTokenKey } from '../constants/keys';
+
+jest.mock('../database', () => ({
+ __esModule: true,
+ default: {
+ servers: {
+ get: jest.fn()
+ }
+ }
+}));
+
+jest.mock('./helpers/log', () => ({
+ __esModule: true,
+ default: jest.fn()
+}));
+
+const mockedFetch = jest.fn();
+
+// Configure which server records `database.servers.get('servers').query().fetch()` resolves to.
+const setServers = (serverIds: string[]) => {
+ mockedFetch.mockResolvedValue(serverIds.map(id => ({ id })));
+ jest.mocked(database.servers.get).mockReturnValue({
+ query: () => ({ fetch: mockedFetch })
+ } as any);
+};
+
+describe('migrateTokenKeysToServerScoped', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ UserPreferences.clearAll();
+ });
+
+ it('is a no-op when the migration flag is already set', async () => {
+ UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true);
+ setServers(['https://open.rocket.chat']);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(database.servers.get).not.toHaveBeenCalled();
+ });
+
+ it('migrates the legacy token to the server-scoped slot and drops the legacy slot', async () => {
+ const server = 'https://open.rocket.chat';
+ const userId = 'user1';
+ UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId);
+ UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'the-token');
+ setServers([server]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('the-token');
+ expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull();
+ expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true);
+ });
+
+ it('drops the legacy slot without migrating when the userId is shared by multiple servers', async () => {
+ const serverA = 'https://a.rocket.chat';
+ const serverB = 'https://b.rocket.chat';
+ const userId = 'shared';
+ UserPreferences.setString(`${TOKEN_KEY}-${serverA}`, userId);
+ UserPreferences.setString(`${TOKEN_KEY}-${serverB}`, userId);
+ UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'ambiguous-token');
+ setServers([serverA, serverB]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull();
+ expect(UserPreferences.getString(getUserTokenKey(serverA, userId))).toBeNull();
+ expect(UserPreferences.getString(getUserTokenKey(serverB, userId))).toBeNull();
+ expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true);
+ });
+
+ it('confines a shared userId to its own servers while the rest of the run migrates', async () => {
+ const serverA = 'https://a.rocket.chat';
+ const serverB = 'https://b.rocket.chat';
+ const serverC = 'https://c.rocket.chat';
+ const sharedUserId = 'shared';
+ const soleUserId = 'sole';
+ UserPreferences.setString(`${TOKEN_KEY}-${serverA}`, sharedUserId);
+ UserPreferences.setString(`${TOKEN_KEY}-${serverB}`, sharedUserId);
+ UserPreferences.setString(`${TOKEN_KEY}-${serverC}`, soleUserId);
+ UserPreferences.setString(`${TOKEN_KEY}-${sharedUserId}`, 'ambiguous-token');
+ UserPreferences.setString(`${TOKEN_KEY}-${soleUserId}`, 'sole-token');
+ setServers([serverA, serverB, serverC]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(getUserTokenKey(serverA, sharedUserId))).toBeNull();
+ expect(UserPreferences.getString(getUserTokenKey(serverB, sharedUserId))).toBeNull();
+ expect(UserPreferences.getString(getUserTokenKey(serverC, soleUserId))).toBe('sole-token');
+ expect(UserPreferences.getString(getServerUserIdKey(serverA))).toBe(sharedUserId);
+ expect(UserPreferences.getString(getServerUserIdKey(serverB))).toBe(sharedUserId);
+ });
+
+ it('does not overwrite an existing server-scoped token', async () => {
+ const server = 'https://open.rocket.chat';
+ const userId = 'user1';
+ UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId);
+ UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'legacy-token');
+ UserPreferences.setString(getUserTokenKey(server, userId), 'existing-token');
+ setServers([server]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('existing-token');
+ expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull();
+ expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true);
+ });
+
+ it('drops a legacy slot whose server has no record left in the database', async () => {
+ const orphanUserId = 'orphan';
+ UserPreferences.setString(`${TOKEN_KEY}-${orphanUserId}`, 'orphan-token');
+ setServers([]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(`${TOKEN_KEY}-${orphanUserId}`)).toBeNull();
+ expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true);
+ });
+
+ it('leaves server-scoped keys untouched while dropping orphaned legacy slots', async () => {
+ const server = 'https://open.rocket.chat';
+ const userId = 'user1';
+ UserPreferences.setString(getServerUserIdKey(server), userId);
+ UserPreferences.setString(getUserTokenKey(server, userId), 'scoped-token');
+ UserPreferences.setString(`${TOKEN_KEY}-orphan`, 'orphan-token');
+ setServers([server]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId);
+ expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('scoped-token');
+ expect(UserPreferences.getString(`${TOKEN_KEY}-orphan`)).toBeNull();
+ });
+
+ it('preserves keys for a schemeless subpath server', async () => {
+ // completeUrl leaves `example.com/rocketchat` as typed, so these keys carry no scheme.
+ const server = 'example.com/rocketchat';
+ const userId = 'user1';
+ UserPreferences.setString(getServerUserIdKey(server), userId);
+ UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'legacy-token');
+ setServers([server]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId);
+ expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('legacy-token');
+ expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull();
+ });
+
+ it('does not sweep a schemeless server whose row is gone', async () => {
+ const server = 'example.com/rocketchat';
+ const userId = 'user1';
+ UserPreferences.setString(getServerUserIdKey(server), userId);
+ UserPreferences.setString(getUserTokenKey(server, userId), 'scoped-token');
+ setServers([]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId);
+ expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('scoped-token');
+ });
+
+ it('skips servers that have no stored userId', async () => {
+ const server = 'https://open.rocket.chat';
+ setServers([server]);
+
+ await migrateTokenKeysToServerScoped();
+
+ expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true);
+ });
+
+ it('logs and swallows errors instead of throwing, leaving the flag unset', async () => {
+ const error = new Error('db exploded');
+ mockedFetch.mockRejectedValue(error);
+ jest.mocked(database.servers.get).mockReturnValue({
+ query: () => ({ fetch: mockedFetch })
+ } as any);
+
+ await expect(migrateTokenKeysToServerScoped()).resolves.toBeUndefined();
+
+ expect(log).toHaveBeenCalledWith(error);
+ expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBeNull();
+ });
+});
diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts
new file mode 100644
index 00000000000..f547d6838f6
--- /dev/null
+++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts
@@ -0,0 +1,71 @@
+import {
+ TOKEN_KEY,
+ TOKEN_KEY_SERVER_SCOPED_MIGRATED,
+ getLegacyUserTokenKey,
+ getServerUserIdKey,
+ getUserTokenKey
+} from '../constants/keys';
+import UserPreferences from './userPreferences';
+import database from '../database';
+import log from './helpers/log';
+
+// A bare alphanumeric suffix is a userId: server ids always carry a dot, a scheme or a path separator.
+const isLegacyUserTokenKey = (key: string): boolean => /^[A-Za-z0-9]+$/.test(key.replace(`${TOKEN_KEY}-`, ''));
+
+export const migrateTokenKeysToServerScoped = async (): Promise => {
+ try {
+ if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) {
+ return;
+ }
+ const serversDB = database.servers;
+ const servers = await serversDB.get('servers').query().fetch();
+
+ const serversByUserId = new Map();
+ for (let i = 0; i < servers.length; i += 1) {
+ const server = servers[i].id;
+ const userId = UserPreferences.getString(getServerUserIdKey(server));
+ if (!userId) {
+ continue;
+ }
+ const sharing = serversByUserId.get(userId);
+ if (sharing) {
+ sharing.push(server);
+ } else {
+ serversByUserId.set(userId, [server]);
+ }
+ }
+
+ serversByUserId.forEach((sharing, userId) => {
+ const legacyKey = getLegacyUserTokenKey(userId);
+ // A userId claimed by more than one server is ambiguous: drop the legacy slot instead of
+ // migrating it, so the session re-authenticates.
+ if (sharing.length > 1) {
+ UserPreferences.removeItem(legacyKey);
+ return;
+ }
+ const newKey = getUserTokenKey(sharing[0], userId);
+ const token = UserPreferences.getString(legacyKey);
+ if (token && !UserPreferences.getString(newKey)) {
+ UserPreferences.setString(newKey, token);
+ }
+ UserPreferences.removeItem(legacyKey);
+ });
+
+ // Legacy slots whose server has no row left in the database are unreachable above, and the
+ // migrated flag stops the native fallbacks from reading them. Drop them instead of stranding them.
+ const liveKeys = new Set();
+ serversByUserId.forEach((sharing, userId) => {
+ sharing.forEach(server => {
+ liveKeys.add(getServerUserIdKey(server));
+ liveKeys.add(getUserTokenKey(server, userId));
+ });
+ });
+ UserPreferences.getAllKeys()
+ .filter(key => key.startsWith(`${TOKEN_KEY}-`) && !liveKeys.has(key) && isLegacyUserTokenKey(key))
+ .forEach(key => UserPreferences.removeItem(key));
+
+ UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true);
+ } catch (e) {
+ log(e);
+ }
+};
diff --git a/app/lib/testUtils/sagaStore.ts b/app/lib/testUtils/sagaStore.ts
index 50b5bc11bf8..ddbf7f7b370 100644
--- a/app/lib/testUtils/sagaStore.ts
+++ b/app/lib/testUtils/sagaStore.ts
@@ -19,16 +19,19 @@ export function cancelSagaTasks(): void {
runningTasks.splice(0).forEach(task => task.cancel());
}
+export type PreloadedState = Parameters[1];
+
export interface RecordingStore {
store: Store;
dispatchedActions: AnyAction[];
}
-export function createRecordingStore(rootSaga: Saga): RecordingStore {
+export function createRecordingStore(rootSaga: Saga, preloadedState?: PreloadedState): RecordingStore {
const dispatchedActions: AnyAction[] = [];
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
reducers,
+ preloadedState,
applyMiddleware(
() => next => action => {
dispatchedActions.push(action);
diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts
index c7639a5349b..2fb3c5a8dd1 100644
--- a/app/sagas/__tests__/deepLinking.test.ts
+++ b/app/sagas/__tests__/deepLinking.test.ts
@@ -78,6 +78,10 @@ jest.mock('i18n-js', () => ({
default: { t: (k: string) => k }
}));
+jest.mock('../../lib/methods/helpers/info', () => ({
+ showConfirmationAlert: jest.fn(({ onPress }: { onPress: () => void }) => onPress())
+}));
+
// Mock helpers to avoid auxStore (getUidDirectMessage / getRoomTitle call reduxStore.getState())
jest.mock('../../lib/methods/helpers', () => ({
getUidDirectMessage: jest.fn(() => null),
@@ -92,10 +96,12 @@ import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLin
import { loginFailure, loginSuccess } from '../../actions/login';
import { selectServerFailure, selectServerSuccess } from '../../actions/server';
import { appStart } from '../../actions/app';
-import { APP, LOGOUT, SERVER } from '../../actions/actionsTypes';
+import { connectSuccess } from '../../actions/connect';
+import { APP, LOGIN, LOGOUT, SERVER } from '../../actions/actionsTypes';
import { RootEnum } from '../../definitions';
import deepLinkingRoot from '../deepLinking';
import UserPreferences from '../../lib/methods/userPreferences';
+import { showConfirmationAlert } from '../../lib/methods/helpers/info';
import { getServerById } from '../../lib/database/services/Server';
import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication';
import { canOpenRoom } from '../../lib/methods/canOpenRoom';
@@ -107,9 +113,13 @@ import sdk from '../../lib/services/sdk';
import database from '../../lib/database';
import EventEmitter from '../../lib/methods/helpers/events';
import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore';
-import type { RecordingStore } from '../../lib/testUtils/sagaStore';
+import type { PreloadedState, RecordingStore } from '../../lib/testUtils/sagaStore';
+
+const setupStore = (preloadedState?: PreloadedState): RecordingStore => createRecordingStore(deepLinkingRoot, preloadedState);
-const setupStore = (): RecordingStore => createRecordingStore(deepLinkingRoot);
+/** Messages pushed through showToast, which emits on the Toast LISTENER channel. */
+const toastedMessages = (emitSpy: jest.SpyInstance): string[] =>
+ emitSpy.mock.calls.map(([, payload]: any[]) => payload?.message).filter(Boolean);
afterEach(cancelSagaTasks);
@@ -200,6 +210,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
await flushSagaMicrotasks();
+ // Saga is now waiting for METEOR.SUCCESS — loginRequest is gated on the socket.
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
// Saga is now waiting for LOGIN.SUCCESS
expect(jest.mocked(goRoom)).not.toHaveBeenCalled();
@@ -214,6 +228,148 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
});
+ // Ordering race: socket connects before SERVER.SELECT_SUCCESS; the guard must
+ // skip the already-fired METEOR.SUCCESS take instead of hanging.
+ it('completes the chain when METEOR.SUCCESS fires before SERVER.SELECT_SUCCESS', async () => {
+ const { store } = setupStore();
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ // Socket connects first — before SERVER.SELECT_SUCCESS is dispatched.
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
+ store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
+ await flushSagaMicrotasks();
+
+ store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
+ await flushSagaMicrotasks();
+
+ store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
+ await flushSagaMicrotasks();
+
+ expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
+ });
+
+ // loginRequest must not fire until the socket is connected (locks the gate).
+ it('does not dispatch loginRequest until METEOR.SUCCESS', async () => {
+ const { store, dispatchedActions } = setupStore();
+ const loginRequested = () => dispatchedActions.some(a => a.type === LOGIN.REQUEST);
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
+ await flushSagaMicrotasks();
+
+ // Server selected but socket not connected yet → still parked at the gate.
+ expect(loginRequested()).toBe(false);
+
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
+ // Socket connected → gate released, loginRequest dispatched.
+ expect(loginRequested()).toBe(true);
+ });
+
+ it('does not touch the deep link server when the login confirmation is declined', async () => {
+ jest.mocked(showConfirmationAlert).mockClear();
+ jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.());
+ const emitSpy = jest.spyOn(EventEmitter, 'emit');
+
+ const { store, dispatchedActions } = setupStore();
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ // Prompt shown, and declining leaves the deep link's server entirely untouched: no
+ // connection attempt, no server added, no navigation away from where the user was.
+ expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1);
+ expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything());
+ expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled();
+ expect(dispatchedActions.some(a => a.type === LOGIN.REQUEST)).toBe(false);
+ expect(dispatchedActions.some(a => a.type === SERVER.INIT_ADD)).toBe(false);
+ expect(dispatchedActions.some(a => a.type === APP.START)).toBe(false);
+ // Cold start: normal init takes over instead of the deep link's server, and there is no
+ // mounted Toast to show a message on.
+ expect(dispatchedActions.some(a => a.type === APP.INIT)).toBe(true);
+ expect(toastedMessages(emitSpy)).not.toContain('Deep_link_login_declined');
+ emitSpy.mockRestore();
+ });
+
+ it('leaves a running app where it was when the login confirmation is declined', async () => {
+ jest.mocked(showConfirmationAlert).mockClear();
+ jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.());
+ const emitSpy = jest.spyOn(EventEmitter, 'emit');
+
+ const { store, dispatchedActions } = setupStore({ app: { root: RootEnum.ROOT_INSIDE } } as PreloadedState);
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1);
+ expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything());
+ expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled();
+ expect(dispatchedActions.some(a => a.type === LOGIN.REQUEST)).toBe(false);
+ expect(dispatchedActions.some(a => a.type === SERVER.INIT_ADD)).toBe(false);
+ expect(dispatchedActions.some(a => a.type === APP.START)).toBe(false);
+ expect(dispatchedActions.some(a => a.type === APP.INIT)).toBe(false);
+ expect(toastedMessages(emitSpy)).toContain('Deep_link_login_declined');
+ emitSpy.mockRestore();
+ });
+
+ // Under RUNNING_E2E_TESTS the prompt is auto-confirmed so most flows don't have to dismiss a
+ // native Alert — except when the deep link carries `forceLoginPrompt=true`, which opts a
+ // dedicated e2e flow back into the real prompt (see the deeplink.yaml Maestro test).
+ describe('RUNNING_E2E_TESTS auto-confirm gate', () => {
+ const original = process.env.RUNNING_E2E_TESTS;
+ beforeEach(() => {
+ process.env.RUNNING_E2E_TESTS = 'true';
+ jest.mocked(showConfirmationAlert).mockClear();
+ });
+ afterEach(() => {
+ process.env.RUNNING_E2E_TESTS = original;
+ });
+
+ it('auto-confirms without showing the prompt when no forceLoginPrompt marker is present', async () => {
+ const { store, dispatchedActions } = setupStore();
+ const loginRequested = () => dispatchedActions.some(a => a.type === LOGIN.REQUEST);
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+ store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
+ await flushSagaMicrotasks();
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
+ // No prompt shown, yet login still proceeds — pre-fix silent behavior preserved.
+ expect(jest.mocked(showConfirmationAlert)).not.toHaveBeenCalled();
+ expect(loginRequested()).toBe(true);
+ });
+
+ it('shows the real prompt when the deep link carries forceLoginPrompt=true', async () => {
+ const { store } = setupStore();
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken({ forceLoginPrompt: 'true' })));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1);
+ });
+ });
+
/**
* Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS.
* Flush microtasks. Assert goRoom NOT yet called.
@@ -231,6 +387,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
await flushSagaMicrotasks();
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
await flushSagaMicrotasks();
@@ -262,6 +421,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
await flushSagaMicrotasks();
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
// Dispatch LOGIN.SUCCESS AND APP.START(ROOT_INSIDE) synchronously before any flush.
// The reducer processes both dispatches before the saga's select runs,
// so the select sees ROOT_INSIDE and skips the take.
@@ -290,6 +452,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
await flushSagaMicrotasks();
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
await flushSagaMicrotasks();
@@ -324,6 +489,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
await flushSagaMicrotasks();
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
await flushSagaMicrotasks();
diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts
index 80ce61ab800..670c8ad4262 100644
--- a/app/sagas/__tests__/init.test.ts
+++ b/app/sagas/__tests__/init.test.ts
@@ -10,6 +10,10 @@ jest.mock('../../lib/database/services/Server', () => ({
getAllServers: jest.fn()
}));
+jest.mock('../../lib/methods/migrateTokenKeysToServerScoped', () => ({
+ migrateTokenKeysToServerScoped: jest.fn(() => Promise.resolve())
+}));
+
jest.mock('../../lib/methods/helpers/localAuthentication', () => ({
localAuthenticate: jest.fn()
}));
diff --git a/app/sagas/__tests__/login.switchCancel.test.ts b/app/sagas/__tests__/login.switchCancel.test.ts
index 85c94e4301f..10bb40e17f2 100644
--- a/app/sagas/__tests__/login.switchCancel.test.ts
+++ b/app/sagas/__tests__/login.switchCancel.test.ts
@@ -99,7 +99,7 @@ import loginRoot from '../login';
import { loginSuccess } from '../../actions/login';
import { selectServerRequest, selectServerSuccess } from '../../actions/server';
import UserPreferences from '../../lib/methods/userPreferences';
-import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys';
+import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../../lib/constants/keys';
import { getPermissions } from '../../lib/methods/getPermissions';
import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore';
import type { RecordingStore } from '../../lib/testUtils/sagaStore';
@@ -115,7 +115,7 @@ const USER_B = { id: 'user-b', token: 'token-b', username: 'userb', name: 'User
describe('login saga — a workspace switch cancels the login bootstrap', () => {
beforeEach(() => {
UserPreferences.removeItem(`${TOKEN_KEY}-${SERVER_A}`);
- UserPreferences.removeItem(`${TOKEN_KEY}-${USER_B.id}`);
+ UserPreferences.removeItem(getUserTokenKey(SERVER_A, USER_B.id));
UserPreferences.removeItem(CURRENT_SERVER);
jest.clearAllMocks();
});
@@ -144,7 +144,7 @@ describe('login saga — a workspace switch cancels the login bootstrap', () =>
await flushSagaMicrotasks();
expect(UserPreferences.getString(`${TOKEN_KEY}-${SERVER_A}`)).toBeNull();
- expect(UserPreferences.getString(`${TOKEN_KEY}-${USER_B.id}`)).toBeNull();
+ expect(UserPreferences.getString(getUserTokenKey(SERVER_A, USER_B.id))).toBeNull();
expect(UserPreferences.getString(CURRENT_SERVER)).toBeNull();
});
@@ -158,7 +158,7 @@ describe('login saga — a workspace switch cancels the login bootstrap', () =>
await flushSagaMicrotasks();
expect(UserPreferences.getString(`${TOKEN_KEY}-${SERVER_A}`)).toBe(USER_B.id);
- expect(UserPreferences.getString(`${TOKEN_KEY}-${USER_B.id}`)).toBe(USER_B.token);
+ expect(UserPreferences.getString(getUserTokenKey(SERVER_A, USER_B.id))).toBe(USER_B.token);
expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_A);
});
});
diff --git a/app/sagas/__tests__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts
index 7302338e5a0..0aa412be27b 100644
--- a/app/sagas/__tests__/selectServer.test.ts
+++ b/app/sagas/__tests__/selectServer.test.ts
@@ -70,7 +70,7 @@ import { RootEnum } from '../../definitions';
import { SERVER } from '../../actions/actionsTypes';
import UserPreferences from '../../lib/methods/userPreferences';
import { BASIC_AUTH_KEY, setBasicAuth } from '../../lib/methods/helpers/fetch';
-import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys';
+import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../../lib/constants/keys';
import { getLoggedUserById } from '../../lib/database/services/LoggedUser';
import { getServerInfo } from '../../lib/methods/getServerInfo';
import { connect } from '../../lib/services/connect';
@@ -83,7 +83,12 @@ const SERVER_URL = 'https://new.rocket.chat';
const USER_ID = 'user-new';
const TOKEN = 'token-new';
-const keysToClear = [`${TOKEN_KEY}-${SERVER_URL}`, `${TOKEN_KEY}-${USER_ID}`, `${BASIC_AUTH_KEY}-${SERVER_URL}`, CURRENT_SERVER];
+const keysToClear = [
+ `${TOKEN_KEY}-${SERVER_URL}`,
+ getUserTokenKey(SERVER_URL, USER_ID),
+ `${BASIC_AUTH_KEY}-${SERVER_URL}`,
+ CURRENT_SERVER
+];
const setupStore = (): RecordingStore => createRecordingStore(selectServerRoot);
@@ -110,9 +115,9 @@ describe('selectServer saga — resolving the target workspace user', () => {
expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_URL);
});
- it('falls back to the token stored under the userId key when there is no record', async () => {
+ it('falls back to the token stored under the server-scoped key when there is no record', async () => {
UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID);
- UserPreferences.setString(`${TOKEN_KEY}-${USER_ID}`, TOKEN);
+ UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN);
jest.mocked(getLoggedUserById).mockResolvedValue(null as any);
const { store } = setupStore();
diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js
index 636dc297ab2..adb50455d91 100644
--- a/app/sagas/deepLinking.js
+++ b/app/sagas/deepLinking.js
@@ -10,7 +10,7 @@ import { inviteLinksRequest, inviteLinksSetToken } from '../actions/inviteLinks'
import { loginRequest } from '../actions/login';
import { selectServerRequest, serverInitAdd } from '../actions/server';
import { RootEnum } from '../definitions';
-import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys';
+import { CURRENT_SERVER, getServerUserIdKey } from '../lib/constants/keys';
import database from '../lib/database';
import { getServerById } from '../lib/database/services/Server';
import { canOpenRoom } from '../lib/methods/canOpenRoom';
@@ -21,6 +21,7 @@ import { goRoom, navigateToRoom } from '../lib/methods/helpers/goRoom';
import { getIsMasterDetail } from '../lib/hooks/useMasterDetail';
import { localAuthenticate } from '../lib/methods/helpers/localAuthentication';
import log from '../lib/methods/helpers/log';
+import { showConfirmationAlert } from '../lib/methods/helpers/info';
import { showToast } from '../lib/methods/helpers/showToast';
import UserPreferences from '../lib/methods/userPreferences';
import { videoConfJoin } from '../lib/methods/videoConf';
@@ -37,6 +38,21 @@ const roomTypes = {
channels: 'l'
};
+const confirmDeepLinkLogin = (host, params = {}) =>
+ new Promise(resolve => {
+ if (process.env.RUNNING_E2E_TESTS === 'true' && params.forceLoginPrompt !== 'true') {
+ resolve(true);
+ return;
+ }
+ showConfirmationAlert({
+ title: I18n.t('Deep_link_login_title'),
+ message: I18n.t('Deep_link_login_description', { server: host }),
+ confirmationText: I18n.t('Login'),
+ onPress: () => resolve(true),
+ onCancel: () => resolve(false)
+ });
+ });
+
const handleInviteLink = function* handleInviteLink({ params, requireLogin = false }) {
if (params.path && params.path.startsWith('invite/')) {
const token = params.path.replace('invite/', '');
@@ -128,6 +144,29 @@ const fallbackNavigation = function* fallbackNavigation() {
yield put(appInit());
};
+const declineDeepLinkLogin = function* declineDeepLinkLogin() {
+ // Only worth a toast while the app is up; on cold start there is no Toast mounted to show it.
+ const currentRoot = yield select(state => state.app.root);
+ if (currentRoot) {
+ showToast(I18n.t('Deep_link_login_declined'));
+ }
+ yield fallbackNavigation();
+};
+
+// Consent before touching anything on the deep link's server: a resume token means this link can
+// sign the user in, so every entry point asks through here while declining is still a no-op.
+const ensureDeepLinkLoginConsent = function* ensureDeepLinkLoginConsent(host, params) {
+ if (!params.token) {
+ return true;
+ }
+ const confirmed = yield call(confirmDeepLinkLogin, host, params);
+ if (!confirmed) {
+ yield declineDeepLinkLogin();
+ return false;
+ }
+ return true;
+};
+
let consumedOAuthToken;
const handleOAuth = function* handleOAuth({ params }) {
@@ -160,7 +199,7 @@ const handleSaml = function* handleSaml({ params }) {
const handleShareExtension = function* handleOpen({ params }) {
const server = UserPreferences.getString(CURRENT_SERVER);
- const user = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
+ const user = UserPreferences.getString(getServerUserIdKey(server));
if (!user) {
yield put(appInit());
@@ -227,7 +266,7 @@ const handleOpen = function* handleOpen({ params }) {
const [server, user] = yield all([
UserPreferences.getString(CURRENT_SERVER),
- UserPreferences.getString(`${TOKEN_KEY}-${host}`)
+ UserPreferences.getString(getServerUserIdKey(host))
]);
const serverRecord = yield getServerById(host);
@@ -255,6 +294,9 @@ const handleOpen = function* handleOpen({ params }) {
} catch (e) {
// do nothing?
}
+ if (!(yield ensureDeepLinkLoginConsent(host, params))) {
+ return;
+ }
// if deep link is from a different server
const result = yield getServerInfo(host);
if (!result.success) {
@@ -278,6 +320,11 @@ const handleOpen = function* handleOpen({ params }) {
if (params.token) {
if (!hostAlreadyConnected) {
yield take(types.SERVER.SELECT_SUCCESS);
+ // SERVER.SELECT_SUCCESS can land after the socket is already connected.
+ const connected = yield select(state => state.meteor.connected);
+ if (!connected) {
+ yield take(types.METEOR.SUCCESS);
+ }
}
yield put(loginRequest({ resume: params.token }, true));
yield take(types.LOGIN.SUCCESS);
@@ -339,7 +386,7 @@ const handleClickCallPush = function* handleClickCallPush({ params }) {
const [server, user] = yield all([
UserPreferences.getString(CURRENT_SERVER),
- UserPreferences.getString(`${TOKEN_KEY}-${host}`)
+ UserPreferences.getString(getServerUserIdKey(host))
]);
const serverRecord = yield getServerById(host);
@@ -360,6 +407,9 @@ const handleClickCallPush = function* handleClickCallPush({ params }) {
yield handleNavigateCallRoom({ params });
return;
}
+ if (!(yield ensureDeepLinkLoginConsent(host, params))) {
+ return;
+ }
// if deep link is from a different server
const result = yield getServerInfo(host);
if (!result.success) {
diff --git a/app/sagas/init.js b/app/sagas/init.js
index 5c7b0487c1b..323dd8ff781 100644
--- a/app/sagas/init.js
+++ b/app/sagas/init.js
@@ -4,6 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { CURRENT_SERVER } from '../lib/constants/keys';
import UserPreferences from '../lib/methods/userPreferences';
+import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped';
import { findLoggedInServer, isLoggedInServer } from '../lib/methods/loggedInServer';
import { selectServerRequest } from '../actions/server';
import { setAllPreferences } from '../actions/sortPreferences';
@@ -36,6 +37,7 @@ const restoreServer = async () => {
const getServerToRestore = function* getServerToRestore() {
try {
+ yield call(migrateTokenKeysToServerScoped);
return (yield call(restoreServer)) || null;
} catch (e) {
log(e);
diff --git a/app/sagas/login.js b/app/sagas/login.js
index 3a24534fb89..24dcd69c562 100644
--- a/app/sagas/login.js
+++ b/app/sagas/login.js
@@ -23,7 +23,7 @@ import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry'
import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib';
import { RootEnum } from '../definitions';
import sdk from '../lib/services/sdk';
-import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys';
+import { CURRENT_SERVER, getServerUserIdKey, getUserTokenKey } from '../lib/constants/keys';
import { getCustomEmojis } from '../lib/methods/getCustomEmojis';
import { getIsMasterDetail } from '../lib/hooks/useMasterDetail';
import { getEnterpriseModules, isOmnichannelModuleAvailable, isVoipModuleAvailable } from '../lib/methods/enterpriseModules';
@@ -355,8 +355,8 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
}
});
- UserPreferences.setString(`${TOKEN_KEY}-${server}`, user.id);
- UserPreferences.setString(`${TOKEN_KEY}-${user.id}`, user.token);
+ UserPreferences.setString(getServerUserIdKey(server), user.id);
+ UserPreferences.setString(getUserTokenKey(server, user.id), user.token);
UserPreferences.setString(CURRENT_SERVER, server);
EventEmitter.emit('connected');
const currentRoot = yield select(state => state.app.root);
diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts
index 24a8498f366..dfed8d105e8 100644
--- a/app/sagas/selectServer.ts
+++ b/app/sagas/selectServer.ts
@@ -29,7 +29,8 @@ import UserPreferences from '../lib/methods/userPreferences';
import { encryptionStop } from '../actions/encryption';
import { inquiryReset } from '../ee/omnichannel/actions/inquiry';
import { type IServerInfo, RootEnum, type TServerModel } from '../definitions';
-import { CERTIFICATE_KEY, CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys';
+import { CERTIFICATE_KEY, CURRENT_SERVER, getServerUserIdKey, getUserTokenKey } from '../lib/constants/keys';
+import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped';
import { checkSupportedVersions } from '../lib/methods/checkSupportedVersions';
import { getLoginSettings, setSettings } from '../lib/methods/getSettings';
import { getServerInfo } from '../lib/methods/getServerInfo';
@@ -150,7 +151,8 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
yield put(inquiryReset());
yield put(encryptionStop());
yield put(clearActiveUsers());
- const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
+ yield* call(migrateTokenKeysToServerScoped);
+ const userId = UserPreferences.getString(getServerUserIdKey(server));
let user = null;
if (userId) {
// search credentials on database
@@ -171,7 +173,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
requirePasswordChange: userRecord.requirePasswordChange
};
} else {
- const token = UserPreferences.getString(`${TOKEN_KEY}-${userId}`);
+ const token = UserPreferences.getString(getUserTokenKey(server, userId));
if (token) {
user = { token };
}
diff --git a/app/views/RoomsListView/components/ServersList.tsx b/app/views/RoomsListView/components/ServersList.tsx
index 29616a6ad8c..57845f72262 100644
--- a/app/views/RoomsListView/components/ServersList.tsx
+++ b/app/views/RoomsListView/components/ServersList.tsx
@@ -11,7 +11,7 @@ import * as List from '../../../containers/List';
import ServerItem from '../../../containers/ServerItem';
import { RootEnum, type TServerModel } from '../../../definitions';
import I18n from '../../../i18n';
-import { TOKEN_KEY } from '../../../lib/constants/keys';
+import { getServerUserIdKey } from '../../../lib/constants/keys';
import database from '../../../lib/database';
import { useAppSelector } from '../../../lib/hooks/useAppSelector';
import { useMasterDetail } from '../../../lib/hooks/useMasterDetail';
@@ -77,7 +77,7 @@ const ServersList = () => {
close();
if (server !== serverParam) {
logEvent(events.RL_CHANGE_SERVER);
- const userId = UserPreferences.getString(`${TOKEN_KEY}-${serverParam}`);
+ const userId = UserPreferences.getString(getServerUserIdKey(serverParam));
if (isMasterDetail) {
goRoom({ item: {}, isMasterDetail });
}
diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift
index f825b4d2cfc..8c0156fa406 100644
--- a/ios/Shared/RocketChat/MMKV.swift
+++ b/ios/Shared/RocketChat/MMKV.swift
@@ -18,11 +18,18 @@ extension MMKVBridge {
return MMKVBridge(id: "default", cryptKey: cryptKey, rootPath: mmkvPath)
}
- func userToken(for userId: String) -> String? {
- guard let userToken = string(forKey: "reactnativemeteor_usertoken-\(userId)") else {
+ // Keep in sync with getUserTokenKey() (JS) and Ejson.token() (Android); falls back to the
+ // legacy userId-only slot until the JS migration runs.
+ func userToken(for userId: String, server: String) -> String? {
+ if let userToken = string(forKey: "reactnativemeteor_usertoken-\(server)-\(userId)") {
+ return userToken
+ }
+ // The legacy slot is ambiguous across servers sharing a userId, so it is only readable
+ // before migrateTokenKeysToServerScoped (JS) runs.
+ if bool(forKey: "RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED") {
return nil
}
- return userToken
+ return string(forKey: "reactnativemeteor_usertoken-\(userId)")
}
func userId(for server: String) -> String? {
diff --git a/ios/Shared/RocketChat/MMKVBridge.h b/ios/Shared/RocketChat/MMKVBridge.h
index 1c4cf510cd4..a4426c77462 100644
--- a/ios/Shared/RocketChat/MMKVBridge.h
+++ b/ios/Shared/RocketChat/MMKVBridge.h
@@ -17,6 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
- (nullable NSString *)stringForKey:(NSString *)key;
- (BOOL)setString:(NSString *)value forKey:(NSString *)key;
+- (BOOL)boolForKey:(NSString *)key;
- (nullable NSData *)dataForKey:(NSString *)key;
- (BOOL)setData:(NSData *)value forKey:(NSString *)key;
- (void)removeValueForKey:(NSString *)key;
diff --git a/ios/Shared/RocketChat/MMKVBridge.mm b/ios/Shared/RocketChat/MMKVBridge.mm
index db48f48476f..59e28334c5f 100644
--- a/ios/Shared/RocketChat/MMKVBridge.mm
+++ b/ios/Shared/RocketChat/MMKVBridge.mm
@@ -48,6 +48,11 @@ - (BOOL)setString:(NSString *)value forKey:(NSString *)key {
return [_mmkvInstance setString:value forKey:key];
}
+- (BOOL)boolForKey:(NSString *)key {
+ if (!_mmkvInstance) return NO;
+ return [_mmkvInstance getBoolForKey:key defaultValue:NO];
+}
+
- (nullable NSData *)dataForKey:(NSString *)key {
if (!_mmkvInstance) return nil;
return [_mmkvInstance getDataForKey:key];
diff --git a/ios/Shared/RocketChat/Storage.swift b/ios/Shared/RocketChat/Storage.swift
index 63ce28eeef3..db7680169db 100644
--- a/ios/Shared/RocketChat/Storage.swift
+++ b/ios/Shared/RocketChat/Storage.swift
@@ -12,7 +12,7 @@ final class Storage {
// Read credentials from MMKV (shared via app group)
// Credentials are stored during login in React Native
guard let userId = mmkv.userId(for: server),
- let userToken = mmkv.userToken(for: userId) else {
+ let userToken = mmkv.userToken(for: userId, server: server) else {
return nil
}
return Credentials(userId: userId, userToken: userToken)
diff --git a/ios/Watch/WatchConnection.swift b/ios/Watch/WatchConnection.swift
index 4e7069fff6e..a2fa0f7863c 100644
--- a/ios/Watch/WatchConnection.swift
+++ b/ios/Watch/WatchConnection.swift
@@ -41,7 +41,7 @@ final class WatchConnection: NSObject {
}
let servers = serversQuery.compactMap { item -> WatchMessage.Server? in
- guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId) else {
+ guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId, server: item.identifier) else {
return nil
}