diff --git a/app/_layout.tsx b/app/_layout.tsx index 9992d696..6679dca3 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,6 +10,7 @@ import * as Device from "expo-device"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { Platform } from "react-native"; import { GlobalModal } from "@/components/GlobalModal"; +import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; import i18n from "@/i18n"; import { DownloadProvider } from "@/providers/DownloadProvider"; @@ -534,6 +535,7 @@ function Layout() { closeButton /> {!Platform.isTV && } + {!Platform.isTV && } diff --git a/components/PendingAccountSaveModal.tsx b/components/PendingAccountSaveModal.tsx new file mode 100644 index 00000000..95d68a6e --- /dev/null +++ b/components/PendingAccountSaveModal.tsx @@ -0,0 +1,45 @@ +import { useAtom, useAtomValue } from "jotai"; +import type React from "react"; +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { SaveAccountModal } from "@/components/SaveAccountModal"; +import { + pendingAccountSaveAtom, + useJellyfin, + userAtom, +} from "@/providers/JellyfinProvider"; + +/** + * Post-login save-account prompt. Login flows (password or Quick Connect) + * only flag the intent via pendingAccountSaveAtom; the protection picker + * shows here, AFTER the session is authorized — the login screen itself + * unmounts as soon as the user is set, so it can't host the modal. + */ +export const PendingAccountSaveModal: React.FC = () => { + const [pending, setPending] = useAtom(pendingAccountSaveAtom); + const user = useAtomValue(userAtom); + const { saveCurrentAccount } = useJellyfin(); + + // A logout before answering drops the intent — it must not resurface on + // the next (possibly different) login. + useEffect(() => { + if (!user && pending) setPending(null); + }, [user, pending, setPending]); + + if (Platform.isTV) return null; + + return ( + setPending(null)} + onSave={(securityType, pinCode) => { + const serverName = pending?.serverName; + setPending(null); + saveCurrentAccount({ securityType, pinCode, serverName }).catch( + () => {}, + ); + }} + /> + ); +}; diff --git a/components/login/Login.tsx b/components/login/Login.tsx index 541e9727..04fb8ebf 100644 --- a/components/login/Login.tsx +++ b/components/login/Login.tsx @@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; import { Alert, @@ -22,13 +22,14 @@ import { Text } from "@/components/common/Text"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal"; import { PreviousServersList } from "@/components/PreviousServersList"; -import { SaveAccountModal } from "@/components/SaveAccountModal"; import { Colors } from "@/constants/Colors"; -import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -import type { - AccountSecurityType, - SavedServer, -} from "@/utils/secureCredentials"; +import { + apiAtom, + pendingAccountSaveAtom, + useJellyfin, + userAtom, +} from "@/providers/JellyfinProvider"; +import type { SavedServer } from "@/utils/secureCredentials"; const CredentialsSchema = z.object({ username: z.string().min(1, t("login.username_required")), @@ -48,6 +49,7 @@ export const Login: React.FC = () => { loginWithSavedCredential, loginWithPassword, } = useJellyfin(); + const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom); const { apiUrl: _apiUrl, @@ -72,8 +74,16 @@ export const Login: React.FC = () => { // Close the code sheet as soon as the session is authorized — the native // Alert used before had no programmatic dismiss and stayed open after login. + // A Quick Connect login with "save account" on flags the post-login save: + // the protection picker shows globally once the session exists (this screen + // unmounts on login, so it can't host the modal). useEffect(() => { - if (user) setQuickConnectCode(null); + if (user) { + if (quickConnectCode && saveAccount) { + setPendingAccountSave({ serverName }); + } + setQuickConnectCode(null); + } }, [user]); // Stop Quick Connect polling when leaving the login page (parity with TVLogin) @@ -93,13 +103,9 @@ export const Login: React.FC = () => { } }, [api?.basePath, stopQuickConnectPolling]); - // Save account state + // Save account state — only the intent lives here; the protection picker is + // the global PendingAccountSaveModal, shown after the login succeeds. const [saveAccount, setSaveAccount] = useState(false); - const [showSaveModal, setShowSaveModal] = useState(false); - const [pendingLogin, setPendingLogin] = useState<{ - username: string; - password: string; - } | null>(null); // Handle URL params for server connection useEffect(() => { @@ -146,29 +152,22 @@ export const Login: React.FC = () => { const result = CredentialsSchema.safeParse(credentials); if (!result.success) return; - if (saveAccount) { - setPendingLogin({ - username: credentials.username, - password: credentials.password, - }); - setShowSaveModal(true); - } else { - await performLogin(credentials.username, credentials.password); + const ok = await performLogin(credentials.username, credentials.password); + // The protection picker shows AFTER a successful login (global modal) — + // never for a failed one. + if (ok && saveAccount) { + setPendingAccountSave({ serverName }); } }; const performLogin = async ( username: string, password: string, - options?: { - saveAccount?: boolean; - securityType?: AccountSecurityType; - pinCode?: string; - }, - ) => { + ): Promise => { setLoading(true); try { - await login(username, password, serverName, options); + await login(username, password, serverName); + return true; } catch (error) { if (error instanceof Error) { Alert.alert(t("login.connection_failed"), error.message); @@ -178,23 +177,9 @@ export const Login: React.FC = () => { t("login.an_unexpected_error_occurred"), ); } + return false; } finally { setLoading(false); - setPendingLogin(null); - } - }; - - const handleSaveAccountConfirm = async ( - securityType: AccountSecurityType, - pinCode?: string, - ) => { - setShowSaveModal(false); - if (pendingLogin) { - await performLogin(pendingLogin.username, pendingLogin.password, { - saveAccount: true, - securityType, - pinCode, - }); } }; @@ -465,16 +450,6 @@ export const Login: React.FC = () => { )} - { - setShowSaveModal(false); - setPendingLogin(null); - }} - onSave={handleSaveAccountConfirm} - username={pendingLogin?.username || credentials.username} - /> - {/* Dismissing only hides the code — polling continues so the login still completes if the code is authorized from another device afterwards. */} (initialApi); export const userAtom = atom(initialUser); export const wsAtom = atom(null); export const cacheVersionAtom = atom(0); +// Set by a login flow that wants the account saved: the protection picker +// shows AFTER the session is authorized (the login screen unmounts on +// success, so the modal lives at the root — see PendingAccountSaveModal). +export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>( + null, +); interface LoginOptions { saveAccount?: boolean; @@ -109,6 +115,11 @@ interface JellyfinContextValue { serverName?: string, options?: LoginOptions, ) => Promise; + saveCurrentAccount: (options?: { + securityType?: AccountSecurityType; + pinCode?: string; + serverName?: string; + }) => Promise; logout: () => Promise; initiateQuickConnect: () => Promise; stopQuickConnectPolling: () => void; @@ -348,6 +359,37 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ }, }); + // Persist the CURRENT session to secure storage — used by the post-login + // save-account modal (the protection picker shows AFTER a successful + // login, for both the password and Quick Connect flows). + const saveCurrentAccount = useCallback( + async (options?: { + securityType?: AccountSecurityType; + pinCode?: string; + serverName?: string; + }) => { + const token = storage.getString("token"); + if (!api?.basePath || !user?.Id || !user.Name || !token) return; + 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: options?.serverName || "", + token, + userId: user.Id, + username: user.Name, + savedAt: Date.now(), + securityType, + pinHash, + primaryImageTag: user.PrimaryImageTag ?? undefined, + }); + }, + [api?.basePath, user], + ); + const loginMutation = useMutation({ mutationFn: async ({ username, @@ -732,6 +774,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ removeServer: () => removeServerMutation.mutateAsync(), login: (username, password, serverName, options) => loginMutation.mutateAsync({ username, password, serverName, options }), + saveCurrentAccount, logout: () => logoutMutation.mutateAsync(), initiateQuickConnect, stopQuickConnectPolling,