From f9d4457b5a5d55e5e3bdd5215333e07e356c374d Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Sat, 21 Feb 2026 21:28:05 -0500 Subject: [PATCH] Add one-time token login flow and deep-link handling --- API.js | 9 +++++++ App.js | 47 ++++++++++++++++++++++++++++++++++- Views/Login.js | 4 ++- components/Login.js | 60 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/API.js b/API.js index 074d41a..c8bc13e 100644 --- a/API.js +++ b/API.js @@ -195,6 +195,15 @@ const API = { return data; }) }, + logInWithPasswordToken: async (token) => { + return postCall("/password/token-login", { token }).then((data) => { + if (data && data.status === "ok") { + CurrentUserId = data.user_sid; + CurrentProfile = data.profile_id; + } + return data; + }); + }, async logout(){ console.log("Logging out...") return getCall("/logout").then(()=>{ diff --git a/App.js b/App.js index 0c67a98..3b6a536 100644 --- a/App.js +++ b/App.js @@ -34,6 +34,7 @@ import { Platform } from 'react-native'; import { PostHogProvider } from 'posthog-react-native' import * as Updates from 'expo-updates'; import { useNavigation } from '@react-navigation/native'; +import * as Linking from 'expo-linking'; const Tab = createBottomTabNavigator(); @@ -60,6 +61,15 @@ Notifications.setNotificationHandler({ }, }); +const parseTokenLoginUrl = (url = "") => { + const parsed = Linking.parse(url); + const token = typeof parsed?.queryParams?.token === "string" ? parsed.queryParams.token.trim() : ""; + if (!token) return null; + const hostOrPath = `${parsed?.hostname || ""}/${parsed?.path || ""}`.toLowerCase(); + if (!hostOrPath.includes("token-login")) return null; + return token; +}; + async function registerForPushNotificationsAsync() { if (Platform.OS === 'android') { @@ -276,16 +286,51 @@ const MainNavigation = ({ route }) => { export default function App() { const appState = useSnapshot(GlobalState); const viewer = appState.me || {}; + const navigationRef = useRef(null); + const pendingTokenRef = useRef(null); const hasUnviewedNotifications = Array.isArray(viewer?.notifications) ? viewer.notifications.some((n) => n && n.viewed !== true) : false; + useEffect(() => { + const routeTokenLogin = (url) => { + const token = parseTokenLoginUrl(url); + if (!token) return; + if (!navigationRef.current) { + pendingTokenRef.current = token; + return; + } + navigationRef.current.navigate("Login", { token }); + }; + + Linking.getInitialURL().then((url) => { + if (!url) return; + routeTokenLogin(url); + }); + + const sub = Linking.addEventListener("url", ({ url }) => { + routeTokenLogin(url); + }); + + return () => { + sub.remove(); + }; + }, []); + return ( , }} theme={theme}> - + { + if (!pendingTokenRef.current) return; + const token = pendingTokenRef.current; + pendingTokenRef.current = null; + navigationRef.current?.navigate("Login", { token }); + }} + > { getData = async () => { + const tokenFromRoute = typeof route?.params?.token === "string" ? route.params.token.trim() : ""; + if (tokenFromRoute) return; let r = await API.isLoggedIn(); if (r) { await API.logout(); @@ -22,7 +24,7 @@ export default function App({ navigation, route }) { return () => { } - }, []); + }, [route?.params?.token]); return ( diff --git a/components/Login.js b/components/Login.js index 248e90c..c694c82 100644 --- a/components/Login.js +++ b/components/Login.js @@ -1,8 +1,8 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Text, View, StyleSheet, Image } from 'react-native'; import { TextInput, Button, HelperText } from 'react-native-paper'; import API from './../API.js'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import i18n from "../i18nMessages.js"; @@ -11,7 +11,12 @@ let LoginForm = () => { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [tries, setTries] = useState(0); + const [tokenLinkSending, setTokenLinkSending] = useState(false); + const [tokenLoginLoading, setTokenLoginLoading] = useState(false); + const [tokenLoginError, setTokenLoginError] = useState(''); const navigation = useNavigation(); + const route = useRoute(); + const handledTokenRef = useRef(''); const resetPasswordBol = tries > 2; const resetPassword = async () => { @@ -40,6 +45,44 @@ let LoginForm = () => { //alert(i18n.t('message.wrongInformation')) }; + const requestOneTimeLink = async () => { + if (!email.trim()) return; + setTokenLinkSending(true); + setTokenLoginError(''); + try { + await API.resetPassword(email.trim()); + alert("If this account exists, we sent a one-time sign-in link to your email."); + } finally { + setTokenLinkSending(false); + } + }; + + useEffect(() => { + const token = typeof route?.params?.token === "string" ? route.params.token.trim() : ""; + if (!token) return; + if (handledTokenRef.current === token) return; + handledTokenRef.current = token; + let cancelled = false; + const consumeToken = async () => { + setTokenLoginLoading(true); + setTokenLoginError(''); + const r = await API.logInWithPasswordToken(token); + if (cancelled) return; + setTokenLoginLoading(false); + if (r?.status === "ok") { + return navigation.reset({ + index: 0, + routes: [{ name: 'MainNavigation' }], + }); + } + setTokenLoginError(r?.status || "Invalid or expired token"); + }; + consumeToken(); + return () => { + cancelled = true; + }; + }, [route?.params?.token]); + return ( { {error === 'incorrect password' ? {i18n.t("message.reviewPassword")} : <>} + {tokenLoginLoading ? + Signing you in from email link... + : <>} + {tokenLoginError ? + {tokenLoginError} + : <>} : <> } + ); }