Compare commits

...

12 Commits

Author SHA1 Message Date
semantic-release-bot c593330a21 chore(release): 1.11.0 [skip ci] 2024-10-27 02:55:48 +00:00
IZUMI-Zu ea4f542b0f
feat: fix new user null bug and text input bug (#31) 2024-10-27 10:39:19 +08:00
semantic-release-bot 4fef2a2e30 chore(release): 1.10.0 [skip ci] 2024-10-10 15:27:10 +00:00
IZUMI-Zu 308b546395
feat: support importing entried from other authenticators (#30) 2024-10-10 23:10:27 +08:00
semantic-release-bot e3f7ab5203 chore(release): 1.9.0 [skip ci] 2024-09-29 09:42:07 +00:00
IZUMI-Zu a1295e09ac
feat: fix bug in icons (#29) 2024-09-29 17:25:40 +08:00
semantic-release-bot beb492e540 chore(release): 1.8.0 [skip ci] 2024-09-27 00:29:43 +00:00
IZUMI-Zu fc4aef3b92
feat: improve login logic (#28) 2024-09-27 08:12:33 +08:00
semantic-release-bot 3cbfa8e253 chore(release): 1.7.0 [skip ci] 2024-09-22 15:22:57 +00:00
IZUMI-Zu 37a03e45cc
feat: can scan Casdoor QR code to login (#27) 2024-09-22 23:06:20 +08:00
semantic-release-bot e74a0ed1c7 chore(release): 1.6.0 [skip ci] 2024-09-08 16:00:43 +00:00
IZUMI-Zu aa7219ac65
feat: support login using QR code (#26) 2024-09-08 23:43:34 +08:00
22 changed files with 6306 additions and 3853 deletions

85
App.js
View File

@ -12,13 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from "react";
import React from "react";
import {Lato_700Bold, useFonts} from "@expo-google-fonts/lato";
import {Roboto_500Medium} from "@expo-google-fonts/roboto";
import {NavigationContainer} from "@react-navigation/native";
import {PaperProvider} from "react-native-paper";
import {SafeAreaView, Text} from "react-native";
import ContentLoader, {Circle, Rect} from "react-content-loader/native";
import Toast from "react-native-toast-message";
import {ZoomInDownZoomOutUp, createNotifications} from "react-native-notificated";
import {GestureHandlerRootView} from "react-native-gesture-handler";
import {useMigrations} from "drizzle-orm/expo-sqlite/migrator";
import {ActionSheetProvider} from "@expo/react-native-action-sheet";
import Header from "./Header";
import NavigationBar from "./NavigationBar";
@ -27,6 +31,26 @@ import migrations from "./drizzle/migrations";
const App = () => {
const {success, error} = useMigrations(db, migrations);
const [fontsLoaded] = useFonts({
Lato_700Bold,
Roboto_500Medium,
});
const {NotificationsProvider} = createNotifications({
duration: 800,
notificationPosition: "top",
animationConfig: ZoomInDownZoomOutUp,
isNotch: true,
notificationWidth: 350,
defaultStylesSettings: {
globalConfig: {
borderRadius: 15,
borderWidth: 2,
multiline: 3,
defaultIconType: "no-icon",
},
},
});
if (error) {
return (
@ -36,36 +60,43 @@ const App = () => {
);
}
if (!success) {
if (!success || !fontsLoaded) {
return (
<ContentLoader
speed={2}
width={400}
height={150}
viewBox="0 0 400 150"
backgroundColor="#f3f3f3"
foregroundColor="#ecebeb"
>
<Circle cx="10" cy="20" r="8" />
<Rect x="25" y="15" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="50" r="8" />
<Rect x="25" y="45" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="80" r="8" />
<Rect x="25" y="75" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="110" r="8" />
<Rect x="25" y="105" rx="5" ry="5" width="220" height="10" />
</ContentLoader>
<SafeAreaView style={{flex: 1, justifyContent: "center", alignItems: "center"}}>
<ContentLoader
speed={2}
width={300}
height={150}
viewBox="0 0 300 150"
backgroundColor="#f3f3f3"
foregroundColor="#ecebeb"
>
<Circle cx="10" cy="20" r="8" />
<Rect x="25" y="15" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="50" r="8" />
<Rect x="25" y="45" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="80" r="8" />
<Rect x="25" y="75" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="110" r="8" />
<Rect x="25" y="105" rx="5" ry="5" width="220" height="10" />
</ContentLoader>
</SafeAreaView>
);
}
return (
<NavigationContainer>
<PaperProvider>
<Header />
<NavigationBar />
</PaperProvider>
<Toast />
</NavigationContainer>
<GestureHandlerRootView style={{flex: 1}}>
<NotificationsProvider>
<ActionSheetProvider>
<NavigationContainer>
<PaperProvider>
<Header />
<NavigationBar />
</PaperProvider>
</NavigationContainer>
</ActionSheetProvider>
</NotificationsProvider>
</GestureHandlerRootView>
);
};
export default App;

View File

@ -12,31 +12,30 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import React, {useState} from "react";
import {View} from "react-native";
import React from "react";
import {Image} from "expo-image";
const AvatarWithFallback = ({source, fallbackSource, size, style}) => {
const [hasError, setHasError] = useState(false);
const handleImageError = () => {
if (!hasError) {
setHasError(true);
}
};
function AvatarWithFallback({source, fallbackSource, size, style}) {
const [imageSource, setImageSource] = React.useState(source);
return (
<View style={{overflow: "hidden", borderRadius: 9999, width: size, height: size, ...style}}>
<Image
style={{width: "100%", height: "100%"}}
source={hasError ? fallbackSource : source}
onError={handleImageError}
contentFit="cover"
transition={300}
cachePolicy={"memory-disk"}
/>
</View>
<Image
style={{
overflow: "hidden",
borderRadius: 9999,
width: size,
height: size,
...style,
}}
source={imageSource}
onError={() => setImageSource(fallbackSource)}
placeholder={fallbackSource}
placeholderContentFit="cover"
contentFit="cover"
transition={300}
cachePolicy="memory-disk"
/>
);
};
}
export default AvatarWithFallback;

View File

@ -16,23 +16,25 @@ import React, {useEffect, useState} from "react";
import {WebView} from "react-native-webview";
import {Platform, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity} from "react-native";
import {Portal} from "react-native-paper";
import {useNotifications} from "react-native-notificated";
import SDK from "casdoor-react-native-sdk";
import PropTypes from "prop-types";
import Toast from "react-native-toast-message";
import EnterCasdoorSdkConfig from "./EnterCasdoorSdkConfig";
import ScanQRCodeForLogin from "./ScanLogin";
import useStore from "./useStorage";
// import {LogBox} from "react-native";
// LogBox.ignoreAllLogs();
import DefaultCasdoorSdkConfig from "./DefaultCasdoorSdkConfig";
let sdk = null;
const CasdoorLoginPage = ({onWebviewClose}) => {
function CasdoorLoginPage({onWebviewClose, initialMethod}) {
CasdoorLoginPage.propTypes = {
onWebviewClose: PropTypes.func.isRequired,
initialMethod: PropTypes.oneOf(["manual", "scan", "demo"]).isRequired,
};
const {notify} = useNotifications();
const [casdoorLoginURL, setCasdoorLoginURL] = useState("");
const [showConfigPage, setShowConfigPage] = useState(true);
const [currentView, setCurrentView] = useState(initialMethod === "scan" ? "scanner" : "config");
const {
serverUrl,
@ -40,33 +42,57 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
redirectPath,
appName,
organizationName,
token,
getCasdoorConfig,
setCasdoorConfig,
setServerUrl,
setClientId,
setAppName,
setOrganizationName,
setUserInfo,
setToken,
} = useStore();
const handleHideConfigPage = () => {
setShowConfigPage(false);
};
useEffect(() => {
if (initialMethod === "demo") {
setCasdoorConfig(DefaultCasdoorSdkConfig);
}
}, [initialMethod, setCasdoorConfig]);
const handleShowConfigPage = () => {
setShowConfigPage(true);
const initSdk = () => {
const configs = {
demo: DefaultCasdoorSdkConfig,
scan: getCasdoorConfig(),
manual: serverUrl && clientId && redirectPath && appName && organizationName ? getCasdoorConfig() : null,
};
sdk = configs[initialMethod] ? new SDK(configs[initialMethod]) : null;
};
const getCasdoorSignInUrl = async() => {
const signinUrl = await sdk.getSigninUrl();
setCasdoorLoginURL(signinUrl);
initSdk();
if (sdk) {
const signinUrl = await sdk.getSigninUrl();
setCasdoorLoginURL(signinUrl);
}
};
useEffect(() => {
if (serverUrl && clientId && redirectPath && appName && organizationName) {
sdk = new SDK(getCasdoorConfig());
getCasdoorSignInUrl();
}
}, [serverUrl, clientId, redirectPath, appName, organizationName]);
const handleLogin = (method) => {
const actions = {
manual: () => {
getCasdoorSignInUrl();
setCurrentView("webview");
},
demo: () => {
getCasdoorSignInUrl();
setCurrentView("webview");
},
scan: () => setCurrentView("scanner"),
};
actions[method]?.();
};
const onNavigationStateChange = async(navState) => {
const {redirectPath} = getCasdoorConfig();
if (navState.url.startsWith(redirectPath)) {
onWebviewClose();
const token = await sdk.getAccessToken(navState.url);
@ -76,57 +102,71 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
}
};
const handleErrorResponse = (error) => {
Toast.show({
type: "error",
text1: "Error",
text2: error.description,
autoHide: true,
});
setShowConfigPage(true);
const handleQRLogin = (loginInfo) => {
setServerUrl(loginInfo.serverUrl);
setClientId("");
setAppName("");
setOrganizationName("");
initSdk();
try {
const accessToken = loginInfo.accessToken;
const userInfo = sdk.JwtDecode(accessToken);
setToken(accessToken);
setUserInfo(userInfo);
notify("success", {params: {title: "Success", description: "Logged in successfully!"}});
setCurrentView("config");
onWebviewClose();
} catch (error) {
notify("error", {params: {title: "Error in login", description: error.message}});
}
};
return (
<Portal>
<SafeAreaView style={styles.container}>
{showConfigPage && (
<EnterCasdoorSdkConfig
onClose={handleHideConfigPage}
onWebviewClose={onWebviewClose}
const renderContent = () => {
const views = {
config: (
<EnterCasdoorSdkConfig
onClose={() => handleLogin(initialMethod)}
onWebviewClose={onWebviewClose}
usePortal={false}
/>
),
scanner: (
<ScanQRCodeForLogin
showScanner={true}
onClose={() => {
setCurrentView("config");
onWebviewClose();
}}
onLogin={handleQRLogin}
/>
),
webview: casdoorLoginURL && !token && (
<SafeAreaView style={styles.safeArea}>
<TouchableOpacity style={styles.backButton} onPress={() => setCurrentView("config")}>
<Text style={styles.backButtonText}>Back to Config</Text>
</TouchableOpacity>
<WebView
source={{uri: casdoorLoginURL}}
onNavigationStateChange={onNavigationStateChange}
onError={({nativeEvent}) => {
notify("error", {params: {title: "Error", description: nativeEvent.description}});
setCurrentView("config");
}}
style={styles.webview}
mixedContentMode="always"
javaScriptEnabled={true}
/>
)}
{!showConfigPage && casdoorLoginURL !== "" && (
<>
<TouchableOpacity
style={styles.backButton}
onPress={handleShowConfigPage}
>
<Text style={styles.backButtonText}>Back to Config</Text>
</TouchableOpacity>
<WebView
source={{uri: casdoorLoginURL}}
onNavigationStateChange={onNavigationStateChange}
onError={(syntheticEvent) => {
const {nativeEvent} = syntheticEvent;
handleErrorResponse(nativeEvent);
}}
style={styles.webview}
mixedContentMode="always"
javaScriptEnabled={true}
/>
</>
)}
</SafeAreaView>
</Portal>
);
};
</SafeAreaView>
),
};
return views[currentView] || null;
};
return <Portal>{renderContent()}</Portal>;
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "white",
paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0,
},
webview: {
flex: 1,
},
@ -139,10 +179,18 @@ const styles = StyleSheet.create({
color: "white",
fontWeight: "bold",
},
safeArea: {
flex: 1,
backgroundColor: "white",
paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0,
},
});
export const CasdoorLogout = () => {
if (sdk) {sdk.clearState();}
if (sdk) {
sdk.clearState();
}
};
export default CasdoorLoginPage;

View File

@ -15,7 +15,7 @@
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 {useNotifications} from "react-native-notificated";
import PropTypes from "prop-types";
const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
@ -25,6 +25,8 @@ const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
validateSecret: PropTypes.func.isRequired,
};
const {notify} = useNotifications();
const [accountName, setAccountName] = useState("");
const [secretKey, setSecretKey] = useState("");
const [secretError, setSecretError] = useState("");
@ -51,21 +53,17 @@ const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
}
if (accountName.trim() === "" || secretKey.trim() === "") {
Toast.show({
type: "error",
text1: "Error",
text2: "Please fill in all the fields!",
autoHide: true,
notify("error", {
title: "Error",
description: "Please fill in all the fields!",
});
return;
}
if (secretError) {
Toast.show({
type: "error",
text1: "Invalid Secret Key",
text2: "Please check your secret key and try again.",
autoHide: true,
notify("error", {
title: "Error",
description: "Invalid Secret Key",
});
return;
}

View File

@ -13,19 +13,13 @@
// limitations under the License.
import React from "react";
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 {StyleSheet, Text, View} from "react-native";
import {Button, Portal, TextInput} from "react-native-paper";
import {useNotifications} from "react-native-notificated";
import PropTypes from "prop-types";
import useStore from "./useStorage";
const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
EnterCasdoorSdkConfig.propTypes = {
onClose: PropTypes.func.isRequired,
onWebviewClose: PropTypes.func.isRequired,
};
function EnterCasdoorSdkConfig({onClose, onWebviewClose, usePortal = true}) {
const {
serverUrl,
clientId,
@ -36,9 +30,10 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
setClientId,
setAppName,
setOrganizationName,
setCasdoorConfig,
} = useStore();
const {notify} = useNotifications();
const closeConfigPage = () => {
onClose();
onWebviewClose();
@ -46,44 +41,22 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
const handleSave = () => {
if (!serverUrl || !clientId || !appName || !organizationName || !redirectPath) {
Toast.show({
type: "error",
text1: "Error",
text2: "Please fill in all the fields!",
autoHide: true,
notify("error", {
params: {
title: "Error",
description: "Please fill in all the fields!",
},
});
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();
};
return (
<Portal>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<View style={styles.content}>
<View style={styles.header}>
<Text style={styles.title}>Casdoor server</Text>
<IconButton
icon="close"
size={24}
onPress={closeConfigPage}
style={styles.closeButton}
/>
</View>
const content = (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Casdoor Configuration</Text>
<View style={styles.formContainer}>
<TextInput
label="Endpoint"
value={serverUrl}
@ -116,112 +89,85 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
style={styles.input}
mode="outlined"
/>
<View style={styles.buttonRow}>
<Button
mode="contained"
onPress={handleSave}
style={[styles.button, styles.confirmButton]}
labelStyle={styles.buttonLabel}
>
Confirm
</Button>
<Button
mode="contained"
onPress={handleScanToLogin}
style={[styles.button, styles.scanButton]}
labelStyle={styles.buttonLabel}
>
Scan to Login
</Button>
</View>
</View>
<View style={styles.buttonContainer}>
<Button
mode="outlined"
onPress={handleUseDefault}
style={[styles.button, styles.outlinedButton]}
labelStyle={styles.outlinedButtonLabel}
onPress={closeConfigPage}
style={styles.button}
labelStyle={styles.buttonLabel}
>
Use Casdoor Demo Site
Cancel
</Button>
<Button
mode="contained"
onPress={handleSave}
style={styles.button}
labelStyle={styles.buttonLabel}
>
Confirm
</Button>
</View>
</ScrollView>
</Portal>
</View>
</View>
);
return usePortal ? <Portal>{content}</Portal> : content;
}
EnterCasdoorSdkConfig.propTypes = {
onClose: PropTypes.func.isRequired,
onWebviewClose: PropTypes.func.isRequired,
usePortal: PropTypes.bool,
};
const styles = {
scrollContainer: {
flexGrow: 1,
width: "100%",
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(255, 255, 255, 0.5)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
content: {
width: "95%",
borderRadius: 10,
padding: 20,
backgroundColor: "#F5F5F5",
width: "90%",
maxWidth: 400,
borderRadius: 28,
padding: 24,
backgroundColor: "#FFFFFF",
shadowColor: "#000",
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.1,
shadowRadius: 10,
shadowRadius: 8,
elevation: 5,
},
input: {
marginVertical: 10,
fontSize: 16,
backgroundColor: "white",
title: {
fontSize: 20,
fontWeight: "bold",
fontFamily: "Lato_700Bold",
color: "#212121",
textAlign: "center",
marginBottom: 16,
},
buttonRow: {
buttonContainer: {
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,
marginHorizontal: 8,
borderRadius: 100,
},
buttonLabel: {
paddingVertical: 4,
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,
formContainer: {
marginBottom: 8,
},
};
input: {
marginBottom: 16,
},
});
export default EnterCasdoorSdkConfig;

159
Header.js
View File

@ -15,18 +15,22 @@
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 {useNotifications} from "react-native-notificated";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
import useStore from "./useStorage";
import {useAccountSync} from "./useAccountStore";
import LoginMethodSelector from "./LoginMethodSelector";
const {width} = Dimensions.get("window");
const Header = () => {
const {userInfo, clearAll} = useStore();
const {syncError, clearSyncError} = useAccountSync();
const {isSyncing, syncError, clearSyncError} = useAccountSync();
const [showLoginPage, setShowLoginPage] = React.useState(false);
const [menuVisible, setMenuVisible] = React.useState(false);
const [loginMethod, setLoginMethod] = React.useState(null);
const {notify} = useNotifications();
const openMenu = () => setMenuVisible(true);
const closeMenu = () => setMenuVisible(false);
@ -36,8 +40,20 @@ const Header = () => {
closeMenu();
};
const handleCasdoorLogin = () => setShowLoginPage(true);
const handleHideLoginPage = () => setShowLoginPage(false);
const {openActionSheet} = LoginMethodSelector({
onSelectMethod: (method) => {
setLoginMethod(method);
setShowLoginPage(true);
},
});
const handleCasdoorLogin = () => {
openActionSheet();
};
const handleHideLoginPage = () => {
setShowLoginPage(false);
};
const handleCasdoorLogout = () => {
CasdoorLogout();
@ -46,32 +62,40 @@ const Header = () => {
};
const handleSyncErrorPress = () => {
Toast.show({
type: "error",
text1: "Sync Error",
text2: syncError || "An unknown error occurred during synchronization.",
autoHide: true,
notify("error", {
params: {
title: "Error",
description: syncError || "An unknown error occurred during synchronization.",
},
});
};
return (
<Appbar.Header mode="center-aligned">
<View style={styles.leftContainer}>
{true && syncError && (
<Appbar.Action
icon="sync-alert"
color="#E53935"
size={24}
onPress={handleSyncErrorPress}
/>
)}
</View>
<Appbar.Header mode="small" style={styles.header}>
<Appbar.Content
title="Casdoor"
titleStyle={styles.titleText}
style={styles.titleContainer}
title={
<View style={styles.titleContainer}>
<Text style={styles.titleTextCasdoor}>Casdoor</Text>
</View>
}
style={styles.titleWrapper}
/>
<View style={styles.rightContainer}>
{userInfo !== null && (
<Icon
name={
isSyncing
? "cloud-sync-outline"
: syncError
? "cloud-off-outline"
: "cloud-check-outline"
}
size={22}
color={isSyncing ? "#FFC107" : syncError ? "#E53935" : "#4CAF50"}
style={styles.syncIcon}
onPress={(isSyncing || syncError === null) ? null : handleSyncErrorPress}
/>
)}
<Menu
visible={menuVisible}
onDismiss={closeMenu}
@ -84,21 +108,19 @@ const Header = () => {
style={styles.buttonContainer}
>
<View style={styles.buttonContent}>
<Text
style={[
styles.buttonText,
userInfo !== null && {marginRight: 8},
]}
>
{userInfo === null ? "Login" : userInfo.name}
</Text>
{userInfo !== null && (
<Avatar.Image
size={24}
size={28}
source={{uri: userInfo.avatar}}
style={styles.avatar}
/>
)}
<Text style={[
styles.buttonText,
userInfo === null && {marginLeft: 0},
]}>
{userInfo === null ? "Login" : userInfo.name}
</Text>
</View>
</TouchableRipple>
}
@ -106,69 +128,74 @@ const Header = () => {
<Menu.Item onPress={handleMenuLogoutClicked} title="Logout" />
</Menu>
</View>
{showLoginPage && <CasdoorLoginPage onWebviewClose={handleHideLoginPage} />}
{showLoginPage && (
<CasdoorLoginPage
onWebviewClose={handleHideLoginPage}
initialMethod={loginMethod}
/>
)}
</Appbar.Header>
);
};
const styles = StyleSheet.create({
leftContainer: {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
justifyContent: "center",
paddingLeft: width * 0.03,
header: {
backgroundColor: "#F2F2F2",
height: 56,
},
rightContainer: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
justifyContent: "center",
paddingRight: width * 0.03,
flexDirection: "row",
alignItems: "center",
paddingRight: width * 0.04,
},
titleWrapper: {
alignItems: "flex-start",
},
titleContainer: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
alignItems: "baseline",
},
titleText: {
fontSize: Math.max(20, width * 0.045),
titleTextCasdoor: {
fontSize: Math.max(24, width * 0.05),
fontWeight: "bold",
textAlign: "center",
color: "#212121",
fontFamily: "Lato_700Bold",
},
buttonContainer: {
borderRadius: 20,
borderRadius: 24,
overflow: "hidden",
borderWidth: 0.5,
borderColor: "#DDDDDD",
},
buttonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 8,
paddingHorizontal: 16,
paddingVertical: 6,
paddingHorizontal: 14,
},
buttonText: {
fontSize: Math.max(14, width * 0.035),
fontWeight: "bold",
fontSize: Math.max(14, width * 0.042),
fontWeight: "600",
marginLeft: 8,
color: "#424242",
fontFamily: "Roboto_500Medium",
},
menuContent: {
backgroundColor: "#FFFFFF",
backgroundColor: "#FAFAFA",
borderRadius: 8,
elevation: 3,
elevation: 2,
shadowColor: "#000000",
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.2,
shadowRadius: 3,
shadowOffset: {width: 0, height: 1},
shadowOpacity: 0.1,
shadowRadius: 2,
},
avatar: {
backgroundColor: "transparent",
},
syncIcon: {
marginRight: 12,
},
});
export default Header;

View File

@ -19,13 +19,14 @@ import {GestureHandlerRootView, Swipeable} from "react-native-gesture-handler";
import {CountdownCircleTimer} from "react-native-countdown-circle-timer";
import {useNetInfo} from "@react-native-community/netinfo";
import {FlashList} from "@shopify/flash-list";
import Toast from "react-native-toast-message";
import {useNotifications} from "react-native-notificated";
import SearchBar from "./SearchBar";
import EnterAccountDetails from "./EnterAccountDetails";
import ScanQRCode from "./ScanQRCode";
import EditAccountDetails from "./EditAccountDetails";
import AvatarWithFallback from "./AvatarWithFallback";
import {useImportManager} from "./ImportManager";
import useStore from "./useStorage";
import {calculateCountdown} from "./totpUtil";
import {generateToken, validateSecret} from "./totpUtil";
@ -50,12 +51,22 @@ export default function HomePage() {
const {isConnected} = useNetInfo();
const [canSync, setCanSync] = useState(false);
const [key, setKey] = useState(0);
const swipeableRef = useRef(null);
const {userInfo, serverUrl, token} = useStore();
const {startSync} = useAccountSync();
const {accounts, refreshAccounts} = useAccountStore();
const {setAccount, updateAccount, insertAccount, insertAccounts, deleteAccount} = useEditAccount();
const {notify} = useNotifications();
const {showImportOptions} = useImportManager((data) => {
handleAddAccount(data);
}, (err) => {
notify("error", {
params: {title: "Import error", description: err.message},
});
}, () => {
setShowScanner(true);
});
useEffect(() => {
refreshAccounts();
@ -79,25 +90,25 @@ export default function HomePage() {
}
}, REFRESH_INTERVAL);
return () => clearInterval(timer);
}, [startSync, canSync]);
}, [startSync, canSync, token]);
const onRefresh = async() => {
setRefreshing(true);
if (canSync) {
const syncError = await startSync(userInfo, serverUrl, token);
if (syncError) {
Toast.show({
type: "error",
text1: "Sync error",
text2: syncError,
autoHide: true,
notify("error", {
params: {
title: "Sync error",
description: syncError,
},
});
} else {
Toast.show({
type: "success",
text1: "Sync success",
text2: "All your accounts are up to date.",
autoHide: true,
notify("success", {
params: {
title: "Sync success",
description: "All your accounts are up to date.",
},
});
}
}
@ -107,7 +118,7 @@ export default function HomePage() {
const handleAddAccount = async(accountDataInput) => {
if (Array.isArray(accountDataInput)) {
insertAccounts(accountDataInput);
await insertAccounts(accountDataInput);
} else {
await setAccount(accountDataInput);
await insertAccount();
@ -151,11 +162,11 @@ export default function HomePage() {
const handleScanError = (error) => {
setShowScanner(false);
Toast.show({
type: "error",
text1: "Scan error",
text2: error,
autoHide: true,
notify("error", {
params: {
title: "Error scanning QR code",
description: error,
},
});
};
@ -175,6 +186,11 @@ export default function HomePage() {
closeOptions();
};
const openImportAccountModal = () => {
showImportOptions();
closeOptions();
};
const closeEnterAccountModal = () => setShowEnterAccountModal(false);
const closeSwipeableMenu = () => {
@ -226,7 +242,8 @@ export default function HomePage() {
<List.Item
style={{
height: 80,
paddingHorizontal: 25,
paddingVertical: 6,
paddingHorizontal: 16,
justifyContent: "center",
}}
title={
@ -290,11 +307,11 @@ export default function HomePage() {
padding: 20,
borderRadius: 10,
width: 300,
height: 150,
height: 225,
position: "absolute",
top: "50%",
left: "50%",
transform: [{translateX: -150}, {translateY: -75}],
transform: [{translateX: -150}, {translateY: -112.5}],
}}
>
<TouchableOpacity
@ -311,6 +328,13 @@ export default function HomePage() {
<IconButton icon={"keyboard"} size={35} />
<Text style={{fontSize: 18}}>Enter Secret code</Text>
</TouchableOpacity>
<TouchableOpacity
style={{flexDirection: "row", alignItems: "center", marginTop: 10}}
onPress={openImportAccountModal}
>
<IconButton icon={"import"} size={35} />
<Text style={{fontSize: 18}}>Import from other app</Text>
</TouchableOpacity>
</Modal>
</Portal>

56
ImportManager.js Normal file
View File

@ -0,0 +1,56 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// 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 {useActionSheet} from "@expo/react-native-action-sheet";
import {importFromMSAuth} from "./MSAuthImportLogic";
const importApps = [
{name: "Google Authenticator", useScanner: true},
{name: "Microsoft Authenticator", importFunction: importFromMSAuth},
];
export const useImportManager = (onImportComplete, onError, onOpenScanner) => {
const {showActionSheetWithOptions} = useActionSheet();
const showImportOptions = () => {
const options = [...importApps.map(app => app.name), "Cancel"];
const cancelButtonIndex = options.length - 1;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
title: "Select app to import from",
},
(selectedIndex) => {
if (selectedIndex !== cancelButtonIndex) {
const selectedApp = importApps[selectedIndex];
if (selectedApp.useScanner) {
onOpenScanner();
} else if (selectedApp.importFunction) {
selectedApp.importFunction()
.then(result => {
if (result) {onImportComplete(result);}
})
.catch(onError);
} else {
onError(new Error(`Import function not implemented for ${selectedApp.name}`));
}
}
}
);
};
return {showImportOptions};
};

62
LoginMethodSelector.js Normal file
View File

@ -0,0 +1,62 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// 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 {useActionSheet} from "@expo/react-native-action-sheet";
const LoginMethodSelector = ({onSelectMethod}) => {
const {showActionSheetWithOptions} = useActionSheet();
const openActionSheet = () => {
const options = [
"Manual Server Setup",
"Login Using QR Code",
"Try Casdoor Demo Site",
"Cancel",
];
const cancelButtonIndex = 3;
showActionSheetWithOptions(
{
title: "Select Login Method",
cancelButtonTintColor: "red",
options,
cancelButtonIndex,
},
(buttonIndex) => {
handleSelection(buttonIndex);
}
);
};
const handleSelection = (buttonIndex) => {
switch (buttonIndex) {
case 0:
onSelectMethod("manual");
break;
case 1:
onSelectMethod("scan");
break;
case 2:
onSelectMethod("demo");
break;
default:
// Cancel was pressed
break;
}
};
return {openActionSheet};
};
export default LoginMethodSelector;

84
MSAuthImportLogic.js Normal file
View File

@ -0,0 +1,84 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// 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 * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import {openDatabaseSync} from "expo-sqlite/next";
const SQLITE_DIR = `${FileSystem.documentDirectory}SQLite`;
const getRandomDBName = () => {
const randomString = Math.random().toString(36).substring(2, 15);
const timestamp = Date.now();
return `${randomString}_${timestamp}.db`;
};
const createDirectory = async(dir) => {
try {
if (!(await FileSystem.getInfoAsync(dir)).exists) {
await FileSystem.makeDirectoryAsync(dir, {intermediates: true});
}
} catch (error) {
throw new Error(`Error creating directory: ${error.message}`);
}
};
const queryMicrosoftAuthenticatorDatabase = async(db) => {
return await db.getAllAsync("SELECT name, username, oath_secret_key FROM accounts WHERE account_type = 0");
};
const formatMicrosoftAuthenticatorData = (rows) => {
return rows.map(row => {
const {name, username, oath_secret_key} = row;
return {issuer: name, accountName: username, secretKey: oath_secret_key};
});
};
export const importFromMSAuth = async() => {
const dbName = getRandomDBName();
const internalDbName = `${SQLITE_DIR}/${dbName}`;
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: false,
copyToCacheDirectory: false,
});
if (!result.canceled) {
const file = result.assets[0];
if ((await FileSystem.getInfoAsync(file.uri)).exists) {
await createDirectory(SQLITE_DIR);
await FileSystem.copyAsync({from: file.uri, to: internalDbName});
try {
const db = openDatabaseSync(dbName);
const rows = await queryMicrosoftAuthenticatorDatabase(db);
if (rows.length === 0) {
throw new Error("No data found in Microsoft Authenticator database");
}
return formatMicrosoftAuthenticatorData(rows);
} catch (dbError) {
if (dbError.message.includes("file is not a database")) {
throw new Error("file is not a database");
}
throw new Error(dbError.message);
}
}
}
} catch (error) {
throw new Error(`Error importing from Microsoft Authenticator: ${error.message}`);
} finally {
await FileSystem.deleteAsync(internalDbName, {idempotent: true});
}
};

94
QRScanner.js Normal file
View File

@ -0,0 +1,94 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// 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, {useEffect, useState} from "react";
import {Text, View} from "react-native";
import {Button, IconButton, Portal} from "react-native-paper";
import {Camera, CameraView, scanFromURLAsync} from "expo-camera";
import * as ImagePicker from "expo-image-picker";
import PropTypes from "prop-types";
const QRScanner = ({onScan, onClose}) => {
const [hasPermission, setHasPermission] = useState(null);
useEffect(() => {
const getPermissions = async() => {
const {status: cameraStatus} = await Camera.requestCameraPermissionsAsync();
setHasPermission(cameraStatus === "granted");
};
getPermissions();
}, []);
const handleBarCodeScanned = ({type, data}) => {
onScan(type, data);
};
const pickImage = async() => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 1,
});
if (!result.canceled && result.assets[0]) {
const scannedData = await scanFromURLAsync(result.assets[0].uri, ["qr", "pdf417"]);
if (scannedData[0]) {
handleBarCodeScanned({type: scannedData[0].type, data: scannedData[0].data});
}
}
};
if (hasPermission === null) {
return <Text style={{margin: "20%"}}>Requesting permissions...</Text>;
}
if (hasPermission === false) {
return <Text style={{margin: "20%"}}>No access to camera or media library</Text>;
}
return (
<View style={{marginTop: "50%", flex: 1}}>
<Portal>
<CameraView
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr", "pdf417"],
}}
style={{flex: 1}}
/>
<IconButton
icon="close"
size={40}
onPress={onClose}
style={{position: "absolute", top: 30, right: 5}}
/>
<Button
icon="image"
mode="contained"
onPress={pickImage}
style={{position: "absolute", bottom: 20, alignSelf: "center"}}
>
Choose Image
</Button>
</Portal>
</View>
);
};
QRScanner.propTypes = {
onScan: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default QRScanner;

58
ScanLogin.js Normal file
View File

@ -0,0 +1,58 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// 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 from "react";
import PropTypes from "prop-types";
import QRScanner from "./QRScanner";
const ScanQRCodeForLogin = ({onClose, showScanner, onLogin}) => {
const handleScan = (type, data) => {
if (isValidLoginQR(data)) {
const loginInfo = parseLoginQR(data);
onLogin(loginInfo);
onClose();
}
};
const isValidLoginQR = (data) => {
return data.startsWith("casdoor-app://login?");
};
const parseLoginQR = (data) => {
const url = new URL(data);
const params = new URLSearchParams(url.search);
return {
// clientId: params.get("clientId"),
// appName: params.get("appName"),
// organizationName: params.get("organizationName"),
serverUrl: params.get("serverUrl"),
accessToken: params.get("accessToken"),
};
};
if (!showScanner) {
return null;
}
return <QRScanner onScan={handleScan} onClose={onClose} />;
};
ScanQRCodeForLogin.propTypes = {
onClose: PropTypes.func.isRequired,
onLogin: PropTypes.func.isRequired,
showScanner: PropTypes.bool.isRequired,
};
export default ScanQRCodeForLogin;

View File

@ -12,37 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import React, {useEffect, useState} from "react";
import {Text, View} from "react-native";
import {Button, IconButton, Portal} from "react-native-paper";
import {Camera, CameraView, scanFromURLAsync} from "expo-camera";
import * as ImagePicker from "expo-image-picker";
import React from "react";
import PropTypes from "prop-types";
import QRScanner from "./QRScanner";
import useProtobufDecoder from "./useProtobufDecoder";
const ScanQRCode = ({onClose, showScanner, onAdd}) => {
ScanQRCode.propTypes = {
onClose: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
showScanner: PropTypes.bool.isRequired,
};
const [hasPermission, setHasPermission] = useState(null);
const decoder = useProtobufDecoder(require("./google/google_auth.proto"));
useEffect(() => {
const getPermissions = async() => {
const {status: cameraStatus} = await Camera.requestCameraPermissionsAsync();
setHasPermission(cameraStatus === "granted");
// const {status: mediaLibraryStatus} = await ImagePicker.requestMediaLibraryPermissionsAsync();
// setHasMediaLibraryPermission(mediaLibraryStatus === "granted");
};
getPermissions();
}, []);
const handleBarCodeScanned = ({type, data}) => {
// console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
const handleScan = (type, data) => {
const supportedProtocols = ["otpauth", "otpauth-migration"];
const protocolMatch = data.match(new RegExp(`^(${supportedProtocols.join("|")}):`));
if (protocolMatch) {
@ -76,55 +54,17 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
onAdd(accounts.map(({accountName, issuer, totpSecret}) => ({accountName, issuer, secretKey: totpSecret})));
};
const pickImage = async() => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 1,
});
if (!result.canceled && result.assets[0]) {
const scannedData = await scanFromURLAsync(result.assets[0].uri, ["qr", "pdf417"]);
if (scannedData[0]) {
handleBarCodeScanned({type: scannedData[0].type, data: scannedData[0].data});
}
}
};
if (hasPermission === null) {
return <Text style={{margin: "20%"}}>Requesting permissions...</Text>;
if (!showScanner) {
return null;
}
if (hasPermission === false) {
return <Text style={{margin: "20%"}}>No access to camera or media library</Text>;
}
return <QRScanner onScan={handleScan} onClose={onClose} />;
};
return (
<View style={{marginTop: "50%", flex: 1}}>
<Portal>
<CameraView
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr", "pdf417"],
}}
style={{flex: 1}}
/>
<IconButton
icon="close"
size={40}
onPress={onClose}
style={{position: "absolute", top: 30, right: 5}}
/>
<Button
icon="image"
mode="contained"
onPress={pickImage}
style={{position: "absolute", bottom: 20, alignSelf: "center"}}
>
Choose Image
</Button>
</Portal>
</View>
);
ScanQRCode.propTypes = {
onClose: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
showScanner: PropTypes.bool.isRequired,
};
export default ScanQRCode;

View File

@ -13,25 +13,47 @@
// limitations under the License.
import * as React from "react";
import {View} from "react-native";
import {Searchbar} from "react-native-paper";
const SearchBar = ({onSearch}) => {
const [searchQuery, setSearchQuery] = React.useState("");
const onChangeSearch = query => {
const onChangeSearch = (query) => {
setSearchQuery(query);
onSearch(query);
};
return (
<Searchbar
placeholder="Search"
onChangeText={onChangeSearch}
value={searchQuery}
style={{height: 48, backgroundColor: "#E6DFF3"}}
inputStyle={{textAlignVertical: "center", justifyContent: "center", alignItems: "center", minHeight: 0}}
/>
<View style={styles.container}>
<Searchbar
placeholder="Search"
onChangeText={onChangeSearch}
value={searchQuery}
style={styles.searchbar}
inputStyle={styles.inputStyle}
/>
</View>
);
};
const styles = {
container: {
alignItems: "center",
paddingTop: 2,
},
searchbar: {
height: 56,
backgroundColor: "#E6DFF3",
borderRadius: 99,
width: "95%",
},
inputStyle: {
minHeight: 0,
textAlignVertical: "center",
justifyContent: "center",
alignItems: "center",
},
};
export default SearchBar;

View File

@ -12,64 +12,88 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from "react";
import {StyleSheet, View, useWindowDimensions} from "react-native";
import React, {useState} from "react";
import {Dimensions, StyleSheet, View} from "react-native";
import {Button, Surface, Text} from "react-native-paper";
import {ActionSheetProvider} from "@expo/react-native-action-sheet";
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
import LoginMethodSelector from "./LoginMethodSelector";
import useStore from "./useStorage";
const SettingPage = () => {
const [showLoginPage, setShowLoginPage] = React.useState(false);
const {userInfo, clearAll} = useStore();
const {width} = useWindowDimensions();
const {width} = Dimensions.get("window");
const handleCasdoorLogin = () => setShowLoginPage(true);
const handleHideLoginPage = () => setShowLoginPage(false);
const SettingPage = () => {
const [showLoginPage, setShowLoginPage] = useState(false);
const [loginMethod, setLoginMethod] = useState(null);
const {userInfo, clearAll} = useStore();
const {openActionSheet} = LoginMethodSelector({
onSelectMethod: (method) => {
setLoginMethod(method);
setShowLoginPage(true);
},
});
const handleCasdoorLogin = () => {
openActionSheet();
};
const handleHideLoginPage = () => {
setShowLoginPage(false);
setLoginMethod(null);
};
const handleCasdoorLogout = () => {
CasdoorLogout();
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 (
<View style={styles.container}>
<Surface style={styles.surface} elevation={4}>
<Text style={styles.title}>Account Settings</Text>
<Button
style={styles.button}
icon={userInfo === null ? "login" : "logout"}
mode="contained"
onPress={userInfo === null ? handleCasdoorLogin : handleCasdoorLogout}
>
{userInfo === null ? "Login with Casdoor" : "Logout"}
</Button>
</Surface>
{showLoginPage && <CasdoorLoginPage onWebviewClose={handleHideLoginPage} />}
</View>
<ActionSheetProvider>
<View style={styles.container}>
<Surface style={styles.surface} elevation={4}>
<Text style={styles.title}>Account Settings</Text>
<Button
style={styles.button}
icon={userInfo === null ? "login" : "logout"}
mode="contained"
onPress={userInfo === null ? handleCasdoorLogin : handleCasdoorLogout}
>
{userInfo === null ? "Login with Casdoor" : "Logout"}
</Button>
</Surface>
{showLoginPage && (
<CasdoorLoginPage
onWebviewClose={handleHideLoginPage}
initialMethod={loginMethod}
/>
)}
</View>
</ActionSheetProvider>
);
};
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%",
},
});
export default SettingPage;

18
api.js
View File

@ -32,7 +32,16 @@ export const getMfaAccounts = async(serverUrl, owner, name, token, timeoutMs = T
]);
const res = await result.json();
return {updatedTime: res.data.updatedTime, mfaAccounts: res.data.mfaAccounts};
// Check the response status and message
if (res.status === "error") {
throw new Error(res.msg);
}
return {
updatedTime: res.data.updatedTime,
mfaAccounts: res.data.mfaAccounts || [],
};
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Request timed out");
@ -58,6 +67,7 @@ export const updateMfaAccounts = async(serverUrl, owner, name, newMfaAccounts, t
]);
const userData = await getUserResult.json();
userData.data.mfaAccounts = newMfaAccounts;
const updateResult = await Promise.race([
@ -74,6 +84,12 @@ export const updateMfaAccounts = async(serverUrl, owner, name, newMfaAccounts, t
]);
const res = await updateResult.json();
// Check the response status and message
if (res.status === "error") {
throw new Error(res.msg);
}
return {status: res.status, data: res.data};
} catch (error) {
if (error.name === "AbortError") {

View File

@ -2,11 +2,12 @@
"expo": {
"name": "Casdoor",
"slug": "casdoor-app",
"version": "1.5.0",
"version": "1.11.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"sdkVersion": "51.0.0",
"scheme": "casdoor-app",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
@ -18,7 +19,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "org.casdoor.casdoorapp",
"buildNumber": "1.5.0"
"buildNumber": "1.11.0"
},
"android": {
"adaptiveIcon": {
@ -26,7 +27,7 @@
"backgroundColor": "#ffffff"
},
"package": "org.casdoor.casdoorapp",
"versionCode": 510010500
"versionCode": 510011100
},
"web": {
"favicon": "./assets/favicon.png"
@ -49,7 +50,8 @@
"photosPermission": "The app accesses your photos to add Totp account."
}
],
"expo-asset"
"expo-asset",
"expo-font"
],
"owner": "casdoor"
}

View File

@ -2,6 +2,14 @@ module.exports = function(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [["inline-import", {"extensions": [".sql"]}]],
plugins: [
[
"inline-import",
{
"extensions": [".sql"],
},
],
"react-native-reanimated/plugin",
],
};
};

8695
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,9 @@
"release": "npx -p semantic-release-expo -p semantic-release -p @semantic-release/git -p @semantic-release/changelog -p @semantic-release/exec semantic-release"
},
"dependencies": {
"@expo-google-fonts/lato": "^0.2.3",
"@expo-google-fonts/roboto": "^0.2.3",
"@expo/react-native-action-sheet": "^4.1.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/masked-view": "^0.1.11",
"@react-native-community/netinfo": "11.3.1",
@ -20,18 +23,20 @@
"casdoor-react-native-sdk": "1.1.0",
"drizzle-orm": "^0.33.0",
"eslint-plugin-import": "^2.28.1",
"expo": "~51.0.31",
"expo": "~51.0.38",
"expo-asset": "~10.0.10",
"expo-camera": "~15.0.15",
"expo-camera": "~15.0.16",
"expo-crypto": "~13.0.2",
"expo-dev-client": "~4.0.25",
"expo-dev-client": "~4.0.28",
"expo-document-picker": "~12.0.2",
"expo-drizzle-studio-plugin": "^0.0.2",
"expo-image": "~1.12.15",
"expo-font": "~12.0.10",
"expo-image": "~1.13.0",
"expo-image-picker": "~15.0.7",
"expo-sqlite": "^14.0.6",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.24",
"expo-updates": "~0.25.27",
"hi-base32": "^0.5.1",
"hotp-totp": "^1.0.6",
"prop-types": "^15.8.1",
@ -42,12 +47,12 @@
"react-native": "0.74.5",
"react-native-countdown-circle-timer": "^3.2.1",
"react-native-gesture-handler": "~2.16.1",
"react-native-notificated": "^0.1.6",
"react-native-paper": "^5.10.3",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-toast-message": "^2.2.0",
"react-native-web": "~0.19.6",
"react-native-webview": "13.8.6",
"totp-generator": "^0.0.14",

View File

@ -16,6 +16,12 @@ import {eq} from "drizzle-orm";
import * as schema from "./db/schema";
import * as api from "./api";
import {generateToken} from "./totpUtil";
import useStore from "./useStorage";
function handleTokenExpiration() {
const {clearAll} = useStore.getState();
clearAll();
}
function getLocalAccounts(db) {
return db.select().from(schema.accounts).all();
@ -134,48 +140,56 @@ function mergeAccounts(localAccounts, serverAccounts, serverTimestamp) {
}
export async function syncWithCloud(db, userInfo, serverUrl, token) {
// db.delete(schema.accounts).run();
const localAccounts = await getLocalAccounts(db);
try {
const localAccounts = await getLocalAccounts(db);
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
token
);
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
token
);
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, updatedTime);
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, updatedTime);
await updateLocalDatabase(db, mergedAccounts);
await updateLocalDatabase(db, mergedAccounts);
const accountsToSync = mergedAccounts.filter(account => account.deletedAt === null || account.deletedAt === undefined)
.map(account => ({
const accountsToSync = mergedAccounts.filter(account => account.deletedAt === null || account.deletedAt === undefined)
.map(account => ({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
}));
const serverAccountsStringified = serverAccounts.map(account => JSON.stringify({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
}));
const serverAccountsStringified = serverAccounts.map(account => JSON.stringify({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
}));
const accountsToSyncStringified = accountsToSync.map(account => JSON.stringify(account));
const accountsToSyncStringified = accountsToSync.map(account => JSON.stringify(account));
if (JSON.stringify(accountsToSyncStringified.sort()) !== JSON.stringify(serverAccountsStringified.sort())) {
const {status} = await api.updateMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
accountsToSync,
token
);
if (JSON.stringify(accountsToSyncStringified.sort()) !== JSON.stringify(serverAccountsStringified.sort())) {
const {status} = await api.updateMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
accountsToSync,
token
);
if (status !== "ok") {
throw new Error("Sync failed");
if (status !== "ok") {
throw new Error("Sync failed");
}
}
}
await db.update(schema.accounts).set({syncAt: new Date()}).run();
await db.update(schema.accounts).set({syncAt: new Date()}).run();
} catch (error) {
if (error.message.includes("Access token has expired")) {
handleTokenExpiration();
throw new Error("Access token has expired, please login again.");
}
throw error;
}
}

View File

@ -129,7 +129,7 @@ const useEditAccountStore = create((set, get) => ({
}
},
insertAccounts: (accounts) => {
insertAccounts: async(accounts) => {
try {
db.transaction((tx) => {
const insertWithDuplicateCheck = (baseAccName, issuer, secretKey) => {