diff --git a/app/login.tsx b/app/login.tsx
index c0ed61d4..33d06d41 100644
--- a/app/login.tsx
+++ b/app/login.tsx
@@ -10,6 +10,7 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
+ Switch,
TouchableOpacity,
View,
} from "react-native";
@@ -20,8 +21,13 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
+import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
+import type {
+ AccountSecurityType,
+ SavedServer,
+} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
@@ -37,6 +43,7 @@ const Login: React.FC = () => {
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
+ loginWithPassword,
} = useJellyfin();
const {
@@ -57,6 +64,14 @@ const Login: React.FC = () => {
password: _password || "",
});
+ // Save account state
+ const [saveAccount, setSaveAccount] = useState(false);
+ const [showSaveModal, setShowSaveModal] = useState(false);
+ const [pendingLogin, setPendingLogin] = useState<{
+ username: string;
+ password: string;
+ } | null>(null);
+
/**
* A way to auto login based on a link
*/
@@ -101,12 +116,34 @@ const Login: React.FC = () => {
const handleLogin = async () => {
Keyboard.dismiss();
+ const result = CredentialsSchema.safeParse(credentials);
+ if (!result.success) return;
+
+ if (saveAccount) {
+ // Show save account modal to choose security type
+ setPendingLogin({
+ username: credentials.username,
+ password: credentials.password,
+ });
+ setShowSaveModal(true);
+ } else {
+ // Login without saving
+ await performLogin(credentials.username, credentials.password);
+ }
+ };
+
+ const performLogin = async (
+ username: string,
+ password: string,
+ options?: {
+ saveAccount?: boolean;
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+ },
+ ) => {
setLoading(true);
try {
- const result = CredentialsSchema.safeParse(credentials);
- if (result.success) {
- await login(credentials.username, credentials.password, serverName);
- }
+ await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
@@ -118,11 +155,45 @@ const Login: React.FC = () => {
}
} finally {
setLoading(false);
+ setPendingLogin(null);
}
};
- const handleQuickLoginWithSavedCredential = async (serverUrl: string) => {
- await loginWithSavedCredential(serverUrl);
+ const handleSaveAccountConfirm = async (
+ securityType: AccountSecurityType,
+ pinCode?: string,
+ ) => {
+ setShowSaveModal(false);
+ if (pendingLogin) {
+ await performLogin(pendingLogin.username, pendingLogin.password, {
+ saveAccount: true,
+ securityType,
+ pinCode,
+ });
+ }
+ };
+
+ const handleQuickLoginWithSavedCredential = async (
+ serverUrl: string,
+ userId: string,
+ ) => {
+ await loginWithSavedCredential(serverUrl, userId);
+ };
+
+ const handlePasswordLogin = async (
+ serverUrl: string,
+ username: string,
+ password: string,
+ ) => {
+ await loginWithPassword(serverUrl, username, password);
+ };
+
+ const handleAddAccount = (server: SavedServer) => {
+ // Server is already selected, go to credential entry
+ setServer({ address: server.address });
+ if (server.name) {
+ setServerName(server.name);
+ }
};
/**
@@ -394,6 +465,8 @@ const Login: React.FC = () => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
+ onPasswordLogin={handlePasswordLogin}
+ onAddAccount={handleAddAccount}
/>
@@ -470,6 +543,21 @@ const Login: React.FC = () => {
clearButtonMode='while-editing'
maxLength={500}
/>
+ setSaveAccount(!saveAccount)}
+ className='flex flex-row items-center py-2'
+ activeOpacity={0.7}
+ >
+
+
+ {t("save_account.save_for_later")}
+
+
)}
+
+ {/* Save Account Modal */}
+ {
+ setShowSaveModal(false);
+ setPendingLogin(null);
+ }}
+ onSave={handleSaveAccountConfirm}
+ username={pendingLogin?.username || credentials.username}
+ />
);
};
diff --git a/bun.lock b/bun.lock
index 371dbfb8..5b155ec3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -29,6 +29,7 @@
"expo-brightness": "~14.0.8",
"expo-build-properties": "~1.0.10",
"expo-constants": "18.0.13",
+ "expo-crypto": "^15.0.8",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-font": "~14.0.10",
@@ -1007,6 +1008,8 @@
"expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="],
+ "expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="],
+
"expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="],
"expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="],
diff --git a/components/AccountsSheet.tsx b/components/AccountsSheet.tsx
new file mode 100644
index 00000000..9f089455
--- /dev/null
+++ b/components/AccountsSheet.tsx
@@ -0,0 +1,223 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, Platform, TouchableOpacity, View } from "react-native";
+import { Swipeable } from "react-native-gesture-handler";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Colors } from "@/constants/Colors";
+import {
+ deleteAccountCredential,
+ type SavedServer,
+ type SavedServerAccount,
+} from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+
+interface AccountsSheetProps {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ server: SavedServer | null;
+ onAccountSelect: (account: SavedServerAccount) => void;
+ onAddAccount: () => void;
+ onAccountDeleted?: () => void;
+}
+
+export const AccountsSheet: React.FC = ({
+ open,
+ setOpen,
+ server,
+ onAccountSelect,
+ onAddAccount,
+ onAccountDeleted,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["50%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (open) {
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [open]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleDeleteAccount = async (account: SavedServerAccount) => {
+ if (!server) return;
+
+ Alert.alert(
+ t("server.remove_saved_login"),
+ t("server.remove_account_description", { username: account.username }),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.remove"),
+ style: "destructive",
+ onPress: async () => {
+ await deleteAccountCredential(server.address, account.userId);
+ onAccountDeleted?.();
+ },
+ },
+ ],
+ );
+ };
+
+ const getSecurityIcon = (
+ securityType: SavedServerAccount["securityType"],
+ ): keyof typeof Ionicons.glyphMap => {
+ switch (securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
+ const renderRightActions = (account: SavedServerAccount) => (
+ handleDeleteAccount(account)}
+ className='bg-red-600 justify-center items-center px-5'
+ >
+
+
+ );
+
+ if (!server) return null;
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("server.select_account")}
+
+
+ {server.name || server.address}
+
+
+
+ {/* Account List */}
+
+ {server.accounts.map((account, index) => (
+ renderRightActions(account)}
+ overshootRight={false}
+ >
+ {
+ setOpen(false);
+ onAccountSelect(account);
+ }}
+ className={`flex-row items-center p-4 bg-neutral-800 ${
+ index < server.accounts.length - 1
+ ? "border-b border-neutral-700"
+ : ""
+ }`}
+ >
+ {/* Avatar */}
+
+
+
+
+ {/* Account Info */}
+
+
+ {account.username}
+
+
+ {account.securityType === "none"
+ ? t("save_account.no_protection")
+ : account.securityType === "pin"
+ ? t("save_account.pin_code")
+ : t("save_account.password")}
+
+
+
+ {/* Security Icon */}
+
+
+
+ ))}
+
+
+ {/* Hint */}
+
+ {t("server.swipe_to_remove")}
+
+
+ {/* Add Account Button */}
+
+
+
+
+ );
+};
diff --git a/components/PINEntryModal.tsx b/components/PINEntryModal.tsx
new file mode 100644
index 00000000..f450f5eb
--- /dev/null
+++ b/components/PINEntryModal.tsx
@@ -0,0 +1,231 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Alert,
+ Animated,
+ Keyboard,
+ Platform,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useHaptic } from "@/hooks/useHaptic";
+import { verifyAccountPIN } from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+import { PinInput } from "./inputs/PinInput";
+
+interface PINEntryModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ onForgotPIN?: () => void;
+ serverUrl: string;
+ userId: string;
+ username: string;
+}
+
+export const PINEntryModal: React.FC = ({
+ visible,
+ onClose,
+ onSuccess,
+ onForgotPIN,
+ serverUrl,
+ userId,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+ const [pinCode, setPinCode] = useState("");
+ const [error, setError] = useState(null);
+ const [isVerifying, setIsVerifying] = useState(false);
+ const shakeAnimation = useRef(new Animated.Value(0)).current;
+ const errorHaptic = useHaptic("error");
+ const successHaptic = useHaptic("success");
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["50%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (visible) {
+ bottomSheetModalRef.current?.present();
+ setPinCode("");
+ setError(null);
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [visible]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setPinCode("");
+ setError(null);
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const shake = () => {
+ Animated.sequence([
+ Animated.timing(shakeAnimation, {
+ toValue: 10,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: -10,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: 10,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shakeAnimation, {
+ toValue: 0,
+ duration: 50,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ };
+
+ const handlePinChange = async (value: string) => {
+ setPinCode(value);
+ setError(null);
+
+ // Auto-verify when 4 digits entered
+ if (value.length === 4) {
+ setIsVerifying(true);
+ try {
+ const isValid = await verifyAccountPIN(serverUrl, userId, value);
+ if (isValid) {
+ Keyboard.dismiss();
+ successHaptic();
+ onSuccess();
+ setPinCode("");
+ } else {
+ errorHaptic();
+ setError(t("pin.invalid_pin"));
+ shake();
+ setPinCode("");
+ }
+ } catch {
+ errorHaptic();
+ setError(t("pin.invalid_pin"));
+ shake();
+ setPinCode("");
+ } finally {
+ setIsVerifying(false);
+ }
+ }
+ };
+
+ const handleForgotPIN = () => {
+ Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("common.continue"),
+ style: "destructive",
+ onPress: () => {
+ onClose();
+ onForgotPIN?.();
+ },
+ },
+ ]);
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("pin.enter_pin")}
+
+
+ {t("pin.enter_pin_for", { username })}
+
+
+
+ {/* PIN Input */}
+
+
+ {error && (
+ {error}
+ )}
+ {isVerifying && (
+
+ {t("common.verifying") || "Verifying..."}
+
+ )}
+
+
+ {/* Forgot PIN */}
+
+
+ {t("pin.forgot_pin")}
+
+
+
+ {/* Cancel Button */}
+
+
+
+
+ );
+};
diff --git a/components/PasswordEntryModal.tsx b/components/PasswordEntryModal.tsx
new file mode 100644
index 00000000..63b4efe6
--- /dev/null
+++ b/components/PasswordEntryModal.tsx
@@ -0,0 +1,185 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetTextInput,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ActivityIndicator, Platform, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useHaptic } from "@/hooks/useHaptic";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+
+interface PasswordEntryModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSubmit: (password: string) => Promise;
+ username: string;
+}
+
+export const PasswordEntryModal: React.FC = ({
+ visible,
+ onClose,
+ onSubmit,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const errorHaptic = useHaptic("error");
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["50%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (visible) {
+ bottomSheetModalRef.current?.present();
+ setPassword("");
+ setError(null);
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [visible]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setPassword("");
+ setError(null);
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleSubmit = async () => {
+ if (!password) {
+ setError(t("password.enter_password"));
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ await onSubmit(password);
+ setPassword("");
+ } catch {
+ errorHaptic();
+ setError(t("password.invalid_password"));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("password.enter_password")}
+
+
+ {t("password.enter_password_for", { username })}
+
+
+
+ {/* Password Input */}
+
+
+ {t("login.password")}
+
+ {
+ setPassword(text);
+ setError(null);
+ }}
+ placeholder={t("login.password")}
+ placeholderTextColor='#6B7280'
+ secureTextEntry
+ autoFocus
+ autoCapitalize='none'
+ autoCorrect={false}
+ style={{
+ backgroundColor: "#1F2937",
+ borderRadius: 8,
+ padding: 12,
+ color: "white",
+ fontSize: 16,
+ }}
+ onSubmitEditing={handleSubmit}
+ returnKeyType='done'
+ />
+ {error && {error}}
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx
index ca801901..008e1be2 100644
--- a/components/PreviousServersList.tsx
+++ b/components/PreviousServersList.tsx
@@ -7,57 +7,171 @@ import { Swipeable } from "react-native-gesture-handler";
import { useMMKVString } from "react-native-mmkv";
import { Colors } from "@/constants/Colors";
import {
- deleteServerCredential,
+ deleteAccountCredential,
+ getPreviousServers,
removeServerFromList,
type SavedServer,
+ type SavedServerAccount,
} from "@/utils/secureCredentials";
+import { AccountsSheet } from "./AccountsSheet";
import { Text } from "./common/Text";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
+import { PasswordEntryModal } from "./PasswordEntryModal";
+import { PINEntryModal } from "./PINEntryModal";
interface PreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
- onQuickLogin?: (serverUrl: string) => Promise;
+ onQuickLogin?: (serverUrl: string, userId: string) => Promise;
+ onPasswordLogin?: (
+ serverUrl: string,
+ username: string,
+ password: string,
+ ) => Promise;
+ onAddAccount?: (server: SavedServer) => void;
}
export const PreviousServersList: React.FC = ({
onServerSelect,
onQuickLogin,
+ onPasswordLogin,
+ onAddAccount,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState(null);
+ // Modal states
+ const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
+ const [selectedServer, setSelectedServer] = useState(
+ null,
+ );
+ const [pinModalVisible, setPinModalVisible] = useState(false);
+ const [passwordModalVisible, setPasswordModalVisible] = useState(false);
+ const [selectedAccount, setSelectedAccount] =
+ useState(null);
+
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as SavedServer[];
}, [_previousServers]);
const { t } = useTranslation();
- const handleServerPress = async (server: SavedServer) => {
- if (loadingServer) return; // Prevent double-tap
+ const refreshServers = () => {
+ const servers = getPreviousServers();
+ setPreviousServers(JSON.stringify(servers));
+ };
- if (server.hasCredentials && onQuickLogin) {
- // Quick login with saved credentials
- setLoadingServer(server.address);
- try {
- await onQuickLogin(server.address);
- } catch (_error) {
- // Token expired/invalid, fall back to manual login
- Alert.alert(
- t("server.session_expired"),
- t("server.please_login_again"),
- [{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
- );
- } finally {
- setLoadingServer(null);
- }
- } else {
- onServerSelect(server);
+ const handleAccountLogin = async (
+ server: SavedServer,
+ account: SavedServerAccount,
+ ) => {
+ switch (account.securityType) {
+ case "none":
+ // Quick login without protection
+ if (onQuickLogin) {
+ setLoadingServer(server.address);
+ try {
+ await onQuickLogin(server.address, account.userId);
+ } catch {
+ Alert.alert(
+ t("server.session_expired"),
+ t("server.please_login_again"),
+ [{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
+ );
+ } finally {
+ setLoadingServer(null);
+ }
+ }
+ break;
+
+ case "pin":
+ // Show PIN entry modal
+ setSelectedServer(server);
+ setSelectedAccount(account);
+ setPinModalVisible(true);
+ break;
+
+ case "password":
+ // Show password entry modal
+ setSelectedServer(server);
+ setSelectedAccount(account);
+ setPasswordModalVisible(true);
+ break;
}
};
- const handleRemoveCredential = async (serverUrl: string) => {
+ const handleServerPress = async (server: SavedServer) => {
+ if (loadingServer) return; // Prevent double-tap
+
+ const accountCount = server.accounts?.length || 0;
+
+ if (accountCount === 0) {
+ // No saved accounts, go to manual login
+ onServerSelect(server);
+ } else {
+ // Has accounts, show account sheet (allows adding new account too)
+ setSelectedServer(server);
+ setAccountsSheetOpen(true);
+ }
+ };
+
+ const handlePinSuccess = async () => {
+ setPinModalVisible(false);
+ if (selectedServer && selectedAccount && onQuickLogin) {
+ setLoadingServer(selectedServer.address);
+ try {
+ await onQuickLogin(selectedServer.address, selectedAccount.userId);
+ } catch {
+ Alert.alert(
+ t("server.session_expired"),
+ t("server.please_login_again"),
+ [
+ {
+ text: t("common.ok"),
+ onPress: () => onServerSelect(selectedServer),
+ },
+ ],
+ );
+ } finally {
+ setLoadingServer(null);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ }
+ };
+
+ const handlePasswordSubmit = async (password: string) => {
+ if (selectedServer && selectedAccount && onPasswordLogin) {
+ await onPasswordLogin(
+ selectedServer.address,
+ selectedAccount.username,
+ password,
+ );
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ };
+
+ const handleForgotPIN = async () => {
+ if (selectedServer && selectedAccount) {
+ await deleteAccountCredential(
+ selectedServer.address,
+ selectedAccount.userId,
+ );
+ refreshServers();
+ // Go to manual login
+ onServerSelect(selectedServer);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }
+ };
+
+ const handleRemoveFirstCredential = async (serverUrl: string) => {
+ const server = previousServers.find((s) => s.address === serverUrl);
+ if (!server || server.accounts.length === 0) return;
+
Alert.alert(
t("server.remove_saved_login"),
t("server.remove_saved_login_description"),
@@ -67,14 +181,9 @@ export const PreviousServersList: React.FC = ({
text: t("common.remove"),
style: "destructive",
onPress: async () => {
- await deleteServerCredential(serverUrl);
- // Update UI
- const updated = previousServers.map((s) =>
- s.address === serverUrl
- ? { ...s, hasCredentials: false, username: undefined }
- : s,
- );
- setPreviousServers(JSON.stringify(updated));
+ // Remove first account
+ await deleteAccountCredential(serverUrl, server.accounts[0].userId);
+ refreshServers();
},
},
],
@@ -84,11 +193,9 @@ export const PreviousServersList: React.FC = ({
const handleRemoveServer = useCallback(
async (serverUrl: string) => {
await removeServerFromList(serverUrl);
- // Update UI
- const filtered = previousServers.filter((s) => s.address !== serverUrl);
- setPreviousServers(JSON.stringify(filtered));
+ refreshServers();
},
- [previousServers, setPreviousServers],
+ [setPreviousServers],
);
const renderRightActions = useCallback(
@@ -106,6 +213,39 @@ export const PreviousServersList: React.FC = ({
[handleRemoveServer],
);
+ const getServerSubtitle = (server: SavedServer): string | undefined => {
+ const accountCount = server.accounts?.length || 0;
+
+ if (accountCount > 1) {
+ return t("server.accounts_count", { count: accountCount });
+ }
+ if (accountCount === 1) {
+ return `${server.accounts[0].username} • ${t("server.saved")}`;
+ }
+ return server.name ? server.address : undefined;
+ };
+
+ const getSecurityIcon = (
+ server: SavedServer,
+ ): keyof typeof Ionicons.glyphMap | null => {
+ const accountCount = server.accounts?.length || 0;
+ if (accountCount === 0) return null;
+
+ if (accountCount > 1) {
+ return "people";
+ }
+
+ const account = server.accounts[0];
+ switch (account.securityType) {
+ case "pin":
+ return "keypad";
+ case "password":
+ return "lock-closed";
+ default:
+ return "key";
+ }
+ };
+
if (!previousServers.length) return null;
return (
@@ -117,9 +257,10 @@ export const PreviousServersList: React.FC = ({
server={s}
loadingServer={loadingServer}
onPress={() => handleServerPress(s)}
- onRemoveCredential={() => handleRemoveCredential(s.address)}
+ onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
renderRightActions={renderRightActions}
- t={t}
+ subtitle={getServerSubtitle(s)}
+ securityIcon={getSecurityIcon(s)}
/>
))}
= ({
{t("server.swipe_to_remove")}
+
+ {/* Account Selection Sheet */}
+ {
+ if (selectedServer) {
+ handleAccountLogin(selectedServer, account);
+ }
+ }}
+ onAddAccount={() => {
+ if (selectedServer && onAddAccount) {
+ onAddAccount(selectedServer);
+ }
+ }}
+ onAccountDeleted={refreshServers}
+ />
+
+ {/* PIN Entry Modal */}
+ {
+ setPinModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSuccess={handlePinSuccess}
+ onForgotPIN={handleForgotPIN}
+ serverUrl={selectedServer?.address || ""}
+ userId={selectedAccount?.userId || ""}
+ username={selectedAccount?.username || ""}
+ />
+
+ {/* Password Entry Modal */}
+ {
+ setPasswordModalVisible(false);
+ setSelectedAccount(null);
+ setSelectedServer(null);
+ }}
+ onSubmit={handlePasswordSubmit}
+ username={selectedAccount?.username || ""}
+ />
);
};
@@ -146,7 +332,8 @@ interface ServerItemProps {
serverUrl: string,
swipeableRef: React.RefObject,
) => React.ReactNode;
- t: (key: string) => string;
+ subtitle?: string;
+ securityIcon: keyof typeof Ionicons.glyphMap | null;
}
const ServerItem: React.FC = ({
@@ -155,9 +342,11 @@ const ServerItem: React.FC = ({
onPress,
onRemoveCredential,
renderRightActions,
- t,
+ subtitle,
+ securityIcon,
}) => {
const swipeableRef = useRef(null);
+ const hasAccounts = server.accounts?.length > 0;
return (
= ({
{loadingServer === server.address ? (
- ) : server.hasCredentials ? (
+ ) : hasAccounts && securityIcon ? (
{
e.stopPropagation();
@@ -191,7 +374,7 @@ const ServerItem: React.FC = ({
className='p-1'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
-
+
) : null}
diff --git a/components/SaveAccountModal.tsx b/components/SaveAccountModal.tsx
new file mode 100644
index 00000000..42615c7d
--- /dev/null
+++ b/components/SaveAccountModal.tsx
@@ -0,0 +1,252 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { AccountSecurityType } from "@/utils/secureCredentials";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
+import { PinInput } from "./inputs/PinInput";
+
+interface SaveAccountModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
+ username: string;
+}
+
+interface SecurityOption {
+ type: AccountSecurityType;
+ titleKey: string;
+ descriptionKey: string;
+ icon: keyof typeof Ionicons.glyphMap;
+}
+
+const SECURITY_OPTIONS: SecurityOption[] = [
+ {
+ type: "none",
+ titleKey: "save_account.no_protection",
+ descriptionKey: "save_account.no_protection_desc",
+ icon: "flash-outline",
+ },
+ {
+ type: "pin",
+ titleKey: "save_account.pin_code",
+ descriptionKey: "save_account.pin_code_desc",
+ icon: "keypad-outline",
+ },
+ {
+ type: "password",
+ titleKey: "save_account.password",
+ descriptionKey: "save_account.password_desc",
+ icon: "lock-closed-outline",
+ },
+];
+
+export const SaveAccountModal: React.FC = ({
+ visible,
+ onClose,
+ onSave,
+ username,
+}) => {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+ const bottomSheetModalRef = useRef(null);
+ const [selectedType, setSelectedType] = useState("none");
+ const [pinCode, setPinCode] = useState("");
+ const [pinError, setPinError] = useState(null);
+
+ const isAndroid = Platform.OS === "android";
+ const snapPoints = useMemo(
+ () => (isAndroid ? ["100%"] : ["70%"]),
+ [isAndroid],
+ );
+
+ useEffect(() => {
+ if (visible) {
+ bottomSheetModalRef.current?.present();
+ } else {
+ bottomSheetModalRef.current?.dismiss();
+ }
+ }, [visible]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ resetState();
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const resetState = () => {
+ setSelectedType("none");
+ setPinCode("");
+ setPinError(null);
+ };
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const handleOptionSelect = (type: AccountSecurityType) => {
+ setSelectedType(type);
+ setPinCode("");
+ setPinError(null);
+ };
+
+ const handleSave = () => {
+ if (selectedType === "pin") {
+ if (pinCode.length !== 4) {
+ setPinError(t("pin.enter_4_digits") || "Enter 4 digits");
+ return;
+ }
+ onSave("pin", pinCode);
+ } else {
+ onSave(selectedType);
+ }
+ resetState();
+ };
+
+ const handleCancel = () => {
+ resetState();
+ onClose();
+ };
+
+ const canSave = () => {
+ if (selectedType === "pin") {
+ return pinCode.length === 4;
+ }
+ return true;
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {t("save_account.title")}
+
+ {username}
+
+
+ {/* PIN Entry Step */}
+ {selectedType === "pin" ? (
+
+
+
+ {t("pin.setup_pin")}
+
+
+ {pinError && (
+
+ {pinError}
+
+ )}
+
+
+ ) : (
+ /* Security Options */
+
+
+ {t("save_account.security_option")}
+
+
+ {SECURITY_OPTIONS.map((option, index) => (
+ handleOptionSelect(option.type)}
+ className={`flex-row items-center p-4 ${
+ index < SECURITY_OPTIONS.length - 1
+ ? "border-b border-neutral-700"
+ : ""
+ }`}
+ >
+
+
+
+
+
+ {t(option.titleKey)}
+
+
+ {t(option.descriptionKey)}
+
+
+
+ {selectedType === option.type && (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/package.json b/package.json
index fa5b2c7b..da56dc08 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"expo-brightness": "~14.0.8",
"expo-build-properties": "~1.0.10",
"expo-constants": "18.0.13",
+ "expo-crypto": "^15.0.8",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-font": "~14.0.10",
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index e3ba6191..b98702f4 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -27,11 +27,14 @@ import { useSettings } from "@/utils/atoms/settings";
import { writeErrorLog, writeInfoLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import {
- deleteServerCredential,
- getServerCredential,
- migrateServersList,
- type SavedServer,
- saveServerCredential,
+ type AccountSecurityType,
+ addServerToList,
+ deleteAccountCredential,
+ getAccountCredential,
+ hashPIN,
+ migrateToMultiAccount,
+ saveAccountCredential,
+ updateAccountToken,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
@@ -43,6 +46,12 @@ export const apiAtom = atom(null);
export const userAtom = atom(null);
export const wsAtom = atom(null);
+interface LoginOptions {
+ saveAccount?: boolean;
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+}
+
interface JellyfinContextValue {
discoverServers: (url: string) => Promise;
setServer: (server: Server) => Promise;
@@ -51,11 +60,20 @@ interface JellyfinContextValue {
username: string,
password: string,
serverName?: string,
+ options?: LoginOptions,
) => Promise;
logout: () => Promise;
initiateQuickConnect: () => Promise;
- loginWithSavedCredential: (serverUrl: string) => Promise;
- removeSavedCredential: (serverUrl: string) => Promise;
+ loginWithSavedCredential: (
+ serverUrl: string,
+ userId: string,
+ ) => Promise;
+ loginWithPassword: (
+ serverUrl: string,
+ username: string,
+ password: string,
+ ) => Promise;
+ removeSavedCredential: (serverUrl: string, userId: string) => Promise;
}
const JellyfinContext = createContext(
@@ -207,28 +225,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("serverUrl", server.address);
},
onSuccess: async (_, server) => {
- const previousServers = JSON.parse(
- storage.getString("previousServers") || "[]",
- ) as SavedServer[];
-
- // Check if we have saved credentials for this server
- const existingServer = previousServers.find(
- (s) => s.address === server.address,
- );
-
- const updatedServers: SavedServer[] = [
- {
- address: server.address,
- name: existingServer?.name,
- hasCredentials: existingServer?.hasCredentials ?? false,
- username: existingServer?.username,
- },
- ...previousServers.filter((s) => s.address !== server.address),
- ];
- storage.set(
- "previousServers",
- JSON.stringify(updatedServers.slice(0, 5)),
- );
+ // Add server to the list (will update existing or add new)
+ addServerToList(server.address);
},
onError: (error) => {
console.error("Failed to set server:", error);
@@ -250,10 +248,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
username,
password,
serverName,
+ options,
}: {
username: string;
password: string;
serverName?: string;
+ options?: LoginOptions;
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
@@ -266,15 +266,24 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
- // Save credentials to secure storage for quick switching
- if (api.basePath) {
- await saveServerCredential({
+ // Save credentials to secure storage if requested
+ if (api.basePath && options?.saveAccount) {
+ const securityType = options.securityType || "none";
+ let pinHash: string | undefined;
+
+ if (securityType === "pin" && options.pinCode) {
+ pinHash = await hashPIN(options.pinCode);
+ }
+
+ await saveAccountCredential({
serverUrl: api.basePath,
serverName: serverName || "",
token: auth.data.AccessToken,
userId: auth.data.User.Id || "",
username,
savedAt: Date.now(),
+ securityType,
+ pinHash,
});
}
@@ -347,10 +356,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
});
const loginWithSavedCredentialMutation = useMutation({
- mutationFn: async (serverUrl: string) => {
+ mutationFn: async ({
+ serverUrl,
+ userId,
+ }: {
+ serverUrl: string;
+ userId: string;
+ }) => {
if (!jellyfin) throw new Error("Jellyfin not initialized");
- const credential = await getServerCredential(serverUrl);
+ const credential = await getAccountCredential(serverUrl, userId);
if (!credential) {
throw new Error("No saved credential found");
}
@@ -372,21 +387,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("token", credential.token);
storage.set("user", JSON.stringify(response.data));
- // Update previousServers list
- const previousServers = JSON.parse(
- storage.getString("previousServers") || "[]",
- ) as SavedServer[];
- const updatedServers: SavedServer[] = [
- {
- address: serverUrl,
- name: credential.serverName,
- hasCredentials: true,
- username: credential.username,
- },
- ...previousServers.filter((s) => s.address !== serverUrl),
- ].slice(0, 5);
- storage.set("previousServers", JSON.stringify(updatedServers));
-
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
} catch (error) {
@@ -395,7 +395,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
axios.isAxiosError(error) &&
(error.response?.status === 401 || error.response?.status === 403)
) {
- await deleteServerCredential(serverUrl);
+ await deleteAccountCredential(serverUrl, userId);
throw new Error(t("server.session_expired"));
}
throw error;
@@ -406,9 +406,60 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
+ const loginWithPasswordMutation = useMutation({
+ mutationFn: async ({
+ serverUrl,
+ username,
+ password,
+ }: {
+ serverUrl: string;
+ username: string;
+ password: string;
+ }) => {
+ if (!jellyfin) throw new Error("Jellyfin not initialized");
+
+ // Create API instance for the server
+ const apiInstance = jellyfin.createApi(serverUrl);
+ if (!apiInstance) {
+ throw new Error("Failed to create API instance");
+ }
+
+ // Authenticate with password
+ const auth = await apiInstance.authenticateUserByName(username, password);
+
+ if (auth.data.AccessToken && auth.data.User) {
+ setUser(auth.data.User);
+ storage.set("user", JSON.stringify(auth.data.User));
+ setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
+ storage.set("serverUrl", serverUrl);
+ storage.set("token", auth.data.AccessToken);
+
+ // Update the saved credential with new token
+ await updateAccountToken(
+ serverUrl,
+ auth.data.User.Id || "",
+ auth.data.AccessToken,
+ );
+
+ // Refresh plugin settings
+ await refreshStreamyfinPluginSettings();
+ }
+ },
+ onError: (error) => {
+ console.error("Password login failed:", error);
+ throw error;
+ },
+ });
+
const removeSavedCredentialMutation = useMutation({
- mutationFn: async (serverUrl: string) => {
- await deleteServerCredential(serverUrl);
+ mutationFn: async ({
+ serverUrl,
+ userId,
+ }: {
+ serverUrl: string;
+ userId: string;
+ }) => {
+ await deleteAccountCredential(serverUrl, userId);
},
onError: (error) => {
console.error("Failed to remove saved credential:", error);
@@ -429,12 +480,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!jellyfin) return;
try {
- // Run migration for server list format (once)
- const migrated = storage.getBoolean("credentialsMigrated");
- if (!migrated) {
- await migrateServersList();
- storage.set("credentialsMigrated", true);
- }
+ // Run migration to multi-account format (once)
+ await migrateToMultiAccount();
const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage();
@@ -452,16 +499,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(response.data);
// Migrate current session to secure storage if not already saved
- const existingCredential = await getServerCredential(serverUrl);
- if (!existingCredential && storedUser?.Name) {
- await saveServerCredential({
+ if (storedUser?.Id && storedUser?.Name) {
+ const existingCredential = await getAccountCredential(
serverUrl,
- serverName: "",
- token,
- userId: storedUser.Id || "",
- username: storedUser.Name,
- savedAt: Date.now(),
- });
+ storedUser.Id,
+ );
+ if (!existingCredential) {
+ await saveAccountCredential({
+ serverUrl,
+ serverName: "",
+ token,
+ userId: storedUser.Id,
+ username: storedUser.Name,
+ savedAt: Date.now(),
+ securityType: "none",
+ });
+ }
}
}
} catch (e) {
@@ -478,14 +531,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
discoverServers,
setServer: (server) => setServerMutation.mutateAsync(server),
removeServer: () => removeServerMutation.mutateAsync(),
- login: (username, password, serverName) =>
- loginMutation.mutateAsync({ username, password, serverName }),
+ login: (username, password, serverName, options) =>
+ loginMutation.mutateAsync({ username, password, serverName, options }),
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
- loginWithSavedCredential: (serverUrl) =>
- loginWithSavedCredentialMutation.mutateAsync(serverUrl),
- removeSavedCredential: (serverUrl) =>
- removeSavedCredentialMutation.mutateAsync(serverUrl),
+ loginWithSavedCredential: (serverUrl, userId) =>
+ loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
+ loginWithPassword: (serverUrl, username, password) =>
+ loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
+ removeSavedCredential: (serverUrl, userId) =>
+ removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
};
useEffect(() => {
diff --git a/translations/en.json b/translations/en.json
index 1b5aae31..c7f144ff 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -38,7 +38,40 @@
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
- "remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time."
+ "remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
+ "accounts_count": "{{count}} accounts",
+ "select_account": "Select Account",
+ "add_account": "Add Account",
+ "remove_account_description": "This will remove the saved credentials for {{username}}."
+ },
+ "save_account": {
+ "title": "Save Account",
+ "save_for_later": "Save this account",
+ "security_option": "Security Option",
+ "no_protection": "No protection",
+ "no_protection_desc": "Quick login without authentication",
+ "pin_code": "PIN code",
+ "pin_code_desc": "4-digit PIN required when switching",
+ "password": "Re-enter password",
+ "password_desc": "Password required when switching",
+ "save_button": "Save",
+ "cancel_button": "Cancel"
+ },
+ "pin": {
+ "enter_pin": "Enter PIN",
+ "enter_pin_for": "Enter PIN for {{username}}",
+ "enter_4_digits": "Enter 4 digits",
+ "invalid_pin": "Invalid PIN",
+ "setup_pin": "Set Up PIN",
+ "confirm_pin": "Confirm PIN",
+ "pins_dont_match": "PINs don't match",
+ "forgot_pin": "Forgot PIN?",
+ "forgot_pin_desc": "Your saved credentials will be removed"
+ },
+ "password": {
+ "enter_password": "Enter Password",
+ "enter_password_for": "Enter password for {{username}}",
+ "invalid_password": "Invalid password"
},
"home": {
"checking_server_connection": "Checking server connection...",
@@ -441,7 +474,11 @@
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
- "remove": "Remove"
+ "remove": "Remove",
+ "next": "Next",
+ "back": "Back",
+ "continue": "Continue",
+ "verifying": "Verifying..."
},
"search": {
"search": "Search...",
diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts
index 90a23eb3..2172d526 100644
--- a/utils/secureCredentials.ts
+++ b/utils/secureCredentials.ts
@@ -1,8 +1,18 @@
+import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import { storage } from "./mmkv";
const CREDENTIAL_KEY_PREFIX = "credential_";
+const MULTI_ACCOUNT_MIGRATED_KEY = "multiAccountMigrated";
+/**
+ * Security type for saved accounts.
+ */
+export type AccountSecurityType = "none" | "pin" | "password";
+
+/**
+ * Credential stored in secure storage for a specific account.
+ */
export interface ServerCredential {
serverUrl: string;
serverName: string;
@@ -10,50 +20,118 @@ export interface ServerCredential {
userId: string;
username: string;
savedAt: number;
+ securityType: AccountSecurityType;
+ pinHash?: string;
+}
+
+/**
+ * Account summary stored in SavedServer for display in UI.
+ */
+export interface SavedServerAccount {
+ userId: string;
+ username: string;
+ securityType: AccountSecurityType;
+ savedAt: number;
+}
+
+/**
+ * Server with multiple saved accounts.
+ */
+export interface SavedServer {
+ address: string;
+ name?: string;
+ accounts: SavedServerAccount[];
+}
+
+/**
+ * Legacy interface for migration purposes.
+ */
+interface LegacySavedServer {
+ address: string;
+ name?: string;
+ hasCredentials?: boolean;
+ username?: string;
+}
+
+/**
+ * Legacy credential interface for migration purposes.
+ */
+interface LegacyServerCredential {
+ serverUrl: string;
+ serverName: string;
+ token: string;
+ userId: string;
+ username: string;
+ savedAt: number;
}
-export interface SavedServer {
- address: string;
- name?: string;
- hasCredentials?: boolean;
- username?: string;
-}
-
/**
- * Encode server URL to valid secure store key.
- * Secure store keys must be alphanumeric with underscores.
+ * Encode server URL to valid secure store key (legacy, for migration).
*/
export function serverUrlToKey(serverUrl: string): string {
- // Use base64 encoding, replace non-alphanumeric chars with underscores
const encoded = btoa(serverUrl).replace(/[^a-zA-Z0-9]/g, "_");
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
}
/**
- * Save credentials for a server to secure storage.
+ * Generate credential key for a specific account (serverUrl + userId).
*/
-export async function saveServerCredential(
- credential: ServerCredential,
-): Promise {
- const key = serverUrlToKey(credential.serverUrl);
- await SecureStore.setItemAsync(key, JSON.stringify(credential));
+export function credentialKey(serverUrl: string, userId: string): string {
+ const combined = `${serverUrl}:${userId}`;
+ const encoded = btoa(combined).replace(/[^a-zA-Z0-9]/g, "_");
+ return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
+}
- // Update previousServers to mark this server as having credentials
- updatePreviousServerCredentialFlag(
- credential.serverUrl,
- true,
- credential.username,
- credential.serverName,
+/**
+ * Hash a PIN using SHA256.
+ */
+export async function hashPIN(pin: string): Promise {
+ return await Crypto.digestStringAsync(
+ Crypto.CryptoDigestAlgorithm.SHA256,
+ pin,
);
}
/**
- * Retrieve credentials for a server from secure storage.
+ * Verify a PIN against stored hash.
*/
-export async function getServerCredential(
+export async function verifyAccountPIN(
serverUrl: string,
+ userId: string,
+ pin: string,
+): Promise {
+ const credential = await getAccountCredential(serverUrl, userId);
+ if (!credential?.pinHash) return false;
+ const inputHash = await hashPIN(pin);
+ return inputHash === credential.pinHash;
+}
+
+/**
+ * Save credential for a specific account.
+ */
+export async function saveAccountCredential(
+ credential: ServerCredential,
+): Promise {
+ const key = credentialKey(credential.serverUrl, credential.userId);
+ await SecureStore.setItemAsync(key, JSON.stringify(credential));
+
+ // Update previousServers to include this account
+ addAccountToServer(credential.serverUrl, credential.serverName, {
+ userId: credential.userId,
+ username: credential.username,
+ securityType: credential.securityType,
+ savedAt: credential.savedAt,
+ });
+}
+
+/**
+ * Retrieve credential for a specific account.
+ */
+export async function getAccountCredential(
+ serverUrl: string,
+ userId: string,
): Promise {
- const key = serverUrlToKey(serverUrl);
+ const key = credentialKey(serverUrl, userId);
const stored = await SecureStore.getItemAsync(key);
if (stored) {
@@ -67,57 +145,138 @@ export async function getServerCredential(
}
/**
- * Delete credentials for a server from secure storage.
+ * Delete credential for a specific account.
*/
-export async function deleteServerCredential(serverUrl: string): Promise {
- const key = serverUrlToKey(serverUrl);
+export async function deleteAccountCredential(
+ serverUrl: string,
+ userId: string,
+): Promise {
+ const key = credentialKey(serverUrl, userId);
await SecureStore.deleteItemAsync(key);
- // Update previousServers to mark this server as not having credentials
- updatePreviousServerCredentialFlag(serverUrl, false);
+ // Remove account from previousServers
+ removeAccountFromServer(serverUrl, userId);
}
/**
- * Check if credentials exist for a server (without retrieving them).
+ * Get all credentials for a server (by iterating through accounts).
*/
-export async function hasServerCredential(serverUrl: string): Promise {
- const key = serverUrlToKey(serverUrl);
+export async function getServerAccounts(
+ serverUrl: string,
+): Promise {
+ const servers = getPreviousServers();
+ const server = servers.find((s) => s.address === serverUrl);
+ if (!server) return [];
+
+ const credentials: ServerCredential[] = [];
+ for (const account of server.accounts) {
+ const credential = await getAccountCredential(serverUrl, account.userId);
+ if (credential) {
+ credentials.push(credential);
+ }
+ }
+ return credentials;
+}
+
+/**
+ * Check if credentials exist for a specific account.
+ */
+export async function hasAccountCredential(
+ serverUrl: string,
+ userId: string,
+): Promise {
+ const key = credentialKey(serverUrl, userId);
const stored = await SecureStore.getItemAsync(key);
return stored !== null;
}
/**
- * Delete all stored credentials for all servers.
+ * Delete all credentials for all accounts on all servers.
*/
export async function clearAllCredentials(): Promise {
const previousServers = getPreviousServers();
for (const server of previousServers) {
- await deleteServerCredential(server.address);
+ for (const account of server.accounts) {
+ const key = credentialKey(server.address, account.userId);
+ await SecureStore.deleteItemAsync(key);
+ }
}
+
+ // Clear all accounts from servers
+ const clearedServers = previousServers.map((server) => ({
+ ...server,
+ accounts: [],
+ }));
+ storage.set("previousServers", JSON.stringify(clearedServers));
}
/**
- * Helper to update the previousServers list in MMKV with credential status.
+ * Add or update an account in a server's accounts list.
*/
-function updatePreviousServerCredentialFlag(
+function addAccountToServer(
serverUrl: string,
- hasCredentials: boolean,
- username?: string,
- serverName?: string,
+ serverName: string,
+ account: SavedServerAccount,
): void {
const previousServers = getPreviousServers();
+ let serverFound = false;
+
const updatedServers = previousServers.map((server) => {
if (server.address === serverUrl) {
+ serverFound = true;
+ // Check if account already exists
+ const existingIndex = server.accounts.findIndex(
+ (a) => a.userId === account.userId,
+ );
+ if (existingIndex >= 0) {
+ // Update existing account
+ const updatedAccounts = [...server.accounts];
+ updatedAccounts[existingIndex] = account;
+ return {
+ ...server,
+ name: serverName || server.name,
+ accounts: updatedAccounts,
+ };
+ }
+ // Add new account
return {
...server,
- hasCredentials,
- username: username || server.username,
name: serverName || server.name,
+ accounts: [...server.accounts, account],
};
}
return server;
});
+
+ // If server not found, add it
+ if (!serverFound) {
+ updatedServers.push({
+ address: serverUrl,
+ name: serverName,
+ accounts: [account],
+ });
+ }
+
+ storage.set("previousServers", JSON.stringify(updatedServers));
+}
+
+/**
+ * Remove an account from a server's accounts list.
+ */
+function removeAccountFromServer(serverUrl: string, userId: string): void {
+ const previousServers = getPreviousServers();
+
+ const updatedServers = previousServers.map((server) => {
+ if (server.address === serverUrl) {
+ return {
+ ...server,
+ accounts: server.accounts.filter((a) => a.userId !== userId),
+ };
+ }
+ return server;
+ });
+
storage.set("previousServers", JSON.stringify(updatedServers));
}
@@ -137,46 +296,217 @@ export function getPreviousServers(): SavedServer[] {
}
/**
- * Remove a server from the previous servers list and delete its credentials.
+ * Remove a server from the list and delete all its account credentials.
*/
export async function removeServerFromList(serverUrl: string): Promise {
- // First delete any saved credentials
- await deleteServerCredential(serverUrl);
- // Then remove from the list
- const previousServers = getPreviousServers();
- const filtered = previousServers.filter((s) => s.address !== serverUrl);
+ const servers = getPreviousServers();
+ const server = servers.find((s) => s.address === serverUrl);
+
+ // Delete all account credentials for this server
+ if (server) {
+ for (const account of server.accounts) {
+ const key = credentialKey(serverUrl, account.userId);
+ await SecureStore.deleteItemAsync(key);
+ }
+ }
+
+ // Remove server from list
+ const filtered = servers.filter((s) => s.address !== serverUrl);
storage.set("previousServers", JSON.stringify(filtered));
}
/**
- * Migrate existing previousServers to new format (add hasCredentials: false).
+ * Add a server to the list without credentials (for server discovery).
+ */
+export function addServerToList(serverUrl: string, serverName?: string): void {
+ const servers = getPreviousServers();
+ const existing = servers.find((s) => s.address === serverUrl);
+
+ if (existing) {
+ // Update name if provided
+ if (serverName) {
+ const updated = servers.map((s) =>
+ s.address === serverUrl ? { ...s, name: serverName } : s,
+ );
+ storage.set("previousServers", JSON.stringify(updated));
+ }
+ return;
+ }
+
+ // Add new server with empty accounts
+ const newServer: SavedServer = {
+ address: serverUrl,
+ name: serverName,
+ accounts: [],
+ };
+
+ // Keep max 10 servers
+ const updatedServers = [newServer, ...servers].slice(0, 10);
+ storage.set("previousServers", JSON.stringify(updatedServers));
+}
+
+/**
+ * Migrate from legacy single-account format to multi-account format.
* Should be called on app startup.
*/
-export async function migrateServersList(): Promise {
+export async function migrateToMultiAccount(): Promise {
+ // Check if already migrated
+ if (storage.getBoolean(MULTI_ACCOUNT_MIGRATED_KEY)) {
+ return;
+ }
+
const stored = storage.getString("previousServers");
- if (!stored) return;
+ if (!stored) {
+ storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
+ return;
+ }
try {
const servers = JSON.parse(stored);
- // Check if migration needed (old format doesn't have hasCredentials)
- if (servers.length > 0 && servers[0].hasCredentials === undefined) {
- const migrated = servers.map((server: SavedServer) => ({
- address: server.address,
- name: server.name,
- hasCredentials: false,
- username: undefined,
- }));
- storage.set("previousServers", JSON.stringify(migrated));
+
+ // Check if already in new format (has accounts array)
+ if (servers.length > 0 && Array.isArray(servers[0].accounts)) {
+ storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
+ return;
}
+
+ // Migrate from legacy format
+ const migratedServers: SavedServer[] = [];
+
+ for (const legacyServer of servers as LegacySavedServer[]) {
+ const newServer: SavedServer = {
+ address: legacyServer.address,
+ name: legacyServer.name,
+ accounts: [],
+ };
+
+ // Try to get existing credential using legacy key
+ if (legacyServer.hasCredentials) {
+ const legacyKey = serverUrlToKey(legacyServer.address);
+ const storedCred = await SecureStore.getItemAsync(legacyKey);
+
+ if (storedCred) {
+ try {
+ const legacyCred = JSON.parse(storedCred) as LegacyServerCredential;
+
+ // Create new credential with security type
+ const newCredential: ServerCredential = {
+ ...legacyCred,
+ securityType: "none", // Existing accounts get no protection (preserve quick-login)
+ };
+
+ // Save with new key format
+ const newKey = credentialKey(
+ legacyServer.address,
+ legacyCred.userId,
+ );
+ await SecureStore.setItemAsync(
+ newKey,
+ JSON.stringify(newCredential),
+ );
+
+ // Delete old key
+ await SecureStore.deleteItemAsync(legacyKey);
+
+ // Add account to server
+ newServer.accounts.push({
+ userId: legacyCred.userId,
+ username: legacyCred.username,
+ securityType: "none",
+ savedAt: legacyCred.savedAt,
+ });
+ } catch {
+ // Skip invalid credentials
+ }
+ }
+ }
+
+ migratedServers.push(newServer);
+ }
+
+ storage.set("previousServers", JSON.stringify(migratedServers));
+ storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
} catch {
// If parsing fails, reset to empty array
storage.set("previousServers", "[]");
+ storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
}
}
/**
- * Migrate current session credentials to secure storage.
- * Should be called on app startup for existing users.
+ * Update account's token after successful login.
+ */
+export async function updateAccountToken(
+ serverUrl: string,
+ userId: string,
+ newToken: string,
+): Promise {
+ const credential = await getAccountCredential(serverUrl, userId);
+ if (credential) {
+ credential.token = newToken;
+ credential.savedAt = Date.now();
+ const key = credentialKey(serverUrl, userId);
+ await SecureStore.setItemAsync(key, JSON.stringify(credential));
+ }
+}
+
+// Legacy functions for backward compatibility during transition
+// These delegate to new functions
+
+/**
+ * @deprecated Use saveAccountCredential instead
+ */
+export async function saveServerCredential(
+ credential: ServerCredential,
+): Promise {
+ return saveAccountCredential(credential);
+}
+
+/**
+ * @deprecated Use getAccountCredential instead
+ */
+export async function getServerCredential(
+ serverUrl: string,
+): Promise {
+ // Try to get first account's credential for backward compatibility
+ const servers = getPreviousServers();
+ const server = servers.find((s) => s.address === serverUrl);
+ if (server && server.accounts.length > 0) {
+ return getAccountCredential(serverUrl, server.accounts[0].userId);
+ }
+ return null;
+}
+
+/**
+ * @deprecated Use deleteAccountCredential instead
+ */
+export async function deleteServerCredential(serverUrl: string): Promise {
+ // Delete first account for backward compatibility
+ const servers = getPreviousServers();
+ const server = servers.find((s) => s.address === serverUrl);
+ if (server && server.accounts.length > 0) {
+ return deleteAccountCredential(serverUrl, server.accounts[0].userId);
+ }
+}
+
+/**
+ * @deprecated Use hasAccountCredential instead
+ */
+export async function hasServerCredential(serverUrl: string): Promise {
+ const servers = getPreviousServers();
+ const server = servers.find((s) => s.address === serverUrl);
+ return server ? server.accounts.length > 0 : false;
+}
+
+/**
+ * @deprecated Use migrateToMultiAccount instead
+ */
+export async function migrateServersList(): Promise {
+ return migrateToMultiAccount();
+}
+
+/**
+ * @deprecated Use saveAccountCredential instead
*/
export async function migrateCurrentSessionToSecureStorage(
serverUrl: string,
@@ -185,17 +515,18 @@ export async function migrateCurrentSessionToSecureStorage(
username: string,
serverName?: string,
): Promise {
- const existingCredential = await getServerCredential(serverUrl);
+ const existingCredential = await getAccountCredential(serverUrl, userId);
// Only save if not already saved
if (!existingCredential) {
- await saveServerCredential({
+ await saveAccountCredential({
serverUrl,
serverName: serverName || "",
token,
userId,
username,
savedAt: Date.now(),
+ securityType: "none",
});
}
}