aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile')
-rw-r--r--apps/mobile/app/signin.tsx24
-rw-r--r--apps/mobile/app/test-connection.tsx6
-rw-r--r--apps/mobile/components/CustomHeadersModal.tsx200
-rw-r--r--apps/mobile/lib/hooks.ts5
-rw-r--r--apps/mobile/lib/settings.ts2
-rw-r--r--apps/mobile/lib/upload.ts3
-rw-r--r--apps/mobile/lib/utils.ts15
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,
+ };
+}