diff --git a/api/apiRequest.ts b/api/apiRequest.ts index 1d5501b..e8f5a0e 100644 --- a/api/apiRequest.ts +++ b/api/apiRequest.ts @@ -48,7 +48,7 @@ export const makeApiRequest = async ( ); if (response.status === 401) { - router.navigate("/profile?forceLogout=true"); + router.replace("/login?logout=true"); throw new Error(`Unauthorized: ${response.status}`); } if (!response.ok) { diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 3e3dd4d..948cffb 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -13,9 +13,8 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { useSession } from "@/hooks/session"; import { View, StyleSheet, Image, TouchableOpacity } from "react-native"; -import { useLocalSearchParams, useNavigation } from "expo-router"; +import { router, useNavigation } from "expo-router"; import React, { useEffect, useRef } from "react"; import { ThemedText } from "@/components/ThemedText"; import QRCode from "react-native-qrcode-svg"; @@ -31,7 +30,6 @@ import AccessSolid from "@/assets/images/access-solid.svg"; import { formatResourceName } from "@/utils/fileUtils"; export default function Profile() { - const { signOut } = useSession(); const { data: userInfo } = useQuery({ queryKey: ["userInfo"], enabled: false, @@ -40,12 +38,6 @@ export default function Profile() { const bottomSheetModalRef = useRef(null); const navigation = useNavigation(); - const { forceLogout } = useLocalSearchParams(); - useEffect(() => { - if (forceLogout) { - signOut(); - } - }, [forceLogout, signOut]); useEffect(() => { navigation.setOptions({ headerTitle: "", @@ -121,7 +113,10 @@ export default function Profile() { signOut()} + onPress={() => { + bottomSheetModalRef.current?.close(); + router.navigate("/login?logout=true"); + }} > Logout diff --git a/app/_layout.tsx b/app/_layout.tsx index fcf9142..987b7f8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -30,6 +30,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import type { AppStateStatus } from "react-native"; import { AppState, Platform } from "react-native"; +import { LoginWebViewProvider } from "@/hooks/useInruptLogin"; // Prevent the splash screen from auto-hiding before asset loading is complete. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -79,22 +80,30 @@ export default function RootLayout() { - - + - + > + + + + diff --git a/app/login.test.tsx b/app/login.test.tsx index c06a015..d8c16d4 100644 --- a/app/login.test.tsx +++ b/app/login.test.tsx @@ -54,6 +54,20 @@ jest.mock("expo-constants", () => ({ appOwnership: "expo", })); +// Jest chokes on importing native SVG. +jest.mock("@/assets/images/future_co.svg", () => { + return jest.fn(); +}); + +// Mock the react-native RCTNetworking module +jest.mock("react-native/Libraries/Network/RCTNetworking", () => ({ + default: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + clearCookies: jest.fn((callback) => callback(true)), + }, +})); + describe("Snapshot testing the login screen", () => { it("renders the login screen when unauthenticated", async () => { // Mocks start... @@ -83,33 +97,4 @@ describe("Snapshot testing the login screen", () => { ) as jest.Mocked; expect(mockedRedirect).not.toHaveBeenCalledWith(); }); - - it("redirects to the home screen when authenticated", async () => { - // Mocks start... - const { useSession: mockedUseSession } = jest.requireMock( - "@/hooks/session" - ) as jest.Mocked; - const [mockedSignIn, mockedSignOut, mockedSession] = [ - jest.fn(), - jest.fn(), - "some-session-id", - ]; - mockedUseSession.mockReturnValueOnce({ - signIn: mockedSignIn, - signOut: mockedSignOut, - session: mockedSession, - }); - const { Redirect: mockedRedirect } = jest.requireMock( - "expo-router" - ) as jest.Mocked; - // This checks that the Redirect component is returned. - mockedRedirect.mockReturnValue("Dummy return" as unknown as null); - // ... mocks end. - - render(); - // The login button should *not* be mounted - await expect(() => screen.findByTestId("login-button")).rejects.toThrow(); - // Check that what we get back here is the result of the redirect. - expect(screen.toJSON()).toBe("Dummy return"); - }); }); diff --git a/app/login.tsx b/app/login.tsx index 6d73ffc..f8592c5 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -13,41 +13,39 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import React, { useEffect, useState } from "react"; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - Modal, - SafeAreaView, -} from "react-native"; -import { Redirect } from "expo-router"; +import React, { useEffect } from "react"; +import { View, StyleSheet } from "react-native"; import Constants from "expo-constants"; -import { SvgXml } from "react-native-svg"; -import type { WebViewNavigation } from "react-native-webview"; -import { WebView } from "react-native-webview"; -import { useSession } from "@/hooks/session"; import CustomButton from "@/components/Button"; import { ThemedText } from "@/components/ThemedText"; import { clearWebViewIOSCache } from "react-native-webview-ios-cache-clear"; -import LogoSvg from "../assets/images/future_co.svg"; +import Logo from "@/assets/images/future_co.svg"; +import { useLoginWebView } from "@/hooks/useInruptLogin"; +import { useLocalSearchParams } from "expo-router"; +import { useSession } from "@/hooks/session"; const isRunningInExpoGo = Constants.appOwnership === "expo"; -// Despite what TS says, this works, so we can keep this as is -// for now. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error -const Logo = () => ; - const LoginScreen = () => { - const [showWebView, setShowWebView] = useState(false); - const { signIn, session } = useSession(); + const { showLoginPage, requestLogout } = useLoginWebView(); + const { logout } = useLocalSearchParams(); + const { session } = useSession(); + + useEffect(() => { + if (session && logout) { + requestLogout(); + } + }, [logout, session, requestLogout]); useEffect(() => { - clearWebViewIOSCache(); + // eslint-disable-next-line @typescript-eslint/no-var-requires,global-require + const RCTNetworking = require("react-native/Libraries/Network/RCTNetworking"); + RCTNetworking.default.clearCookies((result: never) => { + console.log("clearCookies", result); + }); + if (!isRunningInExpoGo) { + clearWebViewIOSCache(); import("@react-native-cookies/cookies") .then((CookieManager) => CookieManager.default @@ -58,27 +56,16 @@ const LoginScreen = () => { } }, []); - if (session) { - return ; - } const handleLoginPress = () => { - setShowWebView(true); - }; - - const handleNavigationStateChange = async (navState: WebViewNavigation) => { - const { url } = navState; - - const isLoginSuccess = url.includes("/login/success"); - - if (isLoginSuccess) { - signIn(); + if (!logout) { + showLoginPage(); } }; return ( - + { variant="primary" customStyle={{ paddingHorizontal: 74 }} testID="login-button" - > - - - - - setShowWebView(false)} - > - Close - - - + /> ); }; @@ -126,20 +94,6 @@ const styles = StyleSheet.create({ alignItems: "center", backgroundColor: "#fff", }, - - closeButton: { - position: "absolute", - top: 40, - right: 20, - backgroundColor: "#fff", - padding: 10, - borderRadius: 5, - elevation: 2, - }, - closeButtonText: { - fontSize: 16, - color: "#007BFF", - }, logoContainer: { marginBottom: 40, justifyContent: "center", diff --git a/components/login/LoginWebViewModal.tsx b/components/login/LoginWebViewModal.tsx new file mode 100644 index 0000000..55ee841 --- /dev/null +++ b/components/login/LoginWebViewModal.tsx @@ -0,0 +1,128 @@ +// +// Copyright Inrupt Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import React, { useRef } from "react"; +import { + SafeAreaView, + TouchableOpacity, + Text, + StyleSheet, + Modal, +} from "react-native"; +import { WebView, type WebViewNavigation } from "react-native-webview"; + +interface LoginWebViewModalProps { + onClose: () => void; + onLoginSuccess: () => void; + onLogoutSuccess: () => void; + requestMode: "login" | "logout" | "blank"; +} + +const LoginWebViewModal: React.FC = ({ + onClose = () => null, + onLoginSuccess = () => null, + onLogoutSuccess = () => null, + requestMode = "blank", +}) => { + const BASE_URL = process.env.EXPO_PUBLIC_WALLET_API || ""; + const LOGIN_URL = process.env.EXPO_PUBLIC_LOGIN_URL || ""; + const LOGOUT_URL = `${BASE_URL}/logout`; + + const webViewRef = useRef(null); + + const handleNavigationStateChange = ({ url }: WebViewNavigation) => { + if (url.includes("/login/success")) { + onLoginSuccess(); + } + }; + + const handleLoadEnd = () => { + if (requestMode === "logout") { + onLogoutSuccess(); + } + }; + + const handleCloseModal = () => { + if (!webViewRef.current) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + webViewRef.current?.clearCache(true); + } + onClose(); + }; + + if (requestMode === "logout") { + return ( + + + + ); + } + + const isVisible = requestMode === "login"; + + return ( + + + + + Close + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + flex: 1, + backgroundColor: "transparent", + }, + + closeButton: { + position: "absolute", + top: 40, + right: 20, + backgroundColor: "#fff", + padding: 10, + borderRadius: 5, + elevation: 2, + }, + closeButtonText: { + fontSize: 16, + color: "#007BFF", + }, +}); + +export default LoginWebViewModal; diff --git a/hooks/session.tsx b/hooks/session.tsx index 93c2b84..8c3a2f6 100644 --- a/hooks/session.tsx +++ b/hooks/session.tsx @@ -15,7 +15,6 @@ // import { useStorageState } from "@/hooks/useStorageState"; import React from "react"; -import { logout } from "@/api/user"; import { SESSION_KEY } from "@/api/apiRequest"; const AuthContext = React.createContext<{ @@ -51,10 +50,6 @@ export function SessionProvider(props: React.PropsWithChildren) { }, signOut: async () => { setSession(null); - logout().catch((e) => - // eslint-disable-next-line no-console - console.log("Error during logout:", e) - ); }, session, }} diff --git a/hooks/useInruptLogin.tsx b/hooks/useInruptLogin.tsx new file mode 100644 index 0000000..64bf874 --- /dev/null +++ b/hooks/useInruptLogin.tsx @@ -0,0 +1,77 @@ +// +// Copyright Inrupt Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import React, { createContext, useCallback, useState } from "react"; +import LoginWebViewModal from "@/components/login/LoginWebViewModal"; +import { useSession } from "@/hooks/session"; +import { router } from "expo-router"; + +const LoginWebViewContext = createContext<{ + showLoginPage: () => void; + requestLogout: () => void; +}>({ + showLoginPage: () => null, + requestLogout: () => null, +}); + +export const LoginWebViewProvider = ({ children }: React.PropsWithChildren) => { + const [modalRequestMode, setModalRequestMode] = useState< + "login" | "logout" | "blank" + >("blank"); + const { signIn, signOut } = useSession(); + + const handleLoginSuccess = () => { + signIn(); + closeModal(); + router.replace("/home"); + }; + + const handleLogoutSuccess = () => { + signOut(); + closeModal(); + router.replace("/login"); + }; + + const closeModal = () => { + setModalRequestMode("blank"); + }; + + const showLoginPage = useCallback(() => { + setModalRequestMode("login"); + }, []); + + const requestLogout = useCallback(() => { + setModalRequestMode("logout"); + }, []); + + return ( + + {children} + + + ); +}; + +export const useLoginWebView = () => React.useContext(LoginWebViewContext);