diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index 8069eab..f16ceae 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -155,6 +155,38 @@ pub fn reset_anisette_state() -> Result { } } +fn parse_two_factor_response( + payload: &str, +) -> Result { + serde_json::from_str(payload) +} + +async fn request_two_factor_response( + window: &Window, + params: TwoFactorCallbackParams, +) -> Result { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let handler_id = window.listen("2fa-response", move |event| { + let response = + parse_two_factor_response(event.payload()).map_err(|error| error.to_string()); + let _ = tx.send(response); + }); + + if let Err(error) = window.emit("2fa-required", params) { + window.unlisten(handler_id); + return Err(report!("Failed to emit 2fa-required event: {error}")); + } + + let response = tokio::time::timeout(Duration::from_secs(300), rx.recv()).await; + window.unlisten(handler_id); + + match response { + Ok(Some(Ok(response))) => Ok(response), + Ok(Some(Err(error))) => Err(report!("Invalid 2FA response: {error}")), + Ok(None) | Err(_) => Ok(TwoFactorCallbackResponse::Abort), + } +} + async fn login( app: &AppHandle, window: &Window, @@ -167,24 +199,7 @@ async fn login( move |params: TwoFactorCallbackParams| { let window_clone = window_clone.clone(); - async move { - window_clone - .emit("2fa-required", params) - .context("Failed to emit 2fa-required event")?; - - let (tx, rx) = std::sync::mpsc::channel::(); - let handler_id = window_clone.listen("2fa-recieved", move |event| { - let code = event.payload(); - let _ = tx.send(code.to_string()); - }); - - let result = rx.recv_timeout(Duration::from_secs(120))?; - window_clone.unlisten(handler_id); - - let code = result.trim_matches('"').to_string(); - Ok(TwoFactorCallbackResponse::SubmitCode(code)) - } - .boxed() + async move { request_two_factor_response(&window_clone, params).await }.boxed() } }; @@ -253,6 +268,42 @@ async fn login( Ok(sideloader) } +#[cfg(test)] +mod tests { + use super::{TwoFactorCallbackResponse, parse_two_factor_response}; + + #[test] + fn parses_all_two_factor_response_variants() { + let cases = [ + r#""Abort""#, + r#""ResendCode""#, + r#""SendToDevices""#, + r#"{"SubmitCode":"123456"}"#, + r#"{"SendSms":2}"#, + ]; + + for payload in cases { + assert!( + parse_two_factor_response(payload).is_ok(), + "failed to parse {payload}" + ); + } + } + + #[test] + fn preserves_two_factor_response_payloads() { + match parse_two_factor_response(r#"{"SubmitCode":"123456"}"#).unwrap() { + TwoFactorCallbackResponse::SubmitCode(code) => assert_eq!(code, "123456"), + _ => panic!("expected SubmitCode"), + } + + match parse_two_factor_response(r#"{"SendSms":2}"#).unwrap() { + TwoFactorCallbackResponse::SendSms(id) => assert_eq!(id, 2), + _ => panic!("expected SendSms"), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CertificateInfo { diff --git a/src/AppleID.css b/src/AppleID.css index dcc2f19..2cca675 100644 --- a/src/AppleID.css +++ b/src/AppleID.css @@ -195,3 +195,48 @@ .credentials-warning { margin: 0; } + +.tfa-dialog { + display: flex; + flex-direction: column; + gap: 0.9rem; + min-width: min(26rem, 80vw); +} + +.tfa-dialog h2, +.tfa-dialog p { + margin: 0; +} + +.tfa-error { + padding: 0.75rem; + border: 1px solid rgba(255, 69, 58, 0.55); + border-radius: 10px; + color: var(--danger); + background: rgba(255, 69, 58, 0.12); + white-space: pre-wrap; +} + +.tfa-code-form, +.tfa-actions, +.tfa-phone-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.tfa-code-form input, +.tfa-code-form button, +.tfa-actions button, +.tfa-phone-list button { + width: 100%; +} + +.tfa-phone-list { + padding-top: 0.25rem; +} + +.tfa-phone-list-label { + color: var(--text-muted); + font-size: 0.9rem; +} diff --git a/src/AppleID.tsx b/src/AppleID.tsx index c89caa6..3d62595 100644 --- a/src/AppleID.tsx +++ b/src/AppleID.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import "./AppleID.css"; import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; @@ -12,6 +12,28 @@ import { useTranslation } from "react-i18next"; const storePromise = load("data.json"); +type TrustedNumber = { + numberWithDialCode: string; + lastTwoDigits: string; + pushMode: string; + id: number; +}; + +type TwoFactorParams = { + lastError: string | null; + unknown: boolean; + sms: boolean; + numbers: TrustedNumber[]; + selectedNumberId: number | null; +}; + +type TwoFactorResponse = + | "Abort" + | "ResendCode" + | "SendToDevices" + | { SubmitCode: string } + | { SendSms: number }; + export const AppleID = ({ loggedInAs, setLoggedInAs, @@ -27,8 +49,9 @@ export const AppleID = ({ const [emailInput, setEmailInput] = useState(""); const [passwordInput, setPasswordInput] = useState(""); const [saveCredentials, setSaveCredentials] = useState(false); - const [tfaOpen, setTfaOpen] = useState(false); + const [tfaParams, setTfaParams] = useState(null); const [tfaCode, setTfaCode] = useState(""); + const [showSmsNumbers, setShowSmsNumbers] = useState(false); const [addAccountOpen, setAddAccountOpen] = useState(false); const [anisetteServer] = useStore( "anisetteServer", @@ -58,24 +81,57 @@ export const AppleID = ({ setSelectedSerials(certs?.map((c) => c.serialNumber) ?? []); }, [certs]); - const listenerAdded = useRef(false); - const unlisten = useRef<() => void>(() => {}); + const tfaRequestId = useRef(0); + const tfaResponding = useRef(false); useEffect(() => { - if (!listenerAdded.current) { - (async () => { - const unlistenFn = await listen("2fa-required", () => { - setTfaOpen(true); - }); - unlisten.current = unlistenFn; - })(); - listenerAdded.current = true; - } + let disposed = false; + let unlisten: (() => void) | null = null; + + listen("2fa-required", (event) => { + tfaRequestId.current += 1; + tfaResponding.current = false; + setTfaCode(""); + setShowSmsNumbers(event.payload.unknown); + setTfaParams(event.payload); + }).then((unlistenFn) => { + if (disposed) { + unlistenFn(); + } else { + unlisten = unlistenFn; + } + }); + return () => { - unlisten.current(); + disposed = true; + unlisten?.(); }; }, []); + const respondTfa = useCallback( + async (response: TwoFactorResponse) => { + if (tfaResponding.current) return; + tfaResponding.current = true; + const requestId = tfaRequestId.current; + + try { + await emit("2fa-response", response); + if (tfaRequestId.current === requestId) { + setTfaParams(null); + setTfaCode(""); + setShowSmsNumbers(false); + } + } catch (error) { + if (tfaRequestId.current === requestId) { + tfaResponding.current = false; + } + toast.error(t("apple_id.two_factor_response_failed")); + console.error("Failed to send 2FA response", error); + } + }, + [t], + ); + const certListenerAdded = useRef(false); const certUnlisten = useRef<() => void>(() => {}); @@ -266,30 +322,121 @@ export const AppleID = ({ )} - -

{t("apple_id.two_factor_title")}

-

{t("apple_id.two_factor_prompt")}

-
{ - e.preventDefault(); - if (tfaCode.length !== 6) { - toast.warning(t("apple_id.valid_6digit")); - return; - } - await emit("2fa-recieved", tfaCode); - setTfaOpen(false); - setTfaCode(""); - }} - > - setTfaCode(e.target.value)} - style={{ marginRight: "0.5em" }} - /> - -
+ respondTfa("Abort")} + zIndex={2000} + > + {tfaParams && ( +
+

+ {tfaParams.unknown + ? t("apple_id.two_factor_choose_method_title") + : tfaParams.sms + ? t("apple_id.two_factor_sms_title") + : t("apple_id.two_factor_title")} +

+ {tfaParams.lastError && ( +

+ {tfaParams.lastError} +

+ )} + {tfaParams.unknown ? ( +

{t("apple_id.two_factor_choose_method_prompt")}

+ ) : ( +

+ {tfaParams.sms + ? t("apple_id.two_factor_sms_prompt", { + number: + tfaParams.numbers.find( + (number) => + number.id === tfaParams.selectedNumberId, + )?.numberWithDialCode ?? + t("apple_id.two_factor_selected_phone"), + }) + : t("apple_id.two_factor_prompt")} +

+ )} + + {!tfaParams.unknown && ( +
{ + e.preventDefault(); + if (!/^\d{6}$/.test(tfaCode)) { + toast.warning(t("apple_id.valid_6digit")); + return; + } + respondTfa({ SubmitCode: tfaCode }); + }} + > + + setTfaCode(e.target.value.replace(/\D/g, "").slice(0, 6)) + } + autoFocus + /> + +
+ )} + +
+ {!tfaParams.unknown && ( + + )} + {(tfaParams.unknown || tfaParams.sms) && ( + + )} + {!tfaParams.unknown && tfaParams.numbers.length > 0 && ( + + )} +
+ + {(tfaParams.unknown || showSmsNumbers) && + tfaParams.numbers.length > 0 && ( +
+

+ {t("apple_id.two_factor_choose_phone")} +

+ {tfaParams.numbers.map((number) => ( + + ))} +
+ )} +
+ )}

