diff --git a/App.js b/App.js index 1c4ed43..0770876 100644 --- a/App.js +++ b/App.js @@ -13,16 +13,19 @@ // limitations under the License. import React from "react"; +import {SafeAreaView, Text} from "react-native"; + import {Lato_700Bold, useFonts} from "@expo-google-fonts/lato"; import {Roboto_500Medium} from "@expo-google-fonts/roboto"; +import * as Crypto from "expo-crypto"; import {NavigationContainer} from "@react-navigation/native"; import {PaperProvider} from "react-native-paper"; -import {SafeAreaView, Text} from "react-native"; import ContentLoader, {Circle, Rect} from "react-content-loader/native"; import {ZoomInDownZoomOutUp, createNotifications} from "react-native-notificated"; import {GestureHandlerRootView} from "react-native-gesture-handler"; import {useMigrations} from "drizzle-orm/expo-sqlite/migrator"; import {ActionSheetProvider} from "@expo/react-native-action-sheet"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import "./i18n"; import Header from "./Header"; @@ -37,6 +40,17 @@ const App = () => { Roboto_500Medium, }); + React.useEffect(() => { + const storeOrigin = async() => { + let origin = await AsyncStorage.getItem("origin"); + if (!origin) { + origin = Crypto.randomUUID(); + await AsyncStorage.setItem("origin", origin); + } + }; + storeOrigin(); + }, []); + const {NotificationsProvider} = createNotifications({ duration: 800, notificationPosition: "top", diff --git a/CasdoorLoginPage.js b/CasdoorLoginPage.js index d830d77..c2a7ce5 100644 --- a/CasdoorLoginPage.js +++ b/CasdoorLoginPage.js @@ -17,6 +17,7 @@ import {WebView} from "react-native-webview"; import {Platform, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity} from "react-native"; import {Portal} from "react-native-paper"; import {useNotifications} from "react-native-notificated"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import SDK from "casdoor-react-native-sdk"; import PropTypes from "prop-types"; import EnterCasdoorSdkConfig from "./EnterCasdoorSdkConfig"; @@ -25,6 +26,7 @@ import useStore from "./useStorage"; import DefaultCasdoorSdkConfig from "./DefaultCasdoorSdkConfig"; import {useTranslation} from "react-i18next"; import {useLanguageSync} from "./useLanguageSync"; +import {useEditAccount} from "./useAccountStore"; let sdk = null; @@ -208,10 +210,20 @@ const styles = StyleSheet.create({ }, }); -export const CasdoorLogout = () => { - if (sdk) { - sdk.clearState(); - } +export const useCasdoorLogout = () => { + const {deleteAccountByOrigin} = useEditAccount(); + + const logout = async() => { + const origin = await AsyncStorage.getItem("origin"); + + if (sdk) { + sdk.clearState(); + } + + deleteAccountByOrigin(origin); + }; + + return logout; }; export default CasdoorLoginPage; diff --git a/EditAccountDetails.js b/EditAccountDetails.js index ad2253b..3dc5c5d 100644 --- a/EditAccountDetails.js +++ b/EditAccountDetails.js @@ -13,7 +13,7 @@ // limitations under the License. import React, {useState} from "react"; -import {Text, TextInput, View} from "react-native"; +import {StyleSheet, Text, TextInput, View} from "react-native"; import {Button, IconButton} from "react-native-paper"; import PropTypes from "prop-types"; import {useTranslation} from "react-i18next"; @@ -26,40 +26,102 @@ export default function EnterAccountDetails({onClose, onEdit, placeholder}) { }; const {t} = useTranslation(); - const [accountName, setAccountName] = useState(""); + const [accountName, setAccountName] = useState(placeholder); const handleConfirm = () => { onEdit(accountName); }; return ( - - {t("editAccount.Enter new account name")} - - + + + + {t("editAccount.Enter new account name")} + + setAccountName(text)} + style={styles.input} + mode="outlined" autoCapitalize="none" - style={{borderWidth: 3, borderColor: "white", margin: 10, width: 230, height: 50, borderRadius: 5, fontSize: 18, color: "gray", paddingLeft: 10}} /> + + + - - ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + borderRadius: 10, + }, + content: { + width: "100%", + maxWidth: 500, + paddingVertical: 16, + paddingHorizontal: 20, + }, + header: { + flexDirection: "row", + alignItems: "center", + position: "relative", + minHeight: 32, + }, + title: { + fontSize: 24, + fontWeight: "bold", + color: "#333", + position: "absolute", + left: 40, + right: 40, + textAlign: "center", + numberOfLines: 1, + }, + closeButton: { + marginLeft: "auto", + zIndex: 1, + }, + input: { + marginVertical: 10, + paddingHorizontal: 10, + fontSize: 16, + height: 50, + backgroundColor: "#F5F5F5", + borderRadius: 10, + }, + buttonContainer: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 12, + }, + addButton: { + flex: 1, + backgroundColor: "#8A7DF7", + height: 50, + justifyContent: "center", + paddingHorizontal: 5, + }, + buttonLabel: { + fontSize: 14, + color: "white", + textAlign: "center", + }, +}); diff --git a/Header.js b/Header.js index 2409c02..5471ba6 100644 --- a/Header.js +++ b/Header.js @@ -17,7 +17,7 @@ import {Dimensions, StyleSheet, View} from "react-native"; import {Appbar, Avatar, Menu, Text, TouchableRipple} from "react-native-paper"; import {useNotifications} from "react-native-notificated"; import Icon from "react-native-vector-icons/MaterialCommunityIcons"; -import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage"; +import CasdoorLoginPage, {useCasdoorLogout} from "./CasdoorLoginPage"; import useStore from "./useStorage"; import {useAccountSync} from "./useAccountStore"; import LoginMethodSelector from "./LoginMethodSelector"; @@ -35,6 +35,7 @@ const Header = () => { const {t} = useTranslation(); const openMenu = () => setMenuVisible(true); const closeMenu = () => setMenuVisible(false); + const logout = useCasdoorLogout(); const handleMenuLogoutClicked = () => { handleCasdoorLogout(); @@ -57,7 +58,7 @@ const Header = () => { }; const handleCasdoorLogout = () => { - CasdoorLogout(); + logout(); clearAll(); clearSyncError(); }; diff --git a/HomePage.js b/HomePage.js index 6318c4a..5401e4f 100644 --- a/HomePage.js +++ b/HomePage.js @@ -1,413 +1,457 @@ -// Copyright 2023 The Casdoor Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import React, {useEffect, useRef, useState} from "react"; -import {Dimensions, InteractionManager, RefreshControl, TouchableOpacity, View} from "react-native"; -import {Divider, IconButton, List, Modal, Portal, Text} from "react-native-paper"; -import {GestureHandlerRootView, Swipeable} from "react-native-gesture-handler"; -import {CountdownCircleTimer} from "react-native-countdown-circle-timer"; -import {useNetInfo} from "@react-native-community/netinfo"; -import {FlashList} from "@shopify/flash-list"; -import {useNotifications} from "react-native-notificated"; -import {useTranslation} from "react-i18next"; - -import SearchBar from "./SearchBar"; -import EnterAccountDetails from "./EnterAccountDetails"; -import ScanQRCode from "./ScanQRCode"; -import EditAccountDetails from "./EditAccountDetails"; -import AvatarWithFallback from "./AvatarWithFallback"; -import {useImportManager} from "./ImportManager"; -import useStore from "./useStorage"; -import {calculateCountdown} from "./totpUtil"; -import {generateToken, validateSecret} from "./totpUtil"; -import {useAccountStore, useAccountSync, useEditAccount} from "./useAccountStore"; - -const {width, height} = Dimensions.get("window"); -const REFRESH_INTERVAL = 10000; -const OFFSET_X = width * 0.45; -const OFFSET_Y = height * 0.2; - -export default function HomePage() { - const [isPlusButton, setIsPlusButton] = useState(true); - const [showOptions, setShowOptions] = useState(false); - const [showEnterAccountModal, setShowEnterAccountModal] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [filteredData, setFilteredData] = useState(accounts); - const [showScanner, setShowScanner] = useState(false); - const [showEditAccountModal, setShowEditAccountModal] = useState(false); - const [editingAccount, setEditingAccount] = useState(null); - const [placeholder, setPlaceholder] = useState(""); - const [refreshing, setRefreshing] = useState(false); - const {isConnected} = useNetInfo(); - const [canSync, setCanSync] = useState(false); - const [key, setKey] = useState(0); - const swipeableRef = useRef(null); - const {userInfo, serverUrl, token} = useStore(); - const {startSync} = useAccountSync(); - const {accounts, refreshAccounts} = useAccountStore(); - const {setAccount, updateAccount, insertAccount, insertAccounts, deleteAccount} = useEditAccount(); - const {notify} = useNotifications(); - const {t} = useTranslation(); - const {showImportOptions} = useImportManager((data) => { - handleAddAccount(data); - }, (err) => { - notify("error", { - params: { - title: t("homepage.Import error"), - description: err.message, - }, - }); - }, () => { - setShowScanner(true); - }); - - useEffect(() => { - refreshAccounts(); - }, []); - - useEffect(() => { - setCanSync(Boolean(isConnected && userInfo && serverUrl)); - }, [isConnected, userInfo, serverUrl]); - - useEffect(() => { - setFilteredData(accounts); - }, [accounts]); - - useEffect(() => { - const timer = setInterval(() => { - if (canSync) { - InteractionManager.runAfterInteractions(() => { - startSync(userInfo, serverUrl, token); - refreshAccounts(); - }); - } - }, REFRESH_INTERVAL); - return () => clearInterval(timer); - }, [startSync, canSync, token]); - - const onRefresh = async() => { - setRefreshing(true); - if (canSync) { - const syncError = await startSync(userInfo, serverUrl, token); - if (syncError) { - notify("error", { - params: { - title: "Sync error", - description: syncError, - }, - }); - } else { - notify("success", { - params: { - title: "Sync success", - description: "All your accounts are up to date.", - }, - }); - } - } - refreshAccounts(); - setRefreshing(false); - }; - - const handleAddAccount = async(accountDataInput) => { - if (Array.isArray(accountDataInput)) { - await insertAccounts(accountDataInput); - } else { - await setAccount(accountDataInput); - await insertAccount(); - closeEnterAccountModal(); - } - refreshAccounts(); - }; - - const handleEditAccount = (account) => { - closeSwipeableMenu(); - setEditingAccount(account); - setPlaceholder(account.accountName); - setShowEditAccountModal(true); - }; - - const onAccountEdit = async(newAccountName) => { - if (editingAccount) { - setAccount({...editingAccount, accountName: newAccountName, oldAccountName: editingAccount.accountName}); - updateAccount(); - refreshAccounts(); - setPlaceholder(""); - setEditingAccount(null); - closeEditAccountModal(); - } - }; - - const onAccountDelete = async(account) => { - deleteAccount(account.id); - refreshAccounts(); - }; - - const closeEditAccountModal = () => setShowEditAccountModal(false); - - const handleScanPress = () => { - setShowScanner(true); - setIsPlusButton(true); - setShowOptions(false); - }; - - const handleCloseScanner = () => setShowScanner(false); - - const handleScanError = (error) => { - setShowScanner(false); - notify("error", { - params: { - title: t("homepage.Error scanning QR code"), - description: error, - }, - }); - }; - - const togglePlusButton = () => { - setIsPlusButton(!isPlusButton); - setShowOptions(!showOptions); - }; - - const closeOptions = () => { - setIsPlusButton(true); - setShowOptions(false); - setShowScanner(false); - }; - - const openEnterAccountModal = () => { - setShowEnterAccountModal(true); - closeOptions(); - }; - - const openImportAccountModal = () => { - showImportOptions(); - closeOptions(); - }; - - const closeEnterAccountModal = () => setShowEnterAccountModal(false); - - const closeSwipeableMenu = () => { - if (swipeableRef.current) { - swipeableRef.current.close(); - } - }; - - const handleSearch = (query) => { - setSearchQuery(query); - setFilteredData(query.trim() !== "" - ? accounts && accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase())) - : accounts - ); - }; - - return ( - - - `${item.id}`} - extraData={key} - estimatedItemSize={80} - refreshControl={ - - } - renderItem={({item}) => ( - - ( - - handleEditAccount(item)} - > - {t("common.edit")} - - onAccountDelete(item)} - > - {t("common.delete")} - - - )} - > - - - {item.accountName} - - {generateToken(item.secretKey)} - - } - left={() => ( - - )} - right={() => ( - - { - setKey(prevKey => prevKey + 1); - return { - shouldRepeat: true, - delay: 0, - newInitialRemainingTime: calculateCountdown(), - }; - }} - strokeWidth={5} - > - {({remainingTime}) => ( - {remainingTime}s - )} - - - )} - /> - - - )} - ItemSeparatorComponent={() => } - /> - - - - - - - {t("homepage.Scan QR Code")} - - - - - - {t("homepage.Enter Secret Code")} - - - - - - {t("homepage.Import from other app")} - - - - - - - - - - - - - - - - - - {showScanner && ( - - )} - - - - - - ); -} +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, {useEffect, useRef, useState} from "react"; +import {Dimensions, InteractionManager, RefreshControl, TouchableOpacity, View} from "react-native"; +import {Divider, IconButton, List, Modal, Portal, Text} from "react-native-paper"; +import {GestureHandlerRootView} from "react-native-gesture-handler"; +import Swipeable from "react-native-gesture-handler/ReanimatedSwipeable"; +import {CountdownCircleTimer} from "react-native-countdown-circle-timer"; +import {useNetInfo} from "@react-native-community/netinfo"; +import {FlashList} from "@shopify/flash-list"; +import {useNotifications} from "react-native-notificated"; +import {useTranslation} from "react-i18next"; +import Animated, { + useAnimatedStyle, + withTiming +} from "react-native-reanimated"; +import {MaterialCommunityIcons} from "@expo/vector-icons"; + +import SearchBar from "./SearchBar"; +import EnterAccountDetails from "./EnterAccountDetails"; +import ScanQRCode from "./ScanQRCode"; +import EditAccountDetails from "./EditAccountDetails"; +import AvatarWithFallback from "./AvatarWithFallback"; +import {useImportManager} from "./ImportManager"; +import useStore from "./useStorage"; +import {calculateCountdown} from "./totpUtil"; +import {generateToken, validateSecret} from "./totpUtil"; +import {useAccountSync, useAccounts, useEditAccount} from "./useAccountStore"; + +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] = useState(true); + const [showOptions, setShowOptions] = useState(false); + const [showEnterAccountModal, setShowEnterAccountModal] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [filteredData, setFilteredData] = useState(accounts); + const [showScanner, setShowScanner] = useState(false); + const [showEditAccountModal, setShowEditAccountModal] = useState(false); + const [editingAccount, setEditingAccount] = useState(null); + const [placeholder, setPlaceholder] = useState(""); + const [refreshing, setRefreshing] = useState(false); + const {isConnected} = useNetInfo(); + const [canSync, setCanSync] = useState(false); + const [key, setKey] = useState(0); + const swipeableRef = useRef(null); + const {userInfo, serverUrl, token} = useStore(); + const {startSync} = useAccountSync(); + const {accounts} = useAccounts(); + const {setAccount, updateAccount, insertAccount, insertAccounts, deleteAccount} = useEditAccount(); + const {notify} = useNotifications(); + const {t} = useTranslation(); + const {showImportOptions} = useImportManager((data) => { + handleAddAccount(data); + }, (err) => { + notify("error", { + params: { + title: t("homepage.Import error"), + description: err.message, + }, + }); + }, () => { + setShowScanner(true); + }); + + useEffect(() => { + setCanSync(Boolean(isConnected && userInfo && serverUrl)); + }, [isConnected, userInfo, serverUrl]); + + useEffect(() => { + setFilteredData(accounts); + }, [accounts]); + + useEffect(() => { + if (canSync) { + startSync(userInfo, serverUrl, token); + + const timer = setInterval(() => { + InteractionManager.runAfterInteractions(() => { + startSync(userInfo, serverUrl, token); + }); + }, REFRESH_INTERVAL); + + return () => clearInterval(timer); + } + }, [startSync, canSync, token]); + + const onRefresh = async() => { + setRefreshing(true); + if (canSync) { + const syncError = await startSync(userInfo, serverUrl, token); + if (syncError) { + notify("error", { + params: { + title: "Sync error", + description: syncError, + }, + }); + } else { + notify("success", { + params: { + title: "Sync success", + description: "All your accounts are up to date.", + }, + }); + } + } + setRefreshing(false); + }; + + const handleAddAccount = async(accountDataInput) => { + if (Array.isArray(accountDataInput)) { + await insertAccounts(accountDataInput); + } else { + await setAccount(accountDataInput); + await insertAccount(); + closeEnterAccountModal(); + } + }; + + const handleEditAccount = (account) => { + closeSwipeableMenu(); + setEditingAccount(account); + setPlaceholder(account.accountName); + setShowEditAccountModal(true); + }; + + const onAccountEdit = async(newAccountName) => { + if (editingAccount) { + setAccount({...editingAccount, accountName: newAccountName, oldAccountName: editingAccount.accountName}); + updateAccount(); + setPlaceholder(""); + setEditingAccount(null); + closeEditAccountModal(); + } + }; + + const onAccountDelete = async(account) => { + deleteAccount(account.id); + }; + + const closeEditAccountModal = () => setShowEditAccountModal(false); + + const handleScanPress = () => { + setShowScanner(true); + setIsPlusButton(true); + setShowOptions(false); + }; + + const handleCloseScanner = () => setShowScanner(false); + + const handleScanError = (error) => { + setShowScanner(false); + notify("error", { + params: { + title: t("homepage.Error scanning QR code"), + description: error, + }, + }); + }; + + const togglePlusButton = () => { + setIsPlusButton(!isPlusButton); + setShowOptions(!showOptions); + }; + + const closeOptions = () => { + setIsPlusButton(true); + setShowOptions(false); + setShowScanner(false); + }; + + const openEnterAccountModal = () => { + setShowEnterAccountModal(true); + closeOptions(); + }; + + const openImportAccountModal = () => { + showImportOptions(); + closeOptions(); + }; + + const closeEnterAccountModal = () => setShowEnterAccountModal(false); + + const closeSwipeableMenu = () => { + if (swipeableRef.current) { + swipeableRef.current.close(); + } + }; + + const handleSearch = (query) => { + setSearchQuery(query); + setFilteredData(query.trim() !== "" + ? accounts && accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase())) + : accounts + ); + }; + + const renderRightActions = (progress, dragX, account, onEdit, onDelete) => { + const styleAnimation = useAnimatedStyle(() => { + return { + transform: [{translateX: dragX.value + 160}], + }; + }); + + return ( + + { + dragX.value = withTiming(0); + onEdit(account); + }} + > + + + {t("common.edit")} + + + + { + dragX.value = withTiming(0); + onDelete(account); + }} + > + + + {t("common.delete")} + + + + ); + }; + + return ( + + + `${item.id}`} + extraData={key} + estimatedItemSize={80} + refreshControl={ + + } + renderItem={({item}) => ( + + + renderRightActions(progress, dragX, item, handleEditAccount, onAccountDelete) + } + rightThreshold={40} + overshootRight={false} + friction={2} + enableTrackpadTwoFingerGesture + onSwipeableOpen={() => { + if (swipeableRef.current) { + swipeableRef.current.close(); + } + }} + > + + + {item.accountName} + + {generateToken(item.secretKey)} + + } + left={() => ( + + )} + right={() => ( + + { + setKey(prevKey => prevKey + 1); + return { + shouldRepeat: true, + delay: 0, + newInitialRemainingTime: calculateCountdown(), + }; + }} + strokeWidth={5} + > + {({remainingTime}) => ( + {remainingTime}s + )} + + + )} + /> + + + )} + ItemSeparatorComponent={() => } + /> + + + + + + + {t("homepage.Scan QR Code")} + + + + + + {t("homepage.Enter Secret Code")} + + + + + + {t("homepage.Import from other app")} + + + + + + + + + + + + + + + + + + {showScanner && ( + + )} + + + + + + ); +} diff --git a/SettingPage.js b/SettingPage.js index ee3d86f..34e231a 100644 --- a/SettingPage.js +++ b/SettingPage.js @@ -13,14 +13,14 @@ // limitations under the License. import React, {useState} from "react"; -import {Dimensions, ScrollView, StyleSheet, View} from "react-native"; +import {Dimensions, ScrollView, StyleSheet, TouchableOpacity, View} from "react-native"; import {Avatar, Button, IconButton, List, Surface, Text, useTheme} from "react-native-paper"; import {ActionSheetProvider} from "@expo/react-native-action-sheet"; import * as Application from "expo-application"; import {useTranslation} from "react-i18next"; import Constants, {ExecutionEnvironment} from "expo-constants"; -import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage"; +import CasdoorLoginPage, {useCasdoorLogout} from "./CasdoorLoginPage"; import LoginMethodSelector from "./LoginMethodSelector"; import useStore from "./useStorage"; import {Language} from "./Language"; @@ -33,7 +33,7 @@ const SettingPage = () => { const {userInfo, clearAll} = useStore(); const theme = useTheme(); const {t} = useTranslation(); - + const logout = useCasdoorLogout(); const {openActionSheet} = LoginMethodSelector({ onSelectMethod: (method) => { setLoginMethod(method); @@ -51,7 +51,7 @@ const SettingPage = () => { }; const handleCasdoorLogout = () => { - CasdoorLogout(); + logout(); clearAll(); }; @@ -80,14 +80,18 @@ const SettingPage = () => { /> ) : ( - + + )} @@ -151,8 +155,7 @@ const styles = StyleSheet.create({ backgroundColor: "#F2F2F2", }, profileCard: { - padding: 12, - marginBottom: 14, + padding: 10, borderRadius: 14, }, profileInfo: { @@ -167,7 +170,6 @@ const styles = StyleSheet.create({ loginButton: { borderRadius: 8, alignSelf: "center", - paddingHorizontal: 20, }, loginButtonLabel: { fontSize: 18, diff --git a/app.json b/app.json index 89a1fcb..56e2f02 100644 --- a/app.json +++ b/app.json @@ -55,7 +55,13 @@ ], "expo-asset", "expo-font", - "expo-localization" + "expo-localization", + [ + "expo-tracking-transparency", + { + "userTrackingPermission": "This identifier will be used to check which item is created by the user." + } + ] ], "owner": "casdoor" } diff --git a/db/schema.js b/db/schema.js index 66922f2..c99962a 100644 --- a/db/schema.js +++ b/db/schema.js @@ -25,6 +25,7 @@ export const accounts = sqliteTable("accounts", { 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), + origin: text("origin").default(null), }, (accounts) => ({ unq: unique().on(accounts.accountName, accounts.issuer), }) diff --git a/drizzle/0001_dear_scream.sql b/drizzle/0001_dear_scream.sql new file mode 100644 index 0000000..dfe1f6e --- /dev/null +++ b/drizzle/0001_dear_scream.sql @@ -0,0 +1 @@ +ALTER TABLE `accounts` ADD `device_id` text DEFAULT 'null'; \ No newline at end of file diff --git a/drizzle/0002_rare_ben_grimm.sql b/drizzle/0002_rare_ben_grimm.sql new file mode 100644 index 0000000..3a55486 --- /dev/null +++ b/drizzle/0002_rare_ben_grimm.sql @@ -0,0 +1 @@ +ALTER TABLE `accounts` RENAME COLUMN "device_id" TO "origin"; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..60689e7 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,113 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9181a8a8-76a3-4557-b299-41f13fe9d0f6", + "prevId": "aaa7b5e3-521e-4c3a-970c-35372e7f05a3", + "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'" + }, + "device_id": { + "name": "device_id", + "type": "text", + "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": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..533c6a1 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,115 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "98e0dfc5-cfb7-45a6-93ff-fe63a96c9e33", + "prevId": "9181a8a8-76a3-4557-b299-41f13fe9d0f6", + "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'" + }, + "origin": { + "name": "origin", + "type": "text", + "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": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"accounts\".\"device_id\"": "\"accounts\".\"origin\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1377ff9..af19f31 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1724248639995, "tag": "0000_smooth_owl", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1734441909137, + "tag": "0001_dear_scream", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1734616589186, + "tag": "0002_rare_ben_grimm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/migrations.js b/drizzle/migrations.js index a5dbbb9..6f0e11f 100644 --- a/drizzle/migrations.js +++ b/drizzle/migrations.js @@ -2,10 +2,14 @@ import journal from "./meta/_journal.json"; import m0000 from "./0000_smooth_owl.sql"; +import m0001 from "./0001_dear_scream.sql"; +import m0002 from "./0002_rare_ben_grimm.sql"; export default { journal, migrations: { m0000, + m0001, + m0002, }, }; diff --git a/metro.config.js b/metro.config.js index a09c5b2..cc73a58 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,4 +1,5 @@ const {getDefaultConfig} = require("expo/metro-config"); +const {wrapWithReanimatedMetroConfig} = require("react-native-reanimated/metro-config"); /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname); @@ -6,4 +7,4 @@ const config = getDefaultConfig(__dirname); config.resolver.sourceExts.push("sql"); config.resolver.assetExts.push("proto"); -module.exports = config; +module.exports = wrapWithReanimatedMetroConfig(config); diff --git a/package-lock.json b/package-lock.json index d147e89..3812428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@expo-google-fonts/lato": "^0.2.3", "@expo-google-fonts/roboto": "^0.2.3", "@expo/react-native-action-sheet": "^4.1.0", + "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/masked-view": "^0.1.11", "@react-native-community/netinfo": "11.4.1", @@ -38,6 +39,7 @@ "expo-sqlite": "~15.0.3", "expo-status-bar": "~2.0.0", "expo-system-ui": "~4.0.6", + "expo-tracking-transparency": "~5.1.0", "expo-updates": "~0.26.10", "hi-base32": "^0.5.1", "hotp-totp": "^1.0.6", @@ -9837,6 +9839,16 @@ "integrity": "sha512-6QRLEok1r55gLqj+94mEWUENuU5A6wsr2OoXpyq/CgQ7THWowbHtru/kRGRr6o3AQXrVnZheR60JNgFcpNYIug==", "license": "MIT" }, + "node_modules/expo-tracking-transparency": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.com/expo-tracking-transparency/-/expo-tracking-transparency-5.1.0.tgz", + "integrity": "sha512-0blcTlYGCXLDFdBKy61EBETdlQ+YOKLfDVv+Bjohpc+OmWy4or5Rb9QBPubK8hwl/BUSeOHxr/3FUwtasFh+UQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-updates": { "version": "0.26.10", "resolved": "https://registry.npmjs.com/expo-updates/-/expo-updates-0.26.10.tgz", diff --git a/package.json b/package.json index a4d7843..3a4b54f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@expo-google-fonts/lato": "^0.2.3", "@expo-google-fonts/roboto": "^0.2.3", "@expo/react-native-action-sheet": "^4.1.0", + "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/masked-view": "^0.1.11", "@react-native-community/netinfo": "11.4.1", @@ -40,6 +41,7 @@ "expo-sqlite": "~15.0.3", "expo-status-bar": "~2.0.0", "expo-system-ui": "~4.0.6", + "expo-tracking-transparency": "~5.1.0", "expo-updates": "~0.26.10", "hi-base32": "^0.5.1", "hotp-totp": "^1.0.6", diff --git a/syncLogic.js b/syncLogic.js index 42d92f9..d6b3f2c 100644 --- a/syncLogic.js +++ b/syncLogic.js @@ -34,9 +34,6 @@ function getAccountKey(account) { 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) { @@ -45,7 +42,8 @@ async function updateLocalDatabase(db, accounts) { if (acc.issuer === account.issuer && acc.accountName === account.accountName && acc.secretKey === account.secretKey && - acc.deletedAt === account.deletedAt + acc.deletedAt === account.deletedAt && + acc.origin === account.origin ) { continue; } @@ -56,6 +54,7 @@ async function updateLocalDatabase(db, accounts) { deletedAt: null, token: generateToken(account.secretKey), changedAt: new Date(), + origin: account.origin, }).where(eq(schema.accounts.id, account.id)); } else { await tx.delete(schema.accounts).where(eq(schema.accounts.id, account.id)); @@ -65,6 +64,7 @@ async function updateLocalDatabase(db, accounts) { issuer: account.issuer || null, accountName: account.accountName, secretKey: account.secretKey, + origin: account.origin || null, token: generateToken(account.secretKey), }); } @@ -155,18 +155,25 @@ export async function syncWithCloud(db, userInfo, serverUrl, token) { 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 accountsToSync = mergedAccounts + .filter(account => account.deletedAt === null || account.deletedAt === undefined) + .map(account => { + const {issuer, accountName, secretKey, origin} = account; + const accountToSync = {issuer, accountName, secretKey}; + if (origin !== null) { + accountToSync.origin = origin; + } + return accountToSync; + }); + + const serverAccountsStringified = serverAccounts.map(account => { + const {issuer, accountName, secretKey, origin} = account; + const accountStringified = {issuer, accountName, secretKey}; + if (origin !== null) { + accountStringified.origin = origin; + } + return JSON.stringify(accountStringified); + }); const accountsToSyncStringified = accountsToSync.map(account => JSON.stringify(account)); diff --git a/useAccountStore.js b/useAccountStore.js index 5da61ed..91a021b 100644 --- a/useAccountStore.js +++ b/useAccountStore.js @@ -12,23 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. +import AsyncStorage from "@react-native-async-storage/async-storage"; + import {db} from "./db/client"; import * as schema from "./db/schema"; -import {and, eq, isNull} from "drizzle-orm"; +import {and, eq, isNull, not, or} from "drizzle-orm"; import {create} from "zustand"; import {generateToken} from "./totpUtil"; import {syncWithCloud} from "./syncLogic"; +import {useLiveQuery} from "drizzle-orm/expo-sqlite"; -export const useAccountStore = create((set, get) => ({ - accounts: [], - refreshAccounts: () => { - const accounts = db.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)).all(); - set({accounts}); - }, - setAccounts: (accounts) => { - set({accounts}); - }, -})); +export const useAccounts = () => { + const {data: accounts} = useLiveQuery( + db.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)) + ); + + return { + accounts: accounts || [], + }; +}; const useEditAccountStore = create((set, get) => ({ account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined, oldAccountName: undefined}, @@ -66,8 +68,9 @@ const useEditAccountStore = create((set, get) => ({ }); }, - insertAccount: () => { + insertAccount: async() => { const {accountName, issuer, secretKey} = get().account; + const origin = await AsyncStorage.getItem("origin"); if (!accountName || !secretKey) {return;} const insertWithDuplicateCheck = (tx, baseAccName) => { let attemptCount = 0; @@ -109,6 +112,7 @@ const useEditAccountStore = create((set, get) => ({ issuer: issuer || null, secretKey, token: generateToken(secretKey), + origin: origin || null, }) .run(); @@ -131,6 +135,7 @@ const useEditAccountStore = create((set, get) => ({ insertAccounts: async(accounts) => { try { + const origin = await AsyncStorage.getItem("origin"); db.transaction((tx) => { const insertWithDuplicateCheck = (baseAccName, issuer, secretKey) => { let attemptCount = 0; @@ -172,6 +177,7 @@ const useEditAccountStore = create((set, get) => ({ issuer: issuer || null, secretKey, token: generateToken(secretKey), + origin: origin || null, }) .run(); @@ -197,6 +203,17 @@ const useEditAccountStore = create((set, get) => ({ .where(eq(schema.accounts.id, id)) .run(); }, + + deleteAccountByOrigin: async(origin) => { + db.delete(schema.accounts) + .where( + or( + not(eq(schema.accounts.origin, origin)), + isNull(schema.accounts.origin) + ) + ) + .run(); + }, })); export const useEditAccount = () => useEditAccountStore(state => ({ @@ -206,6 +223,7 @@ export const useEditAccount = () => useEditAccountStore(state => ({ insertAccount: state.insertAccount, insertAccounts: state.insertAccounts, deleteAccount: state.deleteAccount, + deleteAccountByOrigin: state.deleteAccountByOrigin, })); const useAccountSyncStore = create((set, get) => ({