From af2c35b03b23f752d5323ceaf625effc226e4bc3 Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:07:19 +0100 Subject: [PATCH] feat: edit server pre-authentication secret (#9128) * 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 * Add show/hide toggle to authentication secret field Modified server form to match login form UX with password visibility toggle * Add preauth secret field to edit server with validation and auto-open options * chore: added missing 18n * Increase form padding for better error message spacing * Fix preauth secret removal when not provided * Change edit server title from 'Edit server name' to 'Edit server' * Add custom ping function and improve validation in edit server * chore: revert en_AU * chore: improved error message * feat: animate advanced options * fix: auth secret label being cropped * refactor: doPing --- app/actions/remote/general.ts | 27 ++- app/components/selected_users/index.tsx | 4 +- app/init/credentials.ts | 6 + app/screens/edit_server/form.tsx | 159 +++++++++++++++++- app/screens/edit_server/header.tsx | 2 +- app/screens/edit_server/index.tsx | 125 +++++++++++++- app/screens/server/form.tsx | 11 +- .../settings/notifications/notifications.tsx | 2 +- assets/base/i18n/en.json | 3 +- .../content_view/attachments/attachments.tsx | 2 +- 10 files changed, 313 insertions(+), 28 deletions(-) diff --git a/app/actions/remote/general.ts b/app/actions/remote/general.ts index 8e218ecdf..dc15aa859 100644 --- a/app/actions/remote/general.ts +++ b/app/actions/remote/general.ts @@ -35,12 +35,17 @@ async function getDeviceIdForPing(serverUrl: string, checkDeviceId: boolean) { } // Default timeout interval for ping is 5 seconds -export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000, preauthSecret?: string) => { - let client: Client; - try { - client = await NetworkManager.createClient(serverUrl, undefined, preauthSecret); - } catch (error) { - return {error}; +export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000, preauthSecret?: string, client?: Client) => { + let pingClient: Client; + + if (client) { + pingClient = client; + } else { + try { + pingClient = await NetworkManager.createClient(serverUrl, undefined, preauthSecret); + } catch (error) { + return {error}; + } } const certificateError = defineMessage({ @@ -57,7 +62,7 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou let response: ClientResponse; try { - response = await client.ping(deviceId, timeoutInterval); + response = await pingClient.ping(deviceId, timeoutInterval); if (response.code === 401) { // Don't invalidate the client since we want to eventually @@ -67,7 +72,9 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou } if (!response.ok) { - NetworkManager.invalidateClient(serverUrl); + if (!client) { + NetworkManager.invalidateClient(serverUrl); + } if (response.code === 403 && response.headers?.['x-reject-reason'] === 'pre-auth') { return {error: {intl: pingError}, isPreauthError: true}; } @@ -82,7 +89,9 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou } } - NetworkManager.invalidateClient(serverUrl); + if (!client) { + NetworkManager.invalidateClient(serverUrl); + } return {error: {intl: pingError}}; } diff --git a/app/components/selected_users/index.tsx b/app/components/selected_users/index.tsx index ec416a2b4..d70224f6e 100644 --- a/app/components/selected_users/index.tsx +++ b/app/components/selected_users/index.tsx @@ -228,7 +228,7 @@ export default function SelectedUsers({ useEffect(() => { setIsVisible(numberSelectedIds > 0); - }, [numberSelectedIds > 0]); + }, [numberSelectedIds]); // This effect hides the toast after 4 seconds useEffect(() => { @@ -240,7 +240,7 @@ export default function SelectedUsers({ } return () => clearTimeout(timer); - }, [showToast]); + }, [showToast, setShowToast]); const isDisabled = Boolean(maxUsers && (numberSelectedIds > maxUsers)); return ( diff --git a/app/init/credentials.ts b/app/init/credentials.ts index 74bb0ce59..640c7f1d8 100644 --- a/app/init/credentials.ts +++ b/app/init/credentials.ts @@ -70,6 +70,12 @@ export const setServerCredentials = (serverUrl: string, token: string, preauthSe server: serverUrl, ...options, }); + } else { + // Remove preauth secret if not provided + KeyChain.resetGenericPassword({ + server: serverUrl, + ...options, + }); } } catch (e) { logWarning('could not set credentials', e); diff --git a/app/screens/edit_server/form.tsx b/app/screens/edit_server/form.tsx index 6fc2f016f..52c61825c 100644 --- a/app/screens/edit_server/form.tsx +++ b/app/screens/edit_server/form.tsx @@ -1,11 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {type MutableRefObject, useCallback, useEffect, useRef} from 'react'; +import React, {type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {defineMessages, useIntl} from 'react-intl'; -import {Keyboard, Platform, useWindowDimensions, View} from 'react-native'; +import {Keyboard, Platform, Pressable, TouchableOpacity, useWindowDimensions, View} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Button from '@components/button'; +import CompassIcon from '@components/compass_icon'; import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_input/floating_text_input_label'; import FormattedText from '@components/formatted_text'; import {useIsTablet} from '@hooks/device'; @@ -15,6 +17,9 @@ import {removeProtocol, stripTrailingSlashes} from '@utils/url'; import type {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; +const ADVANCED_OPTIONS_COLLAPSED = 0; +const ADVANCED_OPTIONS_EXPANDED = 150; // Approximate height for the content + type Props = { buttonDisabled: boolean; connecting: boolean; @@ -22,8 +27,13 @@ type Props = { displayNameError?: string; handleUpdate: () => void; handleDisplayNameTextChanged: (text: string) => void; + handlePreauthSecretTextChanged: (text: string) => void; keyboardAwareRef: MutableRefObject; + preauthSecret?: string; + preauthSecretError?: string; serverUrl: string; + setShowAdvancedOptions: React.Dispatch>; + showAdvancedOptions: boolean; theme: Theme; }; @@ -32,7 +42,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ alignItems: 'center', maxWidth: 600, width: '100%', - paddingHorizontal: 20, + paddingHorizontal: 24, }, fullWidth: { width: '100%', @@ -50,8 +60,32 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ width: '100%', marginTop: 32, }, + advancedOptionsContainer: { + width: '100%', + marginTop: 16, + }, + advancedOptionsHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + paddingVertical: 12, + paddingHorizontal: 4, + }, + advancedOptionsTitle: { + color: theme.linkColor, + ...typography('Body', 75, 'SemiBold'), + }, + advancedOptionsContent: { + width: '100%', + overflow: 'visible', + }, + endAdornment: { + top: 2, + }, })); +const hitSlop = {top: 8, right: 8, bottom: 8, left: 8}; + const messages = defineMessages({ save: { id: 'edit_server.save', @@ -61,6 +95,18 @@ const messages = defineMessages({ id: 'edit_server.saving', defaultMessage: 'Saving', }, + advancedOptions: { + id: 'mobile.components.select_server_view.advancedOptions', + defaultMessage: 'Advanced Options', + }, + preauthSecret: { + id: 'mobile.components.select_server_view.sharedSecret', + defaultMessage: 'Authentication secret', + }, + preauthSecretHelp: { + id: 'mobile.components.select_server_view.sharedSecretHelp', + defaultMessage: 'The authentication secret shared by the administrator', + }, }); const EditServerForm = ({ @@ -70,15 +116,40 @@ const EditServerForm = ({ displayNameError, handleUpdate, handleDisplayNameTextChanged, + handlePreauthSecretTextChanged, keyboardAwareRef, + preauthSecret = '', + preauthSecretError, serverUrl, + setShowAdvancedOptions, + showAdvancedOptions, theme, }: Props) => { const {formatMessage} = useIntl(); const isTablet = useIsTablet(); const dimensions = useWindowDimensions(); const displayNameRef = useRef(null); + const preauthSecretRef = useRef(null); const styles = getStyleSheet(theme); + const [isPreauthSecretVisible, setIsPreauthSecretVisible] = useState(false); + + const togglePreauthSecretVisibility = useCallback(() => { + setIsPreauthSecretVisible((prevState) => !prevState); + }, []); + + const preauthSecretEndAdornment = useMemo(() => ( + + + + ), [isPreauthSecretVisible, styles.endAdornment, theme.centerChannelColor, togglePreauthSecretVisibility]); const onBlur = useCallback(() => { if (Platform.OS === 'ios') { @@ -94,6 +165,34 @@ const EditServerForm = ({ handleUpdate(); }, [handleUpdate]); + const onDisplayNameSubmit = useCallback(() => { + if (showAdvancedOptions || preauthSecretError) { + preauthSecretRef.current?.focus(); + } else { + onUpdate(); + } + }, [showAdvancedOptions, preauthSecretError, onUpdate]); + + const toggleAdvancedOptions = useCallback(() => { + setShowAdvancedOptions((prev: boolean) => !prev); + }, [setShowAdvancedOptions]); + + const chevronRotation = useSharedValue(showAdvancedOptions ? 180 : 0); + const advancedOptionsStyle = useAnimatedStyle(() => ({ + height: withTiming(showAdvancedOptions ? ADVANCED_OPTIONS_EXPANDED : ADVANCED_OPTIONS_COLLAPSED, {duration: 250}), + })); + + useEffect(() => { + chevronRotation.value = withTiming(showAdvancedOptions ? 180 : 0, {duration: 250}); + + // chevronRotation is a Reanimated shared value; its reference is stable and does not need to be in the dependency array. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showAdvancedOptions]); + + const chevronAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{rotate: `${chevronRotation.value}deg`}], + })); + const onFocus = useCallback(() => { // For iOS we set the position of the input instead of // having the KeyboardAwareScrollView figure it out by itself @@ -138,9 +237,9 @@ const EditServerForm = ({ onBlur={onBlur} onChangeText={handleDisplayNameTextChanged} onFocus={onFocus} - onSubmitEditing={onUpdate} + onSubmitEditing={onDisplayNameSubmit} ref={displayNameRef} - returnKeyType='done' + returnKeyType={showAdvancedOptions || preauthSecretError ? 'next' : 'done'} testID='edit_server_form.server_display_name.input' theme={theme} value={displayName} @@ -155,6 +254,56 @@ const EditServerForm = ({ values={{url: removeProtocol(stripTrailingSlashes(serverUrl))}} /> } + + + + + + + + + + + {showAdvancedOptions && ( + <> + + + + )} + + +