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:
IZUMI-Zu 2024-07-28 16:36:03 +08:00 committed by GitHub
parent fa8fba4477
commit 90b7351061
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 4019 additions and 6857 deletions

View File

@ -15,20 +15,23 @@
import totp from "totp-generator";
class Account {
constructor(description, secretCode, onUpdate, icon) {
this.title = description;
this.secretCode = secretCode;
this.countdowns = 30;
this.timer = setInterval(this.updateCountdown.bind(this), 1000);
this.token = "";
constructor(accountName, issuer, secretKey, onUpdate) {
this.accountName = accountName;
this.secretKey = secretKey;
this.onUpdate = onUpdate;
this.token = this.generateToken();
this.isEditing = false;
this.icon = icon ? icon : null;
this.issuer = issuer;
}
calculateCountdown() {
const currentTime = Math.floor(Date.now() / 1000);
return 30 - (currentTime % 30);
}
generateToken() {
if (this.secretCode !== null && this.secretCode !== undefined && this.secretCode !== "") {
const token = totp(this.secretCode);
if (this.secretKey !== null && this.secretKey !== undefined && this.secretKey !== "") {
const token = totp(this.secretKey);
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
return tokenWithSpace;
}
@ -39,31 +42,21 @@ class Account {
this.onUpdate();
}
updateCountdown() {
this.countdowns = Math.max(0, this.countdowns - 1);
if (this.countdowns === 0) {
this.generateAndSetToken();
this.countdowns = 30;
}
this.onUpdate();
}
getTitle() {
return this.title;
}
setTitle(title) {
this.title = title;
setAccountName(accountName) {
this.accountName = accountName;
this.setEditingStatus(false);
}
setEditingStatus(status) {
this.isEditing = status;
this.onUpdate();
}
getEditStatus() {
return this.isEditing;
}
deleteAccount() {
clearInterval(this.timer);
this.onUpdate();
}
}

8
App.js
View File

@ -14,18 +14,21 @@
import * as React from "react";
import {PaperProvider} from "react-native-paper";
import NavigationBar from "./NavigationBar";
import {NavigationContainer} from "@react-navigation/native";
import Header from "./Header";
import NavigationBar from "./NavigationBar";
import {UserProvider} from "./UserContext";
import {CasdoorServerProvider} from "./CasdoorServerContext";
const App = () => {
const [userInfo, setUserInfo] = React.useState(null);
const [token, setToken] = React.useState(null);
const [casdoorServer, setCasdoorServer] = React.useState(null);
return (
<CasdoorServerProvider value={{casdoorServer, setCasdoorServer}} >
<UserProvider value={{userInfo, setUserInfo}} >
<UserProvider value={{userInfo, setUserInfo, token, setToken}} >
<NavigationContainer>
<PaperProvider>
<Header />
@ -34,7 +37,6 @@ const App = () => {
</NavigationContainer>
</UserProvider>
</CasdoorServerProvider>
);
};
export default App;

42
AvatarWithFallback.js Normal file
View File

@ -0,0 +1,42 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import 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;

View File

@ -30,7 +30,7 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
onWebviewClose: PropTypes.func.isRequired,
};
const [casdoorLoginURL, setCasdoorLoginURL] = React.useState("");
const {setUserInfo} = React.useContext(UserContext);
const {setUserInfo, setToken} = React.useContext(UserContext);
const [showConfigPage, setShowConfigPage] = React.useState(true);
const {casdoorServer} = React.useContext(CasdoorServerContext);
const handleHideConfigPage = () => {
@ -53,6 +53,7 @@ const CasdoorLoginPage = ({onWebviewClose}) => {
onWebviewClose();
const token = await sdk.getAccessToken(navState.url);
const userInfo = sdk.JwtDecode(token);
setToken(token);
setUserInfo(userInfo);
}
};

View File

@ -24,20 +24,20 @@ export default function EnterAccountDetails({onClose, onEdit, placeholder}) {
placeholder: PropTypes.string.isRequired,
};
const [description, setDescription] = useState("");
const [accountName, setAccountName] = useState("");
const handleConfirm = () => {
onEdit(description);
onEdit(accountName);
};
return (
<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"}}>
<IconButton icon="account-details" size={35} />
<TextInput
placeholder={placeholder}
value={description}
onChangeText={(text) => setDescription(text)}
value={accountName}
onChangeText={(text) => setAccountName(text)}
autoCapitalize="none"
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
/>

View File

@ -23,8 +23,8 @@ export default function EnterAccountDetails({onClose, onAdd}) {
onAdd: PropTypes.func.isRequired,
};
const [description, setDescription] = useState("");
const [secretCode, setSecretCode] = useState("");
const [accountName, setAccountName] = useState("");
const [secretKey, setSecretKey] = useState("");
const [visible, setVisible] = React.useState(false);
const openMenu = () => setVisible(true);
@ -37,9 +37,9 @@ export default function EnterAccountDetails({onClose, onAdd}) {
};
const handleAddAccount = () => {
onAdd({description, secretCode});
setDescription("");
setSecretCode("");
onAdd({accountName, secretKey});
setAccountName("");
setSecretKey("");
};
return (
@ -48,11 +48,11 @@ export default function EnterAccountDetails({onClose, onAdd}) {
<View style={{flexDirection: "row", alignItems: "center"}}>
<IconButton icon="account-details" size={35} />
<TextInput
label="Description"
placeholder="Description"
value={description}
label="Account Name"
placeholder="Account Name"
value={accountName}
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}}
/>
</View>
@ -60,11 +60,11 @@ export default function EnterAccountDetails({onClose, onAdd}) {
<View style={{flexDirection: "row", alignItems: "center"}}>
<IconButton icon="account-key" size={35} />
<TextInput
label="Secret code"
placeholder="Secret code"
value={secretCode}
label="Secret Key"
placeholder="Secret Key"
value={secretKey}
autoCapitalize="none"
onChangeText={(text) => setSecretCode(text)}
onChangeText={(text) => setSecretKey(text)}
secureTextEntry
style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}}
/>

View File

@ -15,11 +15,11 @@
import * as React from "react";
import {Appbar, Avatar, Button, Menu, Text} from "react-native-paper";
import UserContext from "./UserContext";
import {View} from "react-native";
import {StyleSheet, View} from "react-native";
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
const Header = () => {
const {userInfo, setUserInfo} = React.useContext(UserContext);
const {userInfo, setUserInfo, setToken} = React.useContext(UserContext);
const [showLoginPage, setShowLoginPage] = React.useState(false);
const [menuVisible, setMenuVisible] = React.useState(false);
const openMenu = () => setMenuVisible(true);
@ -35,33 +35,50 @@ const Header = () => {
const handleCasdoorLogout = () => {
CasdoorLogout();
setUserInfo(null);
setToken(null);
};
const handleHideLoginPage = () => {
setShowLoginPage(false);
};
return (
<View>
<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
visible={menuVisible}
anchor={
<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}
>
{
userInfo === null ?
null :
<View style={{flexDirection: "row", alignItems: "center"}}>
<View style={{position: "relative", height: 32, justifyContent: "flex-end", marginRight: 8}}>
<Text variant="titleMedium">
{userInfo === null ? "Login" : userInfo.name}
</Text>
</View>
{userInfo !== null && (
<Avatar.Image
size={32}
source={{uri: userInfo.avatar}}
style={{marginRight: 10, backgroundColor: "transparent"}}
style={{backgroundColor: "transparent"}}
/>
}
<Text style={{marginRight: 10}} variant="titleMedium">
{userInfo === null ? "Login" : userInfo.name}
</Text>
)}
</View>
</Button>
}
onDismiss={closeMenu}

View File

@ -12,40 +12,92 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from "react";
import {Dimensions, FlatList, Text, TouchableOpacity, View} from "react-native";
import {Avatar, Divider, IconButton, List, Modal, Portal} from "react-native-paper";
import SearchBar from "./SearchBar";
import React, {useContext, useEffect, useRef, useState} from "react";
import {Dimensions, FlatList, RefreshControl, Text, TouchableOpacity, View} from "react-native";
import {Divider, IconButton, List, Modal, Portal} from "react-native-paper";
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 Account from "./Account";
import ScanQRCode from "./ScanQRCode";
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() {
const [isPlusButton, setIsPlusButton] = React.useState(true);
const [showOptions, setShowOptions] = React.useState(false);
const [showEnterAccountModal, setShowEnterAccountModal] = React.useState(false);
const [accountList, setAccountList] = React.useState([]);
const [searchQuery, setSearchQuery] = React.useState("");
const [filteredData, setFilteredData] = React.useState(accountList);
const [showScanner, setShowScanner] = React.useState(false);
const [showEditAccountModal, setShowEditAccountModal] = React.useState(false);
const swipeableRef = React.useRef(null);
const [placeholder, setPlaceholder] = React.useState("");
const closeEditAccountModal = () => {
setShowEditAccountModal(false);
const [isPlusButton, setIsPlusButton] = useState(true);
const [showOptions, setShowOptions] = useState(false);
const [showEnterAccountModal, setShowEnterAccountModal] = useState(false);
const [accountList, setAccountList] = useState([]);
const [searchQuery, setSearchQuery] = useState("");
const [filteredData, setFilteredData] = useState(accountList);
const [showScanner, setShowScanner] = useState(false);
const [showEditAccountModal, setShowEditAccountModal] = useState(false);
const [placeholder, setPlaceholder] = useState("");
const [refreshing, setRefreshing] = useState(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 = () => {
setShowScanner(true);
setIsPlusButton(true);
setShowOptions(false);
};
const handleCloseScanner = () => {
setShowScanner(false);
};
const handleCloseScanner = () => setShowScanner(false);
const togglePlusButton = () => {
setIsPlusButton(!isPlusButton);
@ -63,46 +115,43 @@ export default function HomePage() {
closeOptions();
};
const closeEnterAccountModal = () => {
setShowEnterAccountModal(false);
};
const closeEnterAccountModal = () => setShowEnterAccountModal(false);
const onUpdate = () => setAccountList(prev => [...prev]);
const onUpdate = () => {
setAccountList(prevList => [...prevList]);
};
const handleAddAccount = (accountData) => {
const newAccount = new Account(accountData.description, accountData.secretCode, onUpdate, accountData.icon);
const token = newAccount.generateToken();
newAccount.token = token;
const newAccount = new Account(accountData.accountName, accountData.issuer, accountData.secretKey, onUpdate);
addToSyncData(newAccount, SYNC_STATUS.ADD);
newAccount.token = newAccount.generateToken();
setAccountList(prevList => [...prevList, newAccount]);
setAccountList(prev => [...prev, newAccount]);
closeEnterAccountModal();
};
const handleDeleteAccount = (accountDescp) => {
const accountToDelete = accountList.find(account => {
return account.getTitle() === accountDescp;
});
const handleDeleteAccount = (accountName) => {
const accountToDelete = accountList.find(account => account.accountName === accountName);
if (accountToDelete) {
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) {
setPlaceholder(accountToEdit.accountName);
setShowEditAccountModal(true);
accountToEdit.setEditingStatus(true);
}
};
const onAccountEdit = (accountDescp) => {
const onAccountEdit = (newAccountName) => {
const accountToEdit = accountList.find(account => account.getEditStatus() === true);
if (accountToEdit) {
accountToEdit.setTitle(accountDescp);
addToSyncData(accountToEdit, SYNC_STATUS.EDIT, newAccountName);
accountToEdit.setAccountName(newAccountName);
}
setPlaceholder("");
closeEditAccountModal();
@ -116,28 +165,21 @@ export default function HomePage() {
const handleSearch = (query) => {
setSearchQuery(query);
if (query.trim() !== "") {
const filteredResults = accountList.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase())
);
setFilteredData(filteredResults);
} else {
setFilteredData(accountList);
}
setFilteredData(query.trim() !== ""
? accountList.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
: accountList
);
};
const {width, height} = Dimensions.get("window");
const offsetX = width * 0.45;
const offsetY = height * 0.2;
return (
<View style={{flex: 1}}>
<SearchBar onSearch={handleSearch} />
<FlatList
data={searchQuery.trim() !== "" ? filteredData : accountList}
keyExtractor={(item, index) => index.toString()}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
renderItem={({item}) => (
<GestureHandlerRootView>
<Swipeable
@ -146,13 +188,13 @@ export default function HomePage() {
<View style={{flexDirection: "row", alignItems: "center"}}>
<TouchableOpacity
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>
</TouchableOpacity>
<TouchableOpacity
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>
</TouchableOpacity>
@ -160,20 +202,36 @@ export default function HomePage() {
)}
>
<List.Item
style={{height: 80, alignItems: "center", justifyContent: "center"}}
style={{height: 80, alignItems: "center", justifyContent: "center", marginLeft: 10}}
title={
<View>
<Text style={{fontSize: 20}}>{item.title}</Text>
<View style={{flexDirection: "row", alignItems: "center"}}>
<Text style={{fontSize: 35, width: 180}}>{item.token}</Text>
<Text style={{fontSize: 20, width: 40}}>{item.countdowns}s</Text>
</View>
<Text style={{fontSize: 20}}>{item.accountName}</Text>
<Text style={{fontSize: 35, width: 180}}>{item.token}</Text>
</View>
}
left={(props) => (
item.icon ?
<Avatar.Image size={60} source={{uri: item.icon}} style={{marginLeft: 20, marginRight: 20, borderRadius: 10, backgroundColor: "transparent"}} />
: <Avatar.Icon size={80} icon={"account"} color={"black"} style={{marginLeft: 10, marginRight: 10, borderRadius: 10, backgroundColor: "transparent"}} />
<AvatarWithFallback
source={{uri: item.issuer ? `https://cdn.casbin.org/img/social_${item.issuer.toLowerCase()}.png` : "https://cdn.casbin.org/img/social_default.png"}}
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>
@ -203,7 +261,7 @@ export default function HomePage() {
onPress={handleScanPress}
>
<IconButton icon={"camera"} size={35} />
<Text style={{fontSize: 18}} >Scan QR code</Text>
<Text style={{fontSize: 18}}>Scan QR code</Text>
</TouchableOpacity>
<TouchableOpacity
style={{flexDirection: "row", alignItems: "center", marginTop: 10}}
@ -214,6 +272,7 @@ export default function HomePage() {
</TouchableOpacity>
</Modal>
</Portal>
<Portal>
<Modal
visible={showEnterAccountModal}
@ -227,12 +286,13 @@ export default function HomePage() {
position: "absolute",
top: "50%",
left: "50%",
transform: [{translateX: -offsetX}, {translateY: -offsetY}],
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
}}
>
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} />
</Modal>
</Portal>
<Portal>
<Modal
visible={showEditAccountModal}
@ -246,12 +306,13 @@ export default function HomePage() {
position: "absolute",
top: "50%",
left: "50%",
transform: [{translateX: -offsetX}, {translateY: -offsetY}],
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
}}
>
<EditAccountDetails onClose={closeEditAccountModal} onEdit={onAccountEdit} placeholder={placeholder} />
</Modal>
</Portal>
{showScanner && (
<ScanQRCode onClose={handleCloseScanner} showScanner={showScanner} onAdd={handleAddAccount} />
)}

View File

@ -6,7 +6,7 @@ Casdoor Authenticator App is a mobile application for iOS and Android that provi
- [x] Multi-platform support (iOS/Android)
- [x] TOTP-based multi-factor authentication
- [ ] Account synchronization with Casdoor
- [x] Account synchronization with Casdoor
- [ ] Integration with Casdoor's central service and desktop client
## Quick Start
@ -21,7 +21,7 @@ npm install && npm run start
- Open the app on your mobile device.
- 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

View File

@ -15,7 +15,7 @@
import React, {useEffect, useState} from "react";
import {Dimensions, Text, View} from "react-native";
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";
const ScanQRCode = ({onClose, showScanner, onAdd}) => {
@ -28,10 +28,12 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
const [hasPermission, setHasPermission] = useState(null);
useEffect(() => {
(async() => {
const {status} = await BarCodeScanner.requestPermissionsAsync();
const getCameraPermissions = async() => {
const {status} = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === "granted");
})();
};
getCameraPermissions();
}, []);
const closeOptions = () => {
@ -42,11 +44,11 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
// 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 description = data.match(/otpauth:\/\/totp\/([^?]+)/); // description casdoor:built-in/admin
const secretCode = data.match(/secret=([^&]+)/); // secretCode II5UO7HIA3SPVXAB6KPAIXZ33AQP7C3R
const icon = data.match(/issuer=([^&]+)/);
if (description && secretCode) {
onAdd({description: description[1], secretCode: secretCode[1], icon: `https://cdn.casbin.org/img/social_${icon && icon[1].toLowerCase()}.png`});
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]});
}
closeOptions();
@ -77,8 +79,11 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
) : hasPermission === false ? (
<Text style={{marginLeft: "20%", marginRight: "20%"}}>No access to camera</Text>
) : (
<BarCodeScanner
onBarCodeScanned={handleBarCodeScanned}
<CameraView
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr", "pdf417"],
}}
style={{flex: 1}}
/>
)}

View File

@ -28,7 +28,7 @@ const SearchBar = ({onSearch}) => {
onChangeText={onChangeSearch}
value={searchQuery}
style={{height: 48, backgroundColor: "#E6DFF3"}}
inputStyle={{textAlignVertical: "center", justifyContent: "center", alignItems: "center"}}
inputStyle={{textAlignVertical: "center", justifyContent: "center", alignItems: "center", minHeight: 0}}
/>
);
};

View File

@ -20,14 +20,16 @@ import UserContext from "./UserContext";
const SettingPage = () => {
const [showLoginPage, setShowLoginPage] = React.useState(false);
const {userInfo, setUserInfo} = React.useContext(UserContext);
const {userInfo, setUserInfo, setToken} = React.useContext(UserContext);
const handleCasdoorLogin = () => {
setShowLoginPage(true);
};
const handleCasdoorLogout = () => {
CasdoorLogout();
setUserInfo(null);
setToken(null);
};
const handleHideLoginPage = () => {
setShowLoginPage(false);
};

86
api.js Normal file
View File

@ -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();
}
};

10200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,30 +9,35 @@
"web": "expo start --web"
},
"dependencies": {
"@expo/webpack-config": "^19.0.0",
"@react-native-community/netinfo": "11.3.1",
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
"casdoor-react-native-sdk": "1.1.0",
"eslint-plugin-import": "^2.28.1",
"expo": "~49.0.8",
"expo-barcode-scanner": "^12.5.3",
"expo-status-bar": "~1.6.0",
"expo": "~51.0.22",
"expo-camera": "~15.0.14",
"expo-image": "^1.12.13",
"expo-status-bar": "~1.12.1",
"expo-updates": "~0.25.21",
"hotp-totp": "^1.0.6",
"prop-types": "^15.8.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "^0.72.5",
"react-native-gesture-handler": "^2.12.1",
"react-native": "0.74.3",
"react-native-countdown-circle-timer": "^3.2.1",
"react-native-gesture-handler": "~2.16.1",
"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-webview": "13.2.2",
"react-native-webview": "13.8.6",
"totp-generator": "^0.0.14"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@babel/core": "^7.24.0",
"@babel/eslint-parser": "^7.18.9",
"@babel/preset-react": "^7.18.6",
"@types/react": "~18.2.14",
"@types/react": "~18.2.79",
"@types/react-native": "^0.72.2",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"eslint": "8.22.0",

31
useNetworkStatus.js Normal file
View File

@ -0,0 +1,31 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {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;

129
useSync.js Normal file
View File

@ -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;