diff --git a/App.js b/App.js index 9061e7f..fb58301 100644 --- a/App.js +++ b/App.js @@ -17,6 +17,7 @@ import {PaperProvider} from "react-native-paper"; import {NavigationContainer} from "@react-navigation/native"; import {BulletList} from "react-content-loader/native"; import {SQLiteProvider} from "expo-sqlite"; +import Toast from "react-native-toast-message"; import Header from "./Header"; import NavigationBar from "./NavigationBar"; import {migrateDb} from "./TotpDatabase"; @@ -31,6 +32,7 @@ const App = () => { + ); diff --git a/CasdoorLoginPage.js b/CasdoorLoginPage.js index 2c4b1b1..effb71f 100644 --- a/CasdoorLoginPage.js +++ b/CasdoorLoginPage.js @@ -14,10 +14,12 @@ import React, {useEffect, useState} from "react"; import {WebView} from "react-native-webview"; -import {View} from "react-native"; +import {Platform, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity} from "react-native"; import {Portal} from "react-native-paper"; import SDK from "casdoor-react-native-sdk"; import PropTypes from "prop-types"; +import Toast from "react-native-toast-message"; + import EnterCasdoorSdkConfig from "./EnterCasdoorSdkConfig"; import useStore from "./useStorage"; // import {LogBox} from "react-native"; @@ -28,6 +30,7 @@ const CasdoorLoginPage = ({onWebviewClose}) => { CasdoorLoginPage.propTypes = { onWebviewClose: PropTypes.func.isRequired, }; + const [casdoorLoginURL, setCasdoorLoginURL] = useState(""); const [showConfigPage, setShowConfigPage] = useState(true); @@ -45,6 +48,11 @@ const CasdoorLoginPage = ({onWebviewClose}) => { const handleHideConfigPage = () => { setShowConfigPage(false); }; + + const handleShowConfigPage = () => { + setShowConfigPage(true); + }; + const getCasdoorSignInUrl = async() => { const signinUrl = await sdk.getSigninUrl(); setCasdoorLoginURL(signinUrl); @@ -68,24 +76,71 @@ const CasdoorLoginPage = ({onWebviewClose}) => { } }; + const handleErrorResponse = (error) => { + Toast.show({ + type: "error", + text1: "Error", + text2: error.description, + autoHide: true, + }); + setShowConfigPage(true); + }; + return ( - - {showConfigPage && } - {!showConfigPage && casdoorLoginURL !== "" && ( - + {showConfigPage && ( + )} - + {!showConfigPage && casdoorLoginURL !== "" && ( + <> + + Back to Config + + { + const {nativeEvent} = syntheticEvent; + handleErrorResponse(nativeEvent); + }} + style={styles.webview} + mixedContentMode="always" + javaScriptEnabled={true} + /> + + )} + ); }; +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "white", + paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0, + }, + webview: { + flex: 1, + }, + backButton: { + padding: 10, + backgroundColor: "#007AFF", + alignItems: "center", + }, + backButtonText: { + color: "white", + fontWeight: "bold", + }, +}); + export const CasdoorLogout = () => { if (sdk) {sdk.clearState();} }; diff --git a/EnterAccountDetails.js b/EnterAccountDetails.js index 4fd2ba9..f831b0e 100644 --- a/EnterAccountDetails.js +++ b/EnterAccountDetails.js @@ -12,104 +12,230 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, {useState} from "react"; -import {Text, TextInput, View} from "react-native"; -import {Button, Divider, IconButton, Menu} from "react-native-paper"; +import React, {useCallback, useState} from "react"; +import {View} from "react-native"; +import {Button, IconButton, Menu, Text, TextInput} from "react-native-paper"; +import Toast from "react-native-toast-message"; import PropTypes from "prop-types"; -export default function EnterAccountDetails({onClose, onAdd}) { +const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => { EnterAccountDetails.propTypes = { onClose: PropTypes.func.isRequired, onAdd: PropTypes.func.isRequired, + validateSecret: PropTypes.func.isRequired, }; const [accountName, setAccountName] = useState(""); const [secretKey, setSecretKey] = useState(""); + const [secretError, setSecretError] = useState(""); + const [accountNameError, setAccountNameError] = useState(""); + const [visible, setVisible] = useState(false); + const [selectedItem, setSelectedItem] = useState("Time based"); + const [showPassword, setShowPassword] = useState(false); - const [visible, setVisible] = React.useState(false); const openMenu = () => setVisible(true); const closeMenu = () => setVisible(false); - const [selectedItem, setSelectedItem] = useState("Time based"); - const handleMenuItemPress = (item) => { + const handleMenuItemPress = useCallback((item) => { setSelectedItem(item); closeMenu(); - }; + }, []); + + const handleAddAccount = useCallback(() => { + if (accountName.trim() === "") { + setAccountNameError("Account Name is required"); + } + + if (secretKey.trim() === "") { + setSecretError("Secret Key is required"); + } + + if (accountName.trim() === "" || secretKey.trim() === "") { + Toast.show({ + type: "error", + text1: "Error", + text2: "Please fill in all the fields!", + autoHide: true, + }); + return; + } + + if (secretError) { + Toast.show({ + type: "error", + text1: "Invalid Secret Key", + text2: "Please check your secret key and try again.", + autoHide: true, + }); + return; + } - const handleAddAccount = () => { onAdd({accountName, secretKey}); setAccountName(""); setSecretKey(""); - }; + setAccountNameError(""); + setSecretError(""); + }, [accountName, secretKey, secretError, onAdd]); + + const handleSecretKeyChange = useCallback((text) => { + setSecretKey(text); + if (validateSecret) { + const isValid = validateSecret(text); + setSecretError(isValid || text.trim() === "" ? "" : "Invalid Secret Key"); + } + }, [validateSecret]); + + const handleAccountNameChange = useCallback((text) => { + setAccountName(text); + if (accountNameError) { + setAccountNameError(""); + } + }, [accountNameError]); return ( - - Add new 2FA account - - + + + + Add Account + + setAccountName(text)} - style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}} + onChangeText={handleAccountNameChange} + error={!!accountNameError} + style={styles.input} + mode="outlined" /> - - - - setSecretKey(text)} - secureTextEntry - style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}} - /> - - - - - - {selectedItem} - + onChangeText={handleSecretKeyChange} + secureTextEntry={!showPassword} + error={!!secretError} + style={styles.input} + mode="outlined" + right={ + setShowPassword(!showPassword)} + /> } - > - handleMenuItemPress("Time based")} title="Time based" /> - - handleMenuItemPress("Counter based")} title="Counter based" /> - + /> + + + {selectedItem} + + } + contentStyle={styles.menuContent} + > + handleMenuItemPress("Time based")} title="Time based" /> + handleMenuItemPress("Counter based")} title="Counter based" /> + + + ); -} +}; + +const styles = { + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + content: { + width: "100%", + borderRadius: 10, + padding: 20, + backgroundColor: "#F5F5F5", + shadowColor: "#000", + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 10, + elevation: 5, + }, + header: { + position: "relative", + alignItems: "center", + marginBottom: 20, + }, + title: { + fontSize: 24, + fontWeight: "bold", + color: "#333", + textAlign: "center", + }, + closeButton: { + position: "absolute", + right: 0, + top: -8, + }, + input: { + marginVertical: 10, + fontSize: 16, + backgroundColor: "white", + }, + buttonContainer: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 10, + }, + menuButton: { + flex: 1, + marginRight: 10, + height: 50, + justifyContent: "center", + fontSize: 12, + }, + menuButtonContent: { + height: 50, + justifyContent: "center", + }, + menuContent: { + backgroundColor: "#FFFFFF", + borderRadius: 8, + elevation: 3, + shadowColor: "#000000", + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.2, + shadowRadius: 3, + }, + addButton: { + flex: 1, + backgroundColor: "#8A7DF7", + height: 50, + justifyContent: "center", + paddingHorizontal: 5, + }, + buttonLabel: { + fontSize: 14, + color: "white", + textAlign: "center", + }, +}; + +export default EnterAccountDetails; diff --git a/EnterCasdoorSdkConfig.js b/EnterCasdoorSdkConfig.js index 2de356b..b7a2c29 100644 --- a/EnterCasdoorSdkConfig.js +++ b/EnterCasdoorSdkConfig.js @@ -13,8 +13,9 @@ // limitations under the License. import React from "react"; -import {Alert, Text, View} from "react-native"; +import {ScrollView, Text, View} from "react-native"; import {Button, IconButton, Portal, TextInput} from "react-native-paper"; +import Toast from "react-native-toast-message"; import DefaultCasdoorSdkConfig from "./DefaultCasdoorSdkConfig"; import PropTypes from "prop-types"; import useStore from "./useStorage"; @@ -22,6 +23,7 @@ import useStore from "./useStorage"; const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => { EnterCasdoorSdkConfig.propTypes = { onClose: PropTypes.func.isRequired, + onWebviewClose: PropTypes.func.isRequired, }; const { @@ -44,12 +46,26 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => { const handleSave = () => { if (!serverUrl || !clientId || !appName || !organizationName || !redirectPath) { - Alert.alert("Please fill in all the fields!"); + Toast.show({ + type: "error", + text1: "Error", + text2: "Please fill in all the fields!", + autoHide: true, + }); return; } onClose(); }; + const handleScanToLogin = () => { + Toast.show({ + type: "info", + text1: "Info", + text2: "Scan to Login functionality not implemented yet.", + autoHide: true, + }); + }; + const handleUseDefault = () => { setCasdoorConfig(DefaultCasdoorSdkConfig); onClose(); @@ -57,115 +73,155 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => { return ( - - - Casdoor server + + + + Casdoor server + + + + + + - - - + ); }; +const styles = { + scrollContainer: { + flexGrow: 1, + width: "100%", + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(255, 255, 255, 0.5)", + }, + content: { + width: "95%", + borderRadius: 10, + padding: 20, + backgroundColor: "#F5F5F5", + shadowColor: "#000", + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 10, + elevation: 5, + }, + input: { + marginVertical: 10, + fontSize: 16, + backgroundColor: "white", + }, + buttonRow: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 14, + marginBottom: 12, + }, + button: { + borderRadius: 5, + paddingVertical: 8, + }, + confirmButton: { + backgroundColor: "#6200EE", + flex: 1, + marginRight: 5, + }, + scanButton: { + backgroundColor: "#03DAC6", + flex: 1, + marginLeft: 5, + }, + buttonLabel: { + fontSize: 16, + color: "white", + }, + outlinedButton: { + borderColor: "#6200EE", + borderWidth: 1, + width: "100%", + }, + outlinedButtonLabel: { + color: "#6200EE", + fontSize: 16, + textAlign: "center", + }, + header: { + position: "relative", + alignItems: "center", + marginBottom: 20, + }, + title: { + fontSize: 24, + fontWeight: "bold", + color: "#333", + textAlign: "center", + }, + closeButton: { + position: "absolute", + right: 0, + top: -8, + }, +}; + export default EnterCasdoorSdkConfig; diff --git a/Header.js b/Header.js index e814259..224960a 100644 --- a/Header.js +++ b/Header.js @@ -15,13 +15,16 @@ import * as React from "react"; import {Dimensions, StyleSheet, View} from "react-native"; import {Appbar, Avatar, Menu, Text, TouchableRipple} from "react-native-paper"; +import Toast from "react-native-toast-message"; import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage"; import useStore from "./useStorage"; +import useSyncStore from "./useSyncStore"; const {width} = Dimensions.get("window"); const Header = () => { const {userInfo, clearAll} = useStore(); + const syncError = useSyncStore(state => state.syncError); const [showLoginPage, setShowLoginPage] = React.useState(false); const [menuVisible, setMenuVisible] = React.useState(false); @@ -41,8 +44,27 @@ const Header = () => { clearAll(); }; + const handleSyncErrorPress = () => { + Toast.show({ + type: "error", + text1: "Sync Error", + text2: syncError || "An unknown error occurred during synchronization.", + autoHide: true, + }); + }; + return ( - + + + {true && syncError && ( + + )} + state.syncError); useEffect(() => { if (db) { @@ -89,11 +92,29 @@ export default function HomePage() { const onRefresh = async() => { setRefreshing(true); - if (canSync) {await startSync(db, userInfo, serverUrl, token);} + if (canSync) { + await startSync(db, userInfo, serverUrl, token); + if (syncError) { + Toast.show({ + type: "error", + text1: "Sync error", + text2: syncError, + autoHide: true, + }); + } else { + Toast.show({ + type: "success", + text1: "Sync success", + text2: "All your accounts are up to date.", + autoHide: true, + }); + } + } setRefreshing(false); }; const handleAddAccount = async(accountData) => { + setKey(prevKey => prevKey + 1); await TotpDatabase.insertAccount(db, accountData); closeEnterAccountModal(); }; @@ -218,6 +239,7 @@ export default function HomePage() { right={() => ( { TotpDatabase.updateToken(db, item.id); - return {shouldRepeat: true, delay: 0}; + return { + shouldRepeat: true, + delay: 0, + newInitialRemainingTime: TotpDatabase.calculateCountdown(), + }; }} strokeWidth={5} > @@ -292,7 +318,7 @@ export default function HomePage() { transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}], }} > - + diff --git a/ScanQRCode.js b/ScanQRCode.js index 7178382..781c817 100644 --- a/ScanQRCode.js +++ b/ScanQRCode.js @@ -13,8 +13,8 @@ // limitations under the License. import React, {useEffect, useState} from "react"; -import {Dimensions, Text, View} from "react-native"; -import {IconButton, Modal, Portal} from "react-native-paper"; +import {Text, View} from "react-native"; +import {IconButton, Portal} from "react-native-paper"; import {Camera, CameraView} from "expo-camera"; import PropTypes from "prop-types"; @@ -54,41 +54,23 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => { closeOptions(); }; - const {width, height} = Dimensions.get("window"); - const offsetX = width * 0.5; - const offsetY = height * 0.5; - return ( - - {hasPermission === null ? ( - Requesting for camera permission - ) : hasPermission === false ? ( - No access to camera - ) : ( - - )} - - + {hasPermission === null ? ( + Requesting for camera permission + ) : hasPermission === false ? ( + No access to camera + ) : ( + + )} + ); diff --git a/SettingPage.js b/SettingPage.js index 336ee71..69503e8 100644 --- a/SettingPage.js +++ b/SettingPage.js @@ -13,14 +13,15 @@ // limitations under the License. import * as React from "react"; -import {Button} from "react-native-paper"; -import {View} from "react-native"; +import {StyleSheet, View, useWindowDimensions} from "react-native"; +import {Button, Surface, Text} from "react-native-paper"; import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage"; import useStore from "./useStorage"; const SettingPage = () => { const [showLoginPage, setShowLoginPage] = React.useState(false); const {userInfo, clearAll} = useStore(); + const {width} = useWindowDimensions(); const handleCasdoorLogin = () => setShowLoginPage(true); const handleHideLoginPage = () => setShowLoginPage(false); @@ -30,16 +31,42 @@ const SettingPage = () => { clearAll(); }; + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 16, + }, + surface: { + padding: 16, + width: width > 600 ? 400 : "100%", + maxWidth: 400, + alignItems: "center", + }, + title: { + fontSize: 24, + marginBottom: 24, + }, + button: { + marginTop: 16, + width: "100%", + }, + }); + return ( - - + + + Account Settings + + {showLoginPage && } ); diff --git a/TotpDatabase.js b/TotpDatabase.js index 0c7b5bb..ac1d30c 100644 --- a/TotpDatabase.js +++ b/TotpDatabase.js @@ -45,6 +45,17 @@ CREATE TABLE accounts ( await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`); } +export async function clearDatabase(db) { + try { + await db.execAsync("DELETE FROM accounts"); + await db.execAsync("DELETE FROM sqlite_sequence WHERE name='accounts'"); + await db.execAsync("PRAGMA user_version = 0"); + return true; + } catch (error) { + return false; + } +} + const generateToken = (secretKey) => { if (secretKey !== null && secretKey !== undefined && secretKey !== "") { try { @@ -147,7 +158,6 @@ export async function getAllAccounts(db) { const mappedAccount = { ...account, accountName: account.account_name, - secretKey: account.secret, }; return mappedAccount; }); @@ -173,10 +183,18 @@ async function updateSyncTimeForAll(db) { } export function calculateCountdown() { - const now = Math.floor(Date.now() / 1000); + const now = Math.round(new Date().getTime() / 1000.0); return 30 - (now % 30); } +export function validateSecret(secret) { + const base32Regex = /^[A-Z2-7]+=*$/i; + if (!secret || secret.length % 8 !== 0) { + return false; + } + return base32Regex.test(secret); +} + async function updateLocalDatabase(db, mergedAccounts) { for (const account of mergedAccounts) { if (account.id) { diff --git a/app.json b/app.json index 2515267..f870a5f 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "expo": { "name": "Casdoor", - "slug": "Casdoor", + "slug": "casdoor-app", "version": "1.2.0", "orientation": "portrait", "icon": "./assets/icon.png",