adding mobile mfa settings control

This commit is contained in:
balzack 2024-05-17 20:01:19 -07:00
parent 97f375472e
commit a1041fb225
8 changed files with 301 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) {
<Text style={styles.optionLink}>{ state.strings.logout }</Text>
</View>
</TouchableOpacity>
<TouchableOpacity style={styles.drawerEntry} activeOpacity={1}>
<View style={styles.icon}>
<MatIcons name="ticket-confirmation-outline" size={20} color={Colors.text} />
</View>
<View style={styles.optionControl}>
<TouchableOpacity activeOpacity={1} onPress={actions.toggleMFA}>
<Text style={styles.optionText}>{ state.strings.mfaTitle }</Text>
</TouchableOpacity>
<Switch value={state.mfaEnabled} style={Platform.OS==='ios' ? styles.notifications : {}} thumbColor={Colors.sliderGrip} ios_backgroundColor={Colors.idleFill}
trackColor={styles.track} onValueChange={actions.toggleMFA} />
</View>
</TouchableOpacity>
<TouchableOpacity style={styles.drawerEntry} activeOpacity={1} onPress={actions.showLogin}>
<View style={styles.icon}>
<MatIcons name="login" size={20} color={Colors.text} />
@ -354,6 +393,19 @@ export function Settings({ drawer }) {
</View>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.entry} activeOpacity={1}>
<View style={styles.icon}>
<MatIcons name="ticket-confirmation-outline" size={20} color={Colors.linkText} />
</View>
<View style={styles.optionControl}>
<TouchableOpacity activeOpacity={1} onPress={toggleMFA}>
<Text style={styles.optionLink}>{ state.strings.mfaTitle }</Text>
</TouchableOpacity>
<Switch value={state.mfaEnabled} style={Platform.OS==='ios' ? styles.notifications : {}} thumbColor={Colors.sliderGrip} ios_backgroundColor={Colors.disabledIndicator}
trackColor={styles.track} onValueChange={toggleMFA} />
</View>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.entry} activeOpacity={1} onPress={actions.showLogin}>
<View style={styles.icon}>
<MatIcons name="login" size={20} color={Colors.linkText} />
@ -833,6 +885,60 @@ export function Settings({ drawer }) {
</View>
</Modal>
<Modal
animationType="fade"
transparent={true}
visible={state.mfaModal}
supportedOrientations={['portrait', 'landscape']}
onRequestClose={actions.dismissMFA}
>
<View>
<BlurView style={styles.mfaOverlay} blurType={Colors.overlay} blurAmount={2} reducedTransparencyFallbackColor="black" />
<View style={styles.mfaBase}>
<View style={styles.mfaContainer}>
<Text style={styles.mfaTitle}>{ state.strings.mfaTitle }</Text>
<Text style={styles.mfaDescription}>{ state.strings.mfaSteps }</Text>
{ state.mfaImage && (
<Image source={{ uri: state.mfaImage }} style={{ width: 128, height: 128 }} />
)}
{ !state.mfaImage && !state.mfaText && (
<ActivityIndicator style={styles.modalBusy} animating={true} color={Colors.primaryButton} />
)}
{ state.mfaText && (
<TouchableOpacity style={styles.mfaSecret} onPress={() => Clipboard.setString(state.mfaText)}>
<Text style={styles.mfaText}>{ state.mfaText }</Text>
<AntIcon style={styles.mfaIcon} name={'copy1'} size={20} />
</TouchableOpacity>
)}
<InputCode style={{ width: '100%' }} onChangeText={actions.setCode} />
<View style={styles.mfaError}>
{ state.mfaError == '401' && (
<Text style={styles.mfaErrorLabel}>{ state.strings.mfaError }</Text>
)}
{ state.mfaError == '429' && (
<Text style={styles.mfaErrorLabel}>{ state.strings.mfaDisabled }</Text>
)}
</View>
<View style={styles.mfaControl}>
<TouchableOpacity style={styles.mfaCancel} onPress={actions.dismissMFA}>
<Text style={styles.mfaCancelLabel}>{ state.strings.cancel }</Text>
</TouchableOpacity>
{ state.mfaCode != '' && (
<TouchableOpacity style={styles.mfaConfirm} onPress={actions.confirmMFA}>
<Text style={styles.mfaConfirmLabel}>{ state.strings.mfaConfirm }</Text>
</TouchableOpacity>
)}
{ state.mfaCode == '' && (
<View style={styles.mfaDisabled}>
<Text style={styles.mfaDisabledLabel}>{ state.strings.mfaConfirm }</Text>
</View>
)}
</View>
</View>
</View>
</View>
</Modal>
</>
);
}

View File

@ -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%',

View File

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

View File

@ -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) => {