From 37b0b10098495273538f588447b958c14a85c3b9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 5 Jan 2026 21:28:00 +0100 Subject: [PATCH] feat: save login credentials when switching servers --- app/login.tsx | 19 ++- bun.lock | 3 + components/PreviousServersList.tsx | 99 +++++++++++++-- package.json | 5 +- providers/JellyfinProvider.tsx | 146 ++++++++++++++++++++-- translations/en.json | 11 +- utils/secureCredentials.ts | 189 +++++++++++++++++++++++++++++ 7 files changed, 445 insertions(+), 27 deletions(-) create mode 100644 utils/secureCredentials.ts diff --git a/app/login.tsx b/app/login.tsx index 26b32b6a..89396fb4 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -31,8 +31,13 @@ const Login: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); - const { setServer, login, removeServer, initiateQuickConnect } = - useJellyfin(); + const { + setServer, + login, + removeServer, + initiateQuickConnect, + loginWithSavedCredential, + } = useJellyfin(); const { apiUrl: _apiUrl, @@ -100,7 +105,7 @@ const Login: React.FC = () => { try { const result = CredentialsSchema.safeParse(credentials); if (result.success) { - await login(credentials.username, credentials.password); + await login(credentials.username, credentials.password, serverName); } } catch (error) { if (error instanceof Error) { @@ -116,6 +121,10 @@ const Login: React.FC = () => { } }; + const handleQuickLoginWithSavedCredential = async (serverUrl: string) => { + await loginWithSavedCredential(serverUrl); + }; + /** * Checks the availability and validity of a Jellyfin server URL. * @@ -380,9 +389,10 @@ const Login: React.FC = () => { }} /> { + onServerSelect={async (s) => { await handleConnect(s.address); }} + onQuickLogin={handleQuickLoginWithSavedCredential} /> @@ -535,6 +545,7 @@ const Login: React.FC = () => { onServerSelect={async (s) => { await handleConnect(s.address); }} + onQuickLogin={handleQuickLoginWithSavedCredential} /> diff --git a/bun.lock b/bun.lock index 48290638..4eb97403 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "expo-notifications": "~0.32.15", "expo-router": "~6.0.21", "expo-screen-orientation": "~9.0.8", + "expo-secure-store": "^15.0.8", "expo-sensors": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", @@ -1044,6 +1045,8 @@ "expo-screen-orientation": ["expo-screen-orientation@9.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw=="], + "expo-secure-store": ["expo-secure-store@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw=="], + "expo-sensors": ["expo-sensors@15.0.8", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ttibOSCYjFAMIfjV+vVukO1v7GKlbcPRfxcRqbTaSMGneewDwVSXbGFImY530fj1BR3mWq4n9jHnuDp8tAEY9g=="], "expo-server": ["expo-server@1.0.5", "", {}, "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA=="], diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx index ffa310d3..ea93f773 100644 --- a/components/PreviousServersList.tsx +++ b/components/PreviousServersList.tsx @@ -1,31 +1,83 @@ +import { Ionicons } from "@expo/vector-icons"; import type React from "react"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native"; import { useMMKVString } from "react-native-mmkv"; +import { Colors } from "@/constants/Colors"; +import { + deleteServerCredential, + type SavedServer, +} from "@/utils/secureCredentials"; import { ListGroup } from "./list/ListGroup"; import { ListItem } from "./list/ListItem"; -interface Server { - address: string; -} - interface PreviousServersListProps { - onServerSelect: (server: Server) => void; + onServerSelect: (server: SavedServer) => void; + onQuickLogin?: (serverUrl: string) => Promise; } export const PreviousServersList: React.FC = ({ onServerSelect, + onQuickLogin, }) => { const [_previousServers, setPreviousServers] = useMMKVString("previousServers"); + const [loadingServer, setLoadingServer] = useState(null); const previousServers = useMemo(() => { - return JSON.parse(_previousServers || "[]") as Server[]; + return JSON.parse(_previousServers || "[]") as SavedServer[]; }, [_previousServers]); const { t } = useTranslation(); + const handleServerPress = async (server: SavedServer) => { + if (loadingServer) return; // Prevent double-tap + + 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 handleRemoveCredential = async (serverUrl: string) => { + Alert.alert( + t("server.remove_saved_login"), + t("server.remove_saved_login_description"), + [ + { text: t("common.cancel"), style: "cancel" }, + { + 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)); + }, + }, + ], + ); + }; + if (!previousServers.length) return null; return ( @@ -34,10 +86,33 @@ export const PreviousServersList: React.FC = ({ {previousServers.map((s) => ( onServerSelect(s)} - title={s.address} - showArrow - /> + onPress={() => handleServerPress(s)} + title={s.name || s.address} + subtitle={ + s.hasCredentials + ? `${s.username} • ${t("server.saved")}` + : s.name + ? s.address + : undefined + } + showArrow={loadingServer !== s.address} + disabled={loadingServer === s.address} + > + {loadingServer === s.address ? ( + + ) : s.hasCredentials ? ( + { + e.stopPropagation(); + handleRemoveCredential(s.address); + }} + className='p-1' + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + ) : null} + ))} { diff --git a/package.json b/package.json index 58ed6d0d..f7bbc732 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "expo-blur": "~15.0.8", "expo-brightness": "~14.0.8", "expo-build-properties": "~1.0.10", - "expo-constants": "~18.0.12", + "expo-constants": "18.0.12", "expo-dev-client": "~6.0.20", "expo-device": "~8.0.10", "expo-font": "~14.0.10", @@ -58,12 +58,13 @@ "expo-notifications": "~0.32.15", "expo-router": "~6.0.21", "expo-screen-orientation": "~9.0.8", + "expo-secure-store": "^15.0.8", "expo-sensors": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", - "expo-task-manager": "~14.0.9", + "expo-task-manager": "14.0.9", "expo-web-browser": "~15.0.10", "i18next": "^25.0.0", "jotai": "2.16.0", diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index f886d87a..85fbc265 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -26,6 +26,13 @@ import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { useSettings } from "@/utils/atoms/settings"; import { writeErrorLog, writeInfoLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; +import { + deleteServerCredential, + getServerCredential, + migrateServersList, + type SavedServer, + saveServerCredential, +} from "@/utils/secureCredentials"; import { store } from "@/utils/store"; interface Server { @@ -40,9 +47,15 @@ interface JellyfinContextValue { discoverServers: (url: string) => Promise; setServer: (server: Server) => Promise; removeServer: () => void; - login: (username: string, password: string) => Promise; + login: ( + username: string, + password: string, + serverName?: string, + ) => Promise; logout: () => Promise; initiateQuickConnect: () => Promise; + loginWithSavedCredential: (serverUrl: string) => Promise; + removeSavedCredential: (serverUrl: string) => Promise; } const JellyfinContext = createContext( @@ -193,13 +206,24 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setApi(apiInstance); storage.set("serverUrl", server.address); }, - onSuccess: (_, server) => { + 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 = [ - server, - ...previousServers.filter((s: Server) => 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", @@ -225,9 +249,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ mutationFn: async ({ username, password, + serverName, }: { username: string; password: string; + serverName?: string; }) => { if (!api || !jellyfin) throw new Error("API not initialized"); @@ -240,6 +266,18 @@ 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({ + serverUrl: api.basePath, + serverName: serverName || "", + token: auth.data.AccessToken, + userId: auth.data.User.Id || "", + username, + savedAt: Date.now(), + }); + } + const recentPluginSettings = await refreshStreamyfinPluginSettings(); if (recentPluginSettings?.jellyseerrServerUrl?.value) { const jellyseerrApi = new JellyseerrApi( @@ -301,12 +339,82 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setApi(null); setPluginSettings(undefined); await clearAllJellyseerData(); + // Note: We keep saved credentials for quick switching back }, onError: (error) => { console.error("Logout failed:", error); }, }); + const loginWithSavedCredentialMutation = useMutation({ + mutationFn: async (serverUrl: string) => { + if (!jellyfin) throw new Error("Jellyfin not initialized"); + + const credential = await getServerCredential(serverUrl); + if (!credential) { + throw new Error("No saved credential found"); + } + + // Create API instance with saved token + const apiInstance = jellyfin.createApi(serverUrl, credential.token); + if (!apiInstance) { + throw new Error("Failed to create API instance"); + } + + // Validate token by fetching current user + try { + const response = await getUserApi(apiInstance).getCurrentUser(); + + // Token is valid, update state + setApi(apiInstance); + setUser(response.data); + storage.set("serverUrl", serverUrl); + 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) { + // Token is invalid/expired - remove it + if ( + axios.isAxiosError(error) && + (error.response?.status === 401 || error.response?.status === 403) + ) { + await deleteServerCredential(serverUrl); + throw new Error(t("server.session_expired")); + } + throw error; + } + }, + onError: (error) => { + console.error("Quick login failed:", error); + }, + }); + + const removeSavedCredentialMutation = useMutation({ + mutationFn: async (serverUrl: string) => { + await deleteServerCredential(serverUrl); + }, + onError: (error) => { + console.error("Failed to remove saved credential:", error); + }, + }); + const [loaded, setLoaded] = useState(false); const [initialLoaded, setInitialLoaded] = useState(false); @@ -321,6 +429,13 @@ 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); + } + const token = getTokenFromStorage(); const serverUrl = getServerUrlFromStorage(); const storedUser = getUserFromStorage(); @@ -335,6 +450,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const response = await getUserApi(apiInstance).getCurrentUser(); setUser(response.data); + + // Migrate current session to secure storage if not already saved + const existingCredential = await getServerCredential(serverUrl); + if (!existingCredential && storedUser?.Name) { + await saveServerCredential({ + serverUrl, + serverName: "", + token, + userId: storedUser.Id || "", + username: storedUser.Name, + savedAt: Date.now(), + }); + } } } catch (e) { console.error(e); @@ -350,10 +478,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ discoverServers, setServer: (server) => setServerMutation.mutateAsync(server), removeServer: () => removeServerMutation.mutateAsync(), - login: (username, password) => - loginMutation.mutateAsync({ username, password }), + login: (username, password, serverName) => + loginMutation.mutateAsync({ username, password, serverName }), logout: () => logoutMutation.mutateAsync(), initiateQuickConnect, + loginWithSavedCredential: (serverUrl) => + loginWithSavedCredentialMutation.mutateAsync(serverUrl), + removeSavedCredential: (serverUrl) => + removeSavedCredentialMutation.mutateAsync(serverUrl), }; useEffect(() => { diff --git a/translations/en.json b/translations/en.json index 2d50411e..1edd9c1a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -32,7 +32,12 @@ "clear_button": "Clear", "search_for_local_servers": "Search for Local Servers", "searching": "Searching...", - "servers": "Servers" + "servers": "Servers", + "saved": "Saved", + "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." }, "home": { "checking_server_connection": "Checking server connection...", @@ -413,7 +418,9 @@ "none": "None", "track": "Track", "cancel": "Cancel", - "delete": "Delete" + "delete": "Delete", + "ok": "OK", + "remove": "Remove" }, "search": { "search": "Search...", diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts new file mode 100644 index 00000000..43228f59 --- /dev/null +++ b/utils/secureCredentials.ts @@ -0,0 +1,189 @@ +import * as SecureStore from "expo-secure-store"; +import { storage } from "./mmkv"; + +const CREDENTIAL_KEY_PREFIX = "credential_"; + +export interface ServerCredential { + 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. + */ +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. + */ +export async function saveServerCredential( + credential: ServerCredential, +): Promise { + const key = serverUrlToKey(credential.serverUrl); + await SecureStore.setItemAsync(key, JSON.stringify(credential)); + + // Update previousServers to mark this server as having credentials + updatePreviousServerCredentialFlag( + credential.serverUrl, + true, + credential.username, + credential.serverName, + ); +} + +/** + * Retrieve credentials for a server from secure storage. + */ +export async function getServerCredential( + serverUrl: string, +): Promise { + const key = serverUrlToKey(serverUrl); + const stored = await SecureStore.getItemAsync(key); + + if (stored) { + try { + return JSON.parse(stored) as ServerCredential; + } catch { + return null; + } + } + return null; +} + +/** + * Delete credentials for a server from secure storage. + */ +export async function deleteServerCredential(serverUrl: string): Promise { + const key = serverUrlToKey(serverUrl); + await SecureStore.deleteItemAsync(key); + + // Update previousServers to mark this server as not having credentials + updatePreviousServerCredentialFlag(serverUrl, false); +} + +/** + * Check if credentials exist for a server (without retrieving them). + */ +export async function hasServerCredential(serverUrl: string): Promise { + const key = serverUrlToKey(serverUrl); + const stored = await SecureStore.getItemAsync(key); + return stored !== null; +} + +/** + * Delete all stored credentials for all servers. + */ +export async function clearAllCredentials(): Promise { + const previousServers = getPreviousServers(); + + for (const server of previousServers) { + await deleteServerCredential(server.address); + } +} + +/** + * Helper to update the previousServers list in MMKV with credential status. + */ +function updatePreviousServerCredentialFlag( + serverUrl: string, + hasCredentials: boolean, + username?: string, + serverName?: string, +): void { + const previousServers = getPreviousServers(); + const updatedServers = previousServers.map((server) => { + if (server.address === serverUrl) { + return { + ...server, + hasCredentials, + username: username || server.username, + name: serverName || server.name, + }; + } + return server; + }); + storage.set("previousServers", JSON.stringify(updatedServers)); +} + +/** + * Get previous servers list from MMKV. + */ +export function getPreviousServers(): SavedServer[] { + const stored = storage.getString("previousServers"); + if (stored) { + try { + return JSON.parse(stored) as SavedServer[]; + } catch { + return []; + } + } + return []; +} + +/** + * Migrate existing previousServers to new format (add hasCredentials: false). + * Should be called on app startup. + */ +export async function migrateServersList(): Promise { + const stored = storage.getString("previousServers"); + if (!stored) 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)); + } + } catch { + // If parsing fails, reset to empty array + storage.set("previousServers", "[]"); + } +} + +/** + * Migrate current session credentials to secure storage. + * Should be called on app startup for existing users. + */ +export async function migrateCurrentSessionToSecureStorage( + serverUrl: string, + token: string, + userId: string, + username: string, + serverName?: string, +): Promise { + const existingCredential = await getServerCredential(serverUrl); + + // Only save if not already saved + if (!existingCredential) { + await saveServerCredential({ + serverUrl, + serverName: serverName || "", + token, + userId, + username, + savedAt: Date.now(), + }); + } +}