{t("apple_id.max_certs_title")}

diff --git a/src/locales/en.json b/src/locales/en.json index 17d70e0..466a46c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -70,7 +70,19 @@ "valid_email": "Please enter a valid email address.", "login": "Login", "two_factor_title": "Two-Factor Authentication", - "two_factor_prompt": "Please enter the verification code sent to your device.", + "two_factor_prompt": "Enter the verification code sent to your trusted Apple devices.", + "two_factor_sms_title": "SMS Verification", + "two_factor_sms_prompt": "Enter the verification code sent to {{number}}.", + "two_factor_selected_phone": "your selected phone number", + "two_factor_choose_method_title": "Choose a verification method", + "two_factor_choose_method_prompt": "The previous verification method could not be used. Choose another method to continue.", + "two_factor_resend_code": "Resend code", + "two_factor_send_to_devices": "Send to trusted devices", + "two_factor_use_sms": "Use SMS instead", + "two_factor_change_number": "Choose another phone number", + "two_factor_choose_phone": "Choose a trusted phone number:", + "two_factor_send_sms_to": "Send SMS to {{number}}", + "two_factor_response_failed": "Unable to send the verification response. Please try again.", "verification_placeholder": "Verification Code...", "valid_6digit": "Please enter a valid 6-digit code.", "submit": "Submit", diff --git a/src/locales/fr.json b/src/locales/fr.json index 16597d0..c8640d3 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -70,7 +70,19 @@ "valid_email": "Veuillez saisir une adresse email valide.", "login": "Connexion", "two_factor_title": "Authentification à deux facteurs", - "two_factor_prompt": "Veuillez saisir le code de vérification envoyé sur votre appareil.", + "two_factor_prompt": "Saisissez le code de vérification envoyé à vos appareils Apple de confiance.", + "two_factor_sms_title": "Vérification par SMS", + "two_factor_sms_prompt": "Saisissez le code de vérification envoyé au {{number}}.", + "two_factor_selected_phone": "numéro de téléphone sélectionné", + "two_factor_choose_method_title": "Choisir une méthode de vérification", + "two_factor_choose_method_prompt": "La méthode de vérification précédente n’a pas pu être utilisée. Choisissez une autre méthode pour continuer.", + "two_factor_resend_code": "Renvoyer le code", + "two_factor_send_to_devices": "Envoyer aux appareils de confiance", + "two_factor_use_sms": "Utiliser un SMS à la place", + "two_factor_change_number": "Choisir un autre numéro", + "two_factor_choose_phone": "Choisissez un numéro de confiance :", + "two_factor_send_sms_to": "Envoyer un SMS au {{number}}", + "two_factor_response_failed": "Impossible d’envoyer la réponse de vérification. Veuillez réessayer.", "verification_placeholder": "Code de vérification...", "valid_6digit": "Veuillez saisir un code valide à 6 chiffres.", "submit": "Soumettre",