diff --git a/app/mobile/src/api/addAccountMFA.js b/app/mobile/src/api/addAccountMFA.js
new file mode 100644
index 00000000..5d3a91c7
--- /dev/null
+++ b/app/mobile/src/api/addAccountMFA.js
@@ -0,0 +1,11 @@
+import { checkResponse, fetchWithTimeout } from './fetchUtil';
+
+export async function addAccountMFA(server, token) {
+ const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server);
+ const protocol = insecure ? 'http' : 'https';
+
+ const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}`, { method: 'POST' })
+ checkResponse(mfa);
+ return mfa.json();
+}
+
diff --git a/app/mobile/src/api/removeAccountMFA.js b/app/mobile/src/api/removeAccountMFA.js
new file mode 100644
index 00000000..96ffe0ed
--- /dev/null
+++ b/app/mobile/src/api/removeAccountMFA.js
@@ -0,0 +1,10 @@
+import { checkResponse, fetchWithTimeout } from './fetchUtil';
+
+export async function removeAccountMFA(server, token) {
+ const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server);
+ const protocol = insecure ? 'http' : 'https';
+
+ const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}`, { method: 'DELETE' });
+ checkResponse(mfa);
+}
+
diff --git a/app/mobile/src/api/setAccountMFA.js b/app/mobile/src/api/setAccountMFA.js
new file mode 100644
index 00000000..8fef1e8e
--- /dev/null
+++ b/app/mobile/src/api/setAccountMFA.js
@@ -0,0 +1,10 @@
+import { checkResponse, fetchWithTimeout } from './fetchUtil';
+
+export async function setAccountMFA(server, token, code) {
+ const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server);
+ const protocol = insecure ? 'http' : 'https';
+
+ const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}&code=${code}`, { method: 'PUT' })
+ checkResponse(mfa);
+}
+
diff --git a/app/mobile/src/context/useAccountContext.hook.js b/app/mobile/src/context/useAccountContext.hook.js
index 73529f48..d87d1991 100644
--- a/app/mobile/src/context/useAccountContext.hook.js
+++ b/app/mobile/src/context/useAccountContext.hook.js
@@ -5,6 +5,9 @@ import { setAccountSearchable } from 'api/setAccountSearchable';
import { setAccountNotifications } from 'api/setAccountNotifications';
import { getAccountStatus } from 'api/getAccountStatus';
import { setAccountLogin } from 'api/setAccountLogin';
+import { addAccountMFA } from 'api/addAccountMFA';
+import { setAccountMFA } from 'api/setAccountMFA';
+import { removeAccountMFA } from 'api/removeAccountMFA';
export function useAccountContext() {
const [state, setState] = useState({
@@ -75,6 +78,19 @@ export function useAccountContext() {
const { server, token } = access.current || {};
await setAccountSearchable(server, token, flag);
},
+ enableMFA: async () => {
+ const { server, token } = access.current || {};
+ const secret = await addAccountMFA(server, token);
+ return secret;
+ },
+ disableMFA: async () => {
+ const { server, token } = access.current || {};
+ await removeAccountMFA(server, token);
+ },
+ confirmMFA: async (code) => {
+ const { server, token } = access.current || {};
+ await setAccountMFA(server, token, code);
+ },
setAccountSeal: async (seal, key) => {
const { guid, server, token } = access.current || {};
await setAccountSeal(server, token, seal);
diff --git a/app/mobile/src/session/settings/Settings.jsx b/app/mobile/src/session/settings/Settings.jsx
index 5497995b..fe77fee9 100644
--- a/app/mobile/src/session/settings/Settings.jsx
+++ b/app/mobile/src/session/settings/Settings.jsx
@@ -1,14 +1,17 @@
import { useState } from 'react';
-import { Linking, ActivityIndicator, FlatList, KeyboardAvoidingView, Modal, ScrollView, View, Switch, Text, TextInput, TouchableOpacity, Alert } from 'react-native';
+import { Linking, ActivityIndicator, FlatList, Image, KeyboardAvoidingView, Modal, ScrollView, View, Switch, Text, TextInput, TouchableOpacity, Alert } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { useNavigate } from 'react-router-dom';
import { styles } from './Settings.styled';
import { useSettings } from './useSettings.hook';
+import AntIcon from 'react-native-vector-icons/AntDesign';
import MatIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import Colors from 'constants/Colors';
import { BlurView } from "@react-native-community/blur";
import { InputField } from 'utils/InputField';
import { Logo } from 'utils/Logo';
+import { InputCode } from 'utils/InputCode';
+import Clipboard from '@react-native-clipboard/clipboard';
export function Settings({ drawer }) {
@@ -99,6 +102,28 @@ export function Settings({ drawer }) {
}
}
+ const toggleMFA = async () => {
+ if (!busy) {
+ try {
+ setBusy(true);
+ if (state.mfaEnabled) {
+ await actions.disableMFA();
+ }
+ else {
+ await actions.enableMFA();
+ }
+ }
+ catch (err) {
+ console.log(err);
+ Alert.alert(
+ state.strings.error,
+ state.strings.tryAgain,
+ );
+ }
+ setBusy(false);
+ }
+ }
+
const deleteAccount = async () => {
if (!busy) {
try {
@@ -199,6 +224,20 @@ export function Settings({ drawer }) {
{ state.strings.logout }
+
+
+
+
+
+
+
+ { state.strings.mfaTitle }
+
+
+
+
+
@@ -354,6 +393,19 @@ export function Settings({ drawer }) {
+
+
+
+
+
+
+ { state.strings.mfaTitle }
+
+
+
+
+
@@ -833,6 +885,60 @@ export function Settings({ drawer }) {
+
+
+
+
+
+ { state.strings.mfaTitle }
+ { state.strings.mfaSteps }
+ { state.mfaImage && (
+
+ )}
+ { !state.mfaImage && !state.mfaText && (
+
+ )}
+ { state.mfaText && (
+ Clipboard.setString(state.mfaText)}>
+ { state.mfaText }
+
+
+ )}
+
+
+ { state.mfaError == '401' && (
+ { state.strings.mfaError }
+ )}
+ { state.mfaError == '429' && (
+ { state.strings.mfaDisabled }
+ )}
+
+
+
+ { state.strings.cancel }
+
+ { state.mfaCode != '' && (
+
+ { state.strings.mfaConfirm }
+
+ )}
+ { state.mfaCode == '' && (
+
+ { state.strings.mfaConfirm }
+
+ )}
+
+
+
+
+
+
>
);
}
diff --git a/app/mobile/src/session/settings/Settings.styled.js b/app/mobile/src/session/settings/Settings.styled.js
index 5656b72b..886b9616 100644
--- a/app/mobile/src/session/settings/Settings.styled.js
+++ b/app/mobile/src/session/settings/Settings.styled.js
@@ -15,6 +15,112 @@ export const styles = StyleSheet.create({
flexDirection: 'row',
padding: 8,
},
+ mfaOverlay: {
+ width: '100%',
+ height: '100%',
+ },
+ mfaSecret: {
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 4,
+ },
+ mfaText: {
+ fontSize: 11,
+ color: Colors.mainText,
+ },
+ mfaIcon: {
+ fontSize: 14,
+ color: Colors.primaryButton,
+ },
+ mfaError: {
+ width: '100%',
+ height: 24,
+ display: 'flex',
+ alignItems: 'center',
+ },
+ mfaErrorLabel: {
+ color: Colors.dangerText,
+ },
+ mfaControl: {
+ height: 32,
+ display: 'flex',
+ flexDirection: 'row',
+ width: '100%',
+ justifyContent: 'flex-end',
+ gap: 16,
+ },
+ mfaCancel: {
+ width: 72,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: Colors.cancelButton,
+ borderRadius: 4,
+ },
+ mfaCancelLabel: {
+ color: Colors.cancelButtonText,
+ },
+ mfaConfirm: {
+ width: 72,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: Colors.primaryButton,
+ borderRadius: 4,
+ },
+ mfaConfirmLabel: {
+ color: Colors.primaryButtonText,
+ },
+ mfaDisabled: {
+ width: 72,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: Colors.disabledButton,
+ borderRadius: 4,
+ },
+ mfaDisabledLabel: {
+ color: Colors.disabledButtonText,
+ },
+ mfaBase: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ mfaContainer: {
+ backgroundColor: Colors.modalBase,
+ borderColor: Colors.modalBorder,
+ borderWidth: 1,
+ width: '80%',
+ maxWidth: 400,
+ display: 'flex',
+ gap: 8,
+ alignItems: 'center',
+ borderRadius: 8,
+ padding: 16,
+ },
+ mfaTitle: {
+ fontSize: 20,
+ color: Colors.descriptionText,
+ paddingBottom: 8,
+ },
+ mfaDescription: {
+ fontSize: 14,
+ color: Colors.descriptionText,
+ textAlign: 'center',
+ },
+ mfaCode: {
+ width: 400,
+ borderWidth: 1,
+ borderColor: '#333333',
+ width: '100%',
+ opacity: 0,
+ },
content: {
width: '100%',
height: '100%',
diff --git a/app/mobile/src/session/settings/useSettings.hook.js b/app/mobile/src/session/settings/useSettings.hook.js
index c701ebda..fbe2f091 100644
--- a/app/mobile/src/session/settings/useSettings.hook.js
+++ b/app/mobile/src/session/settings/useSettings.hook.js
@@ -56,6 +56,14 @@ export function useSettings() {
contacts: [],
topics: [],
messages: [],
+
+ mfaModal: false,
+ mfaEnabled: false,
+ mfaSecret: false,
+ mfaError: null,
+ mfaCode: '',
+ mfaText: null,
+ mfaImage: null,
});
const updateState = (value) => {
@@ -69,11 +77,11 @@ export function useSettings() {
}, [profile.state]);
useEffect(() => {
- const { seal, sealable, pushEnabled } = account.state.status;
+ const { seal, sealable, pushEnabled, mfaEnabled } = account.state.status;
const sealKey = account.state.sealKey;
const sealEnabled = seal?.publicKey != null;
const sealUnlocked = seal?.publicKey === sealKey?.public && sealKey?.private && sealKey?.public;
- updateState({ sealable, seal, sealKey, sealEnabled, sealUnlocked, pushEnabled });
+ updateState({ sealable, seal, sealKey, sealEnabled, sealUnlocked, pushEnabled, mfaEnabled });
}, [account.state]);
const setCardItem = (item) => {
@@ -375,6 +383,37 @@ export function useSettings() {
updateState({ messages: state.messages.filter(item => item.channelId !== channelId || item.topicId !== topicId) });
}
},
+ enableMFA: async () => {
+ updateState({ mfaModal: true, mfaSecret: false, mfaCode: '' });
+ const mfa = await account.actions.enableMFA();
+ updateState({ mfaImage: mfa.secretImage, mfaText: mfa.secretText });
+ },
+ disableMFA: async () => {
+ updateState({ mfaEnabled: false });
+ try {
+ await account.actions.disableMFA();
+ }
+ catch (err) {
+ updateState({ mfaEnabled: true });
+ throw err;
+ }
+ },
+ confirmMFA: async () => {
+ try {
+ updateState({ mfaEnabled: true });
+ await account.actions.confirmMFA(state.mfaCode);
+ updateState({ mfaModal: false });
+ }
+ catch (err) {
+ updateState({ mfaEnabled: false, mfaError: err.message});
+ }
+ },
+ dismissMFA: () => {
+ updateState({ mfaModal: false });
+ },
+ setCode: (mfaCode) => {
+ updateState({ mfaCode });
+ },
};
return { state, actions };
diff --git a/net/web/src/context/useAccountContext.hook.js b/net/web/src/context/useAccountContext.hook.js
index 4ea4a0d3..d6d71741 100644
--- a/net/web/src/context/useAccountContext.hook.js
+++ b/net/web/src/context/useAccountContext.hook.js
@@ -77,15 +77,12 @@ export function useAccountContext() {
},
enableMFA: async () => {
const secret = await addAccountMFA(access.current);
- console.log("SECRET ", secret);
return secret;
},
disableMFA: async () => {
await removeAccountMFA(access.current);
},
confirmMFA: async (code) => {
-console.log("CONFIRMING: ", code);
-
await setAccountMFA(access.current, code);
},
setSeal: async (seal, sealKey) => {