diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 862507ed..108d0c7e 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -43,8 +43,20 @@ "favicon": "./assets/favicon.png" }, "plugins": [ - "expo-router", - "expo-secure-store" + [ + "expo-router" + ], + [ + "expo-secure-store" + ], + [ + "expo-font", + { + "fonts": [ + "./assets/fonts/CalSans-SemiBold.otf" + ] + } + ] ], "extra": { "router": { diff --git a/apps/mobile/app/[site_id]/(tabs)/profile/index.tsx b/apps/mobile/app/[site_id]/(tabs)/profile/index.tsx index 6492aeed..85e86cb9 100644 --- a/apps/mobile/app/[site_id]/(tabs)/profile/index.tsx +++ b/apps/mobile/app/[site_id]/(tabs)/profile/index.tsx @@ -19,6 +19,7 @@ import { revokeAsync } from 'expo-auth-session'; import { useContext } from 'react'; import { SiteContext } from '../../_layout'; import * as SecureStore from 'expo-secure-store'; +import { clearDefaultSite, DEFAULT_SITE_KEY, deleteAccessToken, getAccessTokenKey, getRevocationEndpoint } from '@lib/auth'; const SCREEN_OPTIONS = { title: 'Profile', @@ -117,11 +118,11 @@ function ListFooterComponent() { clientId: siteInformation?.client_id || '', token: tokenParams?.token?.() || '' }, { - revocationEndpoint: siteInformation?.url + '/api/method/frappe.integrations.oauth2.revoke_token' + revocationEndpoint: getRevocationEndpoint(siteInformation?.url || '') }).then(result => { - return SecureStore.deleteItemAsync(`${siteInformation?.sitename}-access-token`) + return deleteAccessToken(siteInformation?.sitename || '') }).then((result) => { - return AsyncStorage.removeItem('default-site') + return clearDefaultSite() }).then(() => { router.replace('/landing') }).catch((error) => { diff --git a/apps/mobile/app/[site_id]/_layout.tsx b/apps/mobile/app/[site_id]/_layout.tsx index 8ec95229..17209afa 100644 --- a/apps/mobile/app/[site_id]/_layout.tsx +++ b/apps/mobile/app/[site_id]/_layout.tsx @@ -1,12 +1,10 @@ -import { Text } from "@components/nativewindui/Text"; import { router, Stack, useLocalSearchParams } from "expo-router"; import { createContext, useEffect, useState } from "react"; -import { View } from "react-native"; import { SiteInformation } from "../../types/SiteInformation"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as SecureStore from 'expo-secure-store'; import { TokenResponse } from "expo-auth-session"; import { FrappeProvider } from "frappe-react-sdk"; +import FullPageLoader from "@components/layout/FullPageLoader"; +import { getAccessToken, getSiteFromStorage, getTokenEndpoint, storeAccessToken } from "@lib/auth"; export default function SiteLayout() { @@ -26,19 +24,8 @@ export default function SiteLayout() { let site_info: SiteInformation | null = null - AsyncStorage.getItem('sites') - .then(sites => { - if (!sites) { - router.replace('/landing') - - // TODO: Show the user a toast saying that the site is not found - - return null - } - - const parsedSites: { [key: string]: SiteInformation } = JSON.parse(sites) - const siteInfo = parsedSites[site_id] - + getSiteFromStorage(site_id) + .then(siteInfo => { if (!siteInfo) { router.replace('/landing') @@ -46,7 +33,6 @@ export default function SiteLayout() { return null } - setSiteInfo(siteInfo) site_info = siteInfo @@ -55,7 +41,7 @@ export default function SiteLayout() { .then((siteInfo: SiteInformation | null) => { if (!siteInfo) return null - return SecureStore.getItemAsync(`${site_id}-access-token`) + return getAccessToken(siteInfo.sitename) }) .then(accessToken => { if (!accessToken) { @@ -65,16 +51,18 @@ export default function SiteLayout() { return null } - const tokenConfig: TokenResponse = JSON.parse(accessToken) - let tokenResponse = new TokenResponse(tokenConfig) + let tokenResponse = new TokenResponse(accessToken) if (tokenResponse.shouldRefresh()) { console.log("Refreshing token") return tokenResponse.refreshAsync({ clientId: site_info?.client_id || '', }, { - tokenEndpoint: site_info?.url + '/api/method/frappe.integrations.oauth2.get_token', + tokenEndpoint: getTokenEndpoint(site_info?.url || ''), + }).then(async (tokenResponse) => { + await storeAccessToken(site_info?.sitename || '', tokenResponse) + return tokenResponse }) } else { return tokenResponse @@ -92,12 +80,7 @@ export default function SiteLayout() { return <> - {loading ? - - {/* TODO: Change this UI */} - raven - Setting up your workspace... - : + {loading ? : + diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx new file mode 100644 index 00000000..bfd7ea1a --- /dev/null +++ b/apps/mobile/app/index.tsx @@ -0,0 +1,12 @@ +import { Stack } from 'expo-router'; +import FullPageLoader from '@components/layout/FullPageLoader'; + +export default function InitialScreen() { + + return ( + <> + + + + ); +} \ No newline at end of file diff --git a/apps/mobile/app/landing.tsx b/apps/mobile/app/landing.tsx index e2f3a90c..527cf844 100644 --- a/apps/mobile/app/landing.tsx +++ b/apps/mobile/app/landing.tsx @@ -3,12 +3,12 @@ import { View } from 'react-native'; import { Text } from '@components/nativewindui/Text'; import AddSite from '@components/features/auth/AddSite'; -export default function NotFoundScreen() { +export default function LandingScreen() { return ( <> - - - raven + + + raven diff --git a/apps/mobile/components/features/auth/AddSite.tsx b/apps/mobile/components/features/auth/AddSite.tsx index a8de18a8..5b47cea3 100644 --- a/apps/mobile/components/features/auth/AddSite.tsx +++ b/apps/mobile/components/features/auth/AddSite.tsx @@ -7,11 +7,10 @@ import { BottomSheetView } from '@gorhom/bottom-sheet' import { useCallback, useState } from 'react' import { Alert, View } from 'react-native' import * as WebBrowser from 'expo-web-browser' -import * as SecureStore from 'expo-secure-store'; import { CodeChallengeMethod, exchangeCodeAsync, makeRedirectUri, ResponseType, TokenResponse, useAuthRequest } from 'expo-auth-session'; -import AsyncStorage from '@react-native-async-storage/async-storage' import { router } from 'expo-router' import { SiteInformation } from '../../../types/SiteInformation' +import { addSiteToStorage, discovery, setDefaultSite, storeAccessToken } from '@lib/auth' WebBrowser.maybeCompleteAuthSession(); @@ -87,12 +86,6 @@ const AddSite = (props: Props) => { ) } -const discovery = { - authorizationEndpoint: '/api/method/frappe.integrations.oauth2.authorize', - tokenEndpoint: '/api/method/frappe.integrations.oauth2.get_token', - revocationEndpoint: '/api/method/frappe.integrations.oauth2.revoke_token', -} - const SiteAuthFlowSheet = ({ siteInformation, onDismiss }: { siteInformation: SiteInformation, onDismiss: () => void }) => { const discoveryWithURL = { @@ -141,8 +134,8 @@ const SiteAuthFlowSheet = ({ siteInformation, onDismiss }: { siteInformation: Si // 3. Redirect the user to the /[sitename] route storeAccessToken(siteInformation.sitename, token) - .then(() => AsyncStorage.mergeItem('sites', JSON.stringify({ [siteInformation.sitename]: siteInformation }))) - .then(() => AsyncStorage.setItem(`default-site`, siteInformation.sitename)) + .then(() => addSiteToStorage(siteInformation.sitename, siteInformation)) + .then(() => setDefaultSite(siteInformation.sitename)) .then(() => router.replace(`/${siteInformation.sitename}`)) .then(() => onDismiss()) } @@ -163,8 +156,4 @@ const SiteAuthFlowSheet = ({ siteInformation, onDismiss }: { siteInformation: Si } -const storeAccessToken = (siteName: string, token: TokenResponse) => { - return SecureStore.setItemAsync(`${siteName}-access-token`, JSON.stringify(token)) -} - export default AddSite \ No newline at end of file diff --git a/apps/mobile/components/layout/FullPageLoader.tsx b/apps/mobile/components/layout/FullPageLoader.tsx new file mode 100644 index 00000000..db7944a0 --- /dev/null +++ b/apps/mobile/components/layout/FullPageLoader.tsx @@ -0,0 +1,19 @@ +import { Text } from '@components/nativewindui/Text' +import React from 'react' +import { View } from 'react-native' + +type Props = { + title?: string + description?: string +} + +const FullPageLoader = ({ title = 'raven', description = 'Setting up your workspace...' }: Props) => { + return ( + + {title} + {description} + + ) +} + +export default FullPageLoader \ No newline at end of file diff --git a/apps/mobile/lib/auth.ts b/apps/mobile/lib/auth.ts new file mode 100644 index 00000000..81542477 --- /dev/null +++ b/apps/mobile/lib/auth.ts @@ -0,0 +1,161 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" +import { TokenResponse } from "expo-auth-session" +import * as SecureStore from 'expo-secure-store' +import { SiteInformation } from "../types/SiteInformation" + +/** + * Function to get the access token key for a site + * + * @param siteName - The name of the site + * @returns The access token key for the site - to be used in SecureStore + */ +export const getAccessTokenKey = (siteName: string) => `${siteName}-access-token` + +/** + * Key to store all sites in AsyncStorage + */ +export const SITES_KEY = 'sites' + +/** + * Key to store the default site in AsyncStorage + */ +export const DEFAULT_SITE_KEY = 'default-site' + +/** + * Discovery object for OAuth2 - Frappe OAuth2 endpoints + */ +export const discovery = { + authorizationEndpoint: '/api/method/frappe.integrations.oauth2.authorize', + tokenEndpoint: '/api/method/frappe.integrations.oauth2.get_token', + revocationEndpoint: '/api/method/frappe.integrations.oauth2.revoke_token', +} + +/** + * Function to get the authorization endpoint for a site + * + * @param siteURL - The URL of the site + * @returns The authorization endpoint for the site + */ +export const getAuthorizationEndpoint = (siteURL: string) => `${siteURL}${discovery.authorizationEndpoint}` + +/** + * Function to get the token endpoint for a site + * + * @param siteURL - The URL of the site + * @returns The token endpoint for the site + */ +export const getTokenEndpoint = (siteURL: string) => `${siteURL}${discovery.tokenEndpoint}` + +/** + * Function to get the revocation endpoint for a site + * + * @param siteURL - The URL of the site + * @returns The revocation endpoint for the site + */ +export const getRevocationEndpoint = (siteURL: string) => `${siteURL}${discovery.revocationEndpoint}` + +/** + * Function to store the access token for a site in SecureStore + * + * @param siteName - The name of the site + * @param token - The access token + */ +export const storeAccessToken = (siteName: string, token: TokenResponse) => { + return SecureStore.setItemAsync(getAccessTokenKey(siteName), JSON.stringify(token)) +} + +/** + * Function to delete the access token for a site from SecureStore + * + * @param siteName - The name of the site + */ +export const deleteAccessToken = (siteName: string) => { + return SecureStore.deleteItemAsync(getAccessTokenKey(siteName)) +} + +/** + * Function to get the access token for a site from SecureStore + * + * @param siteName - The name of the site + * @returns The access token for the site + */ +export const getAccessToken = async (siteName: string): Promise => { + return SecureStore.getItemAsync(getAccessTokenKey(siteName)).then((token) => { + return token ? JSON.parse(token) : null + }) +} + +/** + * Function to get all sites from AsyncStorage + * + * @returns All sites from AsyncStorage + */ +export const getSitesFromStorage = async (): Promise> => { + return AsyncStorage.getItem(SITES_KEY).then((sites) => { + const sitesObj = JSON.parse(sites || '{}') + return sitesObj + }) +} + +/** + * Function to get a site from AsyncStorage + * + * @param siteName - The name of the site + * @returns The site from AsyncStorage + */ +export const getSiteFromStorage = async (siteName: string): Promise => { + return AsyncStorage.getItem(SITES_KEY).then((sites) => { + const sitesObj = JSON.parse(sites || '{}') + return sitesObj[siteName] || null + }) +} + +/** + * Function to add a site to AsyncStorage + * + * @param siteName - The name of the site + * @param siteInfo - The site information + */ +export const addSiteToStorage = async (siteName: string, siteInfo: SiteInformation) => { + return AsyncStorage.mergeItem(SITES_KEY, JSON.stringify({ [siteName]: siteInfo })) +} + +/** + * Function to remove a site from AsyncStorage + * + * @param siteName - The name of the site + */ +export const removeSiteFromStorage = async (siteName: string) => { + return AsyncStorage.getItem(SITES_KEY).then((sites) => { + const sitesObj = JSON.parse(sites || '{}') + delete sitesObj[siteName] + }).then((sitesObj) => { + return AsyncStorage.setItem(SITES_KEY, JSON.stringify(sitesObj)) + }) +} + +/** + * Function to set the default site in AsyncStorage + * + * @param siteName - The name of the site + */ +export const setDefaultSite = (siteName: string) => { + return AsyncStorage.setItem(DEFAULT_SITE_KEY, siteName) +} + +/** + * Function to clear the default site from AsyncStorage + */ +export const clearDefaultSite = () => { + return AsyncStorage.removeItem(DEFAULT_SITE_KEY) +} + + +/** + * Function to get the default site from AsyncStorage + * + * @returns The default site from AsyncStorage + */ +export const getDefaultSite = async (): Promise => { + return AsyncStorage.getItem(DEFAULT_SITE_KEY) +} \ No newline at end of file diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 75dc31f5..5563559c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,6 +26,7 @@ "expo": "~52.0.18", "expo-auth-session": "^6.0.1", "expo-constants": "~17.0.3", + "expo-font": "~13.0.1", "expo-linking": "~7.0.3", "expo-navigation-bar": "~4.0.6", "expo-router": "4.0.11", diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index 1a31c5f4..b7607236 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -44,6 +44,9 @@ module.exports = { borderWidth: { hairline: hairlineWidth(), }, + fontFamily: { + "cal-sans": ["CalSans-SemiBold", "sans-serif"], + } }, }, plugins: [],