diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-08 18:04:46 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-08 18:04:46 +0000 |
| commit | ec621bf55aefda6649ce49d7ece6065ab2c54368 (patch) | |
| tree | 36511cd55f4c3246d7dcca20c7f6c65517ee9ab1 /apps/mobile | |
| parent | 27ed0a198f5c427c7044b1a24deade6054d89dac (diff) | |
| download | karakeep-ec621bf55aefda6649ce49d7ece6065ab2c54368.tar.zst | |
feat(mobile): add custom headers configuration in sign-in screen (#2103)
* feat(mobile): add custom headers configuration in sign-in screen
Add ability for mobile app users to configure custom HTTP headers that are
sent with every API request. This enables users to add authentication headers,
proxy headers, or other custom headers required by their server setup.
Changes:
- Add customHeaders field to mobile app settings schema
- Create CustomHeadersModal component for managing headers
- Update sign-in screen with link to configure custom headers
- Modify tRPC provider to merge custom headers with Authorization header
The custom headers are stored securely in the app settings and persist
across sessions.
* fix keyboard
* add custom headers to other callsites
---------
Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'apps/mobile')
| -rw-r--r-- | apps/mobile/app/signin.tsx | 24 | ||||
| -rw-r--r-- | apps/mobile/app/test-connection.tsx | 6 | ||||
| -rw-r--r-- | apps/mobile/components/CustomHeadersModal.tsx | 200 | ||||
| -rw-r--r-- | apps/mobile/lib/hooks.ts | 5 | ||||
| -rw-r--r-- | apps/mobile/lib/settings.ts | 2 | ||||
| -rw-r--r-- | apps/mobile/lib/upload.ts | 3 | ||||
| -rw-r--r-- | apps/mobile/lib/utils.ts | 15 |
7 files changed, 250 insertions, 5 deletions
diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx index 4f74c331..3dc41a0c 100644 --- a/apps/mobile/app/signin.tsx +++ b/apps/mobile/app/signin.tsx @@ -7,6 +7,7 @@ import { View, } from "react-native"; import { Redirect, useRouter } from "expo-router"; +import { CustomHeadersModal } from "@/components/CustomHeadersModal"; import Logo from "@/components/Logo"; import { TailwindResolver } from "@/components/TailwindResolver"; import { Button } from "@/components/ui/Button"; @@ -31,6 +32,8 @@ export default function Signin() { const [tempServerAddress, setTempServerAddress] = useState( "https://cloud.karakeep.app", ); + const [isCustomHeadersModalVisible, setIsCustomHeadersModalVisible] = + useState(false); const emailRef = useRef<string>(""); const passwordRef = useRef<string>(""); @@ -79,6 +82,10 @@ export default function Signin() { return <Redirect href="dashboard" />; } + const handleSaveCustomHeaders = (headers: Record<string, string>) => { + setSettings({ ...settings, customHeaders: headers }); + }; + const onSignin = () => { if (!tempServerAddress) { setError("Server address is required"); @@ -184,6 +191,17 @@ export default function Signin() { </Button> </View> )} + <Pressable + onPress={() => setIsCustomHeadersModalVisible(true)} + className="mt-1" + > + <Text className="text-xs text-gray-500 underline"> + Configure Custom Headers{" "} + {settings.customHeaders && + Object.keys(settings.customHeaders).length > 0 && + `(${Object.keys(settings.customHeaders).length})`} + </Text> + </Pressable> </View> {loginType === LoginType.Password && ( <> @@ -264,6 +282,12 @@ export default function Signin() { </Pressable> </View> </TouchableWithoutFeedback> + <CustomHeadersModal + visible={isCustomHeadersModalVisible} + customHeaders={settings.customHeaders || {}} + onClose={() => setIsCustomHeadersModalVisible(false)} + onSave={handleSaveCustomHeaders} + /> </KeyboardAvoidingView> ); } diff --git a/apps/mobile/app/test-connection.tsx b/apps/mobile/app/test-connection.tsx index a9ec6e5e..4cf69fcf 100644 --- a/apps/mobile/app/test-connection.tsx +++ b/apps/mobile/app/test-connection.tsx @@ -6,7 +6,7 @@ import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import { Input } from "@/components/ui/Input"; import { Text } from "@/components/ui/Text"; import useAppSettings from "@/lib/settings"; -import { cn } from "@/lib/utils"; +import { buildApiHeaders, cn } from "@/lib/utils"; import { z } from "zod"; export default function TestConnection() { @@ -70,6 +70,10 @@ export default function TestConnection() { appendText("Using address: " + settings.address); request.open("GET", `${settings.address}/api/health`); + const headers = buildApiHeaders(settings.apiKey, settings.customHeaders); + Object.entries(headers).forEach(([key, value]) => { + request.setRequestHeader(key, value); + }); request.send(); } runTest(); diff --git a/apps/mobile/components/CustomHeadersModal.tsx b/apps/mobile/components/CustomHeadersModal.tsx new file mode 100644 index 00000000..6d022eb8 --- /dev/null +++ b/apps/mobile/components/CustomHeadersModal.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; +import { Plus, Trash2, X } from "lucide-react-native"; +import { useColorScheme } from "nativewind"; + +import { Button } from "./ui/Button"; +import { Input } from "./ui/Input"; +import { Text } from "./ui/Text"; + +interface CustomHeadersModalProps { + visible: boolean; + customHeaders: Record<string, string>; + onClose: () => void; + onSave: (headers: Record<string, string>) => void; +} + +export function CustomHeadersModal({ + visible, + customHeaders, + onClose, + onSave, +}: CustomHeadersModalProps) { + const { colorScheme } = useColorScheme(); + const iconColor = colorScheme === "dark" ? "#d1d5db" : "#374151"; + + // Convert headers object to array of entries for easier manipulation + const [headers, setHeaders] = useState<{ key: string; value: string }[]>( + Object.entries(customHeaders).map(([key, value]) => ({ key, value })), + ); + const [newHeaderKey, setNewHeaderKey] = useState(""); + const [newHeaderValue, setNewHeaderValue] = useState(""); + + const handleAddHeader = () => { + if (!newHeaderKey.trim() || !newHeaderValue.trim()) { + return; + } + + // Check if header already exists + const existingIndex = headers.findIndex((h) => h.key === newHeaderKey); + if (existingIndex >= 0) { + // Update existing header + const updatedHeaders = [...headers]; + updatedHeaders[existingIndex].value = newHeaderValue; + setHeaders(updatedHeaders); + } else { + // Add new header + setHeaders([...headers, { key: newHeaderKey, value: newHeaderValue }]); + } + + setNewHeaderKey(""); + setNewHeaderValue(""); + }; + + const handleRemoveHeader = (index: number) => { + setHeaders(headers.filter((_, i) => i !== index)); + }; + + const handleSave = () => { + // Convert array back to object + const headersObject = headers.reduce( + (acc, { key, value }) => { + if (key.trim() && value.trim()) { + acc[key] = value; + } + return acc; + }, + {} as Record<string, string>, + ); + + onSave(headersObject); + onClose(); + }; + + const handleCancel = () => { + // Reset to original headers + setHeaders( + Object.entries(customHeaders).map(([key, value]) => ({ key, value })), + ); + setNewHeaderKey(""); + setNewHeaderValue(""); + onClose(); + }; + + return ( + <Modal + visible={visible} + transparent + animationType="slide" + onRequestClose={handleCancel} + > + <View className="flex-1 justify-end"> + <Pressable + className="absolute inset-0 bg-black/50" + onPress={handleCancel} + /> + <View className="max-h-[85%] rounded-t-3xl bg-card"> + <KeyboardAwareScrollView + contentContainerClassName="p-6" + bottomOffset={20} + keyboardShouldPersistTaps="handled" + > + {/* Header */} + <View className="mb-4 flex flex-row items-center justify-between"> + <Text className="text-lg font-semibold">Custom Headers</Text> + <Pressable onPress={handleCancel} className="p-2"> + <X size={24} color={iconColor} /> + </Pressable> + </View> + + <Text className="mb-4 text-sm text-gray-600 dark:text-gray-400"> + Add custom HTTP headers that will be sent with every API request. + </Text> + + {/* Existing Headers List */} + <View className="mb-4 max-h-64"> + {headers.length === 0 ? ( + <Text className="py-4 text-center text-sm text-gray-500 dark:text-gray-400"> + No custom headers configured + </Text> + ) : ( + <ScrollView> + {headers.map((header, index) => ( + <View + key={index} + className="mb-2 flex-row items-center gap-2 rounded-lg border border-border bg-background p-3" + > + <View className="flex-1"> + <Text className="text-sm font-semibold"> + {header.key} + </Text> + <Text + className="text-xs text-gray-600 dark:text-gray-400" + numberOfLines={1} + > + {header.value} + </Text> + </View> + <Pressable + onPress={() => handleRemoveHeader(index)} + className="p-2" + > + <Trash2 size={18} color="#ef4444" /> + </Pressable> + </View> + ))} + </ScrollView> + )} + </View> + + {/* Add New Header */} + <View className="gap-2 border-t border-border pt-4"> + <Text className="text-sm font-semibold">Add New Header</Text> + <Input + placeholder="Header Name (e.g., X-Custom-Header)" + value={newHeaderKey} + onChangeText={setNewHeaderKey} + autoCapitalize="none" + inputClasses="bg-background" + /> + <Input + placeholder="Header Value" + value={newHeaderValue} + onChangeText={setNewHeaderValue} + autoCapitalize="none" + inputClasses="bg-background" + /> + <Button + variant="secondary" + onPress={handleAddHeader} + disabled={!newHeaderKey.trim() || !newHeaderValue.trim()} + > + <Plus size={16} color={iconColor} /> + <Text className="text-sm">Add Header</Text> + </Button> + </View> + + {/* Action Buttons */} + <View className="mt-4 flex flex-row gap-2 border-t border-border pt-4"> + <Button + variant="secondary" + onPress={handleCancel} + androidRootClassName="flex-1" + > + <Text>Cancel</Text> + </Button> + <Button + variant="primary" + onPress={handleSave} + androidRootClassName="flex-1" + > + <Text>Save</Text> + </Button> + </View> + </KeyboardAwareScrollView> + </View> + </View> + </Modal> + ); +} diff --git a/apps/mobile/lib/hooks.ts b/apps/mobile/lib/hooks.ts index beeab391..38ecebea 100644 --- a/apps/mobile/lib/hooks.ts +++ b/apps/mobile/lib/hooks.ts @@ -1,13 +1,12 @@ import { ImageURISource } from "react-native"; import useAppSettings from "./settings"; +import { buildApiHeaders } from "./utils"; export function useAssetUrl(assetId: string): ImageURISource { const { settings } = useAppSettings(); return { uri: `${settings.address}/api/assets/${assetId}`, - headers: { - Authorization: `Bearer ${settings.apiKey}`, - }, + headers: buildApiHeaders(settings.apiKey, settings.customHeaders), }; } diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts index 4399e04a..aa931b9e 100644 --- a/apps/mobile/lib/settings.ts +++ b/apps/mobile/lib/settings.ts @@ -15,6 +15,7 @@ const zSettingsSchema = z.object({ .optional() .default("reader"), showNotes: z.boolean().optional().default(false), + customHeaders: z.record(z.string(), z.string()).optional().default({}), }); export type Settings = z.infer<typeof zSettingsSchema>; @@ -34,6 +35,7 @@ const useSettings = create<AppSettingsState>((set, get) => ({ theme: "system", defaultBookmarkView: "reader", showNotes: false, + customHeaders: {}, }, }, setSettings: async (settings) => { diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts index 0eeab380..06f007f7 100644 --- a/apps/mobile/lib/upload.ts +++ b/apps/mobile/lib/upload.ts @@ -9,6 +9,7 @@ import { import type { Settings } from "./settings"; import { api } from "./trpc"; +import { buildApiHeaders } from "./utils"; export function useUploadAsset( settings: Settings, @@ -43,7 +44,7 @@ export function useUploadAsset( "POST", `${settings.address}/api/assets`, { - Authorization: `Bearer ${settings.apiKey}`, + ...buildApiHeaders(settings.apiKey, settings.customHeaders), "Content-Type": "multipart/form-data", }, [ diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts index bfb6c9ed..ce729826 100644 --- a/apps/mobile/lib/utils.ts +++ b/apps/mobile/lib/utils.ts @@ -42,3 +42,18 @@ export function condProps( return condition ? { ...acc, ...props } : acc; }, {}); } + +/** + * Build HTTP headers for API requests, merging Authorization and custom headers. + * This ensures all direct HTTP calls (uploads, downloads, health checks) respect + * the user's custom header configuration. + */ +export function buildApiHeaders( + apiKey: string | undefined, + customHeaders: Record<string, string> = {}, +): Record<string, string> { + return { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + ...customHeaders, + }; +} |
