From 8dd10794ee65c71942cb3298905b2bedcbbf05b8 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 27 Apr 2026 22:56:29 +0200 Subject: [PATCH] Add custom header options to login settings (#1939) --- apps/mobile-app/app/login-settings.tsx | 373 +++++++++++++++++++--- apps/mobile-app/context/DialogContext.tsx | 8 +- apps/mobile-app/i18n/locales/en.json | 8 +- 3 files changed, 339 insertions(+), 50 deletions(-) diff --git a/apps/mobile-app/app/login-settings.tsx b/apps/mobile-app/app/login-settings.tsx index e3d24fd7d..22244b483 100644 --- a/apps/mobile-app/app/login-settings.tsx +++ b/apps/mobile-app/app/login-settings.tsx @@ -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(AppInfo.DEFAULT_API_URL); const [customUrl, setCustomUrl] = useState(''); + const [customHeaders, setCustomHeaders] = useState([]); + const [visibleHeaders, setVisibleHeaders] = useState([]); + 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 => { + 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 ( <> - - - - {t('app.loginSettings.title')} - + + + + + {t('app.loginSettings.title')} + - - {DEFAULT_OPTIONS.map(option => ( - handleOptionChange(option.value)} - testID={`api-option-${option.value === 'custom' ? 'custom' : 'default'}`} - > - - {option.label} - - - ))} + + {DEFAULT_OPTIONS.map(option => { + const isSelected = selectedOption === option.value; + const hasPanel = option.value === 'custom' && isSelected; + return ( + + handleOptionChange(option.value)} + testID={`api-option-${option.value === 'custom' ? 'custom' : 'default'}`} + > + + {option.label} + + - {selectedOption === 'custom' && ( - - {t('app.loginSettings.customApiUrl')} - - - )} - - {t('app.loginSettings.version', { version: AppInfo.VERSION })} - - + {hasPanel && ( + + + {t('app.loginSettings.customApiUrl')} + * + + + + + + setAdvancedExpanded(prev => !prev)} + testID="advanced-toggle" + > + {t('app.loginSettings.advancedSettings')} + + + + {advancedExpanded && ( + + {t('app.loginSettings.customProxyHeaders')} + {t('app.loginSettings.customProxyHeadersDescription')} + + + {customHeaders.map((header, index) => ( + + + + {t('app.loginSettings.headerName')} + updateHeader(index, 'name', value)} + placeholder={t('app.loginSettings.headerNamePlaceholder')} + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + testID={`custom-header-name-${index}`} + /> + {t('app.loginSettings.headerValue')} + + updateHeader(index, 'value', value)} + placeholder={t('app.loginSettings.headerValue')} + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry={!visibleHeaders[index]} + testID={`custom-header-value-${index}`} + /> + toggleHeaderVisibility(index)} + testID={`toggle-custom-header-visibility-${index}`} + > + + + + + removeHeader(index)} + testID={`delete-custom-header-${index}`} + > + + + + + ))} + + + + + + + )} + + )} + + ); + })} + + + {t('app.loginSettings.version', { version: AppInfo.VERSION })} + + + ); -} \ No newline at end of file +} diff --git a/apps/mobile-app/context/DialogContext.tsx b/apps/mobile-app/context/DialogContext.tsx index d60568272..c0274ef1c 100644 --- a/apps/mobile-app/context/DialogContext.tsx +++ b/apps/mobile-app/context/DialogContext.tsx @@ -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 => { 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); + }); + } }, })); diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 86125b42a..e48bbe50d 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -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": {