Compare commits

...

18 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
semantic-release-bot 368c9672b1 chore(release): 1.5.0 [skip ci] 2024-08-31 13:48:29 +00:00
IZUMI-Zu 8a65256f15
feat: support `Google Authenticator` migration (#23) 2024-08-31 21:32:44 +08:00
semantic-release-bot e660fbfc4b chore(release): 1.4.0 [skip ci] 2024-08-22 00:58:41 +00:00
IZUMI-Zu 7f87a3bf34
feat: use "drizzle-orm/expo-sqlite" as DB (#21) 2024-08-22 08:42:53 +08:00
semantic-release-bot e245cfa66d chore(release): 1.3.0 [skip ci] 2024-08-17 15:37:35 +00:00
IZUMI-Zu a46b4a25c8
feat: add secret check (#20) 2024-08-17 23:22:44 +08:00
34 changed files with 9059 additions and 4147 deletions

94
App.js
View File

@ -12,27 +12,91 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from "react";
import {PaperProvider} from "react-native-paper";
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 {BulletList} from "react-content-loader/native";
import {SQLiteProvider} from "expo-sqlite";
import {PaperProvider} from "react-native-paper";
import {SafeAreaView, Text} from "react-native";
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";
import {migrateDb} from "./TotpDatabase";
import {db} from "./db/client";
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 (
<SafeAreaView style={{flex: 1}}>
<Text>Migration error: {error.message}</Text>
</SafeAreaView>
);
}
if (!success || !fontsLoaded) {
return (
<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 (
<React.Suspense fallback={<BulletList />}>
<SQLiteProvider databaseName="totp.db" onInit={migrateDb} options={{enableChangeListener: true}}>
<NavigationContainer>
<PaperProvider>
<Header />
<NavigationBar />
</PaperProvider>
</NavigationContainer>
</SQLiteProvider>
</React.Suspense>
<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={"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

@ -14,22 +14,27 @@
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 {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,
@ -37,28 +42,57 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
redirectPath,
appName,
organizationName,
token,
getCasdoorConfig,
setCasdoorConfig,
setServerUrl,
setClientId,
setAppName,
setOrganizationName,
setUserInfo,
setToken,
} = useStore();
const handleHideConfigPage = () => {
setShowConfigPage(false);
};
const getCasdoorSignInUrl = async() => {
const signinUrl = await sdk.getSigninUrl();
setCasdoorLoginURL(signinUrl);
useEffect(() => {
if (initialMethod === "demo") {
setCasdoorConfig(DefaultCasdoorSdkConfig);
}
}, [initialMethod, setCasdoorConfig]);
const initSdk = () => {
const configs = {
demo: DefaultCasdoorSdkConfig,
scan: getCasdoorConfig(),
manual: serverUrl && clientId && redirectPath && appName && organizationName ? getCasdoorConfig() : null,
};
sdk = configs[initialMethod] ? new SDK(configs[initialMethod]) : null;
};
useEffect(() => {
if (serverUrl && clientId && redirectPath && appName && organizationName) {
sdk = new SDK(getCasdoorConfig());
getCasdoorSignInUrl();
const getCasdoorSignInUrl = async() => {
initSdk();
if (sdk) {
const signinUrl = await sdk.getSigninUrl();
setCasdoorLoginURL(signinUrl);
}
}, [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);
@ -68,26 +102,95 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
}
};
return (
<Portal>
<View style={{flex: 1}}>
{showConfigPage && <EnterCasdoorSdkConfig onClose={handleHideConfigPage} onWebviewClose={onWebviewClose} />}
{!showConfigPage && casdoorLoginURL !== "" && (
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}});
}
};
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}
style={{flex: 1}}
onError={({nativeEvent}) => {
notify("error", {params: {title: "Error", description: nativeEvent.description}});
setCurrentView("config");
}}
style={styles.webview}
mixedContentMode="always"
javaScriptEnabled={true}
/>
)}
</View>
</Portal>
);
};
</SafeAreaView>
),
};
return views[currentView] || null;
};
return <Portal>{renderContent()}</Portal>;
}
const styles = StyleSheet.create({
webview: {
flex: 1,
},
backButton: {
padding: 10,
backgroundColor: "#007AFF",
alignItems: "center",
},
backButtonText: {
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,104 +12,228 @@
// 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 {useNotifications} from "react-native-notificated";
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 {notify} = useNotifications();
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() === "") {
notify("error", {
title: "Error",
description: "Please fill in all the fields!",
});
return;
}
if (secretError) {
notify("error", {
title: "Error",
description: "Invalid Secret Key",
});
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 (
<View style={{flex: 1, justifyContent: "center", alignItems: "center"}}>
<Text style={{fontSize: 24, marginBottom: 5}}>Add new 2FA account</Text>
<View style={{flexDirection: "row", alignItems: "center"}}>
<IconButton icon="account-details" size={35} />
<View style={styles.container}>
<View style={styles.content}>
<View style={styles.header}>
<Text style={styles.title}>Add Account</Text>
<IconButton
icon="close"
size={24}
onPress={onClose}
style={styles.closeButton}
/>
</View>
<TextInput
label="Account Name"
placeholder="Account Name"
value={accountName}
autoCapitalize="none"
onChangeText={(text) => 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"
/>
</View>
<View style={{flexDirection: "row", alignItems: "center"}}>
<IconButton icon="account-key" size={35} />
<TextInput
label="Secret Key"
placeholder="Secret Key"
value={secretKey}
autoCapitalize="none"
onChangeText={(text) => setSecretKey(text)}
secureTextEntry
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
onChangeText={handleSecretKeyChange}
secureTextEntry={!showPassword}
error={!!secretError}
style={styles.input}
mode="outlined"
right={(props) => (
<TextInput.Icon
icon={showPassword ? "eye-off" : "eye"}
onPress={() => setShowPassword(!showPassword)}
/>
)}
/>
</View>
<Button
icon="account-plus"
style={{
backgroundColor: "#E6DFF3",
borderRadius: 5,
margin: 10,
alignItems: "center",
position: "absolute",
top: 230,
right: 30,
width: 90,
}}
onPress={handleAddAccount}
>
<Text style={{fontSize: 18}}>Add</Text>
</Button>
<IconButton icon={"close"} size={30} onPress={onClose} style={{position: "absolute", top: 5, right: 5}} />
<View
style={{
backgroundColor: "#E6DFF3",
borderRadius: 5,
position: "absolute",
left: 30,
top: 240,
width: 140,
}}
>
<Menu
visible={visible}
onDismiss={closeMenu}
anchor={
<Button style={{alignItems: "left"}} icon={"chevron-down"} onPress={openMenu}>
{selectedItem}
</Button>
}
>
<Menu.Item onPress={() => handleMenuItemPress("Time based")} title="Time based" />
<Divider />
<Menu.Item onPress={() => handleMenuItemPress("Counter based")} title="Counter based" />
</Menu>
<View style={styles.buttonContainer}>
<Menu
visible={visible}
onDismiss={closeMenu}
anchor={
<Button
onPress={openMenu}
mode="outlined"
icon="chevron-down"
contentStyle={styles.menuButtonContent}
style={styles.menuButton}
>
{selectedItem}
</Button>
}
contentStyle={styles.menuContent}
>
<Menu.Item onPress={() => handleMenuItemPress("Time based")} title="Time based" />
<Menu.Item onPress={() => handleMenuItemPress("Counter based")} title="Counter based" />
</Menu>
<Button
mode="contained"
onPress={handleAddAccount}
style={styles.addButton}
labelStyle={styles.buttonLabel}
>
Add Account
</Button>
</View>
</View>
</View>
);
}
};
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;

View File

@ -13,17 +13,13 @@
// limitations under the License.
import React from "react";
import {Alert, Text, View} from "react-native";
import {Button, IconButton, Portal, TextInput} from "react-native-paper";
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,
};
function EnterCasdoorSdkConfig({onClose, onWebviewClose, usePortal = true}) {
const {
serverUrl,
clientId,
@ -34,9 +30,10 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
setClientId,
setAppName,
setOrganizationName,
setCasdoorConfig,
} = useStore();
const {notify} = useNotifications();
const closeConfigPage = () => {
onClose();
onWebviewClose();
@ -44,128 +41,133 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
const handleSave = () => {
if (!serverUrl || !clientId || !appName || !organizationName || !redirectPath) {
Alert.alert("Please fill in all the fields!");
notify("error", {
params: {
title: "Error",
description: "Please fill in all the fields!",
},
});
return;
}
onClose();
};
const handleUseDefault = () => {
setCasdoorConfig(DefaultCasdoorSdkConfig);
onClose();
};
return (
<Portal>
<View style={{flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "white"}}>
<View style={{top: -60, flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "white"}}>
<Text style={{fontSize: 24, marginBottom: 5}}>Casdoor server</Text>
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}
onChangeText={setServerUrl}
autoCapitalize="none"
style={{
borderWidth: 3,
borderColor: "white",
margin: 10,
width: 300,
height: 50,
borderRadius: 5,
fontSize: 18,
color: "gray",
paddingLeft: 10,
}}
style={styles.input}
mode="outlined"
/>
<TextInput
label="ClientID"
label="Client ID"
value={clientId}
onChangeText={setClientId}
autoCapitalize="none"
style={{
borderWidth: 3,
borderColor: "white",
margin: 10,
width: 300,
height: 50,
borderRadius: 5,
fontSize: 18,
color: "gray",
paddingLeft: 10,
}}
style={styles.input}
mode="outlined"
/>
<TextInput
label="appName"
label="App Name"
value={appName}
onChangeText={setAppName}
autoCapitalize="none"
style={{
borderWidth: 3,
borderColor: "white",
margin: 10,
width: 300,
height: 50,
borderRadius: 5,
fontSize: 18,
color: "gray",
paddingLeft: 10,
}}
style={styles.input}
mode="outlined"
/>
<TextInput
label="organizationName"
label="Organization Name"
value={organizationName}
onChangeText={setOrganizationName}
autoCapitalize="none"
style={{
borderWidth: 3,
borderColor: "white",
margin: 10,
width: 300,
height: 50,
borderRadius: 5,
fontSize: 18,
color: "gray",
paddingLeft: 10,
}}
style={styles.input}
mode="outlined"
/>
</View>
<View style={styles.buttonContainer}>
<Button
mode="outlined"
onPress={closeConfigPage}
style={styles.button}
labelStyle={styles.buttonLabel}
>
Cancel
</Button>
<Button
mode="contained"
onPress={handleSave}
style={{
backgroundColor: "#E6DFF3",
borderRadius: 5,
margin: 10,
alignItems: "center",
position: "absolute",
top: 600,
width: 300,
height: 50,
display: "flex",
justifyContent: "center",
}}
style={styles.button}
labelStyle={styles.buttonLabel}
>
<Text style={{fontSize: 21, width: 300, color: "black"}}>Confirm</Text>
Confirm
</Button>
<Button
mode="contained"
onPress={handleUseDefault}
style={{
backgroundColor: "#E6DFF3",
borderRadius: 5,
margin: 10,
alignItems: "center",
position: "absolute",
top: 660,
width: 300,
}}
>
<Text style={{fontSize: 18, width: 300, color: "black"}}>Use Casdoor Demo Site</Text>
</Button>
<IconButton icon={"close"} size={30} onPress={closeConfigPage} style={{position: "absolute", top: 120, right: -30}} />
</View>
</View>
</Portal>
</View>
);
return usePortal ? <Portal>{content}</Portal> : content;
}
EnterCasdoorSdkConfig.propTypes = {
onClose: PropTypes.func.isRequired,
onWebviewClose: PropTypes.func.isRequired,
usePortal: PropTypes.bool,
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
content: {
width: "90%",
maxWidth: 400,
borderRadius: 28,
padding: 24,
backgroundColor: "#FFFFFF",
shadowColor: "#000",
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5,
},
title: {
fontSize: 20,
fontWeight: "bold",
fontFamily: "Lato_700Bold",
color: "#212121",
textAlign: "center",
marginBottom: 16,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
},
button: {
flex: 1,
marginHorizontal: 8,
borderRadius: 100,
},
buttonLabel: {
paddingVertical: 4,
fontSize: 16,
fontWeight: "bold",
},
formContainer: {
marginBottom: 8,
},
input: {
marginBottom: 16,
},
});
export default EnterCasdoorSdkConfig;

