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>
215 lines
5.6 KiB
TypeScript
215 lines
5.6 KiB
TypeScript
import * as SecureStore from "expo-secure-store";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
import { setAuthToken, clearAuthToken } from "./fetch";
|
|
|
|
const TOKEN_KEY = "waseel_auth_token";
|
|
const USER_KEY = "waseel_auth_user";
|
|
const REMEMBERED_EMAIL_KEY = "waseel_remembered_email";
|
|
const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
|
|
/**
|
|
* The address to prefill on the sign-in screen, or null when the last sign-in
|
|
* cleared "remember me". The token itself is stored either way — what
|
|
* "remember me" changes is how long the server makes it live.
|
|
*/
|
|
export const getRememberedEmail = (): Promise<string | null> =>
|
|
SecureStore.getItemAsync(REMEMBERED_EMAIL_KEY);
|
|
|
|
export const rememberEmail = async (email: string | null): Promise<void> => {
|
|
if (email) {
|
|
await SecureStore.setItemAsync(REMEMBERED_EMAIL_KEY, email);
|
|
} else {
|
|
await SecureStore.deleteItemAsync(REMEMBERED_EMAIL_KEY);
|
|
}
|
|
};
|
|
|
|
export type SessionUser = {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role?: string | null;
|
|
avatarUrl?: string | null;
|
|
};
|
|
|
|
type AuthResult = { token: string; user: SessionUser };
|
|
|
|
type SessionContextValue = {
|
|
isLoaded: boolean;
|
|
isSignedIn: boolean;
|
|
userId: string | null;
|
|
user: SessionUser | null;
|
|
setSession: (result: AuthResult) => Promise<void>;
|
|
setUserRole: (role: string) => void;
|
|
signOut: () => Promise<void>;
|
|
};
|
|
|
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
|
|
|
const BASE64_ALPHABET =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
/**
|
|
* Decodes a base64url segment without `atob`.
|
|
*
|
|
* Neither React Native 0.74 nor Expo SDK 51 defines a global `atob`, so the
|
|
* previous implementation threw on every call — which silently expired the
|
|
* stored session on each launch (see decodeJwtExp). Skipping unknown
|
|
* characters also makes the missing "=" padding in a JWT a non-issue.
|
|
*/
|
|
const decodeBase64Url = (segment: string): string => {
|
|
const normalized = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
|
|
let output = "";
|
|
let buffer = 0;
|
|
let bits = 0;
|
|
|
|
for (const character of normalized) {
|
|
const value = BASE64_ALPHABET.indexOf(character);
|
|
|
|
if (value === -1) continue;
|
|
|
|
buffer = (buffer << 6) | value;
|
|
bits += 6;
|
|
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
output += String.fromCharCode((buffer >> bits) & 0xff);
|
|
}
|
|
}
|
|
|
|
return output;
|
|
};
|
|
|
|
// Mirrors lib/jwt.ts payload decoding (no signature check needed client-side).
|
|
export const decodeJwtExp = (token: string): number | null => {
|
|
try {
|
|
const claims = JSON.parse(decodeBase64Url(token.split(".")[1]));
|
|
|
|
return typeof claims.exp === "number" ? claims.exp : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const SessionProvider = ({ children }: { children: ReactNode }) => {
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const [user, setUser] = useState<SessionUser | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const restore = async () => {
|
|
try {
|
|
const [token, storedUser] = await Promise.all([
|
|
SecureStore.getItemAsync(TOKEN_KEY),
|
|
SecureStore.getItemAsync(USER_KEY),
|
|
]);
|
|
|
|
if (!token || !storedUser) return;
|
|
|
|
const exp = decodeJwtExp(token);
|
|
|
|
// Only discard a session we can positively prove is expired. Treating
|
|
// an unreadable token as expired is what made every launch sign the
|
|
// user back out; if it really is bad, the next request gets a 401.
|
|
if (exp === null) {
|
|
console.warn("[SESSION_RESTORE]: could not read token expiry");
|
|
} else if (exp * 1000 < Date.now()) {
|
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
|
await SecureStore.deleteItemAsync(USER_KEY);
|
|
return;
|
|
}
|
|
|
|
if (cancelled) return;
|
|
|
|
setAuthToken(token);
|
|
setUser(JSON.parse(storedUser) as SessionUser);
|
|
} catch (error) {
|
|
console.error("[SESSION_RESTORE]: ", error);
|
|
} finally {
|
|
if (!cancelled) setIsLoaded(true);
|
|
}
|
|
};
|
|
|
|
void restore();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const setSession = useCallback(async (result: AuthResult) => {
|
|
setAuthToken(result.token);
|
|
setUser(result.user);
|
|
|
|
await SecureStore.setItemAsync(TOKEN_KEY, result.token);
|
|
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(result.user));
|
|
}, []);
|
|
|
|
const setUserRole = useCallback(
|
|
(role: string) => {
|
|
setUser((currentUser) => {
|
|
if (!currentUser) return currentUser;
|
|
|
|
const updated = { ...currentUser, role };
|
|
|
|
void SecureStore.setItemAsync(USER_KEY, JSON.stringify(updated));
|
|
|
|
return updated;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const signOut = useCallback(async () => {
|
|
clearAuthToken();
|
|
setUser(null);
|
|
|
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
|
await SecureStore.deleteItemAsync(USER_KEY);
|
|
}, []);
|
|
|
|
const value = useMemo<SessionContextValue>(
|
|
() => ({
|
|
isLoaded,
|
|
isSignedIn: user !== null,
|
|
userId: user?.id ?? null,
|
|
user,
|
|
setSession,
|
|
setUserRole,
|
|
signOut,
|
|
}),
|
|
[isLoaded, user, setSession, setUserRole, signOut],
|
|
);
|
|
|
|
return (
|
|
<SessionContext.Provider value={value}>
|
|
{children}
|
|
</SessionContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useSession = (): SessionContextValue => {
|
|
const context = useContext(SessionContext);
|
|
|
|
if (!context) {
|
|
throw new Error("useSession must be used within a SessionProvider.");
|
|
}
|
|
|
|
return context;
|
|
};
|
|
|
|
// Alias kept for parity with the previous auth API.
|
|
export const useAuth = useSession;
|
|
|
|
export const TOKEN_TTL_SECONDS = DEFAULT_TOKEN_TTL_SECONDS;
|