feat: save login credentials when switching servers

This commit is contained in:
Fredrik Burmester
2026-01-05 21:28:00 +01:00
parent 9ca852bb7e
commit 37b0b10098
7 changed files with 445 additions and 27 deletions

View File

@@ -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 = () => {
}}
/>
<PreviousServersList
onServerSelect={async (s: any) => {
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
/>
</View>
</View>
@@ -535,6 +545,7 @@ const Login: React.FC = () => {
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
/>
</View>
</View>

View File

@@ -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=="],

View File

@@ -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<void>;
}
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(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<PreviousServersListProps> = ({
{previousServers.map((s) => (
<ListItem
key={s.address}
onPress={() => 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 ? (
<ActivityIndicator size='small' color={Colors.primary} />
) : s.hasCredentials ? (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
handleRemoveCredential(s.address);
}}
className='p-1'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='key' size={16} color={Colors.primary} />
</TouchableOpacity>
) : null}
</ListItem>
))}
<ListItem
onPress={() => {

View File

@@ -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",

View File

@@ -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<Server[]>;
setServer: (server: Server) => Promise<void>;
removeServer: () => void;
login: (username: string, password: string) => Promise<void>;
login: (
username: string,
password: string,
serverName?: string,
) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
loginWithSavedCredential: (serverUrl: string) => Promise<void>;
removeSavedCredential: (serverUrl: string) => Promise<void>;
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
@@ -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(() => {

View File

@@ -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...",

189
utils/secureCredentials.ts Normal file
View File

@@ -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<void> {
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<ServerCredential | null> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
const existingCredential = await getServerCredential(serverUrl);
// Only save if not already saved
if (!existingCredential) {
await saveServerCredential({
serverUrl,
serverName: serverName || "",
token,
userId,
username,
savedAt: Date.now(),
});
}
}