148
Header.js
View File

@ -15,15 +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 {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 {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);
@ -33,22 +40,62 @@ 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();
clearAll();
clearSyncError();
};
const handleSyncErrorPress = () => {
notify("error", {
params: {
title: "Error",
description: syncError || "An unknown error occurred during synchronization.",
},
});
};
return (
<Appbar.Header>
<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}
@ -61,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>
}
@ -83,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

@ -13,22 +13,24 @@
// limitations under the License.
import React, {useEffect, useRef, useState} from "react";
import {Dimensions, RefreshControl, TouchableOpacity, View} from "react-native";
import {Dimensions, InteractionManager, RefreshControl, TouchableOpacity, View} from "react-native";
import {Divider, IconButton, List, Modal, Portal, Text} from "react-native-paper";
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 * as SQLite from "expo-sqlite/next";
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 * as TotpDatabase from "./TotpDatabase";
import {useImportManager} from "./ImportManager";
import useStore from "./useStorage";
import useSyncStore from "./useSyncStore";
import {calculateCountdown} from "./totpUtil";
import {generateToken, validateSecret} from "./totpUtil";
import {useAccountStore, useAccountSync, useEditAccount} from "./useAccountStore";
const {width, height} = Dimensions.get("window");
const REFRESH_INTERVAL = 10000;
@ -40,7 +42,6 @@ export default function HomePage() {
const [showOptions, setShowOptions] = useState(false);
const [showEnterAccountModal, setShowEnterAccountModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [accounts, setAccounts] = useState([]);
const [filteredData, setFilteredData] = useState(accounts);
const [showScanner, setShowScanner] = useState(false);
const [showEditAccountModal, setShowEditAccountModal] = useState(false);
@ -49,18 +50,27 @@ export default function HomePage() {
const [refreshing, setRefreshing] = useState(false);
const {isConnected} = useNetInfo();
const [canSync, setCanSync] = useState(false);
const [key, setKey] = useState(0);
const swipeableRef = useRef(null);
const {userInfo, serverUrl, token} = useStore();
const {startSync} = useSyncStore();
const db = SQLite.useSQLiteContext();
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(() => {
if (db) {
const subscription = SQLite.addDatabaseChangeListener((event) => {loadAccounts();});
return () => {if (subscription) {subscription.remove();}};
}
}, [db]);
refreshAccounts();
}, []);
useEffect(() => {
setCanSync(Boolean(isConnected && userInfo && serverUrl));
@ -70,36 +80,51 @@ export default function HomePage() {
setFilteredData(accounts);
}, [accounts]);
useEffect(() => {
loadAccounts();
}, []);
useEffect(() => {
const timer = setInterval(() => {
if (canSync) {startSync(db, userInfo, serverUrl, token);}
if (canSync) {
InteractionManager.runAfterInteractions(() => {
startSync(userInfo, serverUrl, token);
refreshAccounts();
});
}
}, REFRESH_INTERVAL);
return () => clearInterval(timer);
}, [startSync]);
const loadAccounts = async() => {
const loadedAccounts = await TotpDatabase.getAllAccounts(db);
setAccounts(loadedAccounts);
setFilteredData(loadedAccounts);
};
}, [startSync, canSync, token]);
const onRefresh = async() => {
setRefreshing(true);
if (canSync) {await startSync(db, userInfo, serverUrl, token);}
if (canSync) {
const syncError = await startSync(userInfo, serverUrl, token);
if (syncError) {
notify("error", {
params: {
title: "Sync error",
description: syncError,
},
});
} else {
notify("success", {
params: {
title: "Sync success",
description: "All your accounts are up to date.",
},
});
}
}
refreshAccounts();
setRefreshing(false);
};
const handleAddAccount = async(accountData) => {
await TotpDatabase.insertAccount(db, accountData);
closeEnterAccountModal();
};
const handleDeleteAccount = async(id) => {
await TotpDatabase.deleteAccount(db, id);
const handleAddAccount = async(accountDataInput) => {
if (Array.isArray(accountDataInput)) {
await insertAccounts(accountDataInput);
} else {
await setAccount(accountDataInput);
await insertAccount();
closeEnterAccountModal();
}
refreshAccounts();
};
const handleEditAccount = (account) => {
@ -111,13 +136,20 @@ export default function HomePage() {
const onAccountEdit = async(newAccountName) => {
if (editingAccount) {
await TotpDatabase.updateAccountName(db, editingAccount.id, newAccountName);
setAccount({...editingAccount, accountName: newAccountName, oldAccountName: editingAccount.accountName});
updateAccount();
refreshAccounts();
setPlaceholder("");
setEditingAccount(null);
closeEditAccountModal();
}
};
const onAccountDelete = async(account) => {
deleteAccount(account.id);
refreshAccounts();
};
const closeEditAccountModal = () => setShowEditAccountModal(false);
const handleScanPress = () => {
@ -128,6 +160,16 @@ export default function HomePage() {
const handleCloseScanner = () => setShowScanner(false);
const handleScanError = (error) => {
setShowScanner(false);
notify("error", {
params: {
title: "Error scanning QR code",
description: error,
},
});
};
const togglePlusButton = () => {
setIsPlusButton(!isPlusButton);
setShowOptions(!showOptions);
@ -144,6 +186,11 @@ export default function HomePage() {
closeOptions();
};
const openImportAccountModal = () => {
showImportOptions();
closeOptions();
};
const closeEnterAccountModal = () => setShowEnterAccountModal(false);
const closeSwipeableMenu = () => {
@ -155,7 +202,7 @@ export default function HomePage() {
const handleSearch = (query) => {
setSearchQuery(query);
setFilteredData(query.trim() !== ""
? accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
? accounts && accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
: accounts
);
};
@ -166,7 +213,8 @@ export default function HomePage() {
<FlashList
data={searchQuery.trim() !== "" ? filteredData : accounts}
keyExtractor={(item) => `${item.id}`}
estimatedItemSize={10}
extraData={key}
estimatedItemSize={80}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
@ -184,7 +232,7 @@ export default function HomePage() {
</TouchableOpacity>
<TouchableOpacity
style={{height: 70, width: 80, backgroundColor: "#FFC0CB", alignItems: "center", justifyContent: "center"}}
onPress={() => handleDeleteAccount(item.id)}
onPress={() => onAccountDelete(item)}
>
<Text>Delete</Text>
</TouchableOpacity>
@ -194,22 +242,24 @@ export default function HomePage() {
<List.Item
style={{
height: 80,
paddingVertical: 5,
paddingHorizontal: 25,
paddingVertical: 6,
paddingHorizontal: 16,
justifyContent: "center",
}}
title={
<View style={{flex: 1, justifyContent: "center"}}>
<Text variant="titleMedium">{item.accountName}</Text>
<Text variant="headlineSmall" style={{fontWeight: "bold"}}>{item.token}</Text>
<View style={{justifyContent: "center", paddingLeft: 0, paddingTop: 6}}>
<Text variant="titleMedium" numberOfLines={1}>
{item.accountName}
</Text>
<Text variant="titleLarge" style={{fontWeight: "bold"}}>{generateToken(item.secretKey)}</Text>
</View>
}
left={() => (
<AvatarWithFallback
source={{uri: item.issuer ? `https://cdn.casbin.org/img/social_${item.issuer.toLowerCase()}.png` : "https://cdn.casbin.org/img/social_default.png"}}
source={{uri: `https://cdn.casbin.org/img/social_${item.issuer?.toLowerCase()}.png`}}
fallbackSource={{uri: "https://cdn.casbin.org/img/social_default.png"}}
size={60}
style={{
marginRight: 15,
borderRadius: 10,
backgroundColor: "transparent",
}}
@ -218,15 +268,20 @@ export default function HomePage() {
right={() => (
<View style={{justifyContent: "center", alignItems: "center"}}>
<CountdownCircleTimer
key={key}
isPlaying={true}
duration={30}
initialRemainingTime={TotpDatabase.calculateCountdown()}
initialRemainingTime={calculateCountdown()}
colors={["#004777", "#0072A0", "#0099CC", "#FF6600", "#CC3300", "#A30000"]}
colorsTime={[30, 24, 18, 12, 6, 0]}
size={60}
onComplete={() => {
TotpDatabase.updateToken(db, item.id);
return {shouldRepeat: true, delay: 0};
setKey(prevKey => prevKey + 1);
return {
shouldRepeat: true,
delay: 0,
newInitialRemainingTime: calculateCountdown(),
};
}}
strokeWidth={5}
>
@ -252,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
@ -273,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>
@ -292,7 +354,7 @@ export default function HomePage() {
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
}}
>
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} />
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} validateSecret={validateSecret} />
</Modal>
</Portal>
@ -317,7 +379,7 @@ export default function HomePage() {
</Portal>
{showScanner && (
<ScanQRCode onClose={handleCloseScanner} showScanner={showScanner} onAdd={handleAddAccount} />
<ScanQRCode onClose={handleCloseScanner} showScanner={showScanner} onAdd={handleAddAccount} onError={handleScanError} />
)}
<TouchableOpacity

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,86 +12,59 @@
// See the License for the specific language governing permissions and
// 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 {Camera, CameraView} from "expo-camera";
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 decoder = useProtobufDecoder(require("./google/google_auth.proto"));
const [hasPermission, setHasPermission] = useState(null);
useEffect(() => {
const getCameraPermissions = async() => {
const {status} = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === "granted");
};
getCameraPermissions();
}, []);
const closeOptions = () => {
onClose();
};
const handleBarCodeScanned = ({type, data}) => {
// type org.iso.QRCode
// data otpauth://totp/casdoor:built-in/admin?algorithm=SHA1&digits=6&issuer=casdoor&period=30&secret=DL5XI33M772GSGU73GJPCOIBNJE7TG3J
// console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
const accountName = data.match(/otpauth:\/\/totp\/([^?]+)/); // accountName casdoor:built-in/admin
const secretKey = data.match(/secret=([^&]+)/); // secretKey II5UO7HIA3SPVXAB6KPAIXZ33AQP7C3R
const issuer = data.match(/issuer=([^&]+)/);
if (accountName && secretKey) {
onAdd({accountName: accountName[1], issuer: issuer[1], secretKey: secretKey[1]});
const handleScan = (type, data) => {
const supportedProtocols = ["otpauth", "otpauth-migration"];
const protocolMatch = data.match(new RegExp(`^(${supportedProtocols.join("|")}):`));
if (protocolMatch) {
const protocol = protocolMatch[1];
switch (protocol) {
case "otpauth":
handleOtpAuth(data);
break;
case "otpauth-migration":
handleGoogleMigration(data);
break;
default:
return;
}
onClose();
}
closeOptions();
};
const {width, height} = Dimensions.get("window");
const offsetX = width * 0.5;
const offsetY = height * 0.5;
const handleOtpAuth = (data) => {
const [, accountName] = data.match(/otpauth:\/\/totp\/([^?]+)/) || [];
const [, secretKey] = data.match(/secret=([^&]+)/) || [];
const [, issuer] = data.match(/issuer=([^&]+)/) || [];
return (
<View style={{marginTop: "50%", flex: 1}} >
<Portal>
<Modal
visible={showScanner}
onDismiss={closeOptions}
contentContainerStyle={{
backgroundColor: "white",
width: width,
height: height,
position: "absolute",
top: "50%",
left: "50%",
transform: [{translateX: -offsetX}, {translateY: -offsetY}],
}}
>
{hasPermission === null ? (
<Text style={{marginLeft: "20%", marginRight: "20%"}}>Requesting for camera permission</Text>
) : hasPermission === false ? (
<Text style={{marginLeft: "20%", marginRight: "20%"}}>No access to camera</Text>
) : (
<CameraView
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr", "pdf417"],
}}
style={{flex: 1}}
/>
)}
<IconButton icon={"close"} size={40} onPress={onClose} style={{position: "absolute", top: 30, right: 5}} />
</Modal>
</Portal>
</View>
);
if (accountName && secretKey) {
onAdd({accountName, issuer: issuer || null, secretKey});
}
};
const handleGoogleMigration = (data) => {
const accounts = decoder.decodeExportUri(data);
onAdd(accounts.map(({accountName, issuer, totpSecret}) => ({accountName, issuer, secretKey: totpSecret})));
};
if (!showScanner) {
return null;
}
return <QRScanner onScan={handleScan} onClose={onClose} />;
};
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,18 +12,36 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from "react";
import {Button} from "react-native-paper";
import {View} 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 {width} = Dimensions.get("window");
const SettingPage = () => {
const [showLoginPage, setShowLoginPage] = React.useState(false);
const [showLoginPage, setShowLoginPage] = useState(false);
const [loginMethod, setLoginMethod] = useState(null);
const {userInfo, clearAll} = useStore();
const handleCasdoorLogin = () => setShowLoginPage(true);
const handleHideLoginPage = () => setShowLoginPage(false);
const {openActionSheet} = LoginMethodSelector({
onSelectMethod: (method) => {
setLoginMethod(method);
setShowLoginPage(true);
},
});
const handleCasdoorLogin = () => {
openActionSheet();
};
const handleHideLoginPage = () => {
setShowLoginPage(false);
setLoginMethod(null);
};
const handleCasdoorLogout = () => {
CasdoorLogout();
@ -31,18 +49,51 @@ const SettingPage = () => {
};
return (
<View>
<Button
style={{marginTop: "50%", marginLeft: "20%", marginRight: "20%"}}
icon={userInfo === null ? "login" : "logout"}
mode="contained"
onPress={userInfo === null ? handleCasdoorLogin : handleCasdoorLogout}
>
{userInfo === null ? "Login with Casdoor" : "Logout"}
</Button>
{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;

View File

@ -1,298 +0,0 @@
// 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 totp from "totp-generator";
import * as api from "./api";
export async function migrateDb(db) {
const DATABASE_VERSION = 1;
const result = await db.getFirstAsync("PRAGMA user_version");
let currentVersion = result?.user_version ?? 0;
if (currentVersion === DATABASE_VERSION) {
return;
}
if (currentVersion === 0) {
await db.execAsync(`
PRAGMA journal_mode = 'wal';
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issuer TEXT,
account_name TEXT NOT NULL,
old_account_name TEXT DEFAULT NULL,
secret TEXT NOT NULL,
token TEXT,
is_deleted INTEGER DEFAULT 0,
last_change_time INTEGER DEFAULT (strftime('%s', 'now')),
last_sync_time INTEGER DEFAULT NULL
);
`);
await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);
currentVersion = 1;
}
await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);
}
const generateToken = (secretKey) => {
if (secretKey !== null && secretKey !== undefined && secretKey !== "") {
try {
const token = totp(secretKey);
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
return tokenWithSpace;
} catch (error) {
return "Secret Invalid";
}
} else {
return "Secret Empty";
}
};
export async function insertAccount(db, account) {
const token = generateToken(account.secretKey);
const currentTime = Math.floor(Date.now() / 1000);
return await db.runAsync(
"INSERT INTO accounts (issuer, account_name, secret, token, last_change_time) VALUES (?, ?, ?, ?, ?)",
account.issuer ?? "",
account.accountName,
account.secretKey,
token ?? "",
currentTime
);
}
export async function updateAccountName(db, id, newAccountName) {
const account = await db.getFirstAsync("SELECT * FROM accounts WHERE id = ?", id);
const currentTime = Math.floor(Date.now() / 1000);
// Only update old_account_name if it's null or if last_sync_time is more recent than last_change_time
if (account.old_account_name === null || (account.last_sync_time && account.last_sync_time > account.last_change_time)) {
await db.runAsync(`
UPDATE accounts
SET account_name = ?,
old_account_name = ?,
last_change_time = ?
WHERE id = ?
`, newAccountName, account.account_name, currentTime, id);
} else {
await db.runAsync(`
UPDATE accounts
SET account_name = ?,
last_change_time = ?
WHERE id = ?
`, newAccountName, currentTime, id);
}
}
export async function updateAccount(db, account, id) {
const token = generateToken(account.secretKey);
const currentTime = Math.floor(Date.now() / 1000);
const result = await db.runAsync(
"UPDATE accounts SET issuer = ?, account_name = ?, old_account_name = ?, secret = ?, token = ?, last_change_time = ? WHERE id = ?",
account.issuer,
account.accountName,
account.oldAccountName ?? null,
account.secretKey,
token ?? "",
currentTime,
id
);
if (result.changes === 0) {
throw new Error(`No account updated for id: ${id}`);
}
return result;
}
export async function deleteAccount(db, id) {
const currentTime = Math.floor(Date.now() / 1000);
await db.runAsync("UPDATE accounts SET is_deleted = 1, last_change_time = ? WHERE id = ?", currentTime, id);
}
export async function trueDeleteAccount(db, id) {
return await db.runAsync("DELETE FROM accounts WHERE id = ?", id);
}
export function updateToken(db, id) {
const result = db.getFirstSync("SELECT secret FROM accounts WHERE id = ?", id);
if (result.secret === null) {
return;
}
const token = generateToken(result.secret);
return db.runSync("UPDATE accounts SET token = ? WHERE id = ?", token, id);
}
export async function updateTokenForAll(db) {
const accounts = await db.getAllAsync("SELECT * FROM accounts WHERE is_deleted = 0");
for (const account of accounts) {
const token = generateToken(account.secret);
await db.runAsync("UPDATE accounts SET token = ? WHERE id = ?", token, account.id);
}
}
export async function getAllAccounts(db) {
const accounts = await db.getAllAsync("SELECT * FROM accounts WHERE is_deleted = 0");
return accounts.map(account => {
const mappedAccount = {
...account,
accountName: account.account_name,
secretKey: account.secret,
};
return mappedAccount;
});
}
async function getLocalAccounts(db) {
const accounts = await db.getAllAsync("SELECT * FROM accounts");
return accounts.map(account => ({
id: account.id,
issuer: account.issuer,
accountName: account.account_name,
oldAccountName: account.old_account_name,
secretKey: account.secret,
isDeleted: account.is_deleted === 1,
lastChangeTime: account.last_change_time,
lastSyncTime: account.last_sync_time,
}));
}
async function updateSyncTimeForAll(db) {
const currentTime = Math.floor(Date.now() / 1000);
await db.runAsync("UPDATE accounts SET last_sync_time = ?", currentTime);
}
export function calculateCountdown() {
const now = Math.floor(Date.now() / 1000);
return 30 - (now % 30);
}
async function updateLocalDatabase(db, mergedAccounts) {
for (const account of mergedAccounts) {
if (account.id) {
if (account.isDeleted) {
await db.runAsync("DELETE FROM accounts WHERE id = ?", account.id);
} else {
await updateAccount(db, account, account.id);
}
} else {
await insertAccount(db, account);
}
}
}
function getAccountKey(account) {
return `${account.issuer}:${account.accountName}`;
}
function mergeAccounts(localAccounts, serverAccounts, serverTimestamp) {
const mergedAccounts = new Map();
const localAccountKeys = new Map();
// Process local accounts
for (const local of localAccounts) {
const key = getAccountKey(local);
mergedAccounts.set(key, {
...local,
synced: false,
});
// Store both current and old account keys for local accounts
localAccountKeys.set(key, local);
if (local.oldAccountName) {
const oldKey = getAccountKey({...local, accountName: local.oldAccountName});
localAccountKeys.set(oldKey, local);
}
}
const processedLocalKeys = new Set();
// Merge with server accounts
for (const server of serverAccounts) {
const serverKey = getAccountKey(server);
const localAccount = localAccountKeys.get(serverKey);
if (!localAccount) {
// New account from server
mergedAccounts.set(serverKey, {...server, synced: true});
} else {
const localKey = getAccountKey(localAccount);
const local = mergedAccounts.get(localKey);
if (serverTimestamp > local.lastChangeTime) {
// Server has newer changes
mergedAccounts.set(localKey, {
...server,
id: local.id,
oldAccountName: local.accountName !== server.accountName ? local.accountName : local.oldAccountName,
synced: true,
});
} else if (local.accountName !== server.accountName) {
// Local name change is newer, update the server account name
mergedAccounts.set(localKey, {
...local,
oldAccountName: server.accountName,
synced: false,
});
}
// If local is newer or deleted, keep the local version (already in mergedAccounts)
processedLocalKeys.add(localKey);
}
}
// Handle server-side deletions
for (const [key, local] of mergedAccounts) {
if (!processedLocalKeys.has(key) && local.lastSyncTime && local.lastSyncTime < serverTimestamp) {
// This account was not found on the server and was previously synced
// Mark it as deleted
mergedAccounts.set(key, {...local, isDeleted: true, synced: true});
}
}
return Array.from(mergedAccounts.values());
}
export async function syncWithCloud(db, userInfo, serverUrl, token) {
const localAccounts = await getLocalAccounts(db);
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
token
);
const serverTimestamp = Math.floor(new Date(updatedTime).getTime() / 1000);
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, serverTimestamp);
await updateLocalDatabase(db, mergedAccounts);
const accountsToSync = mergedAccounts.filter(account => !account.isDeleted).map(account => ({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
}));
const {status} = await api.updateMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
accountsToSync,
token
);
if (status !== "ok") {
throw new Error("Sync failed");
}
await updateSyncTimeForAll(db);
await updateTokenForAll(db);
}

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

@ -1,12 +1,13 @@
{
"expo": {
"name": "Casdoor",
"slug": "Casdoor",
"version": "1.2.0",
"slug": "casdoor-app",
"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.2.0"
"buildNumber": "1.11.0"
},
"android": {
"adaptiveIcon": {
@ -26,7 +27,7 @@
"backgroundColor": "#ffffff"
},
"package": "org.casdoor.casdoorapp",
"versionCode": 510010200
"versionCode": 510011100
},
"web": {
"favicon": "./assets/favicon.png"
@ -40,11 +41,17 @@
[
"expo-camera",
{
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera",
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone",
"recordAudioAndroid": true
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
}
]
],
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to add Totp account."
}
],
"expo-asset",
"expo-font"
],
"owner": "casdoor"
}

View File

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

View File

@ -12,27 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import {create} from "zustand";
import * as TotpDatabase from "./TotpDatabase";
import {drizzle} from "drizzle-orm/expo-sqlite";
import {openDatabaseSync} from "expo-sqlite/next";
const useSyncStore = create((set, get) => ({
isSyncing: false,
syncError: null,
const expoDb = openDatabaseSync("account.db", {enableChangeListener: true});
startSync: async(db, userInfo, casdoorServer, token) => {
if (!get().isSyncing) {
set({isSyncing: true, syncError: null});
try {
await TotpDatabase.syncWithCloud(db, userInfo, casdoorServer, token);
} catch (error) {
set({syncError: error.message});
} finally {
set({isSyncing: false});
}
}
},
clearSyncError: () => set({syncError: null}),
}));
export default useSyncStore;
export const db = drizzle(expoDb);

31
db/schema.js Normal file
View File

@ -0,0 +1,31 @@
// 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 {integer, sqliteTable, text, unique} from "drizzle-orm/sqlite-core";
import {sql} from "drizzle-orm";
export const accounts = sqliteTable("accounts", {
id: integer("id", {mode: "number"}).primaryKey({autoIncrement: true}),
accountName: text("account_name").notNull(),
oldAccountName: text("old_account_name").default(null),
secretKey: text("secret").notNull(),
issuer: text("issuer").default(null),
token: text("token"),
deletedAt: integer("deleted_at", {mode: "timestamp_ms"}).default(null),
changedAt: integer("changed_at", {mode: "timestamp_ms"}).default(sql`(CURRENT_TIMESTAMP)`),
syncAt: integer("sync_at", {mode: "timestamp_ms"}).default(null),
}, (accounts) => ({
unq: unique().on(accounts.accountName, accounts.issuer),
})
);

6
drizzle.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
schema: "./db/schema.js",
out: "./drizzle",
dialect: "sqlite",
driver: "expo",
};

View File

@ -0,0 +1,13 @@
CREATE TABLE `accounts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`account_name` text NOT NULL,
`old_account_name` text DEFAULT 'null',
`secret` text NOT NULL,
`issuer` text DEFAULT 'null',
`token` text,
`deleted_at` integer DEFAULT 'null',
`changed_at` integer DEFAULT (CURRENT_TIMESTAMP),
`sync_at` integer DEFAULT 'null'
);
--> statement-breakpoint
CREATE UNIQUE INDEX `accounts_account_name_issuer_unique` ON `accounts` (`account_name`,`issuer`);

View File

@ -0,0 +1,103 @@
{
"version": "6",
"dialect": "sqlite",
"id": "aaa7b5e3-521e-4c3a-970c-35372e7f05a3",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"account_name": {
"name": "account_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"old_account_name": {
"name": "old_account_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"changed_at": {
"name": "changed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"sync_at": {
"name": "sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
}
},
"indexes": {
"accounts_account_name_issuer_unique": {
"name": "accounts_account_name_issuer_unique",
"columns": [
"account_name",
"issuer"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1724248639995,
"tag": "0000_smooth_owl",
"breakpoints": true
}
]
}

11
drizzle/migrations.js Normal file
View File

@ -0,0 +1,11 @@
// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo
import journal from "./meta/_journal.json";
import m0000 from "./0000_smooth_owl.sql";
export default {
journal,
migrations: {
m0000,
},
};

43
google/google_auth.proto Normal file
View File

@ -0,0 +1,43 @@
syntax = "proto3";
package google_auth;
message MigrationPayload {
enum Algorithm {
ALGORITHM_UNSPECIFIED = 0;
SHA1 = 1;
SHA256 = 2;
SHA512 = 3;
MD5 = 4;
}
enum DigitCount {
DIGIT_COUNT_UNSPECIFIED = 0;
SIX = 1;
EIGHT = 2;
SEVEN = 3;
}
enum OtpType {
OTP_TYPE_UNSPECIFIED = 0;
HOTP = 1;
TOTP = 2;
}
message OtpParameters {
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
DigitCount digits = 5;
OtpType type = 6;
int64 counter = 7;
string unique_id = 8;
}
repeated OtpParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}

9
metro.config.js Normal file
View File

@ -0,0 +1,9 @@
const {getDefaultConfig} = require("expo/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts.push("sql");
config.resolver.assetExts.push("proto");
module.exports = config;

10339
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,43 +3,56 @@
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start --tunnel",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"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",
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
"@shopify/flash-list": "1.6.4",
"buffer": "^6.0.3",
"casdoor-react-native-sdk": "1.1.0",
"drizzle-orm": "^0.33.0",
"eslint-plugin-import": "^2.28.1",
"expo": "~51.0.26",
"expo-camera": "~15.0.14",
"expo-dev-client": "~4.0.22",
"expo-image": "^1.12.13",
"expo-sqlite": "~14.0.6",
"expo": "~51.0.38",
"expo-asset": "~10.0.10",
"expo-camera": "~15.0.16",
"expo-crypto": "~13.0.2",
"expo-dev-client": "~4.0.28",
"expo-document-picker": "~12.0.2",
"expo-drizzle-studio-plugin": "^0.0.2",
"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.22",
"expo-updates": "~0.25.27",
"hi-base32": "^0.5.1",
"hotp-totp": "^1.0.6",
"prop-types": "^15.8.1",
"protobufjs": "^7.4.0",
"react": "18.2.0",
"react-content-loader": "^7.0.2",
"react-dom": "18.2.0",
"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",
@ -97,6 +110,8 @@
"@babel/preset-react": "^7.18.6",
"@types/react": "~18.2.79",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"babel-plugin-inline-import": "^3.0.0",
"drizzle-kit": "^0.24.0",
"eslint": "8.22.0",
"eslint-import-resolver-babel-module": "^5.3.2",
"eslint-plugin-react": "^7.31.1",

195
syncLogic.js Normal file
View File

@ -0,0 +1,195 @@
// 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 {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();
}
function getAccountKey(account) {
return `${account.accountName}:${account.issuer ?? ""}`;
}
async function updateLocalDatabase(db, accounts) {
return db.transaction(async(tx) => {
// remove all accounts
// await tx.delete(schema.accounts).run();
for (const account of accounts) {
if (account.id) {
if (account.deletedAt === null || account.deletedAt === undefined) {
// compare all fields
const acc = await tx.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
if (acc.issuer === account.issuer &&
acc.accountName === account.accountName &&
acc.secretKey === account.secretKey &&
acc.deletedAt === account.deletedAt
) {
continue;
}
await tx.update(schema.accounts).set({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
deletedAt: null,
token: generateToken(account.secretKey),
changedAt: new Date(),
}).where(eq(schema.accounts.id, account.id));
} else {
await tx.delete(schema.accounts).where(eq(schema.accounts.id, account.id));
}
} else {
await tx.insert(schema.accounts).values({
issuer: account.issuer || null,
accountName: account.accountName,
secretKey: account.secretKey,
token: generateToken(account.secretKey),
});
}
}
});
}
function mergeAccounts(localAccounts, serverAccounts, serverTimestamp) {
const isNewer = (a, b) => new Date(a) > new Date(b);
const mergedAccounts = new Map();
const localAccountKeys = new Map();
// Process local accounts
for (const local of localAccounts) {
const key = getAccountKey(local);
mergedAccounts.set(key, {
...local,
synced: false,
});
// Store both current and old account keys for local accounts
localAccountKeys.set(key, local);
if (local.oldAccountName) {
const oldKey = getAccountKey({...local, accountName: local.oldAccountName});
localAccountKeys.set(oldKey, local);
}
}
const processedLocalKeys = new Set();
// Merge with server accounts
for (const server of serverAccounts) {
const serverKey = getAccountKey(server);
const localAccount = localAccountKeys.get(serverKey);
if (!localAccount) {
// New account from server
mergedAccounts.set(serverKey, {...server, synced: true});
} else {
const localKey = getAccountKey(localAccount);
const local = mergedAccounts.get(localKey);
if (isNewer(serverTimestamp, local.changedAt)) {
// Server has newer changes
mergedAccounts.set(localKey, {
...server,
id: local.id,
oldAccountName: local.accountName !== server.accountName ? local.accountName : local.oldAccountName,
synced: true,
});
} else if (local.accountName !== server.accountName) {
mergedAccounts.set(localKey, {
...local,
oldAccountName: server.accountName,
synced: false,
});
}
// If local is newer or deleted, keep the local version (already in mergedAccounts)
processedLocalKeys.add(localKey);
}
}
// Handle server-side deletions
for (const [key, local] of mergedAccounts) {
if (!processedLocalKeys.has(key) && local.syncAt && isNewer(serverTimestamp, local.syncAt)) {
// This account was not found on the server and was previously synced
// Mark it as deleted
mergedAccounts.set(key, {...local, deletedAt: new Date(), synced: true});
}
}
return Array.from(mergedAccounts.values());
}
export async function syncWithCloud(db, userInfo, serverUrl, token) {
try {
const localAccounts = await getLocalAccounts(db);
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
token
);
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, updatedTime);
await updateLocalDatabase(db, mergedAccounts);
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 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 (status !== "ok") {
throw new Error("Sync failed");
}
}
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;
}
}

42
totpUtil.js Normal file
View File

@ -0,0 +1,42 @@
// 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 totp from "totp-generator";
export function calculateCountdown(period = 30) {
const now = Math.round(new Date().getTime() / 1000.0);
return period - (now % period);
}
export function validateSecret(secret) {
const base32Regex = /^[A-Z2-7]+=*$/i;
if (!secret || secret.length % 8 !== 0) {
return false;
}
return base32Regex.test(secret);
}
export function generateToken(secret) {
if (secret !== null && secret !== undefined && secret !== "") {
try {
const token = totp(secret);
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
return tokenWithSpace;
} catch (error) {
return "Secret Invalid";
}
} else {
return "Secret Empty";
}
}

258
useAccountStore.js Normal file
View File

@ -0,0 +1,258 @@
// 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 {db} from "./db/client";
import * as schema from "./db/schema";
import {and, eq, isNull} from "drizzle-orm";
import {create} from "zustand";
import {generateToken} from "./totpUtil";
import {syncWithCloud} from "./syncLogic";
export const useAccountStore = create((set, get) => ({
accounts: [],
refreshAccounts: () => {
const accounts = db.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)).all();
set({accounts});
},
setAccounts: (accounts) => {
set({accounts});
},
}));
const useEditAccountStore = create((set, get) => ({
account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined, oldAccountName: undefined},
setAccount: (account) => {
set({account});
},
updateAccount: () => {
const {id, accountName, issuer, secretKey, oldAccountName} = get().account;
if (!id) {return;}
const updateData = {};
if (accountName) {updateData.accountName = accountName;}
if (issuer) {updateData.issuer = issuer;}
if (secretKey) {updateData.secretKey = secretKey;}
if (Object.keys(updateData).length > 0) {
const currentAccount = db.select().from(schema.accounts)
.where(eq(schema.accounts.id, Number(id))).limit(1)
.get();
if (currentAccount) {
if (currentAccount.oldAccountName === null && oldAccountName) {
updateData.oldAccountName = oldAccountName;
}
db.update(schema.accounts).set({...updateData, changedAt: new Date()}).where(eq(schema.accounts.id, id)).run();
}
}
set({
account: {
id: undefined,
issuer: undefined,
accountName: undefined,
oldAccountName: undefined,
secretKey: undefined,
},
});
},
insertAccount: () => {
const {accountName, issuer, secretKey} = get().account;
if (!accountName || !secretKey) {return;}
const insertWithDuplicateCheck = (tx, baseAccName) => {
let attemptCount = 0;
const maxAttempts = 10;
const tryInsert = (accName) => {
const existingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null),
eq(schema.accounts.secretKey, secretKey)
))
.get();
if (existingAccount) {
return accName;
}
const conflictingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null)
))
.get();
if (conflictingAccount) {
if (attemptCount >= maxAttempts) {
throw new Error(`Cannot generate a unique name for account ${baseAccName}, tried ${maxAttempts} times`);
}
attemptCount++;
const newAccountName = `${baseAccName}_${Math.random().toString(36).slice(2, 5)}`;
return tryInsert(newAccountName);
}
tx.insert(schema.accounts)
.values({
accountName: accName,
issuer: issuer || null,
secretKey,
token: generateToken(secretKey),
})
.run();
return accName;
};
return tryInsert(baseAccName);
};
try {
const finalAccountName = db.transaction((tx) => {
return insertWithDuplicateCheck(tx, accountName);
});
set({account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined}});
return finalAccountName;
} catch (error) {
return null;
}
},
insertAccounts: async(accounts) => {
try {
db.transaction((tx) => {
const insertWithDuplicateCheck = (baseAccName, issuer, secretKey) => {
let attemptCount = 0;
const maxAttempts = 10;
const tryInsert = (accName) => {
const existingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null),
eq(schema.accounts.secretKey, secretKey)
))
.get();
if (existingAccount) {
return accName;
}
const conflictingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null)
))
.get();
if (conflictingAccount) {
if (attemptCount >= maxAttempts) {
throw new Error(`Cannot generate a unique name for account ${baseAccName}, tried ${maxAttempts} times`);
}
attemptCount++;
const newAccountName = `${baseAccName}_${Math.random().toString(36).slice(2, 7)}`;
return tryInsert(newAccountName);
}
tx.insert(schema.accounts)
.values({
accountName: accName,
issuer: issuer || null,
secretKey,
token: generateToken(secretKey),
})
.run();
return accName;
};
return tryInsert(baseAccName);
};
for (const account of accounts) {
const {accountName, issuer, secretKey} = account;
if (!accountName || !secretKey) {continue;}
insertWithDuplicateCheck(accountName, issuer, secretKey);
}
});
} catch (error) {
return null;
}
},
deleteAccount: async(id) => {
db.update(schema.accounts)
.set({deletedAt: new Date()})
.where(eq(schema.accounts.id, id))
.run();
},
}));
export const useEditAccount = () => useEditAccountStore(state => ({
account: state.account,
setAccount: state.setAccount,
updateAccount: state.updateAccount,
insertAccount: state.insertAccount,
insertAccounts: state.insertAccounts,
deleteAccount: state.deleteAccount,
}));
const useAccountSyncStore = create((set, get) => ({
isSyncing: false,
syncError: null,
startSync: async(userInfo, serverUrl, token) => {
if (get().isSyncing) {return;}
set({isSyncing: true, syncError: null});
try {
await syncWithCloud(db, userInfo, serverUrl, token);
} catch (error) {
set({syncError: error.message});
} finally {
set({isSyncing: false});
}
return get().syncError;
},
clearSyncError: () => set({syncError: null}),
}));
export const useAccountSync = () => useAccountSyncStore(state => ({
isSyncing: state.isSyncing,
syncError: state.syncError,
startSync: state.startSync,
clearSyncError: state.clearSyncError,
}));
const useUpdateAccountTokenStore = create(() => ({
updateToken: async(id) => {
const account = db.select().from(schema.accounts)
.where(eq(schema.accounts.id, Number(id))).limit(1).get();
if (account) {
db.update(schema.accounts).set({token: generateToken(account.secretKey)}).where(eq(schema.accounts.id, id)).run();
}
},
updateAllTokens: async() => {
db.transaction(async(tx) => {
const accounts = tx.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)).all();
for (const account of accounts) {
tx.update(schema.accounts).set({token: generateToken(account.secretKey)}).where(eq(schema.accounts.id, account.id)).run();
}
});
},
}));
export const useUpdateAccountToken = () => useUpdateAccountTokenStore(state => ({
updateToken: state.updateToken,
updateAllTokens: state.updateAllTokens,
}));

78
useProtobufDecoder.js Normal file
View File

@ -0,0 +1,78 @@
// 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 {useEffect, useState} from "react";
import {useAssets} from "expo-asset";
import * as FileSystem from "expo-file-system";
import protobuf from "protobufjs";
import {encode as base32Encode} from "hi-base32";
import {Buffer} from "buffer";
const useProtobufDecoder = (protobufAsset) => {
const [assets] = useAssets([protobufAsset]);
const [decoder, setDecoder] = useState(null);
useEffect(() => {
const initializeDecoder = async() => {
if (!assets) {return;}
try {
// Read the file content
const fileContent = await FileSystem.readAsStringAsync(assets[0].localUri);
// Parse the protobuf schema
const root = protobuf.parse(fileContent).root;
const MigrationPayload = root.lookupType("google_auth.MigrationPayload");
// Create the decoder object
const newDecoder = {
decodeProtobuf: (payload) => {
const message = MigrationPayload.decode(payload);
return MigrationPayload.toObject(message, {
longs: String,
enums: String,
bytes: String,
});
},
decodeData: (data) => {
const buffer = Buffer.from(decodeURIComponent(data), "base64");
const payload = newDecoder.decodeProtobuf(buffer);
return payload.otpParameters.map(account => ({
accountName: account.name,
issuer: account.issuer || null,
totpSecret: base32Encode(Buffer.from(account.secret, "base64")),
}));
},
decodeExportUri: (uri) => {
const data = new URL(uri).searchParams.get("data");
if (!data) {
throw new Error("No data parameter found in the URI");
}
return newDecoder.decodeData(data);
},
};
setDecoder(newDecoder);
} catch (error) {
throw new Error("Failed to initialize ProtobufDecoder:", error);
}
};
initializeDecoder();
}, [assets]);
return decoder;
};
export default useProtobufDecoder;