diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml
index 4c5780f8b29..d8c805b730c 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, '&e2eConfirmPrompt=true')}
+- extendedWaitUntil:
+ visible:
+ text: '.*Sign in to this server.*'
+ 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, '&e2eConfirmPrompt=true')}
+- extendedWaitUntil:
+ visible:
+ text: '.*Sign in to this server.*'
+ 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" >
- a year ago
+ 2 years ago
diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json
index 53e24e6431b..12c58ecf7fa 100644
--- a/app/i18n/locales/ar.json
+++ b/app/i18n/locales/ar.json
@@ -148,6 +148,8 @@
"Custom": "مخصص",
"Dark": "داكن",
"Dark_level": "مستوى السمة الداكنة",
+ "Deep_link_login_description": "يوجد رابط يطلب تسجيل دخولك إلى {{server}}. تابع فقط إذا كنت قد فتحت هذا الرابط بنفسك وتثق به.",
+ "Deep_link_login_title": "تسجيل الدخول إلى هذا الخادم؟",
"Default": "افتراضي",
"Default_browser": "المتصفح الأساسي",
"DELETE": "حذف",
diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json
index 9daf3675e2f..0af42395d7b 100644
--- a/app/i18n/locales/bn-IN.json
+++ b/app/i18n/locales/bn-IN.json
@@ -213,6 +213,8 @@
"Dark": "অন্ধকার",
"Dark_level": "অন্ধকার স্তর",
"decline": "অস্বীকার করুন",
+ "Deep_link_login_description": "একটি লিঙ্ক আপনাকে {{server}}-এ সাইন ইন করাতে চাইছে। আপনি নিজে এই লিঙ্কটি খুলে থাকেন এবং এটিকে বিশ্বাস করেন তবেই এগিয়ে যান।",
+ "Deep_link_login_title": "এই সার্ভারে সাইন ইন করবেন?",
"Default": "ডিফল্ট",
"Default_browser": "ডিফল্ট ব্রাউজার",
"Defined_user_as_role": "{{user}} একটি {{role}} হিসেবে নির্ধারণ করেছে",
diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json
index 1d18ac3efbb..aba97a51fd9 100644
--- a/app/i18n/locales/cs.json
+++ b/app/i18n/locales/cs.json
@@ -225,6 +225,8 @@
"Dark": "Temný",
"Dark_level": "Temná úroveň",
"decline": "Pokles",
+ "Deep_link_login_description": "Odkaz se vás pokouší přihlásit do {{server}}. Pokračujte pouze, pokud jste tento odkaz otevřeli sami a důvěřujete mu.",
+ "Deep_link_login_title": "Přihlásit se k tomuto serveru?",
"Default": "Výchozí",
"Default_browser": "Výchozí prohlížeč",
"Defined_user_as_role": "definováno {{user}} jako {{role}}",
diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json
index c1925e399ea..f874c61860d 100644
--- a/app/i18n/locales/de.json
+++ b/app/i18n/locales/de.json
@@ -207,6 +207,8 @@
"Dark": "Dunkel",
"Dark_level": "Dunkelstufe",
"decline": "Ablehnen",
+ "Deep_link_login_description": "Ein Link versucht, Sie bei {{server}} anzumelden. Fahren Sie nur fort, wenn Sie diesen Link selbst geöffnet haben und ihm vertrauen.",
+ "Deep_link_login_title": "Bei diesem Server anmelden?",
"Default": "Standard",
"Default_browser": "Standard-Browser",
"Defined_user_as_role": "hat {{user}} als {{role}} gesetzt",
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index 8f9606fe55d..5692ba8792b 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -230,6 +230,8 @@
"Dark": "Dark",
"Dark_level": "Dark level",
"decline": "Decline",
+ "Deep_link_login_description": "A link is asking to sign you in to {{server}}. Only continue if you opened this link yourself and trust it.",
+ "Deep_link_login_title": "Sign in to this server?",
"Default": "Default",
"Default_browser": "Default browser",
"Defined_user_as_role": "defined {{user}} as {{role}}",
diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json
index d15ce6d6cb1..4afb4e86850 100644
--- a/app/i18n/locales/es.json
+++ b/app/i18n/locales/es.json
@@ -132,6 +132,8 @@
"Custom": "Personalizado",
"Dark": "Oscuro",
"Dark_level": "Nivel de oscuridad",
+ "Deep_link_login_description": "Un enlace está intentando iniciar sesión en {{server}}. Continúa solo si abriste este enlace tú mismo y confías en él.",
+ "Deep_link_login_title": "¿Iniciar sesión en este servidor?",
"Default": "Por defecto",
"DELETE": "ELIMINAR",
"Delete": "Eliminar",
diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json
index 45e7e8c9264..53867e698be 100644
--- a/app/i18n/locales/fi.json
+++ b/app/i18n/locales/fi.json
@@ -196,6 +196,8 @@
"Custom": "Mukautettu",
"Dark": "Tumma",
"Dark_level": "Tumman taso",
+ "Deep_link_login_description": "Linkki yrittää kirjata sinut palvelimeen {{server}}. Jatka vain, jos avasit tämän linkin itse ja luotat siihen.",
+ "Deep_link_login_title": "Kirjaudutaanko tälle palvelimelle?",
"Default": "Oletus",
"Default_browser": "Oletusselain",
"Defined_user_as_role": "määritti käyttäjän {{user}} rooliin {{role}}",
diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json
index fa613486ddc..f488d0a3967 100644
--- a/app/i18n/locales/fr.json
+++ b/app/i18n/locales/fr.json
@@ -173,6 +173,8 @@
"Custom": "Personnalisé",
"Dark": "Sombre",
"Dark_level": "Niveau d'obscurité",
+ "Deep_link_login_description": "Un lien tente de vous connecter à {{server}}. Continuez uniquement si vous avez ouvert ce lien vous-même et que vous lui faites confiance.",
+ "Deep_link_login_title": "Se connecter à ce serveur ?",
"Default": "Défaut",
"Default_browser": "Navigateur par défaut",
"DELETE": "SUPPRIMER",
diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json
index 97f3343177e..62502372b56 100644
--- a/app/i18n/locales/hi-IN.json
+++ b/app/i18n/locales/hi-IN.json
@@ -213,6 +213,8 @@
"Dark": "डार्क",
"Dark_level": "डार्क स्तर",
"decline": "तिरस्कार",
+ "Deep_link_login_description": "एक लिंक आपको {{server}} में साइन इन कराना चाहता है। केवल तभी आगे बढ़ें जब आपने यह लिंक स्वयं खोला हो और इस पर भरोसा करते हों।",
+ "Deep_link_login_title": "इस सर्वर में साइन इन करें?",
"Default": "डिफ़ॉल्ट",
"Default_browser": "डिफ़ॉल्ट ब्राउज़र",
"Defined_user_as_role": "{{user}} को {{role}} के रूप में परिभाषित किया गया है",
diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json
index 89f68d4a3b2..02e5a3812ac 100644
--- a/app/i18n/locales/hu.json
+++ b/app/i18n/locales/hu.json
@@ -213,6 +213,8 @@
"Dark": "Sötét",
"Dark_level": "Sötét szint",
"decline": "Elutasítom",
+ "Deep_link_login_description": "Egy hivatkozás be szeretné jelentkeztetni a(z) {{server}} kiszolgálóra. Csak akkor folytassa, ha ezt a hivatkozást Ön nyitotta meg, és megbízik benne.",
+ "Deep_link_login_title": "Bejelentkezik erre a kiszolgálóra?",
"Default": "Alapértelmezett",
"Default_browser": "Alapértelmezett böngésző",
"Defined_user_as_role": "a(z) {{user}} felhasználót mint {{role}} definiálták",
diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json
index 5bd67b24060..21e6661be1c 100644
--- a/app/i18n/locales/it.json
+++ b/app/i18n/locales/it.json
@@ -155,6 +155,8 @@
"Custom": "Personalizzato",
"Dark": "Scuro",
"Dark_level": "Contrasto",
+ "Deep_link_login_description": "Un link sta tentando di farti accedere a {{server}}. Continua solo se hai aperto tu questo link e ti fidi.",
+ "Deep_link_login_title": "Accedere a questo server?",
"Default": "Predefinito",
"Default_browser": "Browser predefinito",
"DELETE": "ELIMINA",
diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json
index 86da11d01d9..e39519f170f 100644
--- a/app/i18n/locales/ja.json
+++ b/app/i18n/locales/ja.json
@@ -147,6 +147,8 @@
"Custom": "カスタム",
"Dark": "ダーク",
"Dark_level": "ダークレベル",
+ "Deep_link_login_description": "リンクが {{server}} へのサインインを求めています。このリンクを自分で開き、信頼できる場合にのみ続行してください。",
+ "Deep_link_login_title": "このサーバーにサインインしますか?",
"Default": "デフォルト",
"Default_browser": "デフォルトのブラウザ",
"DELETE": "削除",
diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json
index 0d86ce4995c..a2ee78bf978 100644
--- a/app/i18n/locales/nl.json
+++ b/app/i18n/locales/nl.json
@@ -173,6 +173,8 @@
"Custom": "Aangepast",
"Dark": "Donker",
"Dark_level": "Donker niveau",
+ "Deep_link_login_description": "Een link probeert je aan te melden bij {{server}}. Ga alleen verder als je deze link zelf hebt geopend en je deze vertrouwt.",
+ "Deep_link_login_title": "Aanmelden bij deze server?",
"Default": "Standaard",
"Default_browser": "Standaard browser",
"DELETE": "VERWIJDEREN",
diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json
index 84f8b93d623..a8c718c2419 100644
--- a/app/i18n/locales/nn.json
+++ b/app/i18n/locales/nn.json
@@ -117,6 +117,8 @@
"Current_Status": "Nåværende status",
"Custom": "Tilpasset",
"decline": "Avslå",
+ "Deep_link_login_description": "Ei lenkje prøver å logge deg inn på {{server}}. Hald berre fram dersom du opna denne lenkja sjølv og stolar på henne.",
+ "Deep_link_login_title": "Logge inn på denne serveren?",
"Default": "Misligholde",
"Delete": "Slett",
"Delete_Account": "Slett konto",
diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json
index 097da054fa0..84994a28492 100644
--- a/app/i18n/locales/no.json
+++ b/app/i18n/locales/no.json
@@ -217,6 +217,8 @@
"Dark": "Mørk",
"Dark_level": "Mørkt nivå",
"decline": "Avslå",
+ "Deep_link_login_description": "En lenke prøver å logge deg inn på {{server}}. Fortsett bare hvis du åpnet denne lenken selv og stoler på den.",
+ "Deep_link_login_title": "Logge inn på denne serveren?",
"Default": "Standard",
"Default_browser": "Standard nettleser",
"Defined_user_as_role": "definert {{user}} som {{role}}",
diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json
index 9b2e3db9f8f..77c8517b57f 100644
--- a/app/i18n/locales/pt-BR.json
+++ b/app/i18n/locales/pt-BR.json
@@ -226,6 +226,8 @@
"Dark": "Escuro",
"Dark_level": "Nível escuro",
"decline": "Recusar",
+ "Deep_link_login_description": "Um link está tentando fazer seu login em {{server}}. Continue apenas se você abriu esse link por conta própria e confia nele.",
+ "Deep_link_login_title": "Fazer login neste servidor?",
"Default": "Padrão",
"Default_browser": "Navegador padrão",
"Defined_user_as_role": "definiu {{user}} como {{role}}",
diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json
index 22b18117bb4..0e3ae5756e9 100644
--- a/app/i18n/locales/pt-PT.json
+++ b/app/i18n/locales/pt-PT.json
@@ -143,6 +143,8 @@
"Custom": "Personalizado",
"Dark": "Escuro",
"Dark_level": "Nível Escuro",
+ "Deep_link_login_description": "Um link está a tentar iniciar a sua sessão em {{server}}. Continue apenas se abriu este link por si próprio e confia nele.",
+ "Deep_link_login_title": "Iniciar sessão neste servidor?",
"Default": "Predefinição",
"Default_browser": "Navegador predefinido",
"DELETE": "APAGAR",
diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json
index 950a186f227..5cb29e37d94 100644
--- a/app/i18n/locales/ru.json
+++ b/app/i18n/locales/ru.json
@@ -190,6 +190,8 @@
"Custom": "Пользовательский",
"Dark": "Темный",
"Dark_level": "Уровень затемненности",
+ "Deep_link_login_description": "Ссылка пытается выполнить вход в {{server}}. Продолжайте, только если вы сами открыли эту ссылку и доверяете ей.",
+ "Deep_link_login_title": "Войти на этот сервер?",
"Default": "По умолчанию",
"Default_browser": "Браузер по умолчанию",
"DELETE": "УДАЛИТЬ",
diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json
index 0957302775d..d977b39d383 100644
--- a/app/i18n/locales/sl-SI.json
+++ b/app/i18n/locales/sl-SI.json
@@ -178,6 +178,8 @@
"Custom": "Po meri",
"Dark": "Temno",
"Dark_level": "Temna raven",
+ "Deep_link_login_description": "Povezava vas poskuša prijaviti v {{server}}. Nadaljujte le, če ste to povezavo odprli sami in ji zaupate.",
+ "Deep_link_login_title": "Se želite prijaviti v ta strežnik?",
"Default": "Privzeto",
"Default_browser": "Privzeti brskalnik",
"DELETE": "Izbrisati",
diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json
index 3466cfea7ca..89ea2f47d8c 100644
--- a/app/i18n/locales/sv.json
+++ b/app/i18n/locales/sv.json
@@ -196,6 +196,8 @@
"Custom": "Anpassad",
"Dark": "Mörk",
"Dark_level": "Mörk nivå",
+ "Deep_link_login_description": "En länk försöker logga in dig på {{server}}. Fortsätt bara om du själv öppnade den här länken och litar på den.",
+ "Deep_link_login_title": "Logga in på den här servern?",
"Default": "Standard",
"Default_browser": "Standardwebbläsare",
"Defined_user_as_role": "angav {{user}} som {{role}}",
diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json
index 83f1a3251fd..89f752326ba 100644
--- a/app/i18n/locales/ta-IN.json
+++ b/app/i18n/locales/ta-IN.json
@@ -213,6 +213,8 @@
"Dark": "கருப்பு",
"Dark_level": "கருப்பு அளவு",
"decline": "மறுக்கு",
+ "Deep_link_login_description": "ஒரு இணைப்பு உங்களை {{server}} இல் உள்நுழையச் கேட்கிறது. இந்த இணைப்பை நீங்களே திறந்து அதைப் நம்பினால் மட்டுமே தொடரவும்.",
+ "Deep_link_login_title": "இந்த சர்வரில் உள்நுழையவா?",
"Default": "இயல்புநிலை",
"Default_browser": "இயல்புநிலை உலாவி",
"Defined_user_as_role": "{{user}} ஐ {{role}} என்று வெளியிடப்பட்டார்",
diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json
index dab8b05080e..13db35a4f5c 100644
--- a/app/i18n/locales/te-IN.json
+++ b/app/i18n/locales/te-IN.json
@@ -212,6 +212,8 @@
"Dark": "గాఢంగా",
"Dark_level": "గాఢంగా స్థాయి",
"decline": "నిరాకరించు",
+ "Deep_link_login_description": "ఒక లింక్ మిమ్మల్ని {{server}} లో సైన్ ఇన్ చేయాలని కోరుతోంది. మీరు ఈ లింక్ను స్వయంగా తెరిచి దానిని నమ్మితేనే కొనసాగండి.",
+ "Deep_link_login_title": "ఈ సర్వర్లో సైన్ ఇన్ చేయాలా?",
"Default": "స్వచ్ఛందం",
"Default_browser": "స్వచ్ఛంద బ్రౌజర్",
"Defined_user_as_role": "{{user}} నియమాలను {{role}} గా ప్రవృత్తించారు",
diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json
index 8c03a15c015..1f494c84a26 100644
--- a/app/i18n/locales/tr.json
+++ b/app/i18n/locales/tr.json
@@ -147,6 +147,8 @@
"Custom": "Özel",
"Dark": "Karanlık",
"Dark_level": "Karanlık Seviyesi",
+ "Deep_link_login_description": "Bir bağlantı sizi {{server}} sunucusunda oturum açtırmak istiyor. Yalnızca bu bağlantıyı kendiniz açtıysanız ve güveniyorsanız devam edin.",
+ "Deep_link_login_title": "Bu sunucuda oturum açılsın mı?",
"Default": "Varsayılan",
"Default_browser": "Varsayılan tarayıcı",
"DELETE": "SİL",
diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json
index 396b899cefe..15050547652 100644
--- a/app/i18n/locales/zh-CN.json
+++ b/app/i18n/locales/zh-CN.json
@@ -146,6 +146,8 @@
"Custom": "自定义",
"Dark": "深色",
"Dark_level": "深色程度",
+ "Deep_link_login_description": "有一个链接正尝试让你登录到 {{server}}。只有在你亲自打开此链接并且信任它的情况下才继续。",
+ "Deep_link_login_title": "登录到此服务器?",
"Default": "默認",
"Default_browser": "预设浏览器",
"DELETE": "删除",
diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json
index a82f36c4aa9..a709d9d8740 100644
--- a/app/i18n/locales/zh-TW.json
+++ b/app/i18n/locales/zh-TW.json
@@ -148,6 +148,8 @@
"Custom": "自訂",
"Dark": "深色",
"Dark_level": "深色程度",
+ "Deep_link_login_description": "有一個連結正嘗試讓你登入 {{server}}。只有在你親自開啟此連結並且信任它的情況下才繼續。",
+ "Deep_link_login_title": "登入此伺服器?",
"Default": "預設",
"Default_browser": "預設瀏覽器",
"DELETE": "刪除",
diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts
index 8c6dc1da7d2..4289979b498 100644
--- a/app/lib/constants/keys.ts
+++ b/app/lib/constants/keys.ts
@@ -23,5 +23,13 @@ export const ALERT_DISPLAY_TYPE_PREFERENCES_KEY = 'RC_ALERT_DISPLAY_TYPE_PREFERE
export const CRASH_REPORT_KEY = 'RC_CRASH_REPORT_KEY';
export const ANALYTICS_EVENTS_KEY = 'RC_ANALYTICS_EVENTS_KEY';
export const TOKEN_KEY = 'reactnativemeteor_usertoken';
+/**
+ * MMKV key for the auth token, scoped to (server, userId). Scoping by userId alone was ambiguous:
+ * two servers can share a userId (a malicious one can force it), so lookups could resolve another
+ * server's token (token confusion / exfiltration). Keep in sync with `Ejson.token()` on Android
+ * and the migration in the init saga.
+ */
+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/methods/logout.ts b/app/lib/methods/logout.ts
index d27819472d4..efd7d253108 100644
--- a/app/lib/methods/logout.ts
+++ b/app/lib/methods/logout.ts
@@ -8,7 +8,14 @@ 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,
+ TOKEN_KEY,
+ getUserTokenKey
+} from '../constants/keys';
import UserPreferences from './userPreferences';
import { removePushToken } from '../services/restApi';
import { roomsSubscription } from './subscriptions/rooms';
@@ -17,6 +24,8 @@ import { _activeUsersSubTimeout } from './getUsersPresence';
function removeServerKeys({ server, userId }: { server: string; userId?: string | null }) {
UserPreferences.removeItem(`${TOKEN_KEY}-${server}`);
if (userId) {
+ UserPreferences.removeItem(getUserTokenKey(server, userId));
+ // Also remove the legacy non-server-scoped token slot, in case this server predates the migration.
UserPreferences.removeItem(`${TOKEN_KEY}-${userId}`);
}
UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`);
@@ -64,7 +73,7 @@ export async function removeServer({ server }: { server: string }): Promise ({
+ __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('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.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true);
+ });
+
+ 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..ca21437522a
--- /dev/null
+++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts
@@ -0,0 +1,57 @@
+import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getUserTokenKey } from '../constants/keys';
+import UserPreferences from './userPreferences';
+import database from '../database';
+import log from './helpers/log';
+
+/**
+ * One-time migration of auth tokens from the legacy `${TOKEN_KEY}-${userId}` slot to the
+ * server-scoped `${TOKEN_KEY}-${server}-${userId}` slot (see `getUserTokenKey`). Only userIds
+ * owned by a single server are migrated; ambiguous ones are dropped, forcing re-authentication.
+ */
+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();
+
+ // Map each server to its userId and count how many servers reference each userId.
+ const serverUserIds: { server: string; userId: string }[] = [];
+ const serverCountByUserId: Record = {};
+ for (let i = 0; i < servers.length; i += 1) {
+ const server = servers[i].id;
+ const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
+ if (!userId) {
+ continue;
+ }
+ serverUserIds.push({ server, userId });
+ serverCountByUserId[userId] = (serverCountByUserId[userId] || 0) + 1;
+ }
+
+ // Collected in a Set so an ambiguous userId shared by N servers is removed once.
+ const legacyKeys = new Set();
+ for (let i = 0; i < serverUserIds.length; i += 1) {
+ const { server, userId } = serverUserIds[i];
+ const legacyKey = `${TOKEN_KEY}-${userId}`;
+ // Ambiguous: don't migrate, just drop the legacy slot so the session re-authenticates.
+ if (serverCountByUserId[userId] > 1) {
+ legacyKeys.add(legacyKey);
+ continue;
+ }
+ const newKey = getUserTokenKey(server, userId);
+ if (!UserPreferences.getString(newKey)) {
+ const token = UserPreferences.getString(legacyKey);
+ if (token) {
+ UserPreferences.setString(newKey, token);
+ legacyKeys.add(legacyKey);
+ }
+ }
+ }
+ // Drop the legacy slots (migrated and ambiguous alike) now that the migration is done.
+ legacyKeys.forEach(key => UserPreferences.removeItem(key));
+ UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true);
+ } catch (e) {
+ log(e);
+ }
+};
diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts
index 632d05f369f..d5ae4672d44 100644
--- a/app/sagas/__tests__/deepLinking.test.ts
+++ b/app/sagas/__tests__/deepLinking.test.ts
@@ -82,6 +82,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),
@@ -100,11 +104,12 @@ import { loginSuccess } from '../../actions/login';
import { selectServerSuccess } from '../../actions/server';
import { connectSuccess } from '../../actions/connect';
import { appStart } from '../../actions/app';
-import { LOGIN } from '../../actions/actionsTypes';
+import { APP, LOGIN } from '../../actions/actionsTypes';
import { RootEnum } from '../../definitions';
import reducers from '../../reducers';
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 { canOpenRoom } from '../../lib/methods/canOpenRoom';
import { getServerInfo } from '../../lib/methods/getServerInfo';
@@ -283,6 +288,41 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
});
+ // Ordering race: the confirm prompt stays open (real native Alert) long enough for the
+ // NewServer connection — kicked off *before* the prompt — to complete. SERVER.SELECT_SUCCESS
+ // then fires while the user is still deciding, so the take must be guarded or the saga hangs
+ // on WorkspaceView without ever logging in (the iOS deep-link symptom).
+ it('completes the chain when SERVER.SELECT_SUCCESS fires while the confirm prompt is open', async () => {
+ // Capture onPress instead of auto-confirming, mimicking a prompt the user hasn't tapped yet.
+ let confirm: (() => void) | undefined;
+ jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onPress }: any) => {
+ confirm = onPress;
+ });
+
+ const { store, actions } = setupRecordingStore();
+ const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST);
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ // Prompt is open, not yet confirmed. The connection completes now: both
+ // SERVER.SELECT_SUCCESS and the socket connect fire before the saga reaches its takes.
+ store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
+ // Still parked in the prompt — no premature login.
+ expect(loginRequested()).toBe(false);
+
+ // User confirms. The guard sees the server is already selected + connected and skips the
+ // stale takes, so loginRequest fires instead of the saga hanging.
+ confirm?.();
+ await flushSagaMicrotasks();
+ expect(loginRequested()).toBe(true);
+ });
+
// loginRequest must not fire until the socket is connected (locks the gate).
it('does not dispatch loginRequest until METEOR.SUCCESS', async () => {
const { store, actions } = setupRecordingStore();
@@ -306,6 +346,67 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
expect(loginRequested()).toBe(true);
});
+ it('does not dispatch loginRequest when the deep-link login confirmation is declined', async () => {
+ jest.mocked(showConfirmationAlert).mockClear();
+ jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.());
+
+ const { store, actions } = setupRecordingStore();
+ const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST);
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ // User declined → confirmation shown, no login, parked outside.
+ expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1);
+ expect(loginRequested()).toBe(false);
+ expect(actions.some(a => a.type === APP.START && (a as any).root === RootEnum.ROOT_OUTSIDE)).toBe(true);
+ });
+
+ // 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 `e2eConfirmPrompt=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 e2eConfirmPrompt marker is present', async () => {
+ const { store, actions } = setupRecordingStore();
+ const loginRequested = () => actions.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 e2eConfirmPrompt=true', async () => {
+ const store = setupStore();
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken({ e2eConfirmPrompt: '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.
diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js
index b6892f18b39..23d0661263d 100644
--- a/app/sagas/deepLinking.js
+++ b/app/sagas/deepLinking.js
@@ -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,23 @@ const roomTypes = {
channels: 'l'
};
+const confirmDeepLinkLogin = (host, params = {}) =>
+ new Promise(resolve => {
+ // Under E2E tests, auto-confirm so flows don't have to dismiss the native Alert.
+ // `e2eConfirmPrompt=true` opts back into the real prompt to test it. Test-only; real users always get the prompt.
+ if (process.env.RUNNING_E2E_TESTS === 'true' && params.e2eConfirmPrompt !== '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/', '');
@@ -242,8 +260,19 @@ const handleOpen = function* handleOpen({ params }) {
}
if (params.token) {
+ const confirmed = yield call(confirmDeepLinkLogin, host, params);
+ if (!confirmed) {
+ yield put(appStart({ root: RootEnum.ROOT_OUTSIDE }));
+ return;
+ }
if (!hostAlreadyConnected) {
- yield take(types.SERVER.SELECT_SUCCESS);
+ // The confirm prompt can sit open for seconds while the NewServer connection
+ // (emitted above) completes, so SERVER.SELECT_SUCCESS may have already fired by
+ // the time we get here. Guard the take instead of hanging on an event that's past.
+ const serverSelected = yield select(state => state.server.server === host && state.server.connected);
+ if (!serverSelected) {
+ yield take(types.SERVER.SELECT_SUCCESS);
+ }
// SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is.
const connected = yield select(state => state.meteor.connected);
if (!connected) {
diff --git a/app/sagas/init.js b/app/sagas/init.js
index d9d6024abe8..1ea6b7373a4 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, TOKEN_KEY } from '../lib/constants/keys';
import UserPreferences from '../lib/methods/userPreferences';
+import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped';
import { selectServerRequest } from '../actions/server';
import { setAllPreferences } from '../actions/sortPreferences';
import { APP } from '../actions/actionsTypes';
@@ -23,6 +24,8 @@ export const initLocalSettings = function* initLocalSettings() {
const restore = function* restore() {
try {
+ yield call(migrateTokenKeysToServerScoped);
+
const server = UserPreferences.getString(CURRENT_SERVER);
let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
diff --git a/app/sagas/login.js b/app/sagas/login.js
index 10740600a33..d1c673d62ed 100644
--- a/app/sagas/login.js
+++ b/app/sagas/login.js
@@ -22,7 +22,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, TOKEN_KEY, 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';
@@ -341,7 +341,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
});
UserPreferences.setString(`${TOKEN_KEY}-${server}`, user.id);
- UserPreferences.setString(`${TOKEN_KEY}-${user.id}`, user.token);
+ 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 5373f6b0fcf..06b755d1a58 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, TOKEN_KEY, 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,6 +151,10 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
yield put(inquiryReset());
yield put(encryptionStop());
yield put(clearActiveUsers());
+ // Deep-link and share-extension startup can dispatch selectServerRequest() directly, without
+ // going through appInit(). Run the (idempotent, flag-guarded) token migration here too so the
+ // server-scoped read below finds legacy sessions instead of falling through to a login screen.
+ yield* call(migrateTokenKeysToServerScoped);
const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
let user = null;
if (userId) {
@@ -171,7 +176,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/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift
index f825b4d2cfc..442199c126b 100644
--- a/ios/Shared/RocketChat/MMKV.swift
+++ b/ios/Shared/RocketChat/MMKV.swift
@@ -18,11 +18,14 @@ 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 {
- return nil
+ // Server-scoped key: reactnativemeteor_usertoken-{server}-{userId}. 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
}
- return userToken
+ return string(forKey: "reactnativemeteor_usertoken-\(userId)")
}
func userId(for server: String) -> String? {
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
}