feat: add cloud sync (#9)
* docs: update readme Signed-off-by: BSS ZU <274620705z@gmail.com> * feat: add cloud sync * feat: optimize token countdown display and update dependencies * chore: add refresh interval for sync timer --------- Signed-off-by: BSS ZU <274620705z@gmail.com>
This commit is contained in:
parent
fa8fba4477
commit
90b7351061
39
Account.js
39
Account.js
|
@ -15,20 +15,23 @@
|
||||||
import totp from "totp-generator";
|
import totp from "totp-generator";
|
||||||
|
|
||||||
class Account {
|
class Account {
|
||||||
constructor(description, secretCode, onUpdate, icon) {
|
constructor(accountName, issuer, secretKey, onUpdate) {
|
||||||
this.title = description;
|
this.accountName = accountName;
|
||||||
this.secretCode = secretCode;
|
this.secretKey = secretKey;
|
||||||
this.countdowns = 30;
|
|
||||||
this.timer = setInterval(this.updateCountdown.bind(this), 1000);
|
|
||||||
this.token = "";
|
|
||||||
this.onUpdate = onUpdate;
|
this.onUpdate = onUpdate;
|
||||||
|
this.token = this.generateToken();
|
||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
this.icon = icon ? icon : null;
|
this.issuer = issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateCountdown() {
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
return 30 - (currentTime % 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateToken() {
|
generateToken() {
|
||||||
if (this.secretCode !== null && this.secretCode !== undefined && this.secretCode !== "") {
|
if (this.secretKey !== null && this.secretKey !== undefined && this.secretKey !== "") {
|
||||||
const token = totp(this.secretCode);
|
const token = totp(this.secretKey);
|
||||||
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
|
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
|
||||||
return tokenWithSpace;
|
return tokenWithSpace;
|
||||||
}
|
}
|
||||||
|
@ -39,31 +42,21 @@ class Account {
|
||||||
this.onUpdate();
|
this.onUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCountdown() {
|
setAccountName(accountName) {
|
||||||
this.countdowns = Math.max(0, this.countdowns - 1);
|
this.accountName = accountName;
|
||||||
if (this.countdowns === 0) {
|
|
||||||
this.generateAndSetToken();
|
|
||||||
this.countdowns = 30;
|
|
||||||
}
|
|
||||||
this.onUpdate();
|
|
||||||
}
|
|
||||||
getTitle() {
|
|
||||||
return this.title;
|
|
||||||
}
|
|
||||||
setTitle(title) {
|
|
||||||
this.title = title;
|
|
||||||
this.setEditingStatus(false);
|
this.setEditingStatus(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditingStatus(status) {
|
setEditingStatus(status) {
|
||||||
this.isEditing = status;
|
this.isEditing = status;
|
||||||
this.onUpdate();
|
this.onUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
getEditStatus() {
|
getEditStatus() {
|
||||||
return this.isEditing;
|
return this.isEditing;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAccount() {
|
deleteAccount() {
|
||||||
clearInterval(this.timer);
|
|
||||||
this.onUpdate();
|
this.onUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
8
App.js
8
App.js
|
@ -14,18 +14,21 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {PaperProvider} from "react-native-paper";
|
import {PaperProvider} from "react-native-paper";
|
||||||
import NavigationBar from "./NavigationBar";
|
|
||||||
import {NavigationContainer} from "@react-navigation/native";
|
import {NavigationContainer} from "@react-navigation/native";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
|
import NavigationBar from "./NavigationBar";
|
||||||
import {UserProvider} from "./UserContext";
|
import {UserProvider} from "./UserContext";
|
||||||
import {CasdoorServerProvider} from "./CasdoorServerContext";
|
import {CasdoorServerProvider} from "./CasdoorServerContext";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
|
||||||
const [userInfo, setUserInfo] = React.useState(null);
|
const [userInfo, setUserInfo] = React.useState(null);
|
||||||
|
const [token, setToken] = React.useState(null);
|
||||||
const [casdoorServer, setCasdoorServer] = React.useState(null);
|
const [casdoorServer, setCasdoorServer] = React.useState(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CasdoorServerProvider value={{casdoorServer, setCasdoorServer}} >
|
<CasdoorServerProvider value={{casdoorServer, setCasdoorServer}} >
|
||||||
<UserProvider value={{userInfo, setUserInfo}} >
|
<UserProvider value={{userInfo, setUserInfo, token, setToken}} >
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<PaperProvider>
|
<PaperProvider>
|
||||||
<Header />
|
<Header />
|
||||||
|
@ -34,7 +37,6 @@ const App = () => {
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</CasdoorServerProvider>
|
</CasdoorServerProvider>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
@ -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 React, {useState} from "react";
|
||||||
|
import {View} from "react-native";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
|
||||||
|
const AvatarWithFallback = ({source, fallbackSource, size, style}) => {
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
if (!hasError) {
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AvatarWithFallback;
|
|
@ -30,7 +30,7 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
|
||||||
onWebviewClose: PropTypes.func.isRequired,
|
onWebviewClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
const [casdoorLoginURL, setCasdoorLoginURL] = React.useState("");
|
const [casdoorLoginURL, setCasdoorLoginURL] = React.useState("");
|
||||||
const {setUserInfo} = React.useContext(UserContext);
|
const {setUserInfo, setToken} = React.useContext(UserContext);
|
||||||
const [showConfigPage, setShowConfigPage] = React.useState(true);
|
const [showConfigPage, setShowConfigPage] = React.useState(true);
|
||||||
const {casdoorServer} = React.useContext(CasdoorServerContext);
|
const {casdoorServer} = React.useContext(CasdoorServerContext);
|
||||||
const handleHideConfigPage = () => {
|
const handleHideConfigPage = () => {
|
||||||
|
@ -53,6 +53,7 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
|
||||||
onWebviewClose();
|
onWebviewClose();
|
||||||
const token = await sdk.getAccessToken(navState.url);
|
const token = await sdk.getAccessToken(navState.url);
|
||||||
const userInfo = sdk.JwtDecode(token);
|
const userInfo = sdk.JwtDecode(token);
|
||||||
|
setToken(token);
|
||||||
setUserInfo(userInfo);
|
setUserInfo(userInfo);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -24,20 +24,20 @@ export default function EnterAccountDetails({onClose, onEdit, placeholder}) {
|
||||||
placeholder: PropTypes.string.isRequired,
|
placeholder: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [description, setDescription] = useState("");
|
const [accountName, setAccountName] = useState("");
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
onEdit(description);
|
onEdit(accountName);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<View style={{flex: 1, justifyContent: "center", alignItems: "center"}}>
|
<View style={{flex: 1, justifyContent: "center", alignItems: "center"}}>
|
||||||
<Text style={{fontSize: 24, marginBottom: 5}}>Enter new description</Text>
|
<Text style={{fontSize: 24, marginBottom: 5}}>Enter new account name</Text>
|
||||||
<View style={{flexDirection: "row", alignItems: "center"}}>
|
<View style={{flexDirection: "row", alignItems: "center"}}>
|
||||||
<IconButton icon="account-details" size={35} />
|
<IconButton icon="account-details" size={35} />
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={description}
|
value={accountName}
|
||||||
onChangeText={(text) => setDescription(text)}
|
onChangeText={(text) => setAccountName(text)}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
|
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -23,8 +23,8 @@ export default function EnterAccountDetails({onClose, onAdd}) {
|
||||||
onAdd: PropTypes.func.isRequired,
|
onAdd: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [description, setDescription] = useState("");
|
const [accountName, setAccountName] = useState("");
|
||||||
const [secretCode, setSecretCode] = useState("");
|
const [secretKey, setSecretKey] = useState("");
|
||||||
|
|
||||||
const [visible, setVisible] = React.useState(false);
|
const [visible, setVisible] = React.useState(false);
|
||||||
const openMenu = () => setVisible(true);
|
const openMenu = () => setVisible(true);
|
||||||
|
@ -37,9 +37,9 @@ export default function EnterAccountDetails({onClose, onAdd}) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddAccount = () => {
|
const handleAddAccount = () => {
|
||||||
onAdd({description, secretCode});
|
onAdd({accountName, secretKey});
|
||||||
setDescription("");
|
setAccountName("");
|
||||||
setSecretCode("");
|
setSecretKey("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -48,11 +48,11 @@ export default function EnterAccountDetails({onClose, onAdd}) {
|
||||||
<View style={{flexDirection: "row", alignItems: "center"}}>
|
<View style={{flexDirection: "row", alignItems: "center"}}>
|
||||||
<IconButton icon="account-details" size={35} />
|
<IconButton icon="account-details" size={35} />
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Description"
|
label="Account Name"
|
||||||
placeholder="Description"
|
placeholder="Account Name"
|
||||||
value={description}
|
value={accountName}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
onChangeText={(text) => setDescription(text)}
|
onChangeText={(text) => setAccountName(text)}
|
||||||
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
|
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
@ -60,11 +60,11 @@ export default function EnterAccountDetails({onClose, onAdd}) {
|
||||||
<View style={{flexDirection: "row", alignItems: "center"}}>
|
<View style={{flexDirection: "row", alignItems: "center"}}>
|
||||||
<IconButton icon="account-key" size={35} />
|
<IconButton icon="account-key" size={35} />
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Secret code"
|
label="Secret Key"
|
||||||
placeholder="Secret code"
|
placeholder="Secret Key"
|
||||||
value={secretCode}
|
value={secretKey}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
onChangeText={(text) => setSecretCode(text)}
|
onChangeText={(text) => setSecretKey(text)}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
|
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
|
||||||
/>
|
/>
|
||||||
|
|
41
Header.js
41
Header.js
|
@ -15,11 +15,11 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {Appbar, Avatar, Button, Menu, Text} from "react-native-paper";
|
import {Appbar, Avatar, Button, Menu, Text} from "react-native-paper";
|
||||||
import UserContext from "./UserContext";
|
import UserContext from "./UserContext";
|
||||||
import {View} from "react-native";
|
import {StyleSheet, View} from "react-native";
|
||||||
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
|
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const {userInfo, setUserInfo} = React.useContext(UserContext);
|
const {userInfo, setUserInfo, setToken} = React.useContext(UserContext);
|
||||||
const [showLoginPage, setShowLoginPage] = React.useState(false);
|
const [showLoginPage, setShowLoginPage] = React.useState(false);
|
||||||
const [menuVisible, setMenuVisible] = React.useState(false);
|
const [menuVisible, setMenuVisible] = React.useState(false);
|
||||||
const openMenu = () => setMenuVisible(true);
|
const openMenu = () => setMenuVisible(true);
|
||||||
|
@ -35,33 +35,50 @@ const Header = () => {
|
||||||
const handleCasdoorLogout = () => {
|
const handleCasdoorLogout = () => {
|
||||||
CasdoorLogout();
|
CasdoorLogout();
|
||||||
setUserInfo(null);
|
setUserInfo(null);
|
||||||
|
setToken(null);
|
||||||
};
|
};
|
||||||
const handleHideLoginPage = () => {
|
const handleHideLoginPage = () => {
|
||||||
setShowLoginPage(false);
|
setShowLoginPage(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Appbar.Header style={{height: 40}}>
|
<Appbar.Header style={{height: 40}}>
|
||||||
<Appbar.Content title="Casdoor" />
|
<View style={[StyleSheet.absoluteFill, {alignItems: "center", justifyContent: "center"}]} pointerEvents="box-none">
|
||||||
|
<Appbar.Content title="Casdoor" style={{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}} />
|
||||||
|
</View>
|
||||||
|
<View style={{flex: 1}} />
|
||||||
<Menu
|
<Menu
|
||||||
visible={menuVisible}
|
visible={menuVisible}
|
||||||
anchor={
|
anchor={
|
||||||
<Button
|
<Button
|
||||||
style={{marginRight: 10, backgroundColor: "transparent", height: 40}}
|
style={{
|
||||||
|
marginRight: 2,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
height: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
onPress={userInfo === null ? handleCasdoorLogin : openMenu}
|
onPress={userInfo === null ? handleCasdoorLogin : openMenu}
|
||||||
>
|
>
|
||||||
{
|
<View style={{flexDirection: "row", alignItems: "center"}}>
|
||||||
userInfo === null ?
|
<View style={{position: "relative", height: 32, justifyContent: "flex-end", marginRight: 8}}>
|
||||||
null :
|
<Text variant="titleMedium">
|
||||||
|
{userInfo === null ? "Login" : userInfo.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{userInfo !== null && (
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
size={32}
|
size={32}
|
||||||
source={{uri: userInfo.avatar}}
|
source={{uri: userInfo.avatar}}
|
||||||
style={{marginRight: 10, backgroundColor: "transparent"}}
|
style={{backgroundColor: "transparent"}}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
<Text style={{marginRight: 10}} variant="titleMedium">
|
</View>
|
||||||
{userInfo === null ? "Login" : userInfo.name}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
onDismiss={closeMenu}
|
onDismiss={closeMenu}
|
||||||
|
|
195
HomePage.js
195
HomePage.js
|
@ -12,40 +12,92 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import * as React from "react";
|
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||||
import {Dimensions, FlatList, Text, TouchableOpacity, View} from "react-native";
|
import {Dimensions, FlatList, RefreshControl, Text, TouchableOpacity, View} from "react-native";
|
||||||
import {Avatar, Divider, IconButton, List, Modal, Portal} from "react-native-paper";
|
import {Divider, IconButton, List, Modal, Portal} from "react-native-paper";
|
||||||
import SearchBar from "./SearchBar";
|
|
||||||
import {GestureHandlerRootView, Swipeable} from "react-native-gesture-handler";
|
import {GestureHandlerRootView, Swipeable} from "react-native-gesture-handler";
|
||||||
|
import {CountdownCircleTimer} from "react-native-countdown-circle-timer";
|
||||||
|
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
import EnterAccountDetails from "./EnterAccountDetails";
|
import EnterAccountDetails from "./EnterAccountDetails";
|
||||||
import Account from "./Account";
|
|
||||||
import ScanQRCode from "./ScanQRCode";
|
import ScanQRCode from "./ScanQRCode";
|
||||||
import EditAccountDetails from "./EditAccountDetails";
|
import EditAccountDetails from "./EditAccountDetails";
|
||||||
|
import AvatarWithFallback from "./AvatarWithFallback";
|
||||||
|
import Account from "./Account";
|
||||||
|
import UserContext from "./UserContext";
|
||||||
|
import CasdoorServerContext from "./CasdoorServerContext";
|
||||||
|
import useSync, {SYNC_STATUS} from "./useSync";
|
||||||
|
|
||||||
|
const {width, height} = Dimensions.get("window");
|
||||||
|
const REFRESH_INTERVAL = 10000;
|
||||||
|
const OFFSET_X = width * 0.45;
|
||||||
|
const OFFSET_Y = height * 0.2;
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [isPlusButton, setIsPlusButton] = React.useState(true);
|
const [isPlusButton, setIsPlusButton] = useState(true);
|
||||||
const [showOptions, setShowOptions] = React.useState(false);
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
const [showEnterAccountModal, setShowEnterAccountModal] = React.useState(false);
|
const [showEnterAccountModal, setShowEnterAccountModal] = useState(false);
|
||||||
const [accountList, setAccountList] = React.useState([]);
|
const [accountList, setAccountList] = useState([]);
|
||||||
const [searchQuery, setSearchQuery] = React.useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [filteredData, setFilteredData] = React.useState(accountList);
|
const [filteredData, setFilteredData] = useState(accountList);
|
||||||
const [showScanner, setShowScanner] = React.useState(false);
|
const [showScanner, setShowScanner] = useState(false);
|
||||||
const [showEditAccountModal, setShowEditAccountModal] = React.useState(false);
|
const [showEditAccountModal, setShowEditAccountModal] = useState(false);
|
||||||
const swipeableRef = React.useRef(null);
|
const [placeholder, setPlaceholder] = useState("");
|
||||||
const [placeholder, setPlaceholder] = React.useState("");
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const closeEditAccountModal = () => {
|
|
||||||
setShowEditAccountModal(false);
|
const swipeableRef = useRef(null);
|
||||||
|
const isSyncing = useRef(false);
|
||||||
|
|
||||||
|
const {userInfo, token} = useContext(UserContext);
|
||||||
|
const {casdoorServer} = useContext(CasdoorServerContext);
|
||||||
|
const {syncAccounts, syncSignal, resetSyncSignal, addToSyncData} = useSync(userInfo, token, casdoorServer);
|
||||||
|
|
||||||
|
const handleSync = async() => {
|
||||||
|
if (isSyncing.current) {return;}
|
||||||
|
isSyncing.current = true;
|
||||||
|
try {
|
||||||
|
const syncedAccounts = await syncAccounts();
|
||||||
|
if (syncedAccounts.success && syncedAccounts.accountList) {
|
||||||
|
accountList.forEach(account => account.deleteAccount());
|
||||||
|
const newAccountList = syncedAccounts.accountList.map(account => new Account(
|
||||||
|
account.accountName,
|
||||||
|
account.issuer,
|
||||||
|
account.secretKey,
|
||||||
|
onUpdate
|
||||||
|
));
|
||||||
|
setAccountList(newAccountList);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSyncing.current = false;
|
||||||
|
setRefreshing(false);
|
||||||
|
resetSyncSignal();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((syncSignal || refreshing) && !isSyncing.current) {
|
||||||
|
handleSync();
|
||||||
|
}
|
||||||
|
}, [syncSignal, refreshing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(handleSync, REFRESH_INTERVAL);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [handleSync]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditAccountModal = () => setShowEditAccountModal(false);
|
||||||
|
|
||||||
const handleScanPress = () => {
|
const handleScanPress = () => {
|
||||||
setShowScanner(true);
|
setShowScanner(true);
|
||||||
setIsPlusButton(true);
|
setIsPlusButton(true);
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseScanner = () => {
|
const handleCloseScanner = () => setShowScanner(false);
|
||||||
setShowScanner(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlusButton = () => {
|
const togglePlusButton = () => {
|
||||||
setIsPlusButton(!isPlusButton);
|
setIsPlusButton(!isPlusButton);
|
||||||
|
@ -63,46 +115,43 @@ export default function HomePage() {
|
||||||
closeOptions();
|
closeOptions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEnterAccountModal = () => {
|
const closeEnterAccountModal = () => setShowEnterAccountModal(false);
|
||||||
setShowEnterAccountModal(false);
|
|
||||||
};
|
const onUpdate = () => setAccountList(prev => [...prev]);
|
||||||
|
|
||||||
const onUpdate = () => {
|
|
||||||
setAccountList(prevList => [...prevList]);
|
|
||||||
};
|
|
||||||
const handleAddAccount = (accountData) => {
|
const handleAddAccount = (accountData) => {
|
||||||
const newAccount = new Account(accountData.description, accountData.secretCode, onUpdate, accountData.icon);
|
const newAccount = new Account(accountData.accountName, accountData.issuer, accountData.secretKey, onUpdate);
|
||||||
const token = newAccount.generateToken();
|
addToSyncData(newAccount, SYNC_STATUS.ADD);
|
||||||
newAccount.token = token;
|
newAccount.token = newAccount.generateToken();
|
||||||
|
|
||||||
setAccountList(prevList => [...prevList, newAccount]);
|
setAccountList(prev => [...prev, newAccount]);
|
||||||
closeEnterAccountModal();
|
closeEnterAccountModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = (accountDescp) => {
|
const handleDeleteAccount = (accountName) => {
|
||||||
const accountToDelete = accountList.find(account => {
|
const accountToDelete = accountList.find(account => account.accountName === accountName);
|
||||||
return account.getTitle() === accountDescp;
|
|
||||||
});
|
|
||||||
if (accountToDelete) {
|
if (accountToDelete) {
|
||||||
accountToDelete.deleteAccount();
|
accountToDelete.deleteAccount();
|
||||||
|
addToSyncData(accountToDelete, SYNC_STATUS.DELETE);
|
||||||
}
|
}
|
||||||
setAccountList(prevList => prevList.filter(account => account.getTitle() !== accountDescp));
|
setAccountList(prevList => prevList.filter(account => account.accountName !== accountName));
|
||||||
};
|
};
|
||||||
const handleEditAccount = (accountDescp) => {
|
|
||||||
closeSwipeableMenu();
|
|
||||||
setPlaceholder(accountDescp);
|
|
||||||
setShowEditAccountModal(true);
|
|
||||||
const accountToEdit = accountList.find(account => account.getTitle() === accountDescp);
|
|
||||||
|
|
||||||
|
const handleEditAccount = (accountName) => {
|
||||||
|
closeSwipeableMenu();
|
||||||
|
const accountToEdit = accountList.find(account => account.accountName === accountName);
|
||||||
if (accountToEdit) {
|
if (accountToEdit) {
|
||||||
|
setPlaceholder(accountToEdit.accountName);
|
||||||
|
setShowEditAccountModal(true);
|
||||||
accountToEdit.setEditingStatus(true);
|
accountToEdit.setEditingStatus(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAccountEdit = (accountDescp) => {
|
const onAccountEdit = (newAccountName) => {
|
||||||
const accountToEdit = accountList.find(account => account.getEditStatus() === true);
|
const accountToEdit = accountList.find(account => account.getEditStatus() === true);
|
||||||
if (accountToEdit) {
|
if (accountToEdit) {
|
||||||
accountToEdit.setTitle(accountDescp);
|
addToSyncData(accountToEdit, SYNC_STATUS.EDIT, newAccountName);
|
||||||
|
accountToEdit.setAccountName(newAccountName);
|
||||||
}
|
}
|
||||||
setPlaceholder("");
|
setPlaceholder("");
|
||||||
closeEditAccountModal();
|
closeEditAccountModal();
|
||||||
|
@ -116,28 +165,21 @@ export default function HomePage() {
|
||||||
|
|
||||||
const handleSearch = (query) => {
|
const handleSearch = (query) => {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
|
setFilteredData(query.trim() !== ""
|
||||||
if (query.trim() !== "") {
|
? accountList.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
|
||||||
const filteredResults = accountList.filter(item =>
|
: accountList
|
||||||
item.title.toLowerCase().includes(query.toLowerCase())
|
|
||||||
);
|
);
|
||||||
setFilteredData(filteredResults);
|
|
||||||
} else {
|
|
||||||
setFilteredData(accountList);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {width, height} = Dimensions.get("window");
|
|
||||||
|
|
||||||
const offsetX = width * 0.45;
|
|
||||||
const offsetY = height * 0.2;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{flex: 1}}>
|
<View style={{flex: 1}}>
|
||||||
<SearchBar onSearch={handleSearch} />
|
<SearchBar onSearch={handleSearch} />
|
||||||
<FlatList
|
<FlatList
|
||||||
data={searchQuery.trim() !== "" ? filteredData : accountList}
|
data={searchQuery.trim() !== "" ? filteredData : accountList}
|
||||||
keyExtractor={(item, index) => index.toString()}
|
keyExtractor={(item, index) => index.toString()}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
renderItem={({item}) => (
|
renderItem={({item}) => (
|
||||||
<GestureHandlerRootView>
|
<GestureHandlerRootView>
|
||||||
<Swipeable
|
<Swipeable
|
||||||
|
@ -146,13 +188,13 @@ export default function HomePage() {
|
||||||
<View style={{flexDirection: "row", alignItems: "center"}}>
|
<View style={{flexDirection: "row", alignItems: "center"}}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{height: 70, width: 80, backgroundColor: "#E6DFF3", alignItems: "center", justifyContent: "center"}}
|
style={{height: 70, width: 80, backgroundColor: "#E6DFF3", alignItems: "center", justifyContent: "center"}}
|
||||||
onPress={handleEditAccount.bind(this, item.title)}
|
onPress={handleEditAccount.bind(this, item.accountName)}
|
||||||
>
|
>
|
||||||
<Text>Edit</Text>
|
<Text>Edit</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{height: 70, width: 80, backgroundColor: "#FFC0CB", alignItems: "center", justifyContent: "center"}}
|
style={{height: 70, width: 80, backgroundColor: "#FFC0CB", alignItems: "center", justifyContent: "center"}}
|
||||||
onPress={handleDeleteAccount.bind(this, item.title)}
|
onPress={handleDeleteAccount.bind(this, item.accountName)}
|
||||||
>
|
>
|
||||||
<Text>Delete</Text>
|
<Text>Delete</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
@ -160,20 +202,36 @@ export default function HomePage() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<List.Item
|
<List.Item
|
||||||
style={{height: 80, alignItems: "center", justifyContent: "center"}}
|
style={{height: 80, alignItems: "center", justifyContent: "center", marginLeft: 10}}
|
||||||
title={
|
title={
|
||||||
<View>
|
<View>
|
||||||
<Text style={{fontSize: 20}}>{item.title}</Text>
|
<Text style={{fontSize: 20}}>{item.accountName}</Text>
|
||||||
<View style={{flexDirection: "row", alignItems: "center"}}>
|
|
||||||
<Text style={{fontSize: 35, width: 180}}>{item.token}</Text>
|
<Text style={{fontSize: 35, width: 180}}>{item.token}</Text>
|
||||||
<Text style={{fontSize: 20, width: 40}}>{item.countdowns}s</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
left={(props) => (
|
left={(props) => (
|
||||||
item.icon ?
|
<AvatarWithFallback
|
||||||
<Avatar.Image size={60} source={{uri: item.icon}} style={{marginLeft: 20, marginRight: 20, borderRadius: 10, backgroundColor: "transparent"}} />
|
source={{uri: item.issuer ? `https://cdn.casbin.org/img/social_${item.issuer.toLowerCase()}.png` : "https://cdn.casbin.org/img/social_default.png"}}
|
||||||
: <Avatar.Icon size={80} icon={"account"} color={"black"} style={{marginLeft: 10, marginRight: 10, borderRadius: 10, backgroundColor: "transparent"}} />
|
fallbackSource={{uri: "https://cdn.casbin.org/img/social_default.png"}}
|
||||||
|
size={60}
|
||||||
|
style={{marginLeft: 10, marginRight: 10, borderRadius: 10, backgroundColor: "transparent"}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
right={(props) => (
|
||||||
|
<CountdownCircleTimer
|
||||||
|
isPlaying={true}
|
||||||
|
duration={30}
|
||||||
|
initialRemainingTime={item.calculateCountdown()}
|
||||||
|
colors={["#004777", "#0072A0", "#0099CC", "#FF6600", "#CC3300", "#A30000"]}
|
||||||
|
colorsTime={[30, 24, 18, 12, 6, 0]}
|
||||||
|
size={60}
|
||||||
|
onComplete={() => {item.generateAndSetToken(); return {shouldRepeat: true};}}
|
||||||
|
strokeWidth={5}
|
||||||
|
>
|
||||||
|
{({remainingTime}) => (
|
||||||
|
<Text style={{fontSize: 20}}>{remainingTime}s</Text>
|
||||||
|
)}
|
||||||
|
</CountdownCircleTimer>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Swipeable>
|
</Swipeable>
|
||||||
|
@ -214,6 +272,7 @@ export default function HomePage() {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
<Portal>
|
<Portal>
|
||||||
<Modal
|
<Modal
|
||||||
visible={showEnterAccountModal}
|
visible={showEnterAccountModal}
|
||||||
|
@ -227,12 +286,13 @@ export default function HomePage() {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: [{translateX: -offsetX}, {translateY: -offsetY}],
|
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} />
|
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
<Portal>
|
<Portal>
|
||||||
<Modal
|
<Modal
|
||||||
visible={showEditAccountModal}
|
visible={showEditAccountModal}
|
||||||
|
@ -246,12 +306,13 @@ export default function HomePage() {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: [{translateX: -offsetX}, {translateY: -offsetY}],
|
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditAccountDetails onClose={closeEditAccountModal} onEdit={onAccountEdit} placeholder={placeholder} />
|
<EditAccountDetails onClose={closeEditAccountModal} onEdit={onAccountEdit} placeholder={placeholder} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
{showScanner && (
|
{showScanner && (
|
||||||
<ScanQRCode onClose={handleCloseScanner} showScanner={showScanner} onAdd={handleAddAccount} />
|
<ScanQRCode onClose={handleCloseScanner} showScanner={showScanner} onAdd={handleAddAccount} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -6,7 +6,7 @@ Casdoor Authenticator App is a mobile application for iOS and Android that provi
|
||||||
|
|
||||||
- [x] Multi-platform support (iOS/Android)
|
- [x] Multi-platform support (iOS/Android)
|
||||||
- [x] TOTP-based multi-factor authentication
|
- [x] TOTP-based multi-factor authentication
|
||||||
- [ ] Account synchronization with Casdoor
|
- [x] Account synchronization with Casdoor
|
||||||
- [ ] Integration with Casdoor's central service and desktop client
|
- [ ] Integration with Casdoor's central service and desktop client
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
@ -21,7 +21,7 @@ npm install && npm run start
|
||||||
|
|
||||||
- Open the app on your mobile device.
|
- Open the app on your mobile device.
|
||||||
- Scan QR codes to add accounts and generate TOTP codes for login.
|
- Scan QR codes to add accounts and generate TOTP codes for login.
|
||||||
- Log in to your accounts for synchronization with Casdoor. (In progress)
|
- Log in to your accounts for synchronization with Casdoor.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {Dimensions, Text, View} from "react-native";
|
import {Dimensions, Text, View} from "react-native";
|
||||||
import {IconButton, Modal, Portal} from "react-native-paper";
|
import {IconButton, Modal, Portal} from "react-native-paper";
|
||||||
import {BarCodeScanner} from "expo-barcode-scanner";
|
import {Camera, CameraView} from "expo-camera";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
const ScanQRCode = ({onClose, showScanner, onAdd}) => {
|
const ScanQRCode = ({onClose, showScanner, onAdd}) => {
|
||||||
|
@ -28,10 +28,12 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
|
||||||
const [hasPermission, setHasPermission] = useState(null);
|
const [hasPermission, setHasPermission] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async() => {
|
const getCameraPermissions = async() => {
|
||||||
const {status} = await BarCodeScanner.requestPermissionsAsync();
|
const {status} = await Camera.requestCameraPermissionsAsync();
|
||||||
setHasPermission(status === "granted");
|
setHasPermission(status === "granted");
|
||||||
})();
|
};
|
||||||
|
|
||||||
|
getCameraPermissions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeOptions = () => {
|
const closeOptions = () => {
|
||||||
|
@ -42,11 +44,11 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
|
||||||
// type org.iso.QRCode
|
// type org.iso.QRCode
|
||||||
// data otpauth://totp/casdoor:built-in/admin?algorithm=SHA1&digits=6&issuer=casdoor&period=30&secret=DL5XI33M772GSGU73GJPCOIBNJE7TG3J
|
// 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!`);
|
// console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
|
||||||
const description = data.match(/otpauth:\/\/totp\/([^?]+)/); // description casdoor:built-in/admin
|
const accountName = data.match(/otpauth:\/\/totp\/([^?]+)/); // accountName casdoor:built-in/admin
|
||||||
const secretCode = data.match(/secret=([^&]+)/); // secretCode II5UO7HIA3SPVXAB6KPAIXZ33AQP7C3R
|
const secretKey = data.match(/secret=([^&]+)/); // secretKey II5UO7HIA3SPVXAB6KPAIXZ33AQP7C3R
|
||||||
const icon = data.match(/issuer=([^&]+)/);
|
const issuer = data.match(/issuer=([^&]+)/);
|
||||||
if (description && secretCode) {
|
if (accountName && secretKey) {
|
||||||
onAdd({description: description[1], secretCode: secretCode[1], icon: `https://cdn.casbin.org/img/social_${icon && icon[1].toLowerCase()}.png`});
|
onAdd({accountName: accountName[1], issuer: issuer[1], secretKey: secretKey[1]});
|
||||||
}
|
}
|
||||||
|
|
||||||
closeOptions();
|
closeOptions();
|
||||||
|
@ -77,8 +79,11 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
|
||||||
) : hasPermission === false ? (
|
) : hasPermission === false ? (
|
||||||
<Text style={{marginLeft: "20%", marginRight: "20%"}}>No access to camera</Text>
|
<Text style={{marginLeft: "20%", marginRight: "20%"}}>No access to camera</Text>
|
||||||
) : (
|
) : (
|
||||||
<BarCodeScanner
|
<CameraView
|
||||||
onBarCodeScanned={handleBarCodeScanned}
|
onBarcodeScanned={handleBarCodeScanned}
|
||||||
|
barcodeScannerSettings={{
|
||||||
|
barcodeTypes: ["qr", "pdf417"],
|
||||||
|
}}
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -28,7 +28,7 @@ const SearchBar = ({onSearch}) => {
|
||||||
onChangeText={onChangeSearch}
|
onChangeText={onChangeSearch}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
style={{height: 48, backgroundColor: "#E6DFF3"}}
|
style={{height: 48, backgroundColor: "#E6DFF3"}}
|
||||||
inputStyle={{textAlignVertical: "center", justifyContent: "center", alignItems: "center"}}
|
inputStyle={{textAlignVertical: "center", justifyContent: "center", alignItems: "center", minHeight: 0}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,14 +20,16 @@ import UserContext from "./UserContext";
|
||||||
|
|
||||||
const SettingPage = () => {
|
const SettingPage = () => {
|
||||||
const [showLoginPage, setShowLoginPage] = React.useState(false);
|
const [showLoginPage, setShowLoginPage] = React.useState(false);
|
||||||
const {userInfo, setUserInfo} = React.useContext(UserContext);
|
const {userInfo, setUserInfo, setToken} = React.useContext(UserContext);
|
||||||
const handleCasdoorLogin = () => {
|
const handleCasdoorLogin = () => {
|
||||||
setShowLoginPage(true);
|
setShowLoginPage(true);
|
||||||
};
|
};
|
||||||
const handleCasdoorLogout = () => {
|
const handleCasdoorLogout = () => {
|
||||||
CasdoorLogout();
|
CasdoorLogout();
|
||||||
setUserInfo(null);
|
setUserInfo(null);
|
||||||
|
setToken(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHideLoginPage = () => {
|
const handleHideLoginPage = () => {
|
||||||
setShowLoginPage(false);
|
setShowLoginPage(false);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
const timeout = (ms) => {
|
||||||
|
return new Promise((_, reject) => setTimeout(() => reject(new Error("Request timed out")), ms));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMfaAccounts = async(serverUrl, owner, name, token, timeoutMs = TIMEOUT_MS) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const {signal} = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
fetch(`${serverUrl}/api/get-user?id=${owner}/${encodeURIComponent(name)}&access_token=${token}`, {
|
||||||
|
method: "GET",
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
timeout(timeoutMs),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await result.json();
|
||||||
|
return {updatedTime: res.data.updatedTime, mfaAccounts: res.data.mfaAccounts};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
throw new Error("Request timed out");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateMfaAccounts = async(serverUrl, owner, name, newMfaAccounts, token, timeoutMs = TIMEOUT_MS) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const {signal} = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const getUserResult = await Promise.race([
|
||||||
|
fetch(`${serverUrl}/api/get-user?id=${owner}/${encodeURIComponent(name)}&access_token=${token}`, {
|
||||||
|
method: "GET",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
timeout(timeoutMs),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userData = await getUserResult.json();
|
||||||
|
userData.data.mfaAccounts = newMfaAccounts;
|
||||||
|
|
||||||
|
const updateResult = await Promise.race([
|
||||||
|
fetch(`${serverUrl}/api/update-user?id=${owner}/${encodeURIComponent(name)}&access_token=${token}`, {
|
||||||
|
method: "POST",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(userData.data),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
timeout(timeoutMs),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await updateResult.json();
|
||||||
|
return {status: res.status, data: res.data};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
throw new Error("Request timed out");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
|
@ -9,30 +9,35 @@
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/webpack-config": "^19.0.0",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-navigation/bottom-tabs": "^6.5.8",
|
"@react-navigation/bottom-tabs": "^6.5.8",
|
||||||
"@react-navigation/native": "^6.1.7",
|
"@react-navigation/native": "^6.1.7",
|
||||||
"casdoor-react-native-sdk": "1.1.0",
|
"casdoor-react-native-sdk": "1.1.0",
|
||||||
"eslint-plugin-import": "^2.28.1",
|
"eslint-plugin-import": "^2.28.1",
|
||||||
"expo": "~49.0.8",
|
"expo": "~51.0.22",
|
||||||
"expo-barcode-scanner": "^12.5.3",
|
"expo-camera": "~15.0.14",
|
||||||
"expo-status-bar": "~1.6.0",
|
"expo-image": "^1.12.13",
|
||||||
|
"expo-status-bar": "~1.12.1",
|
||||||
|
"expo-updates": "~0.25.21",
|
||||||
"hotp-totp": "^1.0.6",
|
"hotp-totp": "^1.0.6",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "^0.72.5",
|
"react-native": "0.74.3",
|
||||||
"react-native-gesture-handler": "^2.12.1",
|
"react-native-countdown-circle-timer": "^3.2.1",
|
||||||
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-paper": "^5.10.3",
|
"react-native-paper": "^5.10.3",
|
||||||
|
"react-native-root-toast": "^3.6.0",
|
||||||
|
"react-native-svg": "15.2.0",
|
||||||
"react-native-web": "~0.19.6",
|
"react-native-web": "~0.19.6",
|
||||||
"react-native-webview": "13.2.2",
|
"react-native-webview": "13.8.6",
|
||||||
"totp-generator": "^0.0.14"
|
"totp-generator": "^0.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.24.0",
|
||||||
"@babel/eslint-parser": "^7.18.9",
|
"@babel/eslint-parser": "^7.18.9",
|
||||||
"@babel/preset-react": "^7.18.6",
|
"@babel/preset-react": "^7.18.6",
|
||||||
"@types/react": "~18.2.14",
|
"@types/react": "~18.2.79",
|
||||||
"@types/react-native": "^0.72.2",
|
"@types/react-native": "^0.72.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"eslint": "8.22.0",
|
"eslint": "8.22.0",
|
||||||
|
|
|
@ -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 {useEffect, useState} from "react";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
|
||||||
|
const useNetworkStatus = () => {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener(state => {
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isConnected;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useNetworkStatus;
|
|
@ -0,0 +1,129 @@
|
||||||
|
// 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 {useCallback, useEffect, useState} from "react";
|
||||||
|
import * as api from "./api";
|
||||||
|
import useNetworkStatus from "./useNetworkStatus";
|
||||||
|
|
||||||
|
export const SYNC_STATUS = {
|
||||||
|
ADD: "add",
|
||||||
|
EDIT: "edit",
|
||||||
|
DELETE: "delete",
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySync = (serverAccountList, toSyncData) => {
|
||||||
|
return toSyncData.reduce((acc, syncItem) => {
|
||||||
|
switch (syncItem.status) {
|
||||||
|
case SYNC_STATUS.ADD:
|
||||||
|
if (!acc.some(account => account.accountName === syncItem.data.accountName && account.secretKey === syncItem.data.secretKey)) {
|
||||||
|
acc.push(syncItem.data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SYNC_STATUS.EDIT:
|
||||||
|
const indexToEdit = acc.findIndex(account => account.accountName === syncItem.data.accountName && account.secretKey === syncItem.data.secretKey);
|
||||||
|
if (indexToEdit !== -1) {
|
||||||
|
acc[indexToEdit] = {...acc[indexToEdit], ...syncItem.data, accountName: syncItem.newAccountName};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SYNC_STATUS.DELETE:
|
||||||
|
return acc.filter(account => !(account.accountName === syncItem.data.accountName && account.secretKey === syncItem.data.secretKey));
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [...serverAccountList]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSync = (userInfo, token, casdoorServer) => {
|
||||||
|
const [toSyncData, setToSyncData] = useState([]);
|
||||||
|
const [syncSignal, setSyncSignal] = useState(false);
|
||||||
|
const isConnected = useNetworkStatus();
|
||||||
|
const [canSync, setCanSync] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCanSync(userInfo && casdoorServer && isConnected);
|
||||||
|
}, [userInfo, casdoorServer, isConnected]);
|
||||||
|
|
||||||
|
const triggerSync = useCallback(() => {
|
||||||
|
if (canSync) {
|
||||||
|
setSyncSignal(true);
|
||||||
|
}
|
||||||
|
}, [canSync]);
|
||||||
|
|
||||||
|
const resetSyncSignal = useCallback(() => {
|
||||||
|
setSyncSignal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToSyncData = useCallback((toSyncAccount, status, newAccountName = null) => {
|
||||||
|
setToSyncData([...toSyncData, {
|
||||||
|
data: {
|
||||||
|
accountName: toSyncAccount.accountName,
|
||||||
|
issuer: toSyncAccount.issuer,
|
||||||
|
secretKey: toSyncAccount.secretKey,
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
newAccountName: newAccountName || "",
|
||||||
|
}]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncAccounts = useCallback(async() => {
|
||||||
|
if (!canSync) {return {success: false, error: "Cannot sync"};}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {mfaAccounts: serverAccountList} = await api.getMfaAccounts(
|
||||||
|
casdoorServer.serverUrl,
|
||||||
|
userInfo.owner,
|
||||||
|
userInfo.name,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!serverAccountList) {
|
||||||
|
return {success: false, error: "Failed to get accounts"};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSyncData.length === 0) {
|
||||||
|
return {success: true, accountList: serverAccountList};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedServerAccountList = applySync(serverAccountList, toSyncData);
|
||||||
|
|
||||||
|
const {status} = await api.updateMfaAccounts(
|
||||||
|
casdoorServer.serverUrl,
|
||||||
|
userInfo.owner,
|
||||||
|
userInfo.name,
|
||||||
|
updatedServerAccountList,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status === "ok") {setToSyncData([]);}
|
||||||
|
return {success: status === "ok", accountList: updatedServerAccountList};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {success: false, error: error.message};
|
||||||
|
}
|
||||||
|
}, [canSync, casdoorServer, userInfo, token, toSyncData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canSync) {triggerSync();}
|
||||||
|
}, [canSync, toSyncData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncSignal,
|
||||||
|
resetSyncSignal,
|
||||||
|
syncAccounts,
|
||||||
|
addToSyncData,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSync;
|
Loading…
Reference in New Issue