Compare commits

...

8 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
16 changed files with 5930 additions and 3709 deletions

62
App.js
View File

@ -12,7 +12,9 @@
// 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";
@ -20,6 +22,7 @@ import ContentLoader, {Circle, Rect} from "react-content-loader/native";
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";
@ -28,6 +31,11 @@ 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",
@ -52,37 +60,41 @@ 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 (
<GestureHandlerRootView style={{flex: 1}}>
<NotificationsProvider>
<NavigationContainer>
<PaperProvider>
<Header />
<NavigationBar />
</PaperProvider>
</NavigationContainer>
<ActionSheetProvider>
<NavigationContainer>
<PaperProvider>
<Header />
<NavigationBar />
</PaperProvider>
</NavigationContainer>
</ActionSheetProvider>
</NotificationsProvider>
</GestureHandlerRootView>
);

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

@ -20,19 +20,21 @@ import {useNotifications} from "react-native-notificated";
import SDK from "casdoor-react-native-sdk";
import PropTypes from "prop-types";
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,
@ -42,38 +44,55 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
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"),
};
useEffect(() => {
if (token) {
onWebviewClose();
}
}, [token]);
actions[method]?.();
};
const onNavigationStateChange = async(navState) => {
const {redirectPath} = getCasdoorConfig();
if (navState.url.startsWith(redirectPath)) {
onWebviewClose();
const token = await sdk.getAccessToken(navState.url);
@ -83,57 +102,71 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
}
};
const handleErrorResponse = (error) => {
notify("error", {
params: {
text1: "Error",
text2: error.description,
},
});
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 !== "" && !token && (
<>
<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,
},
@ -146,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

@ -12,22 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import React, {useState} from "react";
import {ScrollView, Text, View} from "react-native";
import {Button, IconButton, Portal, TextInput} from "react-native-paper";
import React from "react";
import {StyleSheet, Text, View} from "react-native";
import {Button, Portal, TextInput} from "react-native-paper";
import {useNotifications} from "react-native-notificated";
import SDK from "casdoor-react-native-sdk";
import DefaultCasdoorSdkConfig from "./DefaultCasdoorSdkConfig";
import PropTypes from "prop-types";
import ScanQRCodeForLogin from "./ScanLogin";
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,
@ -38,16 +30,10 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
setClientId,
setAppName,
setOrganizationName,
setCasdoorConfig,
getCasdoorConfig,
setToken,
setUserInfo,
} = useStore();
const {notify} = useNotifications();
const [showScanner, setShowScanner] = useState(false);
const closeConfigPage = () => {
onClose();
onWebviewClose();
@ -66,62 +52,11 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
onClose();
};
const handleScanToLogin = () => {
setShowScanner(true);
};
const handleLogin = (loginInfo) => {
setServerUrl(loginInfo.serverUrl);
setClientId("");
setAppName("");
setOrganizationName("");
const sdk = new SDK(getCasdoorConfig());
try {
const accessToken = loginInfo.accessToken;
const userInfo = sdk.JwtDecode(accessToken);
setToken(accessToken);
setUserInfo(userInfo);
notify("success", {
params: {
title: "Success",
description: "Logged in successfully!",
},
});
setShowScanner(false);
onClose();
onWebviewClose();
} catch (error) {
notify("error", {
params: {
title: "Error in login",
description: error,
},
});
}
};
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}
@ -154,119 +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}
>
Try with Casdoor Demo Site
Cancel
</Button>
<Button
mode="contained"
onPress={handleSave}
style={styles.button}
labelStyle={styles.buttonLabel}
>
Confirm
</Button>
</View>
</ScrollView>
{showScanner && (
<ScanQRCodeForLogin
showScanner={showScanner}
onClose={() => setShowScanner(false)}
onLogin={handleLogin}
/>
)}
</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;

View File

@ -20,6 +20,7 @@ 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");
@ -28,6 +29,7 @@ const Header = () => {
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);
@ -38,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();
@ -114,7 +128,12 @@ const Header = () => {
<Menu.Item onPress={handleMenuLogoutClicked} title="Logout" />
</Menu>
</View>
{showLoginPage && <CasdoorLoginPage onWebviewClose={handleHideLoginPage} />}
{showLoginPage && (
<CasdoorLoginPage
onWebviewClose={handleHideLoginPage}
initialMethod={loginMethod}
/>
)}
</Appbar.Header>
);
};
@ -140,7 +159,7 @@ const styles = StyleSheet.create({
fontSize: Math.max(24, width * 0.05),
fontWeight: "bold",
color: "#212121",
fontFamily: "Lato-Bold",
fontFamily: "Lato_700Bold",
},
buttonContainer: {
borderRadius: 24,
@ -160,7 +179,7 @@ const styles = StyleSheet.create({
fontWeight: "600",
marginLeft: 8,
color: "#424242",
fontFamily: "Roboto-Medium",
fontFamily: "Roboto_500Medium",
},
menuContent: {
backgroundColor: "#FAFAFA",

View File

@ -26,6 +26,7 @@ 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";
@ -57,6 +58,16 @@ export default function HomePage() {
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();
}, []);
@ -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();
@ -175,6 +186,11 @@ export default function HomePage() {
closeOptions();
};
const openImportAccountModal = () => {
showImportOptions();
closeOptions();
};
const closeEnterAccountModal = () => setShowEnterAccountModal(false);
const closeSwipeableMenu = () => {
@ -291,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
@ -312,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});
}
};

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;

5
api.js
View File

@ -38,7 +38,10 @@ export const getMfaAccounts = async(serverUrl, owner, name, token, timeoutMs = T
throw new Error(res.msg);
}
return {updatedTime: res.data.updatedTime, mfaAccounts: res.data.mfaAccounts};
return {
updatedTime: res.data.updatedTime,
mfaAccounts: res.data.mfaAccounts || [],
};
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Request timed out");

View File

@ -2,7 +2,7 @@
"expo": {
"name": "Casdoor",
"slug": "casdoor-app",
"version": "1.7.0",
"version": "1.11.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
@ -19,7 +19,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "org.casdoor.casdoorapp",
"buildNumber": "1.7.0"
"buildNumber": "1.11.0"
},
"android": {
"adaptiveIcon": {
@ -27,7 +27,7 @@
"backgroundColor": "#ffffff"
},
"package": "org.casdoor.casdoorapp",
"versionCode": 510010700
"versionCode": 510011100
},
"web": {
"favicon": "./assets/favicon.png"
@ -50,7 +50,8 @@
"photosPermission": "The app accesses your photos to add Totp account."
}
],
"expo-asset"
"expo-asset",
"expo-font"
],
"owner": "casdoor"
}

8655
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.26",
"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",

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) => {