feat: use "drizzle-orm/expo-sqlite" as DB (#21)
This commit is contained in:
parent
e245cfa66d
commit
7f87a3bf34
49
App.js
49
App.js
|
@ -13,28 +13,59 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {PaperProvider} from "react-native-paper";
|
|
||||||
import {NavigationContainer} from "@react-navigation/native";
|
import {NavigationContainer} from "@react-navigation/native";
|
||||||
import {BulletList} from "react-content-loader/native";
|
import {PaperProvider} from "react-native-paper";
|
||||||
import {SQLiteProvider} from "expo-sqlite";
|
import {SafeAreaView, Text} from "react-native";
|
||||||
|
import ContentLoader, {Circle, Rect} from "react-content-loader/native";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
|
import {useMigrations} from "drizzle-orm/expo-sqlite/migrator";
|
||||||
|
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import NavigationBar from "./NavigationBar";
|
import NavigationBar from "./NavigationBar";
|
||||||
import {migrateDb} from "./TotpDatabase";
|
import {db} from "./db/client";
|
||||||
|
import migrations from "./drizzle/migrations";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
const {success, error} = useMigrations(db, migrations);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{flex: 1}}>
|
||||||
|
<Text>Migration error: {error.message}</Text>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return (
|
||||||
|
<ContentLoader
|
||||||
|
speed={2}
|
||||||
|
width={400}
|
||||||
|
height={150}
|
||||||
|
viewBox="0 0 400 150"
|
||||||
|
backgroundColor="#f3f3f3"
|
||||||
|
foregroundColor="#ecebeb"
|
||||||
|
>
|
||||||
|
<Circle cx="10" cy="20" r="8" />
|
||||||
|
<Rect x="25" y="15" rx="5" ry="5" width="220" height="10" />
|
||||||
|
<Circle cx="10" cy="50" r="8" />
|
||||||
|
<Rect x="25" y="45" rx="5" ry="5" width="220" height="10" />
|
||||||
|
<Circle cx="10" cy="80" r="8" />
|
||||||
|
<Rect x="25" y="75" rx="5" ry="5" width="220" height="10" />
|
||||||
|
<Circle cx="10" cy="110" r="8" />
|
||||||
|
<Rect x="25" y="105" rx="5" ry="5" width="220" height="10" />
|
||||||
|
</ContentLoader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Suspense fallback={<BulletList />}>
|
|
||||||
<SQLiteProvider databaseName="totp.db" onInit={migrateDb} options={{enableChangeListener: true}}>
|
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<PaperProvider>
|
<PaperProvider>
|
||||||
<Header />
|
<Header />
|
||||||
<NavigationBar />
|
<NavigationBar />
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
</NavigationContainer>
|
|
||||||
<Toast />
|
<Toast />
|
||||||
</SQLiteProvider>
|
</NavigationContainer>
|
||||||
</React.Suspense>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
@ -18,13 +18,13 @@ import {Appbar, Avatar, Menu, Text, TouchableRipple} from "react-native-paper";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
|
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
|
||||||
import useStore from "./useStorage";
|
import useStore from "./useStorage";
|
||||||
import useSyncStore from "./useSyncStore";
|
import {useAccountSync} from "./useAccountStore";
|
||||||
|
|
||||||
const {width} = Dimensions.get("window");
|
const {width} = Dimensions.get("window");
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const {userInfo, clearAll} = useStore();
|
const {userInfo, clearAll} = useStore();
|
||||||
const syncError = useSyncStore(state => state.syncError);
|
const {syncError, clearSyncError} = useAccountSync();
|
||||||
const [showLoginPage, setShowLoginPage] = React.useState(false);
|
const [showLoginPage, setShowLoginPage] = React.useState(false);
|
||||||
const [menuVisible, setMenuVisible] = React.useState(false);
|
const [menuVisible, setMenuVisible] = React.useState(false);
|
||||||
|
|
||||||
|
@ -42,6 +42,7 @@ const Header = () => {
|
||||||
const handleCasdoorLogout = () => {
|
const handleCasdoorLogout = () => {
|
||||||
CasdoorLogout();
|
CasdoorLogout();
|
||||||
clearAll();
|
clearAll();
|
||||||
|
clearSyncError();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSyncErrorPress = () => {
|
const handleSyncErrorPress = () => {
|
||||||
|
|
61
HomePage.js
61
HomePage.js
|
@ -20,16 +20,19 @@ import {CountdownCircleTimer} from "react-native-countdown-circle-timer";
|
||||||
import {useNetInfo} from "@react-native-community/netinfo";
|
import {useNetInfo} from "@react-native-community/netinfo";
|
||||||
import {FlashList} from "@shopify/flash-list";
|
import {FlashList} from "@shopify/flash-list";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import * as SQLite from "expo-sqlite/next";
|
import {useLiveQuery} from "drizzle-orm/expo-sqlite";
|
||||||
|
import {isNull} from "drizzle-orm";
|
||||||
|
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import EnterAccountDetails from "./EnterAccountDetails";
|
import EnterAccountDetails from "./EnterAccountDetails";
|
||||||
import ScanQRCode from "./ScanQRCode";
|
import ScanQRCode from "./ScanQRCode";
|
||||||
import EditAccountDetails from "./EditAccountDetails";
|
import EditAccountDetails from "./EditAccountDetails";
|
||||||
import AvatarWithFallback from "./AvatarWithFallback";
|
import AvatarWithFallback from "./AvatarWithFallback";
|
||||||
import * as TotpDatabase from "./TotpDatabase";
|
|
||||||
import useStore from "./useStorage";
|
import useStore from "./useStorage";
|
||||||
import useSyncStore from "./useSyncStore";
|
import * as schema from "./db/schema";
|
||||||
|
import {db} from "./db/client";
|
||||||
|
import {calculateCountdown, validateSecret} from "./totpUtil";
|
||||||
|
import {useAccountSync, useEditAccount, useUpdateAccountToken} from "./useAccountStore";
|
||||||
|
|
||||||
const {width, height} = Dimensions.get("window");
|
const {width, height} = Dimensions.get("window");
|
||||||
const REFRESH_INTERVAL = 10000;
|
const REFRESH_INTERVAL = 10000;
|
||||||
|
@ -41,7 +44,7 @@ export default function HomePage() {
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
const [showEnterAccountModal, setShowEnterAccountModal] = useState(false);
|
const [showEnterAccountModal, setShowEnterAccountModal] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [accounts, setAccounts] = useState([]);
|
const {data: accounts} = useLiveQuery(db.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)));
|
||||||
const [filteredData, setFilteredData] = useState(accounts);
|
const [filteredData, setFilteredData] = useState(accounts);
|
||||||
const [showScanner, setShowScanner] = useState(false);
|
const [showScanner, setShowScanner] = useState(false);
|
||||||
const [showEditAccountModal, setShowEditAccountModal] = useState(false);
|
const [showEditAccountModal, setShowEditAccountModal] = useState(false);
|
||||||
|
@ -53,17 +56,10 @@ export default function HomePage() {
|
||||||
const [key, setKey] = useState(0);
|
const [key, setKey] = useState(0);
|
||||||
|
|
||||||
const swipeableRef = useRef(null);
|
const swipeableRef = useRef(null);
|
||||||
const db = SQLite.useSQLiteContext();
|
|
||||||
const {userInfo, serverUrl, token} = useStore();
|
const {userInfo, serverUrl, token} = useStore();
|
||||||
const {startSync} = useSyncStore();
|
const {startSync} = useAccountSync();
|
||||||
const syncError = useSyncStore(state => state.syncError);
|
const {updateToken} = useUpdateAccountToken();
|
||||||
|
const {setAccount, updateAccount, insertAccount, deleteAccount} = useEditAccount();
|
||||||
useEffect(() => {
|
|
||||||
if (db) {
|
|
||||||
const subscription = SQLite.addDatabaseChangeListener((event) => {loadAccounts();});
|
|
||||||
return () => {if (subscription) {subscription.remove();}};
|
|
||||||
}
|
|
||||||
}, [db]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCanSync(Boolean(isConnected && userInfo && serverUrl));
|
setCanSync(Boolean(isConnected && userInfo && serverUrl));
|
||||||
|
@ -73,27 +69,17 @@ export default function HomePage() {
|
||||||
setFilteredData(accounts);
|
setFilteredData(accounts);
|
||||||
}, [accounts]);
|
}, [accounts]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAccounts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (canSync) {startSync(db, userInfo, serverUrl, token);}
|
if (canSync) {startSync(userInfo, serverUrl, token);}
|
||||||
}, REFRESH_INTERVAL);
|
}, REFRESH_INTERVAL);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [startSync]);
|
}, [startSync]);
|
||||||
|
|
||||||
const loadAccounts = async() => {
|
|
||||||
const loadedAccounts = await TotpDatabase.getAllAccounts(db);
|
|
||||||
setAccounts(loadedAccounts);
|
|
||||||
setFilteredData(loadedAccounts);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRefresh = async() => {
|
const onRefresh = async() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
if (canSync) {
|
if (canSync) {
|
||||||
await startSync(db, userInfo, serverUrl, token);
|
const syncError = await startSync(userInfo, serverUrl, token);
|
||||||
if (syncError) {
|
if (syncError) {
|
||||||
Toast.show({
|
Toast.show({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
@ -110,19 +96,17 @@ export default function HomePage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setKey(prevKey => prevKey + 1);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddAccount = async(accountData) => {
|
const handleAddAccount = async(accountData) => {
|
||||||
setKey(prevKey => prevKey + 1);
|
setKey(prevKey => prevKey + 1);
|
||||||
await TotpDatabase.insertAccount(db, accountData);
|
setAccount(accountData);
|
||||||
|
insertAccount();
|
||||||
closeEnterAccountModal();
|
closeEnterAccountModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = async(id) => {
|
|
||||||
await TotpDatabase.deleteAccount(db, id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditAccount = (account) => {
|
const handleEditAccount = (account) => {
|
||||||
closeSwipeableMenu();
|
closeSwipeableMenu();
|
||||||
setEditingAccount(account);
|
setEditingAccount(account);
|
||||||
|
@ -132,7 +116,8 @@ export default function HomePage() {
|
||||||
|
|
||||||
const onAccountEdit = async(newAccountName) => {
|
const onAccountEdit = async(newAccountName) => {
|
||||||
if (editingAccount) {
|
if (editingAccount) {
|
||||||
await TotpDatabase.updateAccountName(db, editingAccount.id, newAccountName);
|
setAccount({...editingAccount, accountName: newAccountName, oldAccountName: editingAccount.accountName});
|
||||||
|
updateAccount();
|
||||||
setPlaceholder("");
|
setPlaceholder("");
|
||||||
setEditingAccount(null);
|
setEditingAccount(null);
|
||||||
closeEditAccountModal();
|
closeEditAccountModal();
|
||||||
|
@ -176,7 +161,7 @@ export default function HomePage() {
|
||||||
const handleSearch = (query) => {
|
const handleSearch = (query) => {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
setFilteredData(query.trim() !== ""
|
setFilteredData(query.trim() !== ""
|
||||||
? accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
|
? accounts && accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
|
||||||
: accounts
|
: accounts
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -205,7 +190,7 @@ export default function HomePage() {
|
||||||
</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(item.id)}
|
onPress={() => deleteAccount(item.id)}
|
||||||
>
|
>
|
||||||
<Text>Delete</Text>
|
<Text>Delete</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
@ -242,16 +227,16 @@ export default function HomePage() {
|
||||||
key={key}
|
key={key}
|
||||||
isPlaying={true}
|
isPlaying={true}
|
||||||
duration={30}
|
duration={30}
|
||||||
initialRemainingTime={TotpDatabase.calculateCountdown()}
|
initialRemainingTime={calculateCountdown()}
|
||||||
colors={["#004777", "#0072A0", "#0099CC", "#FF6600", "#CC3300", "#A30000"]}
|
colors={["#004777", "#0072A0", "#0099CC", "#FF6600", "#CC3300", "#A30000"]}
|
||||||
colorsTime={[30, 24, 18, 12, 6, 0]}
|
colorsTime={[30, 24, 18, 12, 6, 0]}
|
||||||
size={60}
|
size={60}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
TotpDatabase.updateToken(db, item.id);
|
updateToken(item.id);
|
||||||
return {
|
return {
|
||||||
shouldRepeat: true,
|
shouldRepeat: true,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
newInitialRemainingTime: TotpDatabase.calculateCountdown(),
|
newInitialRemainingTime: calculateCountdown(),
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
strokeWidth={5}
|
strokeWidth={5}
|
||||||
|
@ -318,7 +303,7 @@ export default function HomePage() {
|
||||||
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
|
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} validateSecret={TotpDatabase.validateSecret} />
|
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} validateSecret={validateSecret} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
|
|
316
TotpDatabase.js
316
TotpDatabase.js
|
@ -1,316 +0,0 @@
|
||||||
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
import totp from "totp-generator";
|
|
||||||
import * as api from "./api";
|
|
||||||
|
|
||||||
export async function migrateDb(db) {
|
|
||||||
const DATABASE_VERSION = 1;
|
|
||||||
const result = await db.getFirstAsync("PRAGMA user_version");
|
|
||||||
let currentVersion = result?.user_version ?? 0;
|
|
||||||
if (currentVersion === DATABASE_VERSION) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentVersion === 0) {
|
|
||||||
await db.execAsync(`
|
|
||||||
PRAGMA journal_mode = 'wal';
|
|
||||||
CREATE TABLE accounts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
issuer TEXT,
|
|
||||||
account_name TEXT NOT NULL,
|
|
||||||
old_account_name TEXT DEFAULT NULL,
|
|
||||||
secret TEXT NOT NULL,
|
|
||||||
token TEXT,
|
|
||||||
is_deleted INTEGER DEFAULT 0,
|
|
||||||
last_change_time INTEGER DEFAULT (strftime('%s', 'now')),
|
|
||||||
last_sync_time INTEGER DEFAULT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);
|
|
||||||
currentVersion = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearDatabase(db) {
|
|
||||||
try {
|
|
||||||
await db.execAsync("DELETE FROM accounts");
|
|
||||||
await db.execAsync("DELETE FROM sqlite_sequence WHERE name='accounts'");
|
|
||||||
await db.execAsync("PRAGMA user_version = 0");
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateToken = (secretKey) => {
|
|
||||||
if (secretKey !== null && secretKey !== undefined && secretKey !== "") {
|
|
||||||
try {
|
|
||||||
const token = totp(secretKey);
|
|
||||||
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
|
|
||||||
return tokenWithSpace;
|
|
||||||
} catch (error) {
|
|
||||||
return "Secret Invalid";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "Secret Empty";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function insertAccount(db, account) {
|
|
||||||
const token = generateToken(account.secretKey);
|
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
|
||||||
return await db.runAsync(
|
|
||||||
"INSERT INTO accounts (issuer, account_name, secret, token, last_change_time) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
account.issuer ?? "",
|
|
||||||
account.accountName,
|
|
||||||
account.secretKey,
|
|
||||||
token ?? "",
|
|
||||||
currentTime
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateAccountName(db, id, newAccountName) {
|
|
||||||
const account = await db.getFirstAsync("SELECT * FROM accounts WHERE id = ?", id);
|
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
// Only update old_account_name if it's null or if last_sync_time is more recent than last_change_time
|
|
||||||
if (account.old_account_name === null || (account.last_sync_time && account.last_sync_time > account.last_change_time)) {
|
|
||||||
await db.runAsync(`
|
|
||||||
UPDATE accounts
|
|
||||||
SET account_name = ?,
|
|
||||||
old_account_name = ?,
|
|
||||||
last_change_time = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`, newAccountName, account.account_name, currentTime, id);
|
|
||||||
} else {
|
|
||||||
await db.runAsync(`
|
|
||||||
UPDATE accounts
|
|
||||||
SET account_name = ?,
|
|
||||||
last_change_time = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`, newAccountName, currentTime, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateAccount(db, account, id) {
|
|
||||||
const token = generateToken(account.secretKey);
|
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
|
||||||
const result = await db.runAsync(
|
|
||||||
"UPDATE accounts SET issuer = ?, account_name = ?, old_account_name = ?, secret = ?, token = ?, last_change_time = ? WHERE id = ?",
|
|
||||||
account.issuer,
|
|
||||||
account.accountName,
|
|
||||||
account.oldAccountName ?? null,
|
|
||||||
account.secretKey,
|
|
||||||
token ?? "",
|
|
||||||
currentTime,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
throw new Error(`No account updated for id: ${id}`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAccount(db, id) {
|
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
|
||||||
await db.runAsync("UPDATE accounts SET is_deleted = 1, last_change_time = ? WHERE id = ?", currentTime, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trueDeleteAccount(db, id) {
|
|
||||||
return await db.runAsync("DELETE FROM accounts WHERE id = ?", id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateToken(db, id) {
|
|
||||||
const result = db.getFirstSync("SELECT secret FROM accounts WHERE id = ?", id);
|
|
||||||
if (result.secret === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const token = generateToken(result.secret);
|
|
||||||
return db.runSync("UPDATE accounts SET token = ? WHERE id = ?", token, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTokenForAll(db) {
|
|
||||||
const accounts = await db.getAllAsync("SELECT * FROM accounts WHERE is_deleted = 0");
|
|
||||||
for (const account of accounts) {
|
|
||||||
const token = generateToken(account.secret);
|
|
||||||
await db.runAsync("UPDATE accounts SET token = ? WHERE id = ?", token, account.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllAccounts(db) {
|
|
||||||
const accounts = await db.getAllAsync("SELECT * FROM accounts WHERE is_deleted = 0");
|
|
||||||
return accounts.map(account => {
|
|
||||||
const mappedAccount = {
|
|
||||||
...account,
|
|
||||||
accountName: account.account_name,
|
|
||||||
};
|
|
||||||
return mappedAccount;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLocalAccounts(db) {
|
|
||||||
const accounts = await db.getAllAsync("SELECT * FROM accounts");
|
|
||||||
return accounts.map(account => ({
|
|
||||||
id: account.id,
|
|
||||||
issuer: account.issuer,
|
|
||||||
accountName: account.account_name,
|
|
||||||
oldAccountName: account.old_account_name,
|
|
||||||
secretKey: account.secret,
|
|
||||||
isDeleted: account.is_deleted === 1,
|
|
||||||
lastChangeTime: account.last_change_time,
|
|
||||||
lastSyncTime: account.last_sync_time,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateSyncTimeForAll(db) {
|
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
|
||||||
await db.runAsync("UPDATE accounts SET last_sync_time = ?", currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateCountdown() {
|
|
||||||
const now = Math.round(new Date().getTime() / 1000.0);
|
|
||||||
return 30 - (now % 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateSecret(secret) {
|
|
||||||
const base32Regex = /^[A-Z2-7]+=*$/i;
|
|
||||||
if (!secret || secret.length % 8 !== 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return base32Regex.test(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateLocalDatabase(db, mergedAccounts) {
|
|
||||||
for (const account of mergedAccounts) {
|
|
||||||
if (account.id) {
|
|
||||||
if (account.isDeleted) {
|
|
||||||
await db.runAsync("DELETE FROM accounts WHERE id = ?", account.id);
|
|
||||||
} else {
|
|
||||||
await updateAccount(db, account, account.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await insertAccount(db, account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAccountKey(account) {
|
|
||||||
return `${account.issuer}:${account.accountName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeAccounts(localAccounts, serverAccounts, serverTimestamp) {
|
|
||||||
const mergedAccounts = new Map();
|
|
||||||
const localAccountKeys = new Map();
|
|
||||||
|
|
||||||
// Process local accounts
|
|
||||||
for (const local of localAccounts) {
|
|
||||||
const key = getAccountKey(local);
|
|
||||||
mergedAccounts.set(key, {
|
|
||||||
...local,
|
|
||||||
synced: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store both current and old account keys for local accounts
|
|
||||||
localAccountKeys.set(key, local);
|
|
||||||
if (local.oldAccountName) {
|
|
||||||
const oldKey = getAccountKey({...local, accountName: local.oldAccountName});
|
|
||||||
localAccountKeys.set(oldKey, local);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedLocalKeys = new Set();
|
|
||||||
|
|
||||||
// Merge with server accounts
|
|
||||||
for (const server of serverAccounts) {
|
|
||||||
const serverKey = getAccountKey(server);
|
|
||||||
const localAccount = localAccountKeys.get(serverKey);
|
|
||||||
|
|
||||||
if (!localAccount) {
|
|
||||||
// New account from server
|
|
||||||
mergedAccounts.set(serverKey, {...server, synced: true});
|
|
||||||
} else {
|
|
||||||
const localKey = getAccountKey(localAccount);
|
|
||||||
const local = mergedAccounts.get(localKey);
|
|
||||||
|
|
||||||
if (serverTimestamp > local.lastChangeTime) {
|
|
||||||
// Server has newer changes
|
|
||||||
mergedAccounts.set(localKey, {
|
|
||||||
...server,
|
|
||||||
id: local.id,
|
|
||||||
oldAccountName: local.accountName !== server.accountName ? local.accountName : local.oldAccountName,
|
|
||||||
synced: true,
|
|
||||||
});
|
|
||||||
} else if (local.accountName !== server.accountName) {
|
|
||||||
// Local name change is newer, update the server account name
|
|
||||||
mergedAccounts.set(localKey, {
|
|
||||||
...local,
|
|
||||||
oldAccountName: server.accountName,
|
|
||||||
synced: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// If local is newer or deleted, keep the local version (already in mergedAccounts)
|
|
||||||
processedLocalKeys.add(localKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Handle server-side deletions
|
|
||||||
for (const [key, local] of mergedAccounts) {
|
|
||||||
if (!processedLocalKeys.has(key) && local.lastSyncTime && local.lastSyncTime < serverTimestamp) {
|
|
||||||
// This account was not found on the server and was previously synced
|
|
||||||
// Mark it as deleted
|
|
||||||
mergedAccounts.set(key, {...local, isDeleted: true, synced: true});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(mergedAccounts.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncWithCloud(db, userInfo, serverUrl, token) {
|
|
||||||
const localAccounts = await getLocalAccounts(db);
|
|
||||||
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
|
|
||||||
serverUrl,
|
|
||||||
userInfo.owner,
|
|
||||||
userInfo.name,
|
|
||||||
token
|
|
||||||
);
|
|
||||||
|
|
||||||
const serverTimestamp = Math.floor(new Date(updatedTime).getTime() / 1000);
|
|
||||||
|
|
||||||
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, serverTimestamp);
|
|
||||||
await updateLocalDatabase(db, mergedAccounts);
|
|
||||||
|
|
||||||
const accountsToSync = mergedAccounts.filter(account => !account.isDeleted).map(account => ({
|
|
||||||
issuer: account.issuer,
|
|
||||||
accountName: account.accountName,
|
|
||||||
secretKey: account.secretKey,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {status} = await api.updateMfaAccounts(
|
|
||||||
serverUrl,
|
|
||||||
userInfo.owner,
|
|
||||||
userInfo.name,
|
|
||||||
accountsToSync,
|
|
||||||
token
|
|
||||||
);
|
|
||||||
|
|
||||||
if (status !== "ok") {
|
|
||||||
throw new Error("Sync failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateSyncTimeForAll(db);
|
|
||||||
await updateTokenForAll(db);
|
|
||||||
}
|
|
|
@ -2,5 +2,6 @@ module.exports = function(api) {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ["babel-preset-expo"],
|
||||||
|
plugins: [["inline-import", {"extensions": [".sql"]}]],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,27 +12,9 @@
|
||||||
// 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 {create} from "zustand";
|
import {drizzle} from "drizzle-orm/expo-sqlite";
|
||||||
import * as TotpDatabase from "./TotpDatabase";
|
import {openDatabaseSync} from "expo-sqlite/next";
|
||||||
|
|
||||||
const useSyncStore = create((set, get) => ({
|
const expoDb = openDatabaseSync("account.db", {enableChangeListener: true});
|
||||||
isSyncing: false,
|
|
||||||
syncError: null,
|
|
||||||
|
|
||||||
startSync: async(db, userInfo, casdoorServer, token) => {
|
export const db = drizzle(expoDb);
|
||||||
if (!get().isSyncing) {
|
|
||||||
set({isSyncing: true, syncError: null});
|
|
||||||
try {
|
|
||||||
await TotpDatabase.syncWithCloud(db, userInfo, casdoorServer, token);
|
|
||||||
} catch (error) {
|
|
||||||
set({syncError: error.message});
|
|
||||||
} finally {
|
|
||||||
set({isSyncing: false});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clearSyncError: () => set({syncError: null}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default useSyncStore;
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import {integer, sqliteTable, text, unique} from "drizzle-orm/sqlite-core";
|
||||||
|
import {sql} from "drizzle-orm";
|
||||||
|
|
||||||
|
export const accounts = sqliteTable("accounts", {
|
||||||
|
id: integer("id", {mode: "number"}).primaryKey({autoIncrement: true}),
|
||||||
|
accountName: text("account_name").notNull(),
|
||||||
|
oldAccountName: text("old_account_name").default(null),
|
||||||
|
secretKey: text("secret").notNull(),
|
||||||
|
issuer: text("issuer").default(null),
|
||||||
|
token: text("token"),
|
||||||
|
deletedAt: integer("deleted_at", {mode: "timestamp_ms"}).default(null),
|
||||||
|
changedAt: integer("changed_at", {mode: "timestamp_ms"}).default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
syncAt: integer("sync_at", {mode: "timestamp_ms"}).default(null),
|
||||||
|
}, (accounts) => ({
|
||||||
|
unq: unique().on(accounts.accountName, accounts.issuer),
|
||||||
|
})
|
||||||
|
);
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
schema: "./db/schema.js",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
driver: "expo",
|
||||||
|
};
|
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE TABLE `accounts` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`account_name` text NOT NULL,
|
||||||
|
`old_account_name` text DEFAULT 'null',
|
||||||
|
`secret` text NOT NULL,
|
||||||
|
`issuer` text DEFAULT 'null',
|
||||||
|
`token` text,
|
||||||
|
`deleted_at` integer DEFAULT 'null',
|
||||||
|
`changed_at` integer DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`sync_at` integer DEFAULT 'null'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `accounts_account_name_issuer_unique` ON `accounts` (`account_name`,`issuer`);
|
|
@ -0,0 +1,103 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "aaa7b5e3-521e-4c3a-970c-35372e7f05a3",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"accounts": {
|
||||||
|
"name": "accounts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"account_name": {
|
||||||
|
"name": "account_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"old_account_name": {
|
||||||
|
"name": "old_account_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'null'"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"name": "secret",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"name": "issuer",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'null'"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'null'"
|
||||||
|
},
|
||||||
|
"changed_at": {
|
||||||
|
"name": "changed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(CURRENT_TIMESTAMP)"
|
||||||
|
},
|
||||||
|
"sync_at": {
|
||||||
|
"name": "sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'null'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"accounts_account_name_issuer_unique": {
|
||||||
|
"name": "accounts_account_name_issuer_unique",
|
||||||
|
"columns": [
|
||||||
|
"account_name",
|
||||||
|
"issuer"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1724248639995,
|
||||||
|
"tag": "0000_smooth_owl",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo
|
||||||
|
|
||||||
|
import journal from "./meta/_journal.json";
|
||||||
|
import m0000 from "./0000_smooth_owl.sql";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
journal,
|
||||||
|
migrations: {
|
||||||
|
m0000,
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,8 @@
|
||||||
|
const {getDefaultConfig} = require("expo/metro-config");
|
||||||
|
|
||||||
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.resolver.sourceExts.push("sql");
|
||||||
|
|
||||||
|
module.exports = config;
|
File diff suppressed because it is too large
Load Diff
|
@ -3,7 +3,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "node_modules/expo/AppEntry.js",
|
"main": "node_modules/expo/AppEntry.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start --tunnel",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
|
@ -17,12 +17,14 @@
|
||||||
"@react-navigation/native": "^6.1.7",
|
"@react-navigation/native": "^6.1.7",
|
||||||
"@shopify/flash-list": "1.6.4",
|
"@shopify/flash-list": "1.6.4",
|
||||||
"casdoor-react-native-sdk": "1.1.0",
|
"casdoor-react-native-sdk": "1.1.0",
|
||||||
|
"drizzle-orm": "^0.33.0",
|
||||||
"eslint-plugin-import": "^2.28.1",
|
"eslint-plugin-import": "^2.28.1",
|
||||||
"expo": "~51.0.26",
|
"expo": "~51.0.26",
|
||||||
"expo-camera": "~15.0.14",
|
"expo-camera": "~15.0.14",
|
||||||
"expo-dev-client": "~4.0.22",
|
"expo-dev-client": "~4.0.22",
|
||||||
|
"expo-drizzle-studio-plugin": "^0.0.2",
|
||||||
"expo-image": "^1.12.13",
|
"expo-image": "^1.12.13",
|
||||||
"expo-sqlite": "~14.0.6",
|
"expo-sqlite": "^14.0.6",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.7",
|
"expo-system-ui": "~3.0.7",
|
||||||
"expo-updates": "~0.25.22",
|
"expo-updates": "~0.25.22",
|
||||||
|
@ -97,6 +99,8 @@
|
||||||
"@babel/preset-react": "^7.18.6",
|
"@babel/preset-react": "^7.18.6",
|
||||||
"@types/react": "~18.2.79",
|
"@types/react": "~18.2.79",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
|
"babel-plugin-inline-import": "^3.0.0",
|
||||||
|
"drizzle-kit": "^0.24.0",
|
||||||
"eslint": "8.22.0",
|
"eslint": "8.22.0",
|
||||||
"eslint-import-resolver-babel-module": "^5.3.2",
|
"eslint-import-resolver-babel-module": "^5.3.2",
|
||||||
"eslint-plugin-react": "^7.31.1",
|
"eslint-plugin-react": "^7.31.1",
|
||||||
|
|
|
@ -0,0 +1,181 @@
|
||||||
|
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import * as schema from "./db/schema";
|
||||||
|
import * as api from "./api";
|
||||||
|
import {generateToken} from "./totpUtil";
|
||||||
|
|
||||||
|
function getLocalAccounts(db) {
|
||||||
|
return db.select().from(schema.accounts).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccountKey(account) {
|
||||||
|
return `${account.accountName}:${account.issuer ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLocalDatabase(db, accounts) {
|
||||||
|
return db.transaction(async(tx) => {
|
||||||
|
// remove all accounts
|
||||||
|
// await tx.delete(schema.accounts).run();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (account.id) {
|
||||||
|
if (account.deletedAt === null || account.deletedAt === undefined) {
|
||||||
|
// compare all fields
|
||||||
|
const acc = await tx.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||||
|
if (acc.issuer === account.issuer &&
|
||||||
|
acc.accountName === account.accountName &&
|
||||||
|
acc.secretKey === account.secretKey &&
|
||||||
|
acc.deletedAt === account.deletedAt
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await tx.update(schema.accounts).set({
|
||||||
|
issuer: account.issuer,
|
||||||
|
accountName: account.accountName,
|
||||||
|
secretKey: account.secretKey,
|
||||||
|
deletedAt: null,
|
||||||
|
token: generateToken(account.secretKey),
|
||||||
|
changedAt: new Date(),
|
||||||
|
}).where(eq(schema.accounts.id, account.id));
|
||||||
|
} else {
|
||||||
|
await tx.delete(schema.accounts).where(eq(schema.accounts.id, account.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await tx.insert(schema.accounts).values({
|
||||||
|
issuer: account.issuer || null,
|
||||||
|
accountName: account.accountName,
|
||||||
|
secretKey: account.secretKey,
|
||||||
|
token: generateToken(account.secretKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAccounts(localAccounts, serverAccounts, serverTimestamp) {
|
||||||
|
const isNewer = (a, b) => new Date(a) > new Date(b);
|
||||||
|
|
||||||
|
const mergedAccounts = new Map();
|
||||||
|
const localAccountKeys = new Map();
|
||||||
|
|
||||||
|
// Process local accounts
|
||||||
|
for (const local of localAccounts) {
|
||||||
|
const key = getAccountKey(local);
|
||||||
|
mergedAccounts.set(key, {
|
||||||
|
...local,
|
||||||
|
synced: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store both current and old account keys for local accounts
|
||||||
|
localAccountKeys.set(key, local);
|
||||||
|
if (local.oldAccountName) {
|
||||||
|
const oldKey = getAccountKey({...local, accountName: local.oldAccountName});
|
||||||
|
localAccountKeys.set(oldKey, local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedLocalKeys = new Set();
|
||||||
|
|
||||||
|
// Merge with server accounts
|
||||||
|
for (const server of serverAccounts) {
|
||||||
|
const serverKey = getAccountKey(server);
|
||||||
|
const localAccount = localAccountKeys.get(serverKey);
|
||||||
|
|
||||||
|
if (!localAccount) {
|
||||||
|
// New account from server
|
||||||
|
mergedAccounts.set(serverKey, {...server, synced: true});
|
||||||
|
} else {
|
||||||
|
const localKey = getAccountKey(localAccount);
|
||||||
|
const local = mergedAccounts.get(localKey);
|
||||||
|
|
||||||
|
if (isNewer(serverTimestamp, local.changedAt)) {
|
||||||
|
// Server has newer changes
|
||||||
|
mergedAccounts.set(localKey, {
|
||||||
|
...server,
|
||||||
|
id: local.id,
|
||||||
|
oldAccountName: local.accountName !== server.accountName ? local.accountName : local.oldAccountName,
|
||||||
|
synced: true,
|
||||||
|
});
|
||||||
|
} else if (local.accountName !== server.accountName) {
|
||||||
|
mergedAccounts.set(localKey, {
|
||||||
|
...local,
|
||||||
|
oldAccountName: server.accountName,
|
||||||
|
synced: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If local is newer or deleted, keep the local version (already in mergedAccounts)
|
||||||
|
processedLocalKeys.add(localKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle server-side deletions
|
||||||
|
for (const [key, local] of mergedAccounts) {
|
||||||
|
if (!processedLocalKeys.has(key) && local.syncAt && isNewer(serverTimestamp, local.syncAt)) {
|
||||||
|
// This account was not found on the server and was previously synced
|
||||||
|
// Mark it as deleted
|
||||||
|
mergedAccounts.set(key, {...local, deletedAt: new Date(), synced: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(mergedAccounts.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncWithCloud(db, userInfo, serverUrl, token) {
|
||||||
|
// db.delete(schema.accounts).run();
|
||||||
|
const localAccounts = await getLocalAccounts(db);
|
||||||
|
|
||||||
|
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
|
||||||
|
serverUrl,
|
||||||
|
userInfo.owner,
|
||||||
|
userInfo.name,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, updatedTime);
|
||||||
|
|
||||||
|
await updateLocalDatabase(db, mergedAccounts);
|
||||||
|
|
||||||
|
const accountsToSync = mergedAccounts.filter(account => account.deletedAt === null || account.deletedAt === undefined)
|
||||||
|
.map(account => ({
|
||||||
|
issuer: account.issuer,
|
||||||
|
accountName: account.accountName,
|
||||||
|
secretKey: account.secretKey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serverAccountsStringified = serverAccounts.map(account => JSON.stringify({
|
||||||
|
issuer: account.issuer,
|
||||||
|
accountName: account.accountName,
|
||||||
|
secretKey: account.secretKey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const accountsToSyncStringified = accountsToSync.map(account => JSON.stringify(account));
|
||||||
|
|
||||||
|
if (JSON.stringify(accountsToSyncStringified.sort()) !== JSON.stringify(serverAccountsStringified.sort())) {
|
||||||
|
const {status} = await api.updateMfaAccounts(
|
||||||
|
serverUrl,
|
||||||
|
userInfo.owner,
|
||||||
|
userInfo.name,
|
||||||
|
accountsToSync,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status !== "ok") {
|
||||||
|
throw new Error("Sync failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(schema.accounts).set({syncAt: new Date()}).run();
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import totp from "totp-generator";
|
||||||
|
|
||||||
|
export function calculateCountdown(period = 30) {
|
||||||
|
const now = Math.round(new Date().getTime() / 1000.0);
|
||||||
|
return period - (now % period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSecret(secret) {
|
||||||
|
const base32Regex = /^[A-Z2-7]+=*$/i;
|
||||||
|
if (!secret || secret.length % 8 !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return base32Regex.test(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToken(secret) {
|
||||||
|
if (secret !== null && secret !== undefined && secret !== "") {
|
||||||
|
try {
|
||||||
|
const token = totp(secret);
|
||||||
|
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
|
||||||
|
return tokenWithSpace;
|
||||||
|
} catch (error) {
|
||||||
|
return "Secret Invalid";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "Secret Empty";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import {db} from "./db/client";
|
||||||
|
import * as schema from "./db/schema";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {create} from "zustand";
|
||||||
|
import {generateToken} from "./totpUtil";
|
||||||
|
import {syncWithCloud} from "./syncLogic";
|
||||||
|
|
||||||
|
const useEditAccountStore = create((set, get) => ({
|
||||||
|
account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined, oldAccountName: undefined},
|
||||||
|
setAccount: (account) => set({account}),
|
||||||
|
updateAccount: () => {
|
||||||
|
const {id, accountName, issuer, secretKey, oldAccountName} = get().account;
|
||||||
|
if (!id) {return;}
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (accountName) {updateData.accountName = accountName;}
|
||||||
|
if (issuer) {updateData.issuer = issuer;}
|
||||||
|
if (secretKey) {updateData.secretKey = secretKey;}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
const currentAccount = db.select().from(schema.accounts)
|
||||||
|
.where(eq(schema.accounts.id, Number(id))).limit(1)
|
||||||
|
.get();
|
||||||
|
if (currentAccount) {
|
||||||
|
if (currentAccount.oldAccountName === null && oldAccountName) {
|
||||||
|
updateData.oldAccountName = oldAccountName;
|
||||||
|
}
|
||||||
|
db.update(schema.accounts).set({...updateData, changedAt: new Date()}).where(eq(schema.accounts.id, id)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
account: {
|
||||||
|
id: undefined,
|
||||||
|
issuer: undefined,
|
||||||
|
accountName: undefined,
|
||||||
|
oldAccountName: undefined,
|
||||||
|
secretKey: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
insertAccount: () => {
|
||||||
|
const {accountName, issuer, secretKey} = get().account;
|
||||||
|
if (!accountName || !secretKey) {return;}
|
||||||
|
db.insert(schema.accounts)
|
||||||
|
.values({accountName, issuer: issuer ? issuer : null, secretKey, token: generateToken(secretKey)})
|
||||||
|
.run();
|
||||||
|
set({account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined}});
|
||||||
|
},
|
||||||
|
deleteAccount: async(id) => {
|
||||||
|
db.update(schema.accounts).set({deletedAt: new Date()}).where(eq(schema.accounts.id, id)).run();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useEditAccount = () => useEditAccountStore(state => ({
|
||||||
|
account: state.account,
|
||||||
|
setAccount: state.setAccount,
|
||||||
|
updateAccount: state.updateAccount,
|
||||||
|
insertAccount: state.insertAccount,
|
||||||
|
deleteAccount: state.deleteAccount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useAccountSyncStore = create((set, get) => ({
|
||||||
|
isSyncing: false,
|
||||||
|
syncError: null,
|
||||||
|
startSync: async(userInfo, serverUrl, token) => {
|
||||||
|
if (get().isSyncing) {return;}
|
||||||
|
|
||||||
|
set({isSyncing: true, syncError: null});
|
||||||
|
try {
|
||||||
|
await syncWithCloud(db, userInfo, serverUrl, token);
|
||||||
|
} catch (error) {
|
||||||
|
set({syncError: error.message});
|
||||||
|
} finally {
|
||||||
|
set({isSyncing: false});
|
||||||
|
}
|
||||||
|
return get().syncError;
|
||||||
|
},
|
||||||
|
clearSyncError: () => set({syncError: null}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useAccountSync = () => useAccountSyncStore(state => ({
|
||||||
|
isSyncing: state.isSyncing,
|
||||||
|
syncError: state.syncError,
|
||||||
|
startSync: state.startSync,
|
||||||
|
clearSyncError: state.clearSyncError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useUpdateAccountTokenStore = create(() => ({
|
||||||
|
updateToken: async(id) => {
|
||||||
|
const account = db.select().from(schema.accounts)
|
||||||
|
.where(eq(schema.accounts.id, Number(id))).limit(1).get();
|
||||||
|
if (account) {
|
||||||
|
db.update(schema.accounts).set({token: generateToken(account.secretKey)}).where(eq(schema.accounts.id, id)).run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useUpdateAccountToken = () => useUpdateAccountTokenStore(state => ({
|
||||||
|
updateToken: state.updateToken,
|
||||||
|
}));
|
Loading…
Reference in New Issue