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";
|
||||
|
||||
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
8
App.js
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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}}
|
||||
/>
|
||||
|
|
|
@ -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}}
|
||||
/>
|
||||
|
|
41
Header.js
41
Header.js
|
@ -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}
|
||||
|
|
201
HomePage.js
201
HomePage.js
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
"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",
|
||||
|
|
|
@ -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