Sign-in gains a "Keep me signed in" checkbox: checked issues a 30-day token and prefills the address next launch, unchecked drops the session to 12 hours and forgets the address. The TTL is chosen server-side in the login route. Emailed codes are now reachable without retyping. OtpField opts into the iOS one-time-code keyboard suggestion and raises a paste chip when the user returns from Gmail with a code on the clipboard. The mails put the code first in the subject and body, which is what makes Gmail render its "Copy code" notification action at all. Fixes found along the way: - Session was wiped on every launch. decodeJwtExp used atob, which neither RN 0.74 nor Expo SDK 51 defines, so it threw, returned null, and the caller read that as "expired" and deleted the token. Replaced with a dependency-free base64url decoder, and restore now only discards a session it can prove is expired. - Verification and reset codes counted attempts but never enforced them, leaving a 6-digit code open to unlimited guessing. Both routes now charge the attempt before comparing so concurrent guesses can't race past the cap of five, and compare in constant time. - A wrong verification code showed the "Verified" success screen: onModalHide fired unconditionally, so the failure state advanced the flow. Only an explicit "verified" state does that now. - fetchAPI discarded the server's error body, so the UI substring-matched synthetic status strings and showed "Could not sign in" for everything. It now throws ApiError carrying status and the server's message. - Login answered a missing account faster than a wrong password; it now runs the same scrypt work either way. - Blank email or password is caught client-side instead of surfacing as an opaque 400, and a failed attempt only clears the password on a 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
135 lines
4.0 KiB
TypeScript
135 lines
4.0 KiB
TypeScript
import * as Clipboard from "expo-clipboard";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { AppState, Text, TouchableOpacity, View } from "react-native";
|
|
|
|
import { InputField } from "@/components/input-field";
|
|
import { icons } from "@/constants";
|
|
|
|
// `\b` won't match between two digits, so a longer run like an order number
|
|
// never yields a false positive.
|
|
const CODE_PATTERN = /\b\d{6}\b/;
|
|
|
|
/** Pulls the 6-digit code out of whatever the user copied from the email. */
|
|
export const extractCode = (raw: string | null | undefined): string | null =>
|
|
raw ? (CODE_PATTERN.exec(raw)?.[0] ?? null) : null;
|
|
|
|
type OtpFieldProps = {
|
|
label?: string;
|
|
value: string;
|
|
onChange: (code: string) => void;
|
|
/** Fired once the field holds a complete 6-digit code. */
|
|
onComplete?: (code: string) => void;
|
|
};
|
|
|
|
/**
|
|
* Code entry for the emailed verification/reset codes.
|
|
*
|
|
* Three ways in, cheapest first:
|
|
* 1. iOS surfaces the code above the keyboard once Mail has it —
|
|
* `textContentType="oneTimeCode"` is what opts the field into that.
|
|
* 2. Gmail's notification carries a "Copy code" action (Android) and the
|
|
* code is one long-press away on any platform: coming back to the app
|
|
* with a code on the clipboard raises the paste chip below.
|
|
* 3. Typing it.
|
|
*/
|
|
export const OtpField = ({
|
|
label = "Code",
|
|
value,
|
|
onChange,
|
|
onComplete,
|
|
}: OtpFieldProps) => {
|
|
const [pasteReady, setPasteReady] = useState(false);
|
|
const completedFor = useRef<string | null>(null);
|
|
|
|
// `hasStringAsync` inspects the clipboard without reading it, so it never
|
|
// trips the iOS paste prompt — that only fires on the explicit tap below.
|
|
const refreshPasteChip = useCallback(async () => {
|
|
try {
|
|
setPasteReady(await Clipboard.hasStringAsync());
|
|
} catch {
|
|
setPasteReady(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refreshPasteChip();
|
|
|
|
// The user leaves for Gmail and comes back with the code copied.
|
|
const subscription = AppState.addEventListener("change", (state) => {
|
|
if (state === "active") void refreshPasteChip();
|
|
});
|
|
|
|
return () => subscription.remove();
|
|
}, [refreshPasteChip]);
|
|
|
|
const handleChange = useCallback(
|
|
(next: string) => {
|
|
// Paste of a whole line ("123456 is your Waseel…") still lands the code.
|
|
const digits =
|
|
next.length > 6
|
|
? (extractCode(next) ?? next.replace(/\D/g, "").slice(0, 6))
|
|
: next.replace(/\D/g, "");
|
|
|
|
onChange(digits);
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
const onPastePress = useCallback(async () => {
|
|
try {
|
|
const code = extractCode(await Clipboard.getStringAsync());
|
|
|
|
if (code) {
|
|
onChange(code);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Fall through to the hint below.
|
|
}
|
|
|
|
setPasteReady(false);
|
|
}, [onChange]);
|
|
|
|
// Auto-submit on a complete code, but only once per distinct code so a
|
|
// rejected code isn't resubmitted on every re-render.
|
|
useEffect(() => {
|
|
if (value.length !== 6 || !onComplete) return;
|
|
if (completedFor.current === value) return;
|
|
|
|
completedFor.current = value;
|
|
onComplete(value);
|
|
}, [value, onComplete]);
|
|
|
|
return (
|
|
<View>
|
|
<InputField
|
|
label={label}
|
|
icon={icons.lock}
|
|
placeholder="123456"
|
|
value={value}
|
|
onChangeText={handleChange}
|
|
keyboardType="number-pad"
|
|
maxLength={6}
|
|
// iOS reads codes out of Mail; Android's autofill only covers SMS, so
|
|
// there the paste chip is the fast path.
|
|
textContentType="oneTimeCode"
|
|
autoComplete="one-time-code"
|
|
importantForAutofill="yes"
|
|
inputStyles="tracking-[8px] text-lg"
|
|
/>
|
|
|
|
{pasteReady && value.length < 6 ? (
|
|
<TouchableOpacity
|
|
onPress={onPastePress}
|
|
activeOpacity={0.7}
|
|
className="self-start mt-2 rounded-full bg-primary-500/10 px-4 py-2"
|
|
>
|
|
<Text className="text-primary-500 font-JakartaSemiBold text-sm">
|
|
Paste code from Gmail
|
|
</Text>
|
|
</TouchableOpacity>
|
|
) : null}
|
|
</View>
|
|
);
|
|
};
|