Compare commits
16 Commits
Author | SHA1 | Date |
---|---|---|
![]() |
c593330a21 | |
|
ea4f542b0f | |
![]() |
4fef2a2e30 | |
|
308b546395 | |
![]() |
e3f7ab5203 | |
|
a1295e09ac | |
![]() |
beb492e540 | |
|
fc4aef3b92 | |
![]() |
3cbfa8e253 | |
|
37a03e45cc | |
![]() |
e74a0ed1c7 | |
|
aa7219ac65 | |
![]() |
368c9672b1 | |
|
8a65256f15 | |
![]() |
e660fbfc4b | |
|
7f87a3bf34 |
96
App.js
96
App.js
|
@ -12,29 +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 Toast from "react-native-toast-message";
|
||||
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>
|
||||
<Toast />
|
||||
</SQLiteProvider>
|
||||
</React.Suspense>
|
||||
<GestureHandlerRootView style={{flex: 1}}>
|
||||
<NotificationsProvider>
|
||||
<ActionSheetProvider>
|
||||
<NavigationContainer>
|
||||
<PaperProvider>
|
||||
<Header />
|
||||
<NavigationBar />
|
||||
</PaperProvider>
|
||||
</NavigationContainer>
|
||||
</ActionSheetProvider>
|
||||
</NotificationsProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
export default App;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -16,23 +16,25 @@ import React, {useEffect, useState} from "react";
|
|||
import {WebView} from "react-native-webview";
|
||||
import {Platform, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity} from "react-native";
|
||||
import {Portal} from "react-native-paper";
|
||||
import {useNotifications} from "react-native-notificated";
|
||||
import SDK from "casdoor-react-native-sdk";
|
||||
import PropTypes from "prop-types";
|
||||
import Toast from "react-native-toast-message";
|
||||
|
||||
import EnterCasdoorSdkConfig from "./EnterCasdoorSdkConfig";
|
||||
import ScanQRCodeForLogin from "./ScanLogin";
|
||||
import useStore from "./useStorage";
|
||||
// import {LogBox} from "react-native";
|
||||
// LogBox.ignoreAllLogs();
|
||||
import DefaultCasdoorSdkConfig from "./DefaultCasdoorSdkConfig";
|
||||
|
||||
let sdk = null;
|
||||
const CasdoorLoginPage = ({onWebviewClose}) => {
|
||||
|
||||
function CasdoorLoginPage({onWebviewClose, initialMethod}) {
|
||||
CasdoorLoginPage.propTypes = {
|
||||
onWebviewClose: PropTypes.func.isRequired,
|
||||
initialMethod: PropTypes.oneOf(["manual", "scan", "demo"]).isRequired,
|
||||
};
|
||||
|
||||
const {notify} = useNotifications();
|
||||
const [casdoorLoginURL, setCasdoorLoginURL] = useState("");
|
||||
const [showConfigPage, setShowConfigPage] = useState(true);
|
||||
const [currentView, setCurrentView] = useState(initialMethod === "scan" ? "scanner" : "config");
|
||||
|
||||
const {
|
||||
serverUrl,
|
||||
|
@ -40,33 +42,57 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
|
|||
redirectPath,
|
||||
appName,
|
||||
organizationName,
|
||||
token,
|
||||
getCasdoorConfig,
|
||||
setCasdoorConfig,
|
||||
setServerUrl,
|
||||
setClientId,
|
||||
setAppName,
|
||||
setOrganizationName,
|
||||
setUserInfo,
|
||||
setToken,
|
||||
} = useStore();
|
||||
|
||||
const handleHideConfigPage = () => {
|
||||
setShowConfigPage(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (initialMethod === "demo") {
|
||||
setCasdoorConfig(DefaultCasdoorSdkConfig);
|
||||
}
|
||||
}, [initialMethod, setCasdoorConfig]);
|
||||
|
||||
const handleShowConfigPage = () => {
|
||||
setShowConfigPage(true);
|
||||
const initSdk = () => {
|
||||
const configs = {
|
||||
demo: DefaultCasdoorSdkConfig,
|
||||
scan: getCasdoorConfig(),
|
||||
manual: serverUrl && clientId && redirectPath && appName && organizationName ? getCasdoorConfig() : null,
|
||||
};
|
||||
sdk = configs[initialMethod] ? new SDK(configs[initialMethod]) : null;
|
||||
};
|
||||
|
||||
const getCasdoorSignInUrl = async() => {
|
||||
const signinUrl = await sdk.getSigninUrl();
|
||||
setCasdoorLoginURL(signinUrl);
|
||||
initSdk();
|
||||
if (sdk) {
|
||||
const signinUrl = await sdk.getSigninUrl();
|
||||
setCasdoorLoginURL(signinUrl);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (serverUrl && clientId && redirectPath && appName && organizationName) {
|
||||
sdk = new SDK(getCasdoorConfig());
|
||||
getCasdoorSignInUrl();
|
||||
}
|
||||
}, [serverUrl, clientId, redirectPath, appName, organizationName]);
|
||||
const handleLogin = (method) => {
|
||||
const actions = {
|
||||
manual: () => {
|
||||
getCasdoorSignInUrl();
|
||||
setCurrentView("webview");
|
||||
},
|
||||
demo: () => {
|
||||
getCasdoorSignInUrl();
|
||||
setCurrentView("webview");
|
||||
},
|
||||
scan: () => setCurrentView("scanner"),
|
||||
};
|
||||
|
||||
actions[method]?.();
|
||||
};
|
||||
|
||||
const onNavigationStateChange = async(navState) => {
|
||||
const {redirectPath} = getCasdoorConfig();
|
||||
if (navState.url.startsWith(redirectPath)) {
|
||||
onWebviewClose();
|
||||
const token = await sdk.getAccessToken(navState.url);
|
||||
|
@ -76,57 +102,71 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleErrorResponse = (error) => {
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "Error",
|
||||
text2: error.description,
|
||||
autoHide: true,
|
||||
});
|
||||
setShowConfigPage(true);
|
||||
const handleQRLogin = (loginInfo) => {
|
||||
setServerUrl(loginInfo.serverUrl);
|
||||
setClientId("");
|
||||
setAppName("");
|
||||
setOrganizationName("");
|
||||
initSdk();
|
||||
try {
|
||||
const accessToken = loginInfo.accessToken;
|
||||
const userInfo = sdk.JwtDecode(accessToken);
|
||||
setToken(accessToken);
|
||||
setUserInfo(userInfo);
|
||||
notify("success", {params: {title: "Success", description: "Logged in successfully!"}});
|
||||
setCurrentView("config");
|
||||
onWebviewClose();
|
||||
} catch (error) {
|
||||
notify("error", {params: {title: "Error in login", description: error.message}});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<SafeAreaView style={styles.container}>
|
||||
{showConfigPage && (
|
||||
<EnterCasdoorSdkConfig
|
||||
onClose={handleHideConfigPage}
|
||||
onWebviewClose={onWebviewClose}
|
||||
const renderContent = () => {
|
||||
const views = {
|
||||
config: (
|
||||
<EnterCasdoorSdkConfig
|
||||
onClose={() => handleLogin(initialMethod)}
|
||||
onWebviewClose={onWebviewClose}
|
||||
usePortal={false}
|
||||
/>
|
||||
),
|
||||
scanner: (
|
||||
<ScanQRCodeForLogin
|
||||
showScanner={true}
|
||||
onClose={() => {
|
||||
setCurrentView("config");
|
||||
onWebviewClose();
|
||||
}}
|
||||
onLogin={handleQRLogin}
|
||||
/>
|
||||
),
|
||||
webview: casdoorLoginURL && !token && (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => setCurrentView("config")}>
|
||||
<Text style={styles.backButtonText}>Back to Config</Text>
|
||||
</TouchableOpacity>
|
||||
<WebView
|
||||
source={{uri: casdoorLoginURL}}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
onError={({nativeEvent}) => {
|
||||
notify("error", {params: {title: "Error", description: nativeEvent.description}});
|
||||
setCurrentView("config");
|
||||
}}
|
||||
style={styles.webview}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
/>
|
||||
)}
|
||||
{!showConfigPage && casdoorLoginURL !== "" && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleShowConfigPage}
|
||||
>
|
||||
<Text style={styles.backButtonText}>Back to Config</Text>
|
||||
</TouchableOpacity>
|
||||
<WebView
|
||||
source={{uri: casdoorLoginURL}}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
onError={(syntheticEvent) => {
|
||||
const {nativeEvent} = syntheticEvent;
|
||||
handleErrorResponse(nativeEvent);
|
||||
}}
|
||||
style={styles.webview}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
</SafeAreaView>
|
||||
),
|
||||
};
|
||||
|
||||
return views[currentView] || null;
|
||||
};
|
||||
|
||||
return <Portal>{renderContent()}</Portal>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0,
|
||||
},
|
||||
webview: {
|
||||
flex: 1,
|
||||
},
|
||||
|
@ -139,10 +179,18 @@ const styles = StyleSheet.create({
|
|||
color: "white",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0,
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
export const CasdoorLogout = () => {
|
||||
if (sdk) {sdk.clearState();}
|
||||
if (sdk) {
|
||||
sdk.clearState();
|
||||
}
|
||||
};
|
||||
|
||||
export default CasdoorLoginPage;
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
import React, {useCallback, useState} from "react";
|
||||
import {View} from "react-native";
|
||||
import {Button, IconButton, Menu, Text, TextInput} from "react-native-paper";
|
||||
import Toast from "react-native-toast-message";
|
||||
import {useNotifications} from "react-native-notificated";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
|
||||
|
@ -25,6 +25,8 @@ const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
|
|||
validateSecret: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const {notify} = useNotifications();
|
||||
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [secretKey, setSecretKey] = useState("");
|
||||
const [secretError, setSecretError] = useState("");
|
||||
|
@ -51,21 +53,17 @@ const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
|
|||
}
|
||||
|
||||
if (accountName.trim() === "" || secretKey.trim() === "") {
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "Error",
|
||||
text2: "Please fill in all the fields!",
|
||||
autoHide: true,
|
||||
notify("error", {
|
||||
title: "Error",
|
||||
description: "Please fill in all the fields!",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (secretError) {
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "Invalid Secret Key",
|
||||
text2: "Please check your secret key and try again.",
|
||||
autoHide: true,
|
||||
notify("error", {
|
||||
title: "Error",
|
||||
description: "Invalid Secret Key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -120,12 +118,12 @@ const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
|
|||
error={!!secretError}
|
||||
style={styles.input}
|
||||
mode="outlined"
|
||||
right={
|
||||
right={(props) => (
|
||||
<TextInput.Icon
|
||||
icon={showPassword ? "eye-off" : "eye"}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Menu
|
||||
|
|
|
@ -13,19 +13,13 @@
|
|||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {ScrollView, Text, View} from "react-native";
|
||||
import {Button, IconButton, Portal, TextInput} from "react-native-paper";
|
||||
import Toast from "react-native-toast-message";
|
||||
import DefaultCasdoorSdkConfig from "./DefaultCasdoorSdkConfig";
|
||||
import {StyleSheet, Text, View} from "react-native";
|
||||
import {Button, Portal, TextInput} from "react-native-paper";
|
||||
import {useNotifications} from "react-native-notificated";
|
||||
import PropTypes from "prop-types";
|
||||
import useStore from "./useStorage";
|
||||
|
||||
const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
|
||||
EnterCasdoorSdkConfig.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onWebviewClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function EnterCasdoorSdkConfig({onClose, onWebviewClose, usePortal = true}) {
|
||||
const {
|
||||
serverUrl,
|
||||
clientId,
|
||||
|
@ -36,9 +30,10 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
|
|||
setClientId,
|
||||
setAppName,
|
||||
setOrganizationName,
|
||||
setCasdoorConfig,
|
||||
} = useStore();
|
||||
|
||||
const {notify} = useNotifications();
|
||||
|
||||
const closeConfigPage = () => {
|
||||
onClose();
|
||||
onWebviewClose();
|
||||
|
@ -46,44 +41,22 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
|
|||
|
||||
const handleSave = () => {
|
||||
if (!serverUrl || !clientId || !appName || !organizationName || !redirectPath) {
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "Error",
|
||||
text2: "Please fill in all the fields!",
|
||||
autoHide: true,
|
||||
notify("error", {
|
||||
params: {
|
||||
title: "Error",
|
||||
description: "Please fill in all the fields!",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleScanToLogin = () => {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: "Scan to Login functionality not implemented yet.",
|
||||
autoHide: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseDefault = () => {
|
||||
setCasdoorConfig(DefaultCasdoorSdkConfig);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Casdoor server</Text>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size={24}
|
||||
onPress={closeConfigPage}
|
||||
style={styles.closeButton}
|
||||
/>
|
||||
</View>
|
||||
const content = (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Casdoor Configuration</Text>
|
||||
<View style={styles.formContainer}>
|
||||
<TextInput
|
||||
label="Endpoint"
|
||||
value={serverUrl}
|
||||
|
@ -116,112 +89,85 @@ const EnterCasdoorSdkConfig = ({onClose, onWebviewClose}) => {
|
|||
style={styles.input}
|
||||
mode="outlined"
|
||||
/>
|
||||
<View style={styles.buttonRow}>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSave}
|
||||
style={[styles.button, styles.confirmButton]}
|
||||
labelStyle={styles.buttonLabel}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleScanToLogin}
|
||||
style={[styles.button, styles.scanButton]}
|
||||
labelStyle={styles.buttonLabel}
|
||||
>
|
||||
Scan to Login
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleUseDefault}
|
||||
style={[styles.button, styles.outlinedButton]}
|
||||
labelStyle={styles.outlinedButtonLabel}
|
||||
onPress={closeConfigPage}
|
||||
style={styles.button}
|
||||
labelStyle={styles.buttonLabel}
|
||||
>
|
||||
Use Casdoor Demo Site
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSave}
|
||||
style={styles.button}
|
||||
labelStyle={styles.buttonLabel}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Portal>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return usePortal ? <Portal>{content}</Portal> : content;
|
||||
}
|
||||
|
||||
EnterCasdoorSdkConfig.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onWebviewClose: PropTypes.func.isRequired,
|
||||
usePortal: PropTypes.bool,
|
||||
};
|
||||
|
||||
const styles = {
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
width: "100%",
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.5)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
content: {
|
||||
width: "95%",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
backgroundColor: "#F5F5F5",
|
||||
width: "90%",
|
||||
maxWidth: 400,
|
||||
borderRadius: 28,
|
||||
padding: 24,
|
||||
backgroundColor: "#FFFFFF",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
input: {
|
||||
marginVertical: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "white",
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Lato_700Bold",
|
||||
color: "#212121",
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonRow: {
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 5,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: "#6200EE",
|
||||
flex: 1,
|
||||
marginRight: 5,
|
||||
},
|
||||
scanButton: {
|
||||
backgroundColor: "#03DAC6",
|
||||
flex: 1,
|
||||
marginLeft: 5,
|
||||
marginHorizontal: 8,
|
||||
borderRadius: 100,
|
||||
},
|
||||
buttonLabel: {
|
||||
paddingVertical: 4,
|
||||
fontSize: 16,
|
||||
color: "white",
|
||||
},
|
||||
outlinedButton: {
|
||||
borderColor: "#6200EE",
|
||||
borderWidth: 1,
|
||||
width: "100%",
|
||||
},
|
||||
outlinedButtonLabel: {
|
||||
color: "#6200EE",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
header: {
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: -8,
|
||||
formContainer: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
};
|
||||
input: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default EnterCasdoorSdkConfig;
|
||||
|
|
162
Header.js
162
Header.js
|
@ -15,18 +15,22 @@
|
|||
import * as React from "react";
|
||||
import {Dimensions, StyleSheet, View} from "react-native";
|
||||
import {Appbar, Avatar, Menu, Text, TouchableRipple} from "react-native-paper";
|
||||
import Toast from "react-native-toast-message";
|
||||
import {useNotifications} from "react-native-notificated";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
|
||||
import useStore from "./useStorage";
|
||||
import useSyncStore from "./useSyncStore";
|
||||
import {useAccountSync} from "./useAccountStore";
|
||||
import LoginMethodSelector from "./LoginMethodSelector";
|
||||
|
||||
const {width} = Dimensions.get("window");
|
||||
|
||||
const Header = () => {
|
||||
const {userInfo, clearAll} = useStore();
|
||||
const syncError = useSyncStore(state => state.syncError);
|
||||
const {isSyncing, syncError, clearSyncError} = useAccountSync();
|
||||
const [showLoginPage, setShowLoginPage] = React.useState(false);
|
||||
const [menuVisible, setMenuVisible] = React.useState(false);
|
||||
const [loginMethod, setLoginMethod] = React.useState(null);
|
||||
const {notify} = useNotifications();
|
||||
|
||||
const openMenu = () => setMenuVisible(true);
|
||||
const closeMenu = () => setMenuVisible(false);
|
||||
|
@ -36,41 +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 = () => {
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "Sync Error",
|
||||
text2: syncError || "An unknown error occurred during synchronization.",
|
||||
autoHide: true,
|
||||
notify("error", {
|
||||
params: {
|
||||
title: "Error",
|
||||
description: syncError || "An unknown error occurred during synchronization.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Appbar.Header mode="center-aligned">
|
||||
<View style={styles.leftContainer}>
|
||||
{true && syncError && (
|
||||
<Appbar.Action
|
||||
icon="sync-alert"
|
||||
color="#E53935"
|
||||
size={24}
|
||||
onPress={handleSyncErrorPress}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Appbar.Header mode="small" style={styles.header}>
|
||||
<Appbar.Content
|
||||
title="Casdoor"
|
||||
titleStyle={styles.titleText}
|
||||
style={styles.titleContainer}
|
||||
title={
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.titleTextCasdoor}>Casdoor</Text>
|
||||
</View>
|
||||
}
|
||||
style={styles.titleWrapper}
|
||||
/>
|
||||
<View style={styles.rightContainer}>
|
||||
{userInfo !== null && (
|
||||
<Icon
|
||||
name={
|
||||
isSyncing
|
||||
? "cloud-sync-outline"
|
||||
: syncError
|
||||
? "cloud-off-outline"
|
||||
: "cloud-check-outline"
|
||||
}
|
||||
size={22}
|
||||
color={isSyncing ? "#FFC107" : syncError ? "#E53935" : "#4CAF50"}
|
||||
style={styles.syncIcon}
|
||||
onPress={(isSyncing || syncError === null) ? null : handleSyncErrorPress}
|
||||
/>
|
||||
)}
|
||||
<Menu
|
||||
visible={menuVisible}
|
||||
onDismiss={closeMenu}
|
||||
|
@ -83,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>
|
||||
}
|
||||
|
@ -105,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;
|
||||
|
|
164
HomePage.js
164
HomePage.js
|
@ -13,23 +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 Toast from "react-native-toast-message";
|
||||
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;
|
||||
|
@ -41,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);
|
||||
|
@ -51,19 +51,26 @@ export default function HomePage() {
|
|||
const {isConnected} = useNetInfo();
|
||||
const [canSync, setCanSync] = useState(false);
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
const swipeableRef = useRef(null);
|
||||
const db = SQLite.useSQLiteContext();
|
||||
const {userInfo, serverUrl, token} = useStore();
|
||||
const {startSync} = useSyncStore();
|
||||
const syncError = useSyncStore(state => state.syncError);
|
||||
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));
|
||||
|
@ -73,54 +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);
|
||||
const syncError = await startSync(userInfo, serverUrl, token);
|
||||
if (syncError) {
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "Sync error",
|
||||
text2: syncError,
|
||||
autoHide: true,
|
||||
notify("error", {
|
||||
params: {
|
||||
title: "Sync error",
|
||||
description: syncError,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "Sync success",
|
||||
text2: "All your accounts are up to date.",
|
||||
autoHide: true,
|
||||
notify("success", {
|
||||
params: {
|
||||
title: "Sync success",
|
||||
description: "All your accounts are up to date.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
refreshAccounts();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleAddAccount = async(accountData) => {
|
||||
setKey(prevKey => prevKey + 1);
|
||||
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) => {
|
||||
|
@ -132,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 = () => {
|
||||
|
@ -149,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);
|
||||
|
@ -165,6 +186,11 @@ export default function HomePage() {
|
|||
closeOptions();
|
||||
};
|
||||
|
||||
const openImportAccountModal = () => {
|
||||
showImportOptions();
|
||||
closeOptions();
|
||||
};
|
||||
|
||||
const closeEnterAccountModal = () => setShowEnterAccountModal(false);
|
||||
|
||||
const closeSwipeableMenu = () => {
|
||||
|
@ -176,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
|
||||
);
|
||||
};
|
||||
|
@ -187,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} />
|
||||
}
|
||||
|
@ -205,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>
|
||||
|
@ -215,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",
|
||||
}}
|
||||
|
@ -242,16 +271,16 @@ export default function HomePage() {
|
|||
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);
|
||||
setKey(prevKey => prevKey + 1);
|
||||
return {
|
||||
shouldRepeat: true,
|
||||
delay: 0,
|
||||
newInitialRemainingTime: TotpDatabase.calculateCountdown(),
|
||||
newInitialRemainingTime: calculateCountdown(),
|
||||
};
|
||||
}}
|
||||
strokeWidth={5}
|
||||
|
@ -278,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
|
||||
|
@ -299,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>
|
||||
|
||||
|
@ -318,7 +354,7 @@ export default function HomePage() {
|
|||
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
|
||||
}}
|
||||
>
|
||||
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} validateSecret={TotpDatabase.validateSecret} />
|
||||
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} validateSecret={validateSecret} />
|
||||
</Modal>
|
||||
</Portal>
|
||||
|
||||
|
@ -343,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
|
||||
|
|
|
@ -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};
|
||||
};
|
|
@ -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;
|
|
@ -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});
|
||||
}
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
101
ScanQRCode.js
101
ScanQRCode.js
|
@ -12,68 +12,59 @@
|
|||
// 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 {IconButton, 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();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{marginTop: "50%", flex: 1}} >
|
||||
<Portal>
|
||||
{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}} />
|
||||
</Portal>
|
||||
</View>
|
||||
);
|
||||
const handleOtpAuth = (data) => {
|
||||
const [, accountName] = data.match(/otpauth:\/\/totp\/([^?]+)/) || [];
|
||||
const [, secretKey] = data.match(/secret=([^&]+)/) || [];
|
||||
const [, issuer] = data.match(/issuer=([^&]+)/) || [];
|
||||
|
||||
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;
|
||||
|
|
38
SearchBar.js
38
SearchBar.js
|
@ -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;
|
||||
|
|
114
SettingPage.js
114
SettingPage.js
|
@ -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;
|
||||
|
|
316
TotpDatabase.js
316
TotpDatabase.js
|
@ -1,316 +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}`);
|
||||
}
|
||||
|
||||
export async function clearDatabase(db) {
|
||||
try {
|
||||
await db.execAsync("DELETE FROM accounts");
|
||||
await db.execAsync("DELETE FROM sqlite_sequence WHERE name='accounts'");
|
||||
await db.execAsync("PRAGMA user_version = 0");
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const generateToken = (secretKey) => {
|
||||
if (secretKey !== null && secretKey !== undefined && secretKey !== "") {
|
||||
try {
|
||||
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,
|
||||
};
|
||||
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.round(new Date().getTime() / 1000.0);
|
||||
return 30 - (now % 30);
|
||||
}
|
||||
|
||||
export function validateSecret(secret) {
|
||||
const base32Regex = /^[A-Z2-7]+=*$/i;
|
||||
if (!secret || secret.length % 8 !== 0) {
|
||||
return false;
|
||||
}
|
||||
return base32Regex.test(secret);
|
||||
}
|
||||
|
||||
async function updateLocalDatabase(db, mergedAccounts) {
|
||||
for (const account of mergedAccounts) {
|
||||
if (account.id) {
|
||||
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
18
api.js
|
@ -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") {
|
||||
|
|
21
app.json
21
app.json
|
@ -2,11 +2,12 @@
|
|||
"expo": {
|
||||
"name": "Casdoor",
|
||||
"slug": "casdoor-app",
|
||||
"version": "1.3.0",
|
||||
"version": "1.11.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"sdkVersion": "51.0.0",
|
||||
"scheme": "casdoor-app",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
|
@ -18,7 +19,7 @@
|
|||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "org.casdoor.casdoorapp",
|
||||
"buildNumber": "1.3.0"
|
||||
"buildNumber": "1.11.0"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
@ -26,7 +27,7 @@
|
|||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "org.casdoor.casdoorapp",
|
||||
"versionCode": 510010300
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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);
|
|
@ -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),
|
||||
})
|
||||
);
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
schema: "./db/schema.js",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
driver: "expo",
|
||||
};
|
|
@ -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`);
|
|
@ -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": {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1724248639995,
|
||||
"tag": "0000_smooth_owl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}));
|
|
@ -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;
|
Loading…
Reference in New Issue