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
This commit is contained in:
Felipe Martin 2025-11-12 16:07:19 +01:00 committed by GitHub
parent 34ea6b2821
commit af2c35b03b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 313 additions and 28 deletions

View file

@ -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}};
}

View file

@ -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 (

View file

@ -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);

View file

@ -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<KeyboardAwareScrollView | null>;
preauthSecret?: string;
preauthSecretError?: string;
serverUrl: string;
setShowAdvancedOptions: React.Dispatch<React.SetStateAction<boolean>>;
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<FloatingTextInputRef>(null);
const preauthSecretRef = useRef<FloatingTextInputRef>(null);
const styles = getStyleSheet(theme);
const [isPreauthSecretVisible, setIsPreauthSecretVisible] = useState(false);
const togglePreauthSecretVisibility = useCallback(() => {
setIsPreauthSecretVisible((prevState) => !prevState);
}, []);
const preauthSecretEndAdornment = useMemo(() => (
<TouchableOpacity
onPress={togglePreauthSecretVisibility}
hitSlop={hitSlop}
style={styles.endAdornment}
>
<CompassIcon
name={isPreauthSecretVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
</TouchableOpacity>
), [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))}}
/>
}
<View style={styles.advancedOptionsContainer}>
<Pressable
onPress={toggleAdvancedOptions}
style={styles.advancedOptionsHeader}
testID='edit_server_form.advanced_options.toggle'
>
<Animated.View style={chevronAnimatedStyle}>
<CompassIcon
name={'chevron-down'}
size={20}
style={styles.advancedOptionsTitle}
/>
</Animated.View>
<FormattedText
defaultMessage='Advanced Options'
id='mobile.components.select_server_view.advancedOptions'
style={styles.advancedOptionsTitle}
/>
</Pressable>
<Animated.View style={[styles.advancedOptionsContent, advancedOptionsStyle]}>
{showAdvancedOptions && (
<>
<FloatingTextInput
rawInput={true}
enablesReturnKeyAutomatically={true}
endAdornment={preauthSecretEndAdornment}
error={preauthSecretError}
keyboardType={isPreauthSecretVisible ? 'visible-password' : 'default'}
label={formatMessage(messages.preauthSecret)}
onChangeText={handlePreauthSecretTextChanged}
onSubmitEditing={onUpdate}
ref={preauthSecretRef}
returnKeyType='done'
secureTextEntry={!isPreauthSecretVisible}
testID='edit_server_form.preauth_secret.input'
theme={theme}
value={preauthSecret}
/>
<FormattedText
{...messages.preauthSecretHelp}
style={styles.chooseText}
testID='edit_server_form.preauth_secret_help'
/>
</>
)}
</Animated.View>
</View>
<View style={styles.buttonContainer}>
<Button
disabled={buttonDisabled || connecting}

View file

@ -37,7 +37,7 @@ const EditServerHeader = ({theme}: Props) => {
return (
<View style={styles.textContainer}>
<FormattedText
defaultMessage='Edit server name'
defaultMessage='Edit server'
id='edit_server.title'
style={styles.title}
testID='edit_server_header.title'

View file

@ -7,14 +7,19 @@ import {Platform, View} from 'react-native';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import {SafeAreaView} from 'react-native-safe-area-context';
import {doPing} from '@actions/remote/general';
import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {getServerCredentials, setServerCredentials} from '@init/credentials';
import NetworkManager from '@managers/network_manager';
import SecurityManager from '@managers/security_manager';
import {getServerByDisplayName} from '@queries/app/servers';
import Background from '@screens/background';
import {dismissModal} from '@screens/navigation';
import {getErrorMessage} from '@utils/errors';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getServerUrlAfterRedirect} from '@utils/url';
import Form from './form';
import Header from './header';
@ -44,22 +49,96 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) => {
const {formatMessage} = useIntl();
const intl = useIntl();
const {formatMessage} = intl;
const keyboardAwareRef = useRef<KeyboardAwareScrollView>(null);
const [saving, setSaving] = useState(false);
const [displayName, setDisplayName] = useState<string>(server.displayName);
const [buttonDisabled, setButtonDisabled] = useState(true);
const [buttonDisabled, setButtonDisabled] = useState(Boolean(!server.displayName));
const [displayNameError, setDisplayNameError] = useState<string | undefined>();
const [preauthSecret, setPreauthSecret] = useState<string>('');
const [preauthSecretError, setPreauthSecretError] = useState<string | undefined>();
const [showAdvancedOptions, setShowAdvancedOptions] = useState<boolean>(false);
const [validating, setValidating] = useState(false);
const styles = getStyleSheet(theme);
const close = useCallback(() => {
dismissModal({componentId});
}, [componentId]);
// Load current preauth secret from credentials
useEffect(() => {
setButtonDisabled(Boolean(!displayName || displayName === server.displayName));
const loadCredentials = async () => {
try {
const credentials = await getServerCredentials(server.url);
const currentPreauthSecret = credentials?.preauthSecret || '';
setPreauthSecret(currentPreauthSecret);
// Auto-open advanced options if preauth secret exists
if (currentPreauthSecret) {
setShowAdvancedOptions(true);
}
} catch (error) {
// Credentials not found or error loading, keep empty
}
};
loadCredentials();
}, [server.url]);
useEffect(() => {
setButtonDisabled(Boolean(!displayName));
}, [displayName]);
// Validate server connection with preauth secret
const validateServer = useCallback(async (): Promise<boolean> => {
// Only validate if preauth secret has changed
setValidating(true);
try {
const trimmedSecret = preauthSecret.trim();
// Test with the actual preauth secret value (or undefined if empty)
const secretForValidation = trimmedSecret || undefined;
// First try HEAD request
const headRequest = await getServerUrlAfterRedirect(server.url, true, secretForValidation);
if (!headRequest.url) {
setPreauthSecretError(getErrorMessage(headRequest.error, intl));
setShowAdvancedOptions(true);
return false;
}
// Then try ping request - use doPing without client to avoid client pollution
const result = await doPing(headRequest.url, true, undefined, secretForValidation);
if (result.error) {
if (result.isPreauthError) {
setPreauthSecretError(formatMessage({
id: 'mobile.server.preauth_secret.invalid',
defaultMessage: 'Authentication secret is invalid. Try again or contact your admin.',
}));
setShowAdvancedOptions(true);
} else {
setPreauthSecretError(getErrorMessage(result.error, intl));
setShowAdvancedOptions(true);
}
return false;
}
return true;
} catch (error) {
// Handle any unexpected errors during validation
setPreauthSecretError(formatMessage({
id: 'mobile.server.validation.error',
defaultMessage: 'Unable to validate server. Please check your connection and try again.',
}));
setShowAdvancedOptions(true);
return false;
} finally {
setValidating(false);
}
}, [server.url, preauthSecret, formatMessage, intl]);
const handleUpdate = useCallback(async () => {
if (buttonDisabled) {
return;
@ -69,7 +148,13 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) =>
setDisplayNameError(undefined);
}
if (preauthSecretError) {
setPreauthSecretError(undefined);
}
setSaving(true);
// Check display name uniqueness
const knownServer = await getServerByDisplayName(displayName);
if (knownServer && knownServer.lastActiveAt > 0 && knownServer.url !== server.url) {
setButtonDisabled(true);
@ -81,15 +166,40 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) =>
return;
}
// Validate preauth secret if changed
const isValidServer = await validateServer();
if (!isValidServer) {
setSaving(false);
return;
}
// Save display name
await DatabaseManager.updateServerDisplayName(server.url, displayName);
// Save preauth secret to credentials (or remove if empty)
const credentials = await getServerCredentials(server.url);
setServerCredentials(server.url, credentials?.token || '', preauthSecret.trim() || undefined);
// Create and cache new client if preauth secret changed
try {
await NetworkManager.createClient(server.url, credentials?.token, preauthSecret.trim() || undefined);
} catch (error) {
// Client creation failed, but credentials are saved - continue with modal dismissal
}
dismissModal({componentId});
}, [!buttonDisabled && displayName, !buttonDisabled && displayNameError]);
}, [buttonDisabled, displayName, displayNameError, preauthSecretError, server.url, preauthSecret, formatMessage, validateServer, componentId]);
const handleDisplayNameTextChanged = useCallback((text: string) => {
setDisplayName(text);
setDisplayNameError(undefined);
}, []);
const handlePreauthSecretTextChanged = useCallback((text: string) => {
setPreauthSecret(text);
setPreauthSecretError(undefined);
}, []);
useNavButtonPressed(closeButtonId || '', componentId, close, []);
useAndroidHardwareBackHandler(componentId, close);
@ -120,13 +230,18 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) =>
<Header theme={theme}/>
<Form
buttonDisabled={buttonDisabled}
connecting={saving}
connecting={saving || validating}
displayName={displayName}
displayNameError={displayNameError}
handleUpdate={handleUpdate}
handleDisplayNameTextChanged={handleDisplayNameTextChanged}
handlePreauthSecretTextChanged={handlePreauthSecretTextChanged}
keyboardAwareRef={keyboardAwareRef}
preauthSecret={preauthSecret}
preauthSecretError={preauthSecretError}
serverUrl={server.url}
setShowAdvancedOptions={setShowAdvancedOptions}
showAdvancedOptions={showAdvancedOptions}
theme={theme}
/>
</KeyboardAwareScrollView>

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type RefObject, useCallback, useEffect, useRef} from 'react';
import React, {type RefObject, useCallback, useEffect, useRef, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, Pressable, View} from 'react-native';
import Animated, {FlipInEasyX, FlipOutXUp, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
@ -42,7 +42,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
alignItems: 'center',
maxWidth: 600,
width: '100%',
paddingHorizontal: 20,
paddingHorizontal: 24,
},
fullWidth: {
width: '100%',
@ -77,6 +77,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
marginLeft: 20,
marginRight: 20,
},
endAdornment: {
top: 2,
},
inputsContainer: {
gap: 24,
width: '100%',
@ -138,6 +141,7 @@ const ServerForm = ({
const preauthSecretRef = useRef<FloatingTextInputRef>(null);
const urlRef = useRef<FloatingTextInputRef>(null);
const styles = getStyleSheet(theme);
const [isPreauthSecretVisible] = useState(false);
useAvoidKeyboard(keyboardAwareRef, 1.8);
@ -259,12 +263,13 @@ const ServerForm = ({
rawInput={true}
enablesReturnKeyAutomatically={true}
error={preauthSecretError}
keyboardType={isPreauthSecretVisible ? 'visible-password' : 'default'}
label={formatMessage(messages.preauthSecret)}
onChangeText={handlePreauthSecretTextChanged}
onSubmitEditing={onConnect}
ref={preauthSecretRef}
returnKeyType='done'
secureTextEntry={true}
secureTextEntry={!isPreauthSecretVisible}
testID='server_form.preauth_secret.input'
theme={theme}
value={preauthSecret}

View file

@ -64,7 +64,7 @@ const Notifications = ({
}: NotificationsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]);
const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser]);
const callsRingingEnabled = useMemo(() => getCallsConfig(serverUrl).EnableRinging, [serverUrl]);
const [isRegistered, setIsRegistered] = useState(true);

View file

@ -339,7 +339,7 @@
"edit_server.display_help": "Server: {url}",
"edit_server.save": "Save",
"edit_server.saving": "Saving",
"edit_server.title": "Edit server name",
"edit_server.title": "Edit server",
"emoji_picker.activities": "Activities",
"emoji_picker.animals-nature": "Animals & Nature",
"emoji_picker.custom": "Custom",
@ -850,6 +850,7 @@
"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.server.validation.error": "Unable to validate server. Please try again.",
"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}}.",

View file

@ -79,7 +79,7 @@ const Attachments = ({canUploadFiles, maxFileCount, maxFileSize, theme}: Props)
}
return undefined;
}, [canUploadFiles, maxFileCount, maxFileSize, files, intl.locale]);
}, [canUploadFiles, maxFileCount, maxFileSize, files, intl]);
const attachmentsContainerStyle = useMemo(() => [
styles.container,