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,