fix(login): ask how to protect a saved account after the login succeeds

The protection picker used to show before the login attempt, so a wrong
password still walked the user through choosing a PIN/password for an
account that never logged in - and a Quick Connect login could not save
the account at all.

Login flows now only flag the intent (pendingAccountSaveAtom); the
picker is a global PendingAccountSaveModal mounted at the root, shown
once the session is authorized - the login screen unmounts on success,
so it cannot host the modal itself. Works identically for the password
and Quick Connect flows; the credential is saved from the live session
token (saveCurrentAccount). Cancelling saves nothing, and a logout
before answering drops the intent.
This commit is contained in:
Gauvain
2026-06-11 00:42:25 +02:00
parent 98dfefbdd0
commit 34f01e2c13
4 changed files with 128 additions and 54 deletions

View File

@@ -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 (
<SaveAccountModal
visible={!!pending && !!user}
username={user?.Name ?? ""}
onClose={() => setPending(null)}
onSave={(securityType, pinCode) => {
const serverName = pending?.serverName;
setPending(null);
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
() => {},
);
}}
/>
);
};

View File

@@ -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,
@@ -21,13 +21,14 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } 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")),
@@ -35,6 +36,7 @@ const CredentialsSchema = z.object({
export const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
@@ -45,6 +47,7 @@ export const Login: React.FC = () => {
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const {
apiUrl: _apiUrl,
@@ -64,13 +67,24 @@ export const Login: React.FC = () => {
password: _password || "",
});
// 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);
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
const [quickConnectActive, setQuickConnectActive] = useState(false);
// 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) {
if (quickConnectActive && saveAccount) {
setPendingAccountSave({ serverName });
}
setQuickConnectActive(false);
}
}, [user]);
// Handle URL params for server connection
useEffect(() => {
@@ -117,29 +131,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<boolean> => {
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);
@@ -149,23 +156,9 @@ export const Login: React.FC = () => {
t("login.an_unexpected_error_occured"),
);
}
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,
});
}
};
@@ -259,6 +252,7 @@ export const Login: React.FC = () => {
try {
const code = await initiateQuickConnect();
if (code) {
setQuickConnectActive(true);
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
@@ -443,16 +437,6 @@ export const Login: React.FC = () => {
</View>
)}
</KeyboardAvoidingView>
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
};