diff options
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, + }; +} |
