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")} + + + + + + ); +}; 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", }); } }