From 432cfb08fe05ad8a936f571a1fbe424d107d17da Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Wed, 17 Sep 2025 08:04:28 +0200 Subject: [PATCH] feat: show pre-auth secret error on field on server create (#9102) * feat: show potential pre-auth secret error on server create * chroe: address comments * chore: updated message * feat: read response header to check error source * fix: i18n * chore: rename pre-auth to just auth --- app/actions/remote/general.ts | 14 +++++- app/client/rest/error.ts | 2 + app/client/rest/tracking.ts | 5 +- app/screens/server/form.tsx | 25 +++++++--- app/screens/server/index.tsx | 49 +++++++++++++------ assets/base/i18n/en.json | 5 +- .../preauth_secret_connection.e2e.ts | 4 +- types/api/client.d.ts | 1 + 8 files changed, 76 insertions(+), 29 deletions(-) diff --git a/app/actions/remote/general.ts b/app/actions/remote/general.ts index 853f5d0a2..8e218ecdf 100644 --- a/app/actions/remote/general.ts +++ b/app/actions/remote/general.ts @@ -15,6 +15,7 @@ import {logDebug} from '@utils/log'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; +import type ClientError from '@client/rest/error'; import type {ClientResponse} from '@mattermost/react-native-network-client'; async function getDeviceIdForPing(serverUrl: string, checkDeviceId: boolean) { @@ -66,12 +67,21 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou } if (!response.ok) { - logDebug('Server ping returned not ok response', response); NetworkManager.invalidateClient(serverUrl); + if (response.code === 403 && response.headers?.['x-reject-reason'] === 'pre-auth') { + return {error: {intl: pingError}, isPreauthError: true}; + } return {error: {intl: pingError}}; } } catch (error) { - logDebug('Server ping threw an exception', getFullErrorMessage(error)); + // Check if this is a 403 with pre-auth header + const errorObj = error as ClientError; + if (errorObj.status_code === 403) { + if (errorObj.headers?.['x-reject-reason'] === 'pre-auth') { + return {error: {intl: pingError}, isPreauthError: true}; + } + } + NetworkManager.invalidateClient(serverUrl); return {error: {intl: pingError}}; } diff --git a/app/client/rest/error.ts b/app/client/rest/error.ts index 90dee80f8..89a5b8af0 100644 --- a/app/client/rest/error.ts +++ b/app/client/rest/error.ts @@ -9,6 +9,7 @@ export default class ClientError extends Error { server_error_id?: string; status_code?: number; details?: unknown; + headers?: Record; constructor(baseUrl: string, data: ClientErrorProps) { super(data.message + ': ' + cleanUrlForLogging(baseUrl, data.url)); @@ -18,6 +19,7 @@ export default class ClientError extends Error { this.server_error_id = data.server_error_id; this.status_code = data.status_code; this.details = data.details; + this.headers = data.headers; // Ensure message is treated as a property of this class when object spreading. Without this, // copying the object by using `{...error}` would not include the message. diff --git a/app/client/rest/tracking.ts b/app/client/rest/tracking.ts index d86486539..1f307bca3 100644 --- a/app/client/rest/tracking.ts +++ b/app/client/rest/tracking.ts @@ -387,6 +387,7 @@ export default class ClientTracking { try { response = await request!(url, this.buildRequestOptions(options)); } catch (error) { + const response_error = error as ClientError; const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined; throw new ClientError(this.apiClient.baseUrl, { message: 'Received invalid response from the server.', @@ -395,7 +396,8 @@ export default class ClientTracking { defaultMessage: 'Received invalid response from the server.', }), url, - details: error, + details: response_error, + headers: response_error.headers ? response_error.headers : undefined, status_code, }); } finally { @@ -433,6 +435,7 @@ export default class ClientTracking { server_error_id: response.data?.id as string, status_code: response.code, url, + headers, }); }; } diff --git a/app/screens/server/form.tsx b/app/screens/server/form.tsx index b9d4da6f5..1d86488e8 100644 --- a/app/screens/server/form.tsx +++ b/app/screens/server/form.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {type RefObject, useCallback, useRef, useState} from 'react'; +import React, {type RefObject, useCallback, useRef} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {Keyboard, Pressable, View} from 'react-native'; @@ -28,6 +28,9 @@ type Props = { handleUrlTextChanged: (text: string) => void; keyboardAwareRef: RefObject; preauthSecret?: string; + preauthSecretError?: string; + setShowAdvancedOptions: (show: boolean) => void; + showAdvancedOptions: boolean; theme: Theme; url?: string; urlError?: string; @@ -93,11 +96,15 @@ const messages = defineMessages({ }, preauthSecret: { id: 'mobile.components.select_server_view.sharedSecret', - defaultMessage: 'Pre-authentication secret', + defaultMessage: 'Authentication secret', }, preauthSecretHelp: { id: 'mobile.components.select_server_view.sharedSecretHelp', - defaultMessage: 'The pre-authentication secret shared by the administrator', + defaultMessage: 'The authentication secret shared by the administrator', + }, + preauthSecretInvalid: { + id: 'mobile.server.preauth_secret.invalid', + defaultMessage: 'Authentication secret is invalid. Try again or contact your admin.', }, }); @@ -114,6 +121,9 @@ const ServerForm = ({ handleUrlTextChanged, keyboardAwareRef, preauthSecret = '', + preauthSecretError, + setShowAdvancedOptions, + showAdvancedOptions, theme, url = '', urlError, @@ -124,8 +134,6 @@ const ServerForm = ({ const urlRef = useRef(null); const styles = getStyleSheet(theme); - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - useAvoidKeyboard(keyboardAwareRef); const onConnect = useCallback(() => { @@ -138,12 +146,12 @@ const ServerForm = ({ }, []); const onDisplayNameSubmit = useCallback(() => { - if (showAdvancedOptions) { + if (showAdvancedOptions || preauthSecretError) { preauthSecretRef.current?.focus(); } else { onConnect(); } - }, [showAdvancedOptions, onConnect]); + }, [showAdvancedOptions, preauthSecretError, onConnect]); const toggleAdvancedOptions = useCallback(() => { setShowAdvancedOptions(!showAdvancedOptions); @@ -190,7 +198,7 @@ const ServerForm = ({ onChangeText={handleDisplayNameTextChanged} onSubmitEditing={onDisplayNameSubmit} ref={displayNameRef} - returnKeyType={showAdvancedOptions ? 'next' : 'done'} + returnKeyType={showAdvancedOptions || preauthSecretError ? 'next' : 'done'} spellCheck={false} testID='server_form.server_display_name.input' theme={theme} @@ -231,6 +239,7 @@ const ServerForm = ({ autoCorrect={false} autoCapitalize={'none'} enablesReturnKeyAutomatically={true} + error={preauthSecretError} label={formatMessage(messages.preauthSecret)} onChangeText={handlePreauthSecretTextChanged} onSubmitEditing={onConnect} diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index da9557b01..6b0515b00 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -94,6 +94,8 @@ const Server = ({ const [url, setUrl] = useState(''); const [displayNameError, setDisplayNameError] = useState(); const [urlError, setUrlError] = useState(); + const [preauthSecretError, setPreauthSecretError] = useState(); + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const styles = getStyleSheet(theme); const {formatMessage} = intl; const disableServerUrl = Boolean(managedConfig?.allowOtherServers === 'false' && managedConfig?.serverUrl); @@ -146,12 +148,12 @@ const Server = ({ }, [managedConfig?.allowOtherServers, managedConfig?.serverUrl, managedConfig?.serverName, defaultServerUrl]); useEffect(() => { - if (url && displayName) { + if (url && displayName && !urlError && !preauthSecretError) { setButtonDisabled(false); } else { setButtonDisabled(true); } - }, [url, displayName]); + }, [url, displayName, urlError, preauthSecretError]); useEffect(() => { const listener = { @@ -277,7 +279,15 @@ const Server = ({ const handlePreauthSecretTextChanged = useCallback((text: string) => { setPreauthSecret(text); - }, []); + + // Clear any connection errors when preauth secret is modified + if (urlError) { + setUrlError(undefined); + } + if (preauthSecretError) { + setPreauthSecretError(undefined); + } + }, [urlError, preauthSecretError]); const isServerUrlValid = (serverUrl?: string) => { const testUrl = sanitizeUrl(serverUrl ?? url); @@ -300,34 +310,42 @@ const Server = ({ cancelPing = undefined; }; - const ping = await getServerUrlAfterRedirect(pingUrl, !retryWithHttp, preauthSecret.trim() || undefined); - if (!ping.url) { + const headRequest = await getServerUrlAfterRedirect(pingUrl, !retryWithHttp, preauthSecret.trim() || undefined); + if (!headRequest.url) { cancelPing(); if (retryWithHttp) { const nurl = pingUrl.replace('https:', 'http:'); pingServer(nurl, false); } else { - setUrlError(getErrorMessage(ping.error, intl)); + setUrlError(getErrorMessage(headRequest.error, intl)); setButtonDisabled(true); setConnecting(false); } return; } - const result = await doPing(ping.url, true, managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined, preauthSecret.trim() || undefined); + const result = await doPing(headRequest.url, true, managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined, preauthSecret.trim() || undefined); if (canceled) { return; } if (result.error) { - setUrlError(getErrorMessage(result.error, intl)); + if (result.isPreauthError) { + setPreauthSecretError(intl.formatMessage({ + id: 'mobile.server.preauth_secret.invalid', + defaultMessage: 'Authentication secret is invalid. Try again or contact your admin.', + })); + setShowAdvancedOptions(true); + } else { + setUrlError(getErrorMessage(result.error, intl)); + } setButtonDisabled(true); setConnecting(false); return; } - canReceiveNotifications(ping.url, result.canReceiveNotifications as string, intl); - const data = await fetchConfigAndLicense(ping.url, true); + canReceiveNotifications(headRequest.url, result.canReceiveNotifications as string, intl); + const data = await fetchConfigAndLicense(headRequest.url, true); if (data.error) { setButtonDisabled(true); setUrlError(getErrorMessage(data.error, intl)); @@ -345,7 +363,7 @@ const Server = ({ } if (data.config.MobileJailbreakProtection === 'true') { - const isJailbroken = await SecurityManager.isDeviceJailbroken(ping.url, data.config.SiteName); + const isJailbroken = await SecurityManager.isDeviceJailbroken(headRequest.url, data.config.SiteName); if (isJailbroken) { setConnecting(false); return; @@ -353,7 +371,7 @@ const Server = ({ } if (data.config.MobileEnableBiometrics === 'true') { - const biometricsResult = await SecurityManager.authenticateWithBiometrics(ping.url, data.config.SiteName); + const biometricsResult = await SecurityManager.authenticateWithBiometrics(headRequest.url, data.config.SiteName); if (!biometricsResult) { setConnecting(false); return; @@ -361,7 +379,7 @@ const Server = ({ } const server = await getServerByIdentifier(data.config.DiagnosticId); - const credentials = await getServerCredentials(ping.url); + const credentials = await getServerCredentials(headRequest.url); setConnecting(false); if (server && server.lastActiveAt > 0 && credentials?.token) { @@ -373,7 +391,7 @@ const Server = ({ return; } - displayLogin(ping.url, data.config!, data.license!); + displayLogin(headRequest.url, data.config!, data.license!); }; return ( @@ -417,6 +435,9 @@ const Server = ({ handleUrlTextChanged={handleUrlTextChanged} keyboardAwareRef={keyboardAwareRef} preauthSecret={preauthSecret} + preauthSecretError={preauthSecretError} + setShowAdvancedOptions={setShowAdvancedOptions} + showAdvancedOptions={showAdvancedOptions} theme={theme} url={url} urlError={urlError} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 54c22a322..d8d235e6a 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -632,8 +632,8 @@ "mobile.components.select_server_view.msg_description": "A server is your team's communication hub accessed using a unique URL", "mobile.components.select_server_view.msg_welcome": "Welcome", "mobile.components.select_server_view.proceed": "Proceed", - "mobile.components.select_server_view.sharedSecret": "Pre-authentication secret", - "mobile.components.select_server_view.sharedSecretHelp": "The pre-authentication secret shared by the administrator", + "mobile.components.select_server_view.sharedSecret": "Authentication secret", + "mobile.components.select_server_view.sharedSecretHelp": "The authentication secret shared by the administrator", "mobile.create_channel": "Create", "mobile.create_channel.title": "New channel", "mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members", @@ -849,6 +849,7 @@ "mobile.server_url.deeplink.emm.denied": "This app is controlled by an EMM and the DeepLink server url does not match the EMM allowed server", "mobile.server_url.empty": "Please enter a valid server URL", "mobile.server_url.invalid_format": "URL must start with http:// or https://", + "mobile.server.preauth_secret.invalid": "Authentication secret is invalid. Try again or contact your admin.", "mobile.session_expired_days": "Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.", "mobile.session_expired_days_hrs": "Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}} and {hoursCount, number} {hoursCount, plural, one {hour} other {hours}}.", "mobile.session_expired_hrs": "Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {hoursCount, number} {hoursCount, plural, one {hour} other {hours}}.", diff --git a/detox/e2e/test/products/channels/server_login/preauth_secret_connection.e2e.ts b/detox/e2e/test/products/channels/server_login/preauth_secret_connection.e2e.ts index 6beb8e7fb..ca81e2ac8 100644 --- a/detox/e2e/test/products/channels/server_login/preauth_secret_connection.e2e.ts +++ b/detox/e2e/test/products/channels/server_login/preauth_secret_connection.e2e.ts @@ -68,8 +68,8 @@ describe('Server Login - Preauth Secret Connection', () => { // * Verify preauth secret field is now visible with correct text and styling await expect(preauthSecretInput).toBeVisible(); await expect(preauthSecretHelp).toBeVisible(); - await expect(element(by.text('Pre-authentication secret'))).toBeVisible(); - await expect(preauthSecretHelp).toHaveText('The pre-authentication secret shared by the administrator'); + await expect(element(by.text('Authentication secret'))).toBeVisible(); + await expect(preauthSecretHelp).toHaveText('The authentication secret shared by the administrator'); // # Toggle advanced options again to hide await ServerScreen.toggleAdvancedOptions(); diff --git a/types/api/client.d.ts b/types/api/client.d.ts index d9cc36cc5..e0a0ad862 100644 --- a/types/api/client.d.ts +++ b/types/api/client.d.ts @@ -27,5 +27,6 @@ interface ClientErrorProps { url: string; server_error_id?: string; status_code?: number; + headers?: Record; message: string; }