Compare commits

..

4 Commits

Author SHA1 Message Date
LiBr
8ea1b9c0bc Merge pull request #5 from Esonhugh/main
chore: login logic update
2026-04-28 09:26:42 +08:00
Esonhugh
34be882f88 fix: reauth error catch 2026-04-28 02:14:19 +08:00
Esonhugh
a31181ea95 update: add try again 2026-04-28 02:03:26 +08:00
Esonhugh
27ad1001a7 update: add feature of SMS push fallback logic and login error prompt. login logic optimized 2026-04-28 01:27:36 +08:00
6 changed files with 1957 additions and 128 deletions

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { DirectUsbMuxClient } from 'webmuxd'; import type { DirectUsbMuxClient } from 'webmuxd';
import type { AnisetteData } from './anisette-service'; import type { AnisetteData } from './anisette-service';
import type { AppleDeveloperContext } from './apple-signing'; import type { AppleDeveloperContext, TwoFactorContext } from './apple-signing';
import { Header, type AppPage } from './components/Header'; import { Header, type AppPage } from './components/Header';
import { LoginPage } from './components/LoginPage'; import { LoginPage } from './components/LoginPage';
@@ -80,9 +80,13 @@ export function App() {
const [loginContext, setLoginContext] = useState<AppleDeveloperContext | null>(null); const [loginContext, setLoginContext] = useState<AppleDeveloperContext | null>(null);
const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList()); const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList());
const accountContextMapRef = useRef<Map<string, AppleDeveloperContext>>(new Map()); const accountContextMapRef = useRef<Map<string, AppleDeveloperContext>>(new Map());
// Version counter so cachedAccountKeys useMemo re-runs when the map changes.
// useRef mutations don't trigger re-renders; this state variable does.
const [accountCacheVersion, setAccountCacheVersion] = useState(0);
// Login modal // Login modal
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
// Device // Device
const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null); const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null);
@@ -114,9 +118,12 @@ export function App() {
const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED); const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
const [twoFactor, setTwoFactor] = useState<{ const [twoFactor, setTwoFactor] = useState<{
open: boolean; open: boolean;
submit: ((code: string) => void) | null; ctx: TwoFactorContext | null;
error: string | null; error: string | null;
}>({ open: false, submit: null, error: null }); }>({ open: false, ctx: null, error: null });
// Set to true by handleTwoFactorCancel so handleLogin's catch can distinguish
// a user-cancelled 2FA from a genuine verification failure.
const twoFactorCancelledRef = useRef(false);
// Derived // Derived
const isWebUsbSupported = useMemo(() => { const isWebUsbSupported = useMemo(() => {
@@ -136,7 +143,7 @@ export function App() {
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null; const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]); const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [accountCacheVersion]);
const trustOpen = trustState !== TRUST_MODAL_CLOSED; const trustOpen = trustState !== TRUST_MODAL_CLOSED;
// ---- log + progress plumbing ---- // ---- log + progress plumbing ----
@@ -183,6 +190,7 @@ export function App() {
useEffect(() => { useEffect(() => {
const restored = restorePersistedAccountContexts(); const restored = restorePersistedAccountContexts();
accountContextMapRef.current = restored; accountContextMapRef.current = restored;
setAccountCacheVersion((v) => v + 1);
const summary = loadStoredAccountSummary(); const summary = loadStoredAccountSummary();
if (summary) { if (summary) {
const active = restored.get(accountKey(summary.appleId, summary.teamId)); const active = restored.get(accountKey(summary.appleId, summary.teamId));
@@ -332,13 +340,17 @@ export function App() {
} }
setBusy((prev) => ({ ...prev, loginSign: true })); setBusy((prev) => ({ ...prev, loginSign: true }));
twoFactorCancelledRef.current = false;
setLoginError(null);
let twoFactorOpened = false; let twoFactorOpened = false;
let twoFactorErrorShown = false; let twoFactorErrorShown = false;
try { try {
saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId); saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId);
addLog('login: initializing anisette...'); addLog('login: initializing anisette...');
const { anisetteData: nextAnisette } = await ensureAnisetteData(anisetteData, addLog); // Always fetch a fresh anisette OTP for login — the OTP is one-time-use
// and the cached value is consumed after any previous attempt (even failed).
const { anisetteData: nextAnisette } = await ensureAnisetteData(null, addLog);
setAnisetteData(nextAnisette); setAnisetteData(nextAnisette);
setAnisetteProvisioned(true); setAnisetteProvisioned(true);
addLog('login: anisette ready, authenticating...'); addLog('login: anisette ready, authenticating...');
@@ -348,17 +360,19 @@ export function App() {
password, password,
anisetteData: nextAnisette, anisetteData: nextAnisette,
log: addLog, log: addLog,
onTwoFactorRequired: (submit) => { onTwoFactorRequired: (ctx) => {
twoFactorOpened = true; twoFactorOpened = true;
setTwoFactor({ open: true, submit, error: null }); setTwoFactor({ open: true, ctx, error: null });
addLog('login: 2FA required, opening verification dialog'); addLog('login: 2FA required, opening verification dialog');
}, },
}); });
setTwoFactor({ open: false, submit: null, error: null }); setTwoFactor({ open: false, ctx: null, error: null });
setLoginError(null);
addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`); addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`);
const key = accountKey(context.appleId, context.team.identifier); const key = accountKey(context.appleId, context.team.identifier);
accountContextMapRef.current.set(key, context); accountContextMapRef.current.set(key, context);
setAccountCacheVersion((v) => v + 1);
persistAccountSummary(context); persistAccountSummary(context);
persistAccountSession(context, nextAnisette); persistAccountSession(context, nextAnisette);
addLog('login: session persisted'); addLog('login: session persisted');
@@ -371,36 +385,43 @@ export function App() {
addLog('login: done, navigating to sign page'); addLog('login: done, navigating to sign page');
navigateToPage('sign'); navigateToPage('sign');
} catch (error) { } catch (error) {
console.error('[login] caught error:', error);
const msg = formatError(error); const msg = formatError(error);
addLog(`login failed: ${msg}`); addLog(`login failed: ${msg}`);
if (twoFactorOpened) { // Only show the error inside the 2FA modal if it's still open and the user
// did NOT cancel — a cancellation is intentional, not an error.
if (twoFactorOpened && !twoFactorCancelledRef.current) {
setTwoFactor((prev) => ({ ...prev, error: msg })); setTwoFactor((prev) => ({ ...prev, error: msg }));
twoFactorErrorShown = true; twoFactorErrorShown = true;
} else if (!twoFactorOpened) {
// Pre-2FA failure (wrong password, rate limit, etc.) — show in login modal.
setLoginError(msg);
} }
} finally { } finally {
if (!twoFactorErrorShown) { if (!twoFactorErrorShown) {
setTwoFactor({ open: false, submit: null, error: null }); setTwoFactor({ open: false, ctx: null, error: null });
} }
setBusy((prev) => ({ ...prev, loginSign: false })); setBusy((prev) => ({ ...prev, loginSign: false }));
} }
}, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]); }, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]);
const handleTwoFactorSubmit = useCallback(
(code: string) => {
const submit = twoFactor.submit;
if (submit) submit(code);
},
[twoFactor.submit],
);
const handleTwoFactorCancel = useCallback(() => { const handleTwoFactorCancel = useCallback(() => {
const submit = twoFactor.submit; const ctx = twoFactor.ctx;
setTwoFactor({ open: false, submit: null, error: null }); twoFactorCancelledRef.current = true;
if (submit) { setTwoFactor({ open: false, ctx: null, error: null });
if (ctx) {
addLog('login: 2FA canceled'); addLog('login: 2FA canceled');
submit('__CANCELLED__'); // Signal cancellation via the device-code path with a sentinel value.
ctx.submitDeviceCode('__CANCELLED__');
} }
}, [addLog, twoFactor.submit]); }, [addLog, twoFactor.ctx]);
const handleTwoFactorRetry = useCallback(() => {
// Close the modal (clearing the stale ctx + error) then re-run the full
// login flow so the user doesn't have to re-type their password.
setTwoFactor({ open: false, ctx: null, error: null });
void handleLogin();
}, [handleLogin]);
// ---- sign flow ---- // ---- sign flow ----
const handleSign = useCallback(async () => { const handleSign = useCallback(async () => {
@@ -534,6 +555,7 @@ export function App() {
// Remove from session map // Remove from session map
removeStoredAccountSession(summary.appleId, summary.teamId); removeStoredAccountSession(summary.appleId, summary.teamId);
accountContextMapRef.current.delete(key); accountContextMapRef.current.delete(key);
setAccountCacheVersion((v) => v + 1);
// Remove from account list // Remove from account list
const list = loadStoredAccountList().filter( const list = loadStoredAccountList().filter(
@@ -598,7 +620,7 @@ export function App() {
<main className="min-h-screen bg-bg"> <main className="min-h-screen bg-bg">
<Header currentPage={currentPage} onNavigate={navigateToPage} /> <Header currentPage={currentPage} onNavigate={navigateToPage} />
<section className="mx-auto max-w-[760px] px-5 py-10 sm:px-7"> <section className="mx-auto max-w-190 px-5 py-10 sm:px-7">
{currentPage === 'login' ? ( {currentPage === 'login' ? (
<LoginPage <LoginPage
loggedIn={!!loginContext} loggedIn={!!loginContext}
@@ -642,14 +664,15 @@ export function App() {
<LoginModal <LoginModal
open={loginModalOpen} open={loginModalOpen}
onClose={() => setLoginModalOpen(false)} onClose={() => { setLoginModalOpen(false); setLoginError(null); }}
appleId={appleId} appleId={appleId}
password={password} password={password}
busyLoginSign={busy.loginSign} busyLoginSign={busy.loginSign}
canSubmit={loginCanSubmit} canSubmit={loginCanSubmit}
onAppleIdChange={setAppleId} error={loginError}
onAppleIdChange={(v) => { setAppleId(v); setLoginError(null); }}
onAppleIdBlur={handleAppleIdBlur} onAppleIdBlur={handleAppleIdBlur}
onPasswordChange={setPassword} onPasswordChange={(v) => { setPassword(v); setLoginError(null); }}
onSubmit={handleLogin} onSubmit={handleLogin}
/> />
@@ -668,9 +691,10 @@ export function App() {
/> />
<TwoFactorModal <TwoFactorModal
open={twoFactor.open} open={twoFactor.open}
onSubmit={handleTwoFactorSubmit} ctx={twoFactor.ctx}
onCancel={handleTwoFactorCancel} onCancel={handleTwoFactorCancel}
serverError={twoFactor.error} serverError={twoFactor.error}
onRetry={handleTwoFactorRetry}
/> />
</main> </main>
); );

View File

@@ -57,11 +57,29 @@ export interface AppleDeveloperContext {
devices: Device[]; devices: Device[];
} }
export interface TrustedPhoneNumber {
id: number;
numberWithDialCode: string;
obfuscatedNumber: string;
pushMode: string;
}
export interface TwoFactorContext {
/** Submit a 6-digit code that was pushed to a trusted device. */
submitDeviceCode: (code: string) => void;
/** Available trusted phone numbers for SMS fallback. Empty when none. */
trustedPhoneNumbers: TrustedPhoneNumber[];
/** Request an SMS be sent to the given phone id, then call submitSmsCode. */
requestSms: (phoneId: number) => Promise<void>;
/** Submit the code received via SMS for the given phone id. */
submitSmsCode: (phoneId: number, code: string) => Promise<void>;
}
export interface AppleDeveloperLoginRequest { export interface AppleDeveloperLoginRequest {
anisetteData: AnisetteData; anisetteData: AnisetteData;
credentials: AppleSigningCredentials; credentials: AppleSigningCredentials;
onLog?: (message: string) => void; onLog?: (message: string) => void;
onTwoFactorRequired?: (submitCode: (code: string) => void) => void; onTwoFactorRequired?: (ctx: TwoFactorContext) => void;
} }
export interface AppleSigningWithContextRequest { export interface AppleSigningWithContextRequest {
@@ -101,8 +119,348 @@ interface AltsignModule {
}): Promise<{ data: Uint8Array }>; }): Promise<{ data: Uint8Array }>;
} }
let appleApiInstance: AppleAPI | null = null; /** Minimal type alias for the Fetch instance used in the SMS patch. */
type AltsignFetch = {
get(url: string, headers?: Record<string, string>): Promise<{ text(): Promise<string>; ok: boolean }>;
request(
method: string,
url: string,
headers: Record<string, string>,
body?: string,
): Promise<{ text(): Promise<string>; ok: boolean }>;
};
let altsignModulePromise: Promise<AltsignModule> | null = null; let altsignModulePromise: Promise<AltsignModule> | null = null;
/** Cached fetch wrapper — the libcurl WASM underneath is expensive to re-init. */
let appleFetchInstance: AltsignFetch | null = null;
// ---------- Apple GSA SMS 2FA endpoints ----------
const GSA_TRUSTED_DEVICE_URL = 'https://gsa.apple.com/auth/verify/trusteddevice';
const GSA_VALIDATE_URL = 'https://gsa.apple.com/grandslam/GsService2/validate';
/** Request an SMS be sent to a phone number (PUT, no `/put` suffix). */
const GSA_PHONE_URL = 'https://gsa.apple.com/auth/verify/phone';
const GSA_PHONE_CODE_URL = 'https://gsa.apple.com/auth/verify/phone/securitycode';
/** Replace the private handleTwoFactor method on the auth sub-object so we get
* the trusted-phone list and can drive SMS verification from the UI. */
function patchHandleTwoFactor(api: AppleAPI, fetch: AltsignFetch): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const auth: any = (api as any).auth;
if (!auth || typeof auth.handleTwoFactor !== 'function') return;
auth.handleTwoFactor = async function (
dsid: string,
idmsToken: string,
anisetteData: AnisetteData,
verificationHandler: ((ctx: TwoFactorContext) => void) | undefined,
): Promise<boolean> {
if (!verificationHandler) return false;
try {
const identityToken = btoa(`${dsid}:${idmsToken}`);
const headers: Record<string, string> = {
'Content-Type': 'text/x-xml-plist',
'User-Agent': 'Xcode',
Accept: 'text/x-xml-plist',
'Accept-Language': 'en-us',
'X-Apple-App-Info': 'com.apple.gs.xcode.auth',
'X-Xcode-Version': '11.2 (11B41)',
'X-Apple-Identity-Token': identityToken,
'X-Apple-I-MD-M': anisetteData.machineID,
'X-Apple-I-MD': anisetteData.oneTimePassword,
'X-Apple-I-MD-LU': anisetteData.localUserID,
'X-Apple-I-MD-RINFO': String(anisetteData.routingInfo),
'X-Mme-Device-Id': anisetteData.deviceUniqueIdentifier,
'X-MMe-Client-Info': anisetteData.deviceDescription,
'X-Apple-I-Client-Time': auth.formatDate(anisetteData.date),
'X-Apple-Locale': anisetteData.locale,
'X-Apple-I-TimeZone': anisetteData.timeZone,
};
// Trigger push to trusted devices AND get trusted phone numbers.
let trustedPhoneNumbers: TrustedPhoneNumber[] = [];
try {
const deviceResp = await fetch.get(GSA_TRUSTED_DEVICE_URL, headers);
const deviceText = await deviceResp.text();
trustedPhoneNumbers = parseTrustedPhones(deviceText);
console.log('[2FA] parsed trustedPhoneNumbers:', trustedPhoneNumbers);
if (trustedPhoneNumbers.length === 0) {
// Log key fragments to diagnose the response format
const snippets = [
deviceText.match(/"trustedPhoneNumbers"\s*:\s*(\[[\s\S]{0,300}?\])/)?.[1],
deviceText.match(/phoneNumbers[\s\S]{0,200}/)?.[0],
// Apple auth HTML embeds data in various script tags
deviceText.match(/window\.AUTH_INIT_DATA\s*=\s*({[\s\S]{0,400}?});/)?.[1],
deviceText.match(/var\s+bootstrap\s*=\s*({[\s\S]{0,400}?});/)?.[1],
deviceText.match(/<script[^>]*id="boot_args"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
deviceText.match(/<script[^>]*type="text\/x-apple-plist"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
deviceText.match(/<script[^>]*type="application\/json"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
].filter(Boolean);
console.log('[2FA] phone fragments:', snippets);
}
} catch (e) {
console.warn('[2FA] trusteddevice request failed (non-fatal):', e);
}
return await new Promise<boolean>((resolve) => {
const jsonHeaders = {
...headers,
'Content-Type': 'application/json',
Accept: 'application/json',
};
const submitDeviceCode = async (code: string) => {
const trimmed = code.trim();
// '__CANCELLED__' sentinel: user dismissed the modal — skip network request.
if (!trimmed || trimmed === '__CANCELLED__') { resolve(false); return; }
try {
const verifyHeaders = { ...headers, 'security-code': trimmed };
const resp = await fetch.get(GSA_VALIDATE_URL, verifyHeaders);
const text = await resp.text();
const plist = parsePlistSimple(text);
resolve((plist as Record<string, unknown>)['ec'] === 0);
} catch { resolve(false); }
};
const requestSms = async (phoneId: number): Promise<void> => {
const body = JSON.stringify({ phoneNumber: { id: phoneId }, mode: 'sms' });
console.log('[2FA] requestSms → PUT', GSA_PHONE_URL, 'phoneId:', phoneId);
const resp = await fetch.request('PUT', GSA_PHONE_URL, jsonHeaders, body);
const respText = await resp.text();
console.log('[2FA] requestSms response ok:', resp.ok, 'body:', respText.slice(0, 200));
if (!resp.ok) throw new Error(`Failed to send SMS (${respText.slice(0, 100)})`);
};
const submitSmsCode = async (phoneId: number, code: string): Promise<void> => {
const body = JSON.stringify({
phoneNumber: { id: phoneId },
securityCode: { code: code.trim() },
mode: 'sms',
});
const resp = await fetch.request('POST', GSA_PHONE_CODE_URL, jsonHeaders, body);
const text = await resp.text();
console.log('[2FA] submitSmsCode response ok:', resp.ok, 'body:', text.slice(0, 200));
let ok = resp.ok;
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
if (parsed['ec'] !== undefined) ok = parsed['ec'] === 0;
} catch { /* plist fallback */ }
if (!ok) throw new Error('SMS verification failed');
// SMS 2FA is complete — resolve(true) so altsign.js retries authenticate()
// with fresh anisette (handled by patchAuthenticateRetry).
resolve(true);
};
try {
verificationHandler({ submitDeviceCode, trustedPhoneNumbers, requestSms, submitSmsCode });
} catch { resolve(false); }
});
} catch {
return false;
}
};
}
/** patchAuthenticateRetry: on the post-2FA re-call, swap in a fresh anisette OTP
* so the SRP exchange doesn't crash on a stale one-time password.
*
* patchSendAuthRequest: Apple may return au:"secondaryAuth" instead of
* au:"trustedDeviceSecondaryAuth" for accounts without a trusted device (SMS-only
* 2FA). altsign.js only checks for the latter, so it silently falls through to
* fetchAuthToken which then crashes with atob(undefined). We normalize the value
* so altsign.js's existing 2FA path fires correctly.
*
* For accounts where Apple signals 2FA ONLY at the apptokens stage (no au in the
* SRP complete response), we intercept the empty apptokens response, trigger 2FA,
* then throw a sentinel so the authenticate wrapper performs a full fresh SRP
* exchange (Apple invalidates the SRP session after 2FA at this stage). */
const REAUTH_SENTINEL = '__SIDEIMPACTOR_NEEDS_FULL_REAUTH__';
function patchAuthenticateRetry(api: AppleAPI, _apiFetch: AltsignFetch): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const auth: any = (api as any).auth;
if (!auth || typeof auth.authenticate !== 'function') return;
// Store the verificationHandler from the current authenticate() call so the
// sendAuthRequest patch can forward it to handleTwoFactor when needed.
let storedVerificationHandler: ((ctx: TwoFactorContext) => void) | undefined;
// After 2FA completes, mark it done so we don't re-trigger if the second
// apptokens attempt also returns empty (shouldn't happen, but guards loops).
let twoFactorDone = false;
// --- patch sendAuthRequest ---
if (typeof auth.sendAuthRequest === 'function') {
const origSend: (...a: unknown[]) => Promise<Record<string, unknown>> =
auth.sendAuthRequest.bind(auth);
auth.sendAuthRequest = async function (...args: unknown[]): Promise<Record<string, unknown>> {
const params = args[0] as Record<string, unknown>;
const anisetteArg = args[1] as AnisetteData;
// Wrap origSend so we can intercept apptokens 2FA signals.
// Apple uses two different conventions to signal "2FA required at apptokens":
// (a) Older: returns a non-plist / empty body → parsePlist yields {} (no Status)
// (b) Newer: returns ec=-22421 "This action could not be completed. Try again."
// which altsign.js converts into a thrown Error before returning to us.
// We catch case (b) here and fall through to the shared 2FA-gate logic below.
let response: Record<string, unknown>;
try {
response = await origSend(...args);
} catch (e) {
const isApptokens = params?.['o'] === 'apptokens';
const is22421 = e instanceof Error && e.message.includes('(-22421)');
if (isApptokens && is22421 && !twoFactorDone) {
console.log('[auth] apptokens threw -22421 — treating as 2FA gate (Apple new-style signal)');
response = {}; // fall through to 2FA handling below
} else {
throw e;
}
}
const status = response['Status'] as Record<string, unknown> | undefined;
const au = status?.['au'];
console.log('[auth] sendAuthRequest o=%s au=%s keys=%o', params?.['o'], au, Object.keys(response));
// Normalize legacy au value
if (au === 'secondaryAuth') {
console.log('[auth] normalizing au: secondaryAuth → trustedDeviceSecondaryAuth');
(status as Record<string, unknown>)['au'] = 'trustedDeviceSecondaryAuth';
return response;
}
// Detect 2FA required at apptokens stage.
// Covers both empty response (case a) and the -22421 catch above (case b).
if (params?.['o'] === 'apptokens' && Object.keys(response).length === 0) {
if (twoFactorDone) {
// 2FA was already completed — this is a genuine failure, not a gate.
throw new Error('Authentication failed after two-factor verification. Please sign in again.');
}
console.log('[auth] apptokens 2FA gate detected — triggering verification');
const adsid = params['u'] as string;
const idmsToken = params['t'] as string;
if (adsid && idmsToken && storedVerificationHandler) {
const success = await auth.handleTwoFactor(
adsid, idmsToken, anisetteArg, storedVerificationHandler,
);
if (success) {
// Apple invalidates the SRP session after 2FA at this stage.
// Throw a sentinel so loginAppleDeveloperAccount starts a fresh SRP exchange.
console.log('[auth] 2FA done — signalling full re-authentication');
twoFactorDone = true;
throw new Error(REAUTH_SENTINEL);
}
}
// 2FA was cancelled by the user or no handler was available.
// Throw instead of returning {} so altsign.js doesn't try to process
// an invalid empty response (which would crash with atob(undefined)).
throw new Error('Two-factor authentication was cancelled. Please sign in again.');
}
return response;
};
} else {
console.warn('[auth] sendAuthRequest not found on auth — patch skipped');
}
// --- patch authenticate ---
// Responsibilities:
// 1. Capture verificationHandler so sendAuthRequest can pass it to handleTwoFactor.
// 2. On post-2FA retry (callCount > 1), swap in fresh anisette.
// 3. If REAUTH_SENTINEL is thrown from the apptokens path, re-throw it unchanged
// so loginAppleDeveloperAccount can catch it and do a clean fresh login with a
// new AppleAPI instance (avoids state-pollution in the current auth object).
const original: (...args: unknown[]) => Promise<unknown> = auth.authenticate.bind(auth);
let callCount = 0;
auth.authenticate = async function (...args: unknown[]): Promise<unknown> {
callCount++;
storedVerificationHandler = args[3] as ((ctx: TwoFactorContext) => void) | undefined;
console.log(`[auth] authenticate call #${callCount}`);
// Always use fresh anisette on any retry call (callCount > 1).
if (callCount > 1) {
try {
const { getAnisetteData } = await import('./anisette-service');
const freshAnisette = await getAnisetteData();
args = [...args];
args[2] = freshAnisette;
console.log('[auth] post-2FA retry: swapped in fresh anisette OTP');
} catch (e) {
console.warn('[auth] post-2FA retry: could not fetch fresh anisette, proceeding with original', e);
}
}
try {
return await original(...args);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Bubble the sentinel unchanged — loginAppleDeveloperAccount handles it
// by creating a fresh AppleAPI instance for a clean re-auth.
if (msg === REAUTH_SENTINEL) {
throw e;
}
console.error(`[auth] authenticate call #${callCount} threw:`, e);
throw e;
}
};
}
/** Extract `ec` from an Apple GSA response (JSON or XML plist). */
function parsePlistSimple(text: string): unknown {
const trimmed = text.trim();
if (trimmed.startsWith('{')) {
try { return JSON.parse(trimmed); } catch { /* fall through */ }
}
// XML plist — reuse the file's own parser
return parseXmlPlist(trimmed);
}
function parseTrustedPhones(text: string): TrustedPhoneNumber[] {
const trimmed = text.trim();
// Path 1: plain JSON response { "trustedPhoneNumbers": [...] }
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const data = JSON.parse(trimmed) as Record<string, unknown>;
const phones = data['trustedPhoneNumbers'];
if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
} catch { /* fall through */ }
}
// Path 2: Apple Auth HTML — phone numbers are in
// <script type="application/json" class="boot_args">{ "direct": { "trustedDeviceVerification": { "phoneNumberVerification": { "trustedPhoneNumbers": [...] } } } }</script>
const bootArgsMatch = trimmed.match(
/<script[^>]+class="boot_args"[^>]*>([\s\S]*?)<\/script>/i,
);
if (bootArgsMatch) {
try {
const boot = JSON.parse(bootArgsMatch[1]) as Record<string, unknown>;
const tdv = (boot['direct'] as Record<string, unknown> | undefined)?.['trustedDeviceVerification'] as Record<string, unknown> | undefined;
const pnv = tdv?.['phoneNumberVerification'] as Record<string, unknown> | undefined;
const phones = pnv?.['trustedPhoneNumbers'];
if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
} catch { /* fall through */ }
}
// Path 3: XML plist (older Apple endpoints)
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<plist')) {
const data = parseXmlPlist(trimmed);
if (data && typeof data === 'object' && !Array.isArray(data)) {
const phones = (data as Record<string, unknown>)['trustedPhoneNumbers'];
if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
}
}
return [];
}
function mapPhones(raw: Record<string, unknown>[]): TrustedPhoneNumber[] {
return raw.map((p) => ({
id: typeof p['id'] === 'number' ? p['id'] : Number(p['id']),
numberWithDialCode: typeof p['numberWithDialCode'] === 'string' ? p['numberWithDialCode'] : '',
obfuscatedNumber: typeof p['obfuscatedNumber'] === 'string' ? p['obfuscatedNumber'] : '',
pushMode: typeof p['pushMode'] === 'string' ? p['pushMode'] : 'sms',
}));
}
/** /**
* Keep `altsign.js` and its transitive `zsign-wasm` bundle out of the initial * Keep `altsign.js` and its transitive `zsign-wasm` bundle out of the initial
@@ -117,14 +475,17 @@ async function loadAltsignModule(): Promise<AltsignModule> {
return await altsignModulePromise; return await altsignModulePromise;
} }
async function getAppleApi(): Promise<AppleAPI> { async function getAppleApi(log?: (msg: string) => void): Promise<{ api: AppleAPI; fetch: AltsignFetch }> {
if (appleApiInstance) { const uiLog = log ?? (() => undefined);
return appleApiInstance;
}
const { AppleAPI, Fetch } = await loadAltsignModule(); const { AppleAPI, Fetch } = await loadAltsignModule();
const appleFetch = new Fetch(initLibcurl, async (url, options) => { if (!appleFetchInstance) {
const doFetch = async (url: string, options: {
method?: string;
headers?: HeadersInit;
body?: BodyInit | null;
}): Promise<Response> => {
const libcurl = requireLibcurl(); const libcurl = requireLibcurl();
const response = await libcurl.fetch(url, { const libcurlOpts = {
method: options.method, method: options.method,
headers: options.headers, headers: options.headers,
body: options.body, body: options.body,
@@ -132,11 +493,47 @@ async function getAppleApi(): Promise<AppleAPI> {
insecure: true, insecure: true,
verbose: 4, verbose: 4,
_libcurl_http_version: 1.1, _libcurl_http_version: 1.1,
} as never); } as never;
let response = await libcurl.fetch(url, libcurlOpts) as Response;
// Auto-retry on Apple GSA 503 (transient rate-limit / overload).
// Use exponential backoff: 5s, 10s, 20s, 40s — up to ~75s total.
if (url.includes('gsa.apple.com') && response.status === 503) {
const delays = [5000, 10000, 20000, 40000];
for (let attempt = 0; attempt < delays.length; attempt++) {
const waitSec = delays[attempt] / 1000;
const msg = `login: Apple server busy (503) — retrying in ${waitSec}s (${attempt + 1}/${delays.length})...`;
console.warn(`[fetch] ${msg}`);
uiLog(msg);
await new Promise((r) => setTimeout(r, delays[attempt]));
response = await libcurl.fetch(url, libcurlOpts) as Response;
if (response.status !== 503) break;
}
if (response.status === 503) {
throw new Error('Apple authentication server is temporarily unavailable (503). Please wait a few minutes and try again.');
}
}
// Log raw response body for Apple auth endpoint calls (diagnosis aid).
if (url.includes('gsa.apple.com')) {
const origText = response.text.bind(response);
response.text = async () => {
const text = await origText();
console.log('[fetch] raw text from %s status=%d (%d chars): %s',
url.split('?')[0], response.status, text.length, text.slice(0, 300));
return text;
};
}
return response; return response;
}); };
appleApiInstance = new AppleAPI(appleFetch); const f = new Fetch(initLibcurl, doFetch);
return appleApiInstance; appleFetchInstance = f as AltsignFetch;
}
// Always create a fresh AppleAPI per call — it holds no expensive state,
// but reusing it across separate authenticate() calls causes -22421 because
// the anisette OTP embedded in the previous call is already consumed.
const api = new AppleAPI(appleFetchInstance);
patchHandleTwoFactor(api, appleFetchInstance);
patchAuthenticateRetry(api, appleFetchInstance);
return { api, fetch: appleFetchInstance };
} }
export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginRequest): Promise<AppleDeveloperContext> { export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginRequest): Promise<AppleDeveloperContext> {
@@ -149,24 +546,56 @@ export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginReq
const log = request.onLog ?? (() => undefined); const log = request.onLog ?? (() => undefined);
log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`); log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`);
const api = await getAppleApi(); // Reset the shared fetch instance so each login attempt starts with a fresh
const { session } = await api.authenticate( // libcurl TCP connection — prevents state bleed between accounts and between
appleId, // a cancelled-then-retried login on the same account.
password, appleFetchInstance = null;
request.anisetteData,
(submitCode: (code: string) => void) => { const verificationCallback = (submitCode: (code: string) => void) => {
if (!request.onTwoFactorRequired) { if (!request.onTwoFactorRequired) {
throw new Error('2FA required but no in-page handler provided'); throw new Error('2FA required but no in-page handler provided');
} }
request.onTwoFactorRequired((code) => { // The patched handleTwoFactor passes a TwoFactorContext; the raw altsign.js
const normalized = code.trim(); // VerificationHandler signature still receives `submitCode` as first arg,
if (normalized.length === 0) { // but our patch replaces that with the full context object.
throw new Error('2FA code is required'); const ctx = submitCode as unknown as TwoFactorContext;
request.onTwoFactorRequired(ctx);
};
let { api } = await getAppleApi(log);
let session: AppleDeveloperSession;
try {
({ session } = await api.authenticate(appleId, password, request.anisetteData, verificationCallback));
} catch (e) {
// After 2FA at the apptokens stage, Apple invalidates the SRP session.
// The sendAuthRequest patch throws REAUTH_SENTINEL to signal this.
// We create a completely fresh AppleAPI (new auth state, new patches) and
// retry the full SRP exchange with fresh anisette.
if (e instanceof Error && e.message === REAUTH_SENTINEL) {
log('Login stage: 2FA verified — restarting SRP with fresh session...');
// Reset the shared fetch instance so the re-auth uses a fresh libcurl
// TCP connection to Apple — eliminates connection-state as a failure cause.
appleFetchInstance = null;
const fresh = await getAppleApi(log);
api = fresh.api;
const { getAnisetteData } = await import('./anisette-service');
const freshAnisette = await getAnisetteData();
try {
({ session } = await api.authenticate(appleId, password, freshAnisette, verificationCallback));
} catch (reauth2) {
// If the fresh SRP exchange also hits the apptokens 2FA gate (rare),
// REAUTH_SENTINEL escapes here. Surface it as a human-readable error
// instead of exposing the internal sentinel string.
if (reauth2 instanceof Error && reauth2.message === REAUTH_SENTINEL) {
throw new Error('Authentication failed after two-factor verification. Please sign in again.');
}
throw reauth2;
}
} else {
throw e;
}
} }
submitCode(normalized);
});
},
);
log('Login stage: fetching team/certificates/devices...'); log('Login stage: fetching team/certificates/devices...');
const team = await api.fetchTeam(session); const team = await api.fetchTeam(session);
@@ -191,7 +620,7 @@ export async function refreshAppleDeveloperContext(
onLog?: (message: string) => void, onLog?: (message: string) => void,
): Promise<AppleDeveloperContext> { ): Promise<AppleDeveloperContext> {
const log = onLog ?? (() => undefined); const log = onLog ?? (() => undefined);
const api = await getAppleApi(); const { api } = await getAppleApi();
log('Signing stage: refreshing team/certificates/devices...'); log('Signing stage: refreshing team/certificates/devices...');
const team = await api.fetchTeam(context.session); const team = await api.fetchTeam(context.session);
const [certificates, devices] = await Promise.all([ const [certificates, devices] = await Promise.all([
@@ -234,7 +663,7 @@ export async function signIpaWithAppleContext(request: AppleSigningWithContextRe
throw new Error('Cannot sign IPA: bundle identifier is missing'); throw new Error('Cannot sign IPA: bundle identifier is missing');
} }
const api = await getAppleApi(); const { api } = await getAppleApi();
const team = context.team; const team = context.team;
onLog(`Signing stage: using team ${team.identifier} (${team.name}).`); onLog(`Signing stage: using team ${team.identifier} (${team.name}).`);

View File

@@ -9,6 +9,7 @@ interface LoginModalProps {
password: string; password: string;
busyLoginSign: boolean; busyLoginSign: boolean;
canSubmit: boolean; canSubmit: boolean;
error?: string | null;
onAppleIdChange: (value: string) => void; onAppleIdChange: (value: string) => void;
onAppleIdBlur: () => void; onAppleIdBlur: () => void;
onPasswordChange: (value: string) => void; onPasswordChange: (value: string) => void;
@@ -22,6 +23,7 @@ export function LoginModal({
password, password,
busyLoginSign, busyLoginSign,
canSubmit, canSubmit,
error,
onAppleIdChange, onAppleIdChange,
onAppleIdBlur, onAppleIdBlur,
onPasswordChange, onPasswordChange,
@@ -31,7 +33,7 @@ export function LoginModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 backdrop-blur-sm p-4 pt-[8vh]"> <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 backdrop-blur-sm p-4 pt-[8vh]">
<div className="w-full max-w-[440px] rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in"> <div className="w-full max-w-110 rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2> <h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2>
<button <button
@@ -83,24 +85,31 @@ export function LoginModal({
<p className="text-[11.5px] text-muted"> <p className="text-[11.5px] text-muted">
Your credentials are stored locally in this browser and are sent directly to Apple. Your credentials are stored locally in this browser and are sent directly to Apple.
</p> </p>
<p className="text-[11.5px] text-[var(--color-danger)] underline underline-offset-2 decoration-[var(--color-danger)]/40"> <p className="text-[11.5px] text-danger underline underline-offset-2 decoration-danger/40">
Verify that you trust the server hosting this page. A compromised server can intercept your credentials. Verify that you trust the server hosting this page. A compromised server can intercept your credentials.
</p> </p>
</div> </div>
<div className="mt-5 flex justify-end"> <div className="mt-5 flex flex-col gap-3">
{error && (
<p className="rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[12.5px] text-danger">
{error}
</p>
)}
<div className="flex justify-end">
<Button <Button
variant="primary" variant="primary"
busy={busyLoginSign} busy={busyLoginSign}
busyLabel="Signing In…" busyLabel="Signing In…"
disabled={!canSubmit} disabled={!canSubmit}
onClick={onSubmit} onClick={onSubmit}
className="min-w-[140px]" className="min-w-35"
> >
Sign In Sign In
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -1,40 +1,122 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Modal } from './ui/Modal'; import { Modal } from './ui/Modal';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
import type { TrustedPhoneNumber, TwoFactorContext } from '../apple-signing';
type Mode = 'device' | 'sms';
interface TwoFactorModalProps { interface TwoFactorModalProps {
open: boolean; open: boolean;
onSubmit: (code: string) => void; ctx: TwoFactorContext | null;
onCancel: () => void; onCancel: () => void;
/** Server-side error (e.g. wrong code). Shown when the login flow rejects after 2FA submit. */ /** Server-side error passed back from the login flow after a failed verify. */
serverError?: string | null; serverError?: string | null;
/** Called when the user wants to retry the entire login after a server error. */
onRetry?: () => void;
} }
export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFactorModalProps) { export function TwoFactorModal({ open, ctx, onCancel, serverError, onRetry }: TwoFactorModalProps) {
const [mode, setMode] = useState<Mode>('device');
const [selectedPhone, setSelectedPhone] = useState<TrustedPhoneNumber | null>(null);
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [error, setError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
const [smsSent, setSmsSent] = useState(false);
const [smsBusy, setSmsBusy] = useState(false);
const [verifyBusy, setVerifyBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const displayError = serverError || error;
const displayError = serverError || localError;
const phones = ctx?.trustedPhoneNumbers ?? [];
// Reset state on open/close
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMode('device');
setCode(''); setCode('');
setError(null); setLocalError(null);
setSmsSent(false);
setSmsBusy(false);
setVerifyBusy(false);
setSelectedPhone(phones.length > 0 ? phones[0] : null);
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
inputRef.current?.select(); inputRef.current?.select();
}, 0); }, 0);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]); }, [open]);
const handleSubmit = () => { // Focus input when switching modes or after SMS sent
useEffect(() => {
if (open && (mode === 'device' || smsSent)) {
const timer = window.setTimeout(() => inputRef.current?.focus(), 50);
return () => window.clearTimeout(timer);
}
}, [mode, smsSent, open]);
const switchToSms = () => {
setMode('sms');
setCode('');
setLocalError(null);
setSmsSent(false);
};
const switchToDevice = () => {
setMode('device');
setCode('');
setLocalError(null);
};
const handleRequestSms = async () => {
if (!ctx || !selectedPhone) return;
setSmsBusy(true);
setLocalError(null);
try {
await ctx.requestSms(selectedPhone.id);
setSmsSent(true);
} catch (err) {
setLocalError(err instanceof Error ? err.message : 'Failed to send SMS');
} finally {
setSmsBusy(false);
}
};
const handleSubmit = async () => {
const trimmed = code.trim(); const trimmed = code.trim();
if (trimmed.length === 0) { if (trimmed.length === 0) {
setError('Please enter verification code.'); setLocalError('Please enter the verification code.');
return; return;
} }
onSubmit(trimmed); if (!ctx) return;
setVerifyBusy(true);
setLocalError(null);
try {
if (mode === 'device') {
ctx.submitDeviceCode(trimmed);
// submitDeviceCode resolves the outer Promise — no async result here
} else {
if (!selectedPhone) return;
await ctx.submitSmsCode(selectedPhone.id, trimmed);
}
} catch (err) {
setLocalError(err instanceof Error ? err.message : 'Verification failed');
setVerifyBusy(false);
}
// On success the parent modal will close; don't clear busy so button stays
// disabled until the modal is removed.
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
if (mode === 'sms' && !smsSent) {
void handleRequestSms();
} else {
void handleSubmit();
}
}
}; };
return ( return (
@@ -42,8 +124,11 @@ export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFac
<h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink"> <h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink">
Two-Factor Authentication Two-Factor Authentication
</h2> </h2>
{mode === 'device' ? (
<>
<p className="mt-1.5 text-[13px] leading-[1.55] text-muted"> <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
Enter the verification code from your trusted Apple device. Enter the verification code from your trusted Apple device or Mac.
</p> </p>
<label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted"> <label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
@@ -59,27 +144,132 @@ export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFac
placeholder="123456" placeholder="123456"
className="field-input font-mono text-center text-[18px] tracking-[0.3em]" className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
value={code} value={code}
onChange={(e) => { onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
setCode(e.target.value); onKeyDown={handleKeyDown}
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
/> />
<p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p> <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
<div className="mt-4 grid grid-cols-2 gap-2"> <div className="mt-4 grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={onCancel}> <Button variant="ghost" onClick={onCancel}>Cancel</Button>
Cancel {serverError ? (
<Button variant="primary" onClick={onRetry}>
Retry Login
</Button> </Button>
<Button variant="primary" onClick={handleSubmit} disabled={!!serverError}> ) : (
<Button
variant="primary"
busy={verifyBusy}
busyLabel="Verifying…"
onClick={() => void handleSubmit()}
disabled={verifyBusy}
>
Verify Verify
</Button> </Button>
)}
</div> </div>
{phones.length > 0 && (
<button
type="button"
onClick={switchToSms}
className="mt-3 w-full text-center text-[12px] text-muted underline underline-offset-2 hover:text-ink transition-colors"
>
Get SMS instead
</button>
)}
</>
) : (
<>
<p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
{smsSent
? 'Enter the code sent via SMS.'
: 'Choose a phone number to receive a verification code.'}
</p>
{/* Phone picker */}
{!smsSent && phones.length > 1 && (
<div className="mt-4 space-y-1">
{phones.map((phone) => (
<button
key={phone.id}
type="button"
onClick={() => setSelectedPhone(phone)}
className={`w-full rounded-lg border px-3 py-2 text-left text-[13px] transition-colors ${
selectedPhone?.id === phone.id
? 'border-blue-500 bg-blue-500/10 text-ink'
: 'border-border text-muted hover:border-blue-400 hover:text-ink'
}`}
>
{phone.numberWithDialCode || phone.obfuscatedNumber}
</button>
))}
</div>
)}
{!smsSent && phones.length === 1 && (
<p className="mt-3 rounded-lg border border-border px-3 py-2 text-[13px] text-muted">
{phones[0].numberWithDialCode || phones[0].obfuscatedNumber}
</p>
)}
<p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{localError ?? ''}</p>
{!smsSent ? (
<div className="mt-2 grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={switchToDevice}>Back</Button>
<Button
variant="primary"
busy={smsBusy}
busyLabel="Sending…"
disabled={!selectedPhone || smsBusy}
onClick={() => void handleRequestSms()}
>
Send SMS
</Button>
</div>
) : (
<>
<label htmlFor="sms-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
SMS Code
</label>
<input
ref={inputRef}
id="sms-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={8}
placeholder="123456"
className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
value={code}
onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
onKeyDown={handleKeyDown}
/>
<p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={() => { setSmsSent(false); setCode(''); }}>
Resend
</Button>
{serverError ? (
<Button variant="primary" onClick={onRetry}>
Retry Login
</Button>
) : (
<Button
variant="primary"
busy={verifyBusy}
busyLabel="Verifying…"
onClick={() => void handleSubmit()}
disabled={verifyBusy}
>
Verify
</Button>
)}
</div>
</>
)}
</>
)}
</Modal> </Modal>
); );
} }

View File

@@ -1,5 +1,5 @@
import type { AnisetteData } from '../anisette-service'; import type { AnisetteData } from '../anisette-service';
import type { AppleDeveloperContext } from '../apple-signing'; import type { AppleDeveloperContext, TwoFactorContext } from '../apple-signing';
import { shortToken } from '../lib/ids'; import { shortToken } from '../lib/ids';
type AnisetteService = typeof import('../anisette-service'); type AnisetteService = typeof import('../anisette-service');
@@ -61,7 +61,7 @@ export interface LoginRequest {
password: string; password: string;
anisetteData: AnisetteData; anisetteData: AnisetteData;
log: (msg: string) => void; log: (msg: string) => void;
onTwoFactorRequired: (submit: (code: string) => void) => void; onTwoFactorRequired: (ctx: TwoFactorContext) => void;
} }
export async function loginAccount(req: LoginRequest): Promise<AppleDeveloperContext> { export async function loginAccount(req: LoginRequest): Promise<AppleDeveloperContext> {

1177
sms-2fa.patch Normal file

File diff suppressed because it is too large Load Diff