Files
streamyfin/components/login/TVLogin.tsx
Lance Chant 1f324f8d46 Adding QR code login
Added the ability to login to the TV via the mobile app
Fixed some other login issues with back button presses not working

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-15 14:08:32 +02:00

801 lines
23 KiB
TypeScript

import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer";
import { storage } from "@/utils/mmkv";
import {
generatePairingCode,
type PairingCredentials,
startPairingListener,
} from "@/utils/pairingService";
import {
type AccountSecurityType,
getPreviousServers,
hashPIN,
removeServerFromList,
type SavedServer,
type SavedServerAccount,
saveAccountCredential,
} from "@/utils/secureCredentials";
import { TVAddServerForm } from "./TVAddServerForm";
import { TVAddUserForm } from "./TVAddUserForm";
import { TVPasswordEntryModal } from "./TVPasswordEntryModal";
import { TVPINEntryModal } from "./TVPINEntryModal";
import { TVQRCodeDisplay } from "./TVQRCodeDisplay";
import { TVSaveAccountModal } from "./TVSaveAccountModal";
import { TVServerSelectionScreen } from "./TVServerSelectionScreen";
import { TVUserSelectionScreen } from "./TVUserSelectionScreen";
type TVLoginScreen =
| "server-selection"
| "qr-code-display"
| "loading"
| "user-selection"
| "add-server"
| "add-user";
export const TVLogin: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
setServer,
login,
removeServer,
initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const {
apiUrl: _apiUrl,
username: _username,
password: _password,
} = params as { apiUrl: string; username: string; password: string };
// Selected server persistence
const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom);
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
// Get current servers list
const previousServers = useMemo(() => {
try {
return JSON.parse(_previousServers || "[]") as SavedServer[];
} catch {
return [];
}
}, [_previousServers]);
// Current screen state
const [currentScreen, setCurrentScreen] =
useState<TVLoginScreen>("server-selection");
// Current selected server for user selection screen
const [currentServer, setCurrentServer] = useState<SavedServer | null>(null);
const [serverName, setServerName] = useState<string>("");
// Loading states
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
// Save account state
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
// PIN/Password entry for saved accounts
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen =
showSaveModal || pinModalVisible || passwordModalVisible;
// Pairing state (companion login via phone)
const [showPairingQR, setShowPairingQR] = useState(false);
const [pairingCode, setPairingCode] = useState("");
const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{
serverUrl: string;
username: string;
password: string;
} | null>(null);
// Ref to prevent double-handling when onSave and onClose both fire
const pairingHandledRef = useRef(false);
// Refresh servers list helper
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
// Initialize on mount - check if we have a persisted server
useEffect(() => {
if (selectedTVServer) {
// Find the full server data from previousServers
const server = previousServers.find(
(s) => s.address === selectedTVServer.address,
);
if (server) {
setCurrentServer(server);
setServerName(selectedTVServer.name || "");
setCurrentScreen("user-selection");
} else {
// Server no longer exists, clear persistence
setSelectedTVServer(null);
}
}
}, []);
// Stop Quick Connect polling when leaving the login page
useEffect(() => {
return () => {
stopQuickConnectPolling();
setShowPairingQR(false);
};
}, [stopQuickConnectPolling]);
// Handle URL params for server connection
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({ address: _apiUrl });
}
})();
}, [_apiUrl]);
// Handle auto-login when api is ready and credentials are provided via URL params
useEffect(() => {
if (api?.basePath && _apiUrl && _username && _password) {
login(_username, _password);
}
}, [api?.basePath, _apiUrl, _username, _password]);
// Update header
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerShown: false,
});
}, [serverName, navigation]);
// Server URL checking
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try {
return checkHttp(baseUrl, protocols);
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{ mode: "cors" },
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
}
// Handle connecting to a new server
const handleConnect = useCallback(
async (url: string) => {
url = url.trim().replace(/\/$/, "");
try {
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
);
return;
}
await setServer({ address: result });
// Update server list and get the new server data
refreshServers();
// Find or create server entry
const servers = getPreviousServers();
const server = servers.find((s) => s.address === result);
if (server) {
setCurrentServer(server);
setSelectedTVServer({ address: result, name: serverName });
setCurrentScreen("user-selection");
}
} catch (error) {
console.error("[TVLogin] Error in handleConnect:", error);
}
},
[checkUrl, setServer, serverName, setSelectedTVServer],
);
// Handle selecting an existing server
const handleServerSelect = (server: SavedServer) => {
setCurrentServer(server);
setServerName(server.name || "");
setSelectedTVServer({ address: server.address, name: server.name });
setCurrentScreen("user-selection");
};
// Handle changing server (back from user selection)
const handleChangeServer = () => {
setSelectedTVServer(null);
setCurrentServer(null);
setServerName("");
removeServer();
setCurrentScreen("server-selection");
};
// Handle deleting a server
const handleDeleteServer = async (server: SavedServer) => {
await removeServerFromList(server.address);
refreshServers();
// If we deleted the currently selected server, clear it
if (selectedTVServer?.address === server.address) {
setSelectedTVServer(null);
setCurrentServer(null);
}
};
// Handle user selection
const handleUserSelect = async (account: SavedServerAccount) => {
if (!currentServer) return;
switch (account.securityType) {
case "none":
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithSavedCredential(currentServer.address, account.userId);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
}
break;
case "pin":
setSelectedAccount(account);
setPinModalVisible(true);
break;
case "password":
setSelectedAccount(account);
setPasswordModalVisible(true);
break;
}
};
// Handle PIN success
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (currentServer && selectedAccount) {
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithSavedCredential(
currentServer.address,
selectedAccount.userId,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
}
}
setSelectedAccount(null);
};
// Handle password submit
const handlePasswordSubmit = async (password: string) => {
if (currentServer && selectedAccount) {
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithPassword(
currentServer.address,
selectedAccount.username,
password,
);
} catch {
Alert.alert(
t("login.connection_failed"),
t("login.invalid_username_or_password"),
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
}
}
setPasswordModalVisible(false);
setSelectedAccount(null);
};
// Handle forgot PIN
const handleForgotPIN = async () => {
setSelectedAccount(null);
setPinModalVisible(false);
};
// Handle login with credentials (from add user form)
const handleLogin = async (
username: string,
password: string,
saveAccount: boolean,
) => {
if (!currentServer) return;
if (saveAccount) {
setPendingLogin({ username, password });
setShowSaveModal(true);
} else {
await performLogin(username, password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
const pairingCreds = pendingPairingCredentials;
if (pairingCreds) {
// Pairing flow: mark as handled, login, then save credential
pairingHandledRef.current = true;
setPendingPairingCredentials(null);
setPendingLogin(null);
setLoading(true);
try {
await loginWithPassword(
pairingCreds.serverUrl,
pairingCreds.username,
pairingCreds.password,
);
// Save credential after successful login
try {
const token = storage.getString("token");
const userJson = storage.getString("user");
const storedServerUrl = storage.getString("serverUrl");
if (token && userJson && storedServerUrl) {
const user = JSON.parse(userJson);
let pinHash: string | undefined;
if (securityType === "pin" && pinCode) {
pinHash = await hashPIN(pinCode);
}
await saveAccountCredential({
serverUrl: storedServerUrl,
serverName: storedServerUrl,
token,
userId: user.Id || "",
username: pairingCreds.username,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
}
} catch (saveError) {
console.error(
"[TVLogin] Failed to save pairing credential:",
saveError,
);
}
} catch (error) {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
setCurrentScreen("qr-code-display");
} finally {
setLoading(false);
}
return;
}
// Normal login flow
if (pendingLogin && currentServer) {
setLoading(true);
try {
await login(pendingLogin.username, pendingLogin.password, serverName, {
saveAccount: true,
securityType,
pinCode,
});
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
setLoading(false);
setPendingLogin(null);
}
}
};
// Handle quick connect
const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[{ text: t("login.got_it") }],
);
}
} catch (_error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
}
};
// Handle pairing with companion phone
const handleStartPairing = useCallback(() => {
setCurrentScreen("qr-code-display");
const code = generatePairingCode();
setPairingCode(code);
setShowPairingQR(true);
}, []);
// Handle credentials received from companion
const handlePairingCredentials = useCallback(
(credentials: PairingCredentials) => {
setShowPairingQR(false);
setCurrentScreen("loading");
// Store credentials and show save modal (same UX as normal login)
setPendingPairingCredentials({
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
});
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
},
[],
);
// Listen for pairing credentials when QR is shown
useEffect(() => {
if (!showPairingQR || !pairingCode) return;
const cleanup = startPairingListener(
pairingCode,
handlePairingCredentials,
(error) => {
console.error("[TVLogin] Pairing error:", error);
setShowPairingQR(false);
Alert.alert(t("login.error_title"), t("companion_login.error_generic"));
},
);
// Auto-dismiss after 5 minutes
const timeout = setTimeout(
() => {
setShowPairingQR(false);
},
5 * 60 * 1000,
);
return () => {
cleanup();
clearTimeout(timeout);
};
}, [showPairingQR, pairingCode, handlePairingCredentials]);
// Render current screen
const renderScreen = () => {
// If API is connected but we're on server/user selection,
// it means we need to show add-user form
if (
api?.basePath &&
currentScreen !== "add-user" &&
currentScreen !== "loading"
) {
// API is ready, show add-user form
return (
<TVAddUserForm
serverName={serverName}
serverAddress={api.basePath}
onLogin={handleLogin}
onQuickConnect={handleQuickConnect}
onBack={handleChangeServer}
loading={loading}
disabled={isAnyModalOpen}
/>
);
}
switch (currentScreen) {
case "server-selection":
return (
<TVServerSelectionScreen
onServerSelect={handleServerSelect}
onAddServer={() => setCurrentScreen("add-server")}
onDeleteServer={handleDeleteServer}
disabled={isAnyModalOpen}
/>
);
case "user-selection":
if (!currentServer) {
setCurrentScreen("server-selection");
return null;
}
return (
<TVUserSelectionScreen
server={currentServer}
onUserSelect={handleUserSelect}
onAddUser={() => {
// Set the server in JellyfinProvider and go to add-user
setServer({ address: currentServer.address });
setCurrentScreen("add-user");
}}
onChangeServer={handleChangeServer}
disabled={isAnyModalOpen || loading}
/>
);
case "add-server":
return (
<TVAddServerForm
onConnect={handleConnect}
onStartPairing={handleStartPairing}
onBack={() => setCurrentScreen("server-selection")}
loading={loadingServerCheck}
disabled={isAnyModalOpen}
/>
);
case "qr-code-display":
return (
<TVQRCodeDisplay
code={pairingCode}
mode='pairing'
onBack={() => {
setShowPairingQR(false);
setCurrentScreen("add-server");
}}
/>
);
case "loading":
return (
<View
style={{
flex: 1,
backgroundColor: "#000000",
justifyContent: "center",
alignItems: "center",
}}
>
<Text
style={{
fontSize: 24,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 12,
}}
>
{t("pairing.logging_in")}
</Text>
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
}}
>
{t("pairing.logging_in_description")}
</Text>
</View>
);
case "add-user":
return (
<TVAddUserForm
serverName={serverName}
serverAddress={currentServer?.address || api?.basePath || ""}
onLogin={handleLogin}
onQuickConnect={handleQuickConnect}
onBack={() => {
removeServer();
setCurrentScreen("user-selection");
}}
loading={loading}
disabled={isAnyModalOpen}
/>
);
default:
return null;
}
};
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>{renderScreen()}</View>
{/* Save Account Modal */}
<TVSaveAccountModal
visible={showSaveModal}
onClose={() => {
// If onSave already handled this, just clean up
if (pairingHandledRef.current) {
pairingHandledRef.current = false;
return;
}
setShowSaveModal(false);
if (pendingPairingCredentials) {
// Pairing: user dismissed without saving, login anyway
const creds = pendingPairingCredentials;
setPendingPairingCredentials(null);
setPendingLogin(null);
loginWithPassword(
creds.serverUrl,
creds.username,
creds.password,
).catch((error) => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
setCurrentScreen("qr-code-display");
});
return;
}
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || ""}
/>
{/* PIN Entry Modal */}
<TVPINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={handleForgotPIN}
serverUrl={currentServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<TVPasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View>
);
};