Add custom header options to login settings (#1939)

This commit is contained in:
Leendert de Borst
2026-04-27 22:56:29 +02:00
parent 1dd0df6c8d
commit 8dd10794ee
3 changed files with 339 additions and 50 deletions

View File

@@ -1,6 +1,7 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useNavigation, useRouter } from 'expo-router';
import { useState, useEffect, useCallback, useMemo, useLayoutEffect } from 'react';
import { StyleSheet, View, Text, TextInput, ActivityIndicator } from 'react-native';
import { StyleSheet, View, Text, TextInput, ActivityIndicator, KeyboardAvoidingView, Platform } from 'react-native';
import { AppInfo } from '@/utils/AppInfo';
@@ -19,6 +20,19 @@ type ApiOption = {
value: string;
};
type CustomHeader = {
name: string;
value: string;
};
/**
* Check whether a header name conflicts with built-in AliasVault headers.
*/
const isReservedHeaderName = (name: string): boolean => {
const lower = name.trim().toLowerCase();
return lower === 'authorization' || lower.startsWith('x-aliasvault-');
};
/**
* Settings screen (for logged out users).
*/
@@ -29,6 +43,9 @@ export default function SettingsScreen() : React.ReactNode {
const { t } = useTranslation();
const [selectedOption, setSelectedOption] = useState<string>(AppInfo.DEFAULT_API_URL);
const [customUrl, setCustomUrl] = useState<string>('');
const [customHeaders, setCustomHeaders] = useState<CustomHeader[]>([]);
const [visibleHeaders, setVisibleHeaders] = useState<boolean[]>([]);
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const DEFAULT_OPTIONS: ApiOption[] = useMemo(() => [
@@ -64,6 +81,15 @@ export default function SettingsScreen() : React.ReactNode {
setSelectedOption('custom');
setCustomUrl(apiUrl);
}
const headersJson = await NativeVaultManager.getCustomProxyHeaders();
const parsed = JSON.parse(headersJson || '[]');
if (Array.isArray(parsed) && parsed.length > 0) {
setCustomHeaders(parsed.map((h: CustomHeader) => ({ name: h?.name ?? '', value: h?.value ?? '' })));
setVisibleHeaders(parsed.map(() => false));
// Pre-expand the advanced section so existing config is visible after a re-open.
setAdvancedExpanded(true);
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
@@ -102,12 +128,97 @@ export default function SettingsScreen() : React.ReactNode {
}
};
/**
* Persist the current header list to native storage, dropping invalid/empty/reserved entries.
*/
const persistHeaders = useCallback(async (next: CustomHeader[]): Promise<void> => {
const cleaned = next
.map(h => ({ name: h.name.trim(), value: h.value.trim() }))
.filter(h => h.name.length > 0 && h.value.length > 0 && !isReservedHeaderName(h.name));
try {
await NativeVaultManager.setCustomProxyHeaders(JSON.stringify(cleaned));
} catch (error) {
console.error('Failed to save custom proxy headers:', error);
}
}, []);
/**
* Update a field of a header row and persist.
*/
const updateHeader = (index: number, field: 'name' | 'value', value: string): void => {
setCustomHeaders(prev => {
const next = prev.map((h, i) => (i === index ? { ...h, [field]: value } : h));
persistHeaders(next);
return next;
});
};
/**
* Remove a header row and persist.
*/
const removeHeader = (index: number): void => {
setCustomHeaders(prev => {
const next = prev.filter((_, i) => i !== index);
persistHeaders(next);
return next;
});
setVisibleHeaders(prev => prev.filter((_, i) => i !== index));
};
/**
* Add a new empty header row.
*/
const addHeader = (): void => {
setCustomHeaders(prev => [...prev, { name: '', value: '' }]);
setVisibleHeaders(prev => [...prev, false]);
};
/**
* Toggle visibility of a header value.
*/
const toggleHeaderVisibility = (index: number): void => {
setVisibleHeaders(prev => prev.map((v, i) => (i === index ? !v : v)));
};
const styles = StyleSheet.create({
addButton: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
justifyContent: 'center',
padding: 12,
},
content: {
flex: 1,
},
deleteButton: {
alignItems: 'center',
height: 44,
justifyContent: 'center',
width: 44,
},
formContainer: {
gap: 16,
gap: 8,
},
headerBlock: {
backgroundColor: colors.background,
borderRadius: 8,
padding: 12,
},
headerFields: {
flex: 1,
gap: 8,
},
headerRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
headersList: {
gap: 12,
marginTop: 8,
},
input: {
backgroundColor: colors.accentBackground,
@@ -118,12 +229,73 @@ export default function SettingsScreen() : React.ReactNode {
fontSize: 16,
padding: 12,
},
valueInputWrapper: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
flexDirection: 'row',
},
valueInputInner: {
color: colors.text,
flex: 1,
fontSize: 16,
padding: 12,
},
visibilityToggle: {
alignItems: 'center',
borderLeftColor: colors.accentBorder,
borderLeftWidth: 1,
height: 44,
justifyContent: 'center',
paddingHorizontal: 12,
},
label: {
color: colors.text,
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
},
advancedBody: {
gap: 8,
marginTop: 8,
},
advancedDescription: {
color: colors.textMuted,
fontSize: 13,
lineHeight: 18,
},
advancedToggle: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 6,
},
advancedToggleText: {
color: colors.text,
fontSize: 14,
fontWeight: '600',
},
connectedPanel: {
backgroundColor: colors.accentBackground,
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
borderColor: colors.accentBorder,
borderTopWidth: 0,
borderWidth: 1,
marginBottom: 8,
padding: 16,
},
panelDivider: {
backgroundColor: colors.accentBorder,
height: 1,
marginVertical: 16,
},
requiredMark: {
color: colors.red,
fontWeight: '700',
},
optionButton: {
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
@@ -132,6 +304,11 @@ export default function SettingsScreen() : React.ReactNode {
marginBottom: 8,
padding: 12,
},
optionButtonAttached: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
marginBottom: 0,
},
optionButtonSelected: {
backgroundColor: colors.primary,
borderColor: colors.primary,
@@ -177,54 +354,154 @@ export default function SettingsScreen() : React.ReactNode {
return (
<>
<ThemedContainer>
<ThemedScrollView>
<ThemedView style={styles.content}>
<View style={styles.titleContainer}>
<Text style={styles.title}>{t('app.loginSettings.title')}</Text>
</View>
<KeyboardAvoidingView
style={styles.content}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 30 : 80}
>
<ThemedScrollView keyboardShouldPersistTaps="handled">
<ThemedView style={styles.content}>
<View style={styles.titleContainer}>
<Text style={styles.title}>{t('app.loginSettings.title')}</Text>
</View>
<View style={styles.formContainer}>
{DEFAULT_OPTIONS.map(option => (
<RobustPressable
key={option.value}
style={[
styles.optionButton,
selectedOption === option.value && styles.optionButtonSelected
]}
onPress={() => handleOptionChange(option.value)}
testID={`api-option-${option.value === 'custom' ? 'custom' : 'default'}`}
>
<Text style={[
styles.optionButtonText,
selectedOption === option.value && styles.optionButtonTextSelected
]}>
{option.label}
</Text>
</RobustPressable>
))}
<View style={styles.formContainer}>
{DEFAULT_OPTIONS.map(option => {
const isSelected = selectedOption === option.value;
const hasPanel = option.value === 'custom' && isSelected;
return (
<View key={option.value}>
<RobustPressable
style={[
styles.optionButton,
isSelected && styles.optionButtonSelected,
hasPanel && styles.optionButtonAttached,
]}
onPress={() => handleOptionChange(option.value)}
testID={`api-option-${option.value === 'custom' ? 'custom' : 'default'}`}
>
<Text style={[
styles.optionButtonText,
isSelected && styles.optionButtonTextSelected
]}>
{option.label}
</Text>
</RobustPressable>
{selectedOption === 'custom' && (
<View>
<Text style={styles.label}>{t('app.loginSettings.customApiUrl')}</Text>
<TextInput
style={styles.input}
value={customUrl}
onChangeText={handleCustomUrlChange}
placeholder={t('app.loginSettings.customApiUrlPlaceholder')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
multiline={false}
numberOfLines={1}
testID="custom-api-url-input"
/>
</View>
)}
</View>
<Text style={styles.versionText}>{t('app.loginSettings.version', { version: AppInfo.VERSION })}</Text>
</ThemedView>
</ThemedScrollView>
{hasPanel && (
<View style={styles.connectedPanel}>
<Text style={styles.label}>
{t('app.loginSettings.customApiUrl')}
<Text style={styles.requiredMark}> *</Text>
</Text>
<TextInput
style={styles.input}
value={customUrl}
onChangeText={handleCustomUrlChange}
placeholder={t('app.loginSettings.customApiUrlPlaceholder')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
multiline={false}
numberOfLines={1}
testID="custom-api-url-input"
/>
<View style={styles.panelDivider} />
<RobustPressable
style={styles.advancedToggle}
onPress={() => setAdvancedExpanded(prev => !prev)}
testID="advanced-toggle"
>
<Text style={styles.advancedToggleText}>{t('app.loginSettings.advancedSettings')}</Text>
<MaterialIcons
name={advancedExpanded ? 'expand-less' : 'expand-more'}
size={24}
color={colors.textMuted}
/>
</RobustPressable>
{advancedExpanded && (
<View style={styles.advancedBody}>
<Text style={styles.label}>{t('app.loginSettings.customProxyHeaders')}</Text>
<Text style={styles.advancedDescription}>{t('app.loginSettings.customProxyHeadersDescription')}</Text>
<View style={styles.headersList}>
{customHeaders.map((header, index) => (
<View key={index} style={styles.headerBlock}>
<View style={styles.headerRow}>
<View style={styles.headerFields}>
<Text style={styles.label}>{t('app.loginSettings.headerName')}</Text>
<TextInput
style={styles.input}
value={header.name}
onChangeText={value => updateHeader(index, 'name', value)}
placeholder={t('app.loginSettings.headerNamePlaceholder')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
testID={`custom-header-name-${index}`}
/>
<Text style={styles.label}>{t('app.loginSettings.headerValue')}</Text>
<View style={styles.valueInputWrapper}>
<TextInput
style={styles.valueInputInner}
value={header.value}
onChangeText={value => updateHeader(index, 'value', value)}
placeholder={t('app.loginSettings.headerValue')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!visibleHeaders[index]}
testID={`custom-header-value-${index}`}
/>
<RobustPressable
style={styles.visibilityToggle}
onPress={() => toggleHeaderVisibility(index)}
testID={`toggle-custom-header-visibility-${index}`}
>
<MaterialIcons
name={visibleHeaders[index] ? 'visibility-off' : 'visibility'}
size={20}
color={colors.primary}
/>
</RobustPressable>
</View>
</View>
<RobustPressable
style={styles.deleteButton}
onPress={() => removeHeader(index)}
testID={`delete-custom-header-${index}`}
>
<MaterialIcons name="delete-outline" size={24} color={colors.red} />
</RobustPressable>
</View>
</View>
))}
<RobustPressable
style={styles.addButton}
onPress={addHeader}
testID="add-custom-header"
>
<MaterialIcons name="add" size={24} color={colors.primary} />
</RobustPressable>
</View>
</View>
)}
</View>
)}
</View>
);
})}
</View>
<Text style={styles.versionText}>{t('app.loginSettings.version', { version: AppInfo.VERSION })}</Text>
</ThemedView>
</ThemedScrollView>
</KeyboardAvoidingView>
</ThemedContainer>
</>
);
}
}

View File

@@ -231,9 +231,15 @@ export function DialogProvider({ children }: DialogProviderProps): React.ReactNo
// Wrap button onPress to auto-close dialog
const wrappedButtons = buttons.map(btn => ({
...btn,
onPress: async (): Promise<void> => {
await btn.onPress?.();
onPress: (): void => {
setDialogConfig(null);
const result = btn.onPress?.();
if (result instanceof Promise) {
result.catch((error) => {
console.error('Dialog button action error:', error);
});
}
},
}));

View File

@@ -633,7 +633,13 @@
"selfHosted": "Self-hosted",
"customApiUrl": "Custom API URL",
"customApiUrlPlaceholder": "https://my-aliasvault-instance.com/api",
"version": "Version: {{version}}"
"version": "Version: {{version}}",
"advancedSettings": "Advanced settings",
"customProxyHeaders": "Custom proxy headers",
"customProxyHeadersDescription": "Add HTTP headers that will be sent with every request to your AliasVault server. Can be used for self-hosted setups behind a reverse proxy that checks for custom headers (e.g. Pangolin, Cloudflare Access).",
"headerName": "Header name",
"headerNamePlaceholder": "X-Custom-Header",
"headerValue": "Header value"
}
},
"upgrade": {