Skip to content

Commit d975b54

Browse files
Remember the last mobile sign-in method
1 parent c5b3761 commit d975b54

5 files changed

Lines changed: 203 additions & 74 deletions

File tree

apps/mobile/src/app/_layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ function Gate() {
141141
return (
142142
<SignInScreen
143143
busy={signingIn}
144+
lastSignInMethod={auth.lastSignInMethod}
144145
onSignIn={(method) => void handleSignIn(method)}
145146
/>
146147
);

apps/mobile/src/auth/context.tsx

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import {
2525
} from "@/auth/billing";
2626
import { refreshBillingEntitlement as retryBillingEntitlement } from "@/auth/billing-handoff";
2727
import { authStorageKey, supabase } from "@/auth/client";
28-
import { buildSignInUrl, type SignInMethod } from "@/auth/sign-in";
28+
import {
29+
buildSignInUrl,
30+
lastSignInMethodStorageKey,
31+
parseLastSignInMethod,
32+
type SignInMethod,
33+
} from "@/auth/sign-in";
2934
import {
3035
captureAnalytics,
3136
identifyAnalytics,
@@ -37,6 +42,7 @@ import {
3742
captureOperationalError,
3843
setErrorReportingUser,
3944
} from "@/lib/error-reporting";
45+
import { useMountEffect } from "@/lib/use-mount-effect";
4046
import { suspendMobileSync } from "@/sync/mobile-sync";
4147
import { updateWatchAccount } from "@/watch-connectivity";
4248

@@ -46,6 +52,7 @@ export type AuthState = {
4652
session: Session | null;
4753
billing: BillingInfo;
4854
billingReady: boolean;
55+
lastSignInMethod: SignInMethod | null;
4956
signIn: (method: SignInMethod) => Promise<void>;
5057
refreshBilling: () => Promise<boolean>;
5158
signOut: () => Promise<void>;
@@ -102,13 +109,16 @@ function parseAuthCallbackUrl(
102109
// Deep links can be delivered twice (auth-session result + Linking event);
103110
// mirror desktop's 5s dedupe window (apps/desktop/src/auth/deeplink.ts).
104111
const RECENT_CALLBACK_WINDOW_MS = 5_000;
105-
const inFlightTokens = new Set<string>();
112+
const inFlightTokens = new Map<string, Promise<boolean>>();
106113
const recentTokens = new Map<string, number>();
107114

108-
function acceptAuthTokens(accessToken: string, refreshToken: string): void {
115+
function acceptAuthTokens(
116+
accessToken: string,
117+
refreshToken: string,
118+
): Promise<boolean> {
109119
const client = supabase;
110120
if (!client) {
111-
return;
121+
return Promise.resolve(false);
112122
}
113123

114124
const now = Date.now();
@@ -119,16 +129,18 @@ function acceptAuthTokens(accessToken: string, refreshToken: string): void {
119129
}
120130

121131
const key = `${accessToken}\n${refreshToken}`;
122-
if (inFlightTokens.has(key) || recentTokens.has(key)) {
123-
return;
132+
if (recentTokens.has(key)) {
133+
return Promise.resolve(true);
134+
}
135+
const inFlight = inFlightTokens.get(key);
136+
if (inFlight) {
137+
return inFlight;
124138
}
125139

126-
inFlightTokens.add(key);
127-
void client.auth
140+
const request = client.auth
128141
.setSession({ access_token: accessToken, refresh_token: refreshToken })
129142
.then(
130143
({ error }) => {
131-
inFlightTokens.delete(key);
132144
if (error) {
133145
captureOperationalError(error, {
134146
operation: "auth_session_set",
@@ -138,12 +150,12 @@ function acceptAuthTokens(accessToken: string, refreshToken: string): void {
138150
method: "browser_handoff",
139151
failure_stage: "set_session",
140152
});
141-
} else {
142-
recentTokens.set(key, Date.now());
153+
return false;
143154
}
155+
recentTokens.set(key, Date.now());
156+
return true;
144157
},
145158
(error) => {
146-
inFlightTokens.delete(key);
147159
captureOperationalError(error, {
148160
operation: "auth_session_set",
149161
tags: { method: "browser_handoff" },
@@ -152,15 +164,20 @@ function acceptAuthTokens(accessToken: string, refreshToken: string): void {
152164
method: "browser_handoff",
153165
failure_stage: "set_session",
154166
});
167+
return false;
155168
},
156-
);
169+
)
170+
.finally(() => inFlightTokens.delete(key));
171+
inFlightTokens.set(key, request);
172+
return request;
157173
}
158174

159-
function handleAuthCallbackUrl(url: string): void {
175+
function handleAuthCallbackUrl(url: string): Promise<boolean> {
160176
const tokens = parseAuthCallbackUrl(url);
161-
if (tokens) {
162-
acceptAuthTokens(tokens.accessToken, tokens.refreshToken);
177+
if (!tokens) {
178+
return Promise.resolve(false);
163179
}
180+
return acceptAuthTokens(tokens.accessToken, tokens.refreshToken);
164181
}
165182

166183
// Offline fallback: a retryable getSession error must not lock a Pro user out
@@ -201,6 +218,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
201218
const [session, setSessionState] = useState<Session | null | undefined>(
202219
bypass ? null : undefined,
203220
);
221+
const [lastSignInMethod, setLastSignInMethod] = useState<SignInMethod | null>(
222+
null,
223+
);
204224
const sessionRef = useRef<Session | null | undefined>(session);
205225
const identifiedUserIdRef = useRef<string | null>(null);
206226
const reportedUndecodableUserIdRef = useRef<string | null>(null);
@@ -242,6 +262,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
242262
}
243263
}, []);
244264

265+
useMountEffect(() => {
266+
let active = true;
267+
void AsyncStorage.getItem(lastSignInMethodStorageKey).then(
268+
(value) => {
269+
if (active) {
270+
setLastSignInMethod(parseLastSignInMethod(value));
271+
}
272+
},
273+
(error) => {
274+
captureOperationalError(error, {
275+
operation: "auth_last_sign_in_method_read",
276+
level: "warning",
277+
});
278+
},
279+
);
280+
return () => {
281+
active = false;
282+
};
283+
});
284+
245285
useEffect(() => {
246286
const client = supabase;
247287
if (!client) {
@@ -311,12 +351,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
311351
}
312352

313353
const subscription = Linking.addEventListener("url", (event) => {
314-
handleAuthCallbackUrl(event.url);
354+
void handleAuthCallbackUrl(event.url);
315355
});
316356
void Linking.getInitialURL().then(
317357
(url) => {
318358
if (url) {
319-
handleAuthCallbackUrl(url);
359+
void handleAuthCallbackUrl(url);
320360
}
321361
},
322362
(error) => {
@@ -348,7 +388,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
348388
"anarlog://auth/callback",
349389
);
350390
if (result.type === "success") {
351-
handleAuthCallbackUrl(result.url);
391+
const signedIn = await handleAuthCallbackUrl(result.url);
392+
if (signedIn) {
393+
setLastSignInMethod(signInMethod);
394+
await AsyncStorage.setItem(
395+
lastSignInMethodStorageKey,
396+
signInMethod,
397+
).catch((error) => {
398+
captureOperationalError(error, {
399+
operation: "auth_last_sign_in_method_write",
400+
level: "warning",
401+
});
402+
});
403+
}
352404
} else {
353405
captureAnalytics("auth_failed", {
354406
method: "browser_handoff",
@@ -464,6 +516,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
464516
session: session ?? null,
465517
billing,
466518
billingReady,
519+
lastSignInMethod,
467520
signIn,
468521
refreshBilling,
469522
signOut,
@@ -474,6 +527,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
474527
session,
475528
billing,
476529
billingReady,
530+
lastSignInMethod,
477531
signIn,
478532
refreshBilling,
479533
signOut,

0 commit comments

Comments
 (